scustomtkinter 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. scustomtkinter/__init__.py +81 -0
  2. scustomtkinter/assets/images/error.png +0 -0
  3. scustomtkinter/assets/images/info.png +0 -0
  4. scustomtkinter/assets/images/warning.png +0 -0
  5. scustomtkinter/assets/sCTkThemes.json +1963 -0
  6. scustomtkinter/license_morecustomtkinterwidgets.txt +22 -0
  7. scustomtkinter/sctk_button_primary.py +366 -0
  8. scustomtkinter/sctk_button_secondary.py +354 -0
  9. scustomtkinter/sctk_button_tertiary.py +375 -0
  10. scustomtkinter/sctk_checkbox.py +270 -0
  11. scustomtkinter/sctk_combobox.py +155 -0
  12. scustomtkinter/sctk_core.py +15 -0
  13. scustomtkinter/sctk_dial.py +1472 -0
  14. scustomtkinter/sctk_dialog.py +701 -0
  15. scustomtkinter/sctk_dialog_toplevel.py +268 -0
  16. scustomtkinter/sctk_entry_primary.py +400 -0
  17. scustomtkinter/sctk_entry_secondary.py +342 -0
  18. scustomtkinter/sctk_file_explorer.py +879 -0
  19. scustomtkinter/sctk_frame.py +210 -0
  20. scustomtkinter/sctk_frame_labeled_primary.py +667 -0
  21. scustomtkinter/sctk_frame_labeled_secondary.py +598 -0
  22. scustomtkinter/sctk_frequency_display.py +636 -0
  23. scustomtkinter/sctk_label_primary.py +283 -0
  24. scustomtkinter/sctk_label_secondary.py +275 -0
  25. scustomtkinter/sctk_label_tertiary.py +278 -0
  26. scustomtkinter/sctk_messagebox.py +275 -0
  27. scustomtkinter/sctk_notebook.py +1103 -0
  28. scustomtkinter/sctk_optionmenu_border_mixin.py +203 -0
  29. scustomtkinter/sctk_optionmenu_primary.py +378 -0
  30. scustomtkinter/sctk_optionmenu_secondary.py +346 -0
  31. scustomtkinter/sctk_path_chooser.py +516 -0
  32. scustomtkinter/sctk_progress_bar.py +274 -0
  33. scustomtkinter/sctk_radiobutton.py +360 -0
  34. scustomtkinter/sctk_scroll_mixin.py +546 -0
  35. scustomtkinter/sctk_scrollable_frame.py +795 -0
  36. scustomtkinter/sctk_scrollbar.py +313 -0
  37. scustomtkinter/sctk_segmentedbutton.py +424 -0
  38. scustomtkinter/sctk_selector.py +581 -0
  39. scustomtkinter/sctk_separator.py +308 -0
  40. scustomtkinter/sctk_slider.py +264 -0
  41. scustomtkinter/sctk_smeter.py +405 -0
  42. scustomtkinter/sctk_smeter_bar.py +557 -0
  43. scustomtkinter/sctk_spinbox.py +664 -0
  44. scustomtkinter/sctk_switch.py +463 -0
  45. scustomtkinter/sctk_tableview.py +869 -0
  46. scustomtkinter/sctk_tabview.py +409 -0
  47. scustomtkinter/sctk_textbox_primary.py +291 -0
  48. scustomtkinter/sctk_textbox_secondary.py +225 -0
  49. scustomtkinter/sctk_toplevel.py +153 -0
  50. scustomtkinter/themeable_widget.py +938 -0
  51. scustomtkinter-0.2.1.dist-info/METADATA +336 -0
  52. scustomtkinter-0.2.1.dist-info/RECORD +101 -0
  53. scustomtkinter-0.2.1.dist-info/WHEEL +5 -0
  54. scustomtkinter-0.2.1.dist-info/entry_points.txt +2 -0
  55. scustomtkinter-0.2.1.dist-info/licenses/LICENSE +21 -0
  56. scustomtkinter-0.2.1.dist-info/licenses/scustomtkinter/license_morecustomtkinterwidgets.txt +22 -0
  57. scustomtkinter-0.2.1.dist-info/top_level.txt +2 -0
  58. scustomtkinter_pygubu/__init__.py +1 -0
  59. scustomtkinter_pygubu/designer/__init__.py +0 -0
  60. scustomtkinter_pygubu/designer/plugin.py +1854 -0
  61. scustomtkinter_pygubu/designer/properties.py +247 -0
  62. scustomtkinter_pygubu/sCTkButtonPrimarybo.py +46 -0
  63. scustomtkinter_pygubu/sCTkButtonSecondarybo.py +45 -0
  64. scustomtkinter_pygubu/sCTkButtonTertiarybo.py +44 -0
  65. scustomtkinter_pygubu/sCTkCheckBoxbo.py +45 -0
  66. scustomtkinter_pygubu/sCTkComboBoxbo.py +45 -0
  67. scustomtkinter_pygubu/sCTkCorebo.py +120 -0
  68. scustomtkinter_pygubu/sCTkDialbo.py +371 -0
  69. scustomtkinter_pygubu/sCTkDialogbo.py +397 -0
  70. scustomtkinter_pygubu/sCTkEntryPrimarybo.py +100 -0
  71. scustomtkinter_pygubu/sCTkEntrySecondarybo.py +98 -0
  72. scustomtkinter_pygubu/sCTkFileExplorerbo.py +189 -0
  73. scustomtkinter_pygubu/sCTkFrameLabeledPrimarybo.py +69 -0
  74. scustomtkinter_pygubu/sCTkFrameLabeledSecondarybo.py +71 -0
  75. scustomtkinter_pygubu/sCTkFramebo.py +46 -0
  76. scustomtkinter_pygubu/sCTkFrequencyDisplaybo.py +175 -0
  77. scustomtkinter_pygubu/sCTkLabelPrimarybo.py +45 -0
  78. scustomtkinter_pygubu/sCTkLabelSecondarybo.py +44 -0
  79. scustomtkinter_pygubu/sCTkLabelTertiarybo.py +45 -0
  80. scustomtkinter_pygubu/sCTkNotebookbo.py +392 -0
  81. scustomtkinter_pygubu/sCTkOptionMenuPrimarybo.py +45 -0
  82. scustomtkinter_pygubu/sCTkOptionMenuSecondarybo.py +39 -0
  83. scustomtkinter_pygubu/sCTkPathChooserbo.py +204 -0
  84. scustomtkinter_pygubu/sCTkProgressBarbo.py +45 -0
  85. scustomtkinter_pygubu/sCTkRadioButtonbo.py +45 -0
  86. scustomtkinter_pygubu/sCTkSMeterBarbo.py +153 -0
  87. scustomtkinter_pygubu/sCTkSMeterbo.py +101 -0
  88. scustomtkinter_pygubu/sCTkScrollableFramebo.py +66 -0
  89. scustomtkinter_pygubu/sCTkScrollbarbo.py +48 -0
  90. scustomtkinter_pygubu/sCTkSegmentedButtonbo.py +47 -0
  91. scustomtkinter_pygubu/sCTkSelectorbo.py +167 -0
  92. scustomtkinter_pygubu/sCTkSeparatorbo.py +141 -0
  93. scustomtkinter_pygubu/sCTkSliderbo.py +45 -0
  94. scustomtkinter_pygubu/sCTkSpinboxbo.py +200 -0
  95. scustomtkinter_pygubu/sCTkSwitchbo.py +45 -0
  96. scustomtkinter_pygubu/sCTkTableviewbo.py +188 -0
  97. scustomtkinter_pygubu/sCTkTabviewbo.py +226 -0
  98. scustomtkinter_pygubu/sCTkTextboxPrimarybo.py +45 -0
  99. scustomtkinter_pygubu/sCTkTextboxSecondarybo.py +45 -0
  100. scustomtkinter_pygubu/sCTkToplevelbo.py +55 -0
  101. scustomtkinter_pygubu/sCTkWidgetSetForPygubuDesigner.py +141 -0
@@ -0,0 +1,1103 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ sCTkNotebook
4
+
5
+ A multi-page container with its tabs down the left or right edge, rather than
6
+ across the top. The same idea as sCTkTabview, turned ninety degrees.
7
+
8
+ WHY THIS IS NOT A SUBCLASS OF sCTkTabview. Native CTkTabview builds its tab
9
+ strip from a CTkSegmentedButton, which lays its buttons out in a single grid
10
+ ROW and offers no vertical mode. Everything about the strip -- its geometry,
11
+ its selection handling, its colours -- comes from that widget. Reusing it
12
+ would mean overriding nearly all of it, so this class owns its strip outright.
13
+
14
+ WHY EVERYTHING IS ON ONE CANVAS. Tab labels run along the strip rather than
15
+ across it, so the text is rotated ninety degrees, and a Tk button cannot
16
+ rotate its text. That alone would only need a canvas for the strip -- but the
17
+ page outline has to break where the selected tab meets it, the way a real
18
+ notebook divider does, and a border drawn by a frame cannot have a gap cut in
19
+ it. Outline and tabs are therefore drawn together, on a single canvas
20
+ spanning the whole widget, and the content pages are placed on top of it.
21
+
22
+ Selection, hover and hit-testing are consequently this class's own code
23
+ rather than a button's.
24
+ """
25
+ import sys
26
+ import tkinter as tk
27
+ import tkinter.font as tkfont
28
+ import math
29
+ import customtkinter as ctk
30
+
31
+ from . import themeable_widget as _tw
32
+ from .themeable_widget import ThemeableWidget
33
+ from .sctk_frame import sCTkFrame
34
+
35
+
36
+ class sCTkNotebook(ctk.CTkFrame, ThemeableWidget):
37
+ """A tabbed container with a vertical tab strip."""
38
+
39
+ # Required at the TOP LEVEL of the theme block.
40
+ _REQUIRED_THEME_KEYS = (
41
+ "fg_color", "font", "text_color", "selected_text_color",
42
+ "tab_fg_color", "tab_selected_color", "tab_hover_color",
43
+ "border_color",
44
+ )
45
+
46
+ # Required inside disabled_map. The strip and the outline dim; the page
47
+ # area does not, matching the dials and sCTkScrollableFrame -- the content
48
+ # carries its own state, and a greyed page would hide it.
49
+ _REQUIRED_DISABLED_KEYS = (
50
+ "text_color", "tab_fg_color", "tab_selected_color", "border_color",
51
+ )
52
+
53
+ # Keys this widget reads itself must not reach CTkFrame, which raises on
54
+ # anything it does not recognise.
55
+ _NATIVE_FRAME_KWARGS = frozenset({
56
+ "width", "height", "bg_color",
57
+ })
58
+
59
+ # Geometry, in UNSCALED pixels. Every one of these goes through
60
+ # _apply_widget_scaling() before it reaches the canvas, so the strip grows
61
+ # with the rest of the interface on a scaled display. Drawing raw pixels
62
+ # on a CTkCanvas is the easy mistake here: the canvas widget scales, the
63
+ # coordinates inside it do not.
64
+ TAB_GAP = 2 # space between adjacent tabs
65
+ TAB_PAD = 18 # space either end of a label, along the tab
66
+ TAB_CORNER = 6 # corner rounding on the outer edge of a tab
67
+ # An angled tab's corners are CHAMFERED -- a straight cut between a point
68
+ # measured in from the corner along each edge. Independent, because the
69
+ # two edges are not equivalent: x runs across the strip, whose width is
70
+ # fixed, while y runs along the tab, whose length depends on the label
71
+ # and on whether the text is rotated.
72
+ TAB_CHAMFER_X = 6 # in from the outer corner, across the strip
73
+ TAB_CHAMFER_Y = 6 # in from the outer corner, along the tab
74
+ TAB_CHAMFER_MAX_FRACTION = 0.25 # ceiling on the Y cut, per end, as a
75
+ # fraction of the tab's length
76
+ PAGE_CORNER = 8 # corner rounding on the page outline
77
+ PAGE_INSET = 8 # gap between the outline and a page's contents
78
+ BORDER_WIDTH = 2 # page outline thickness
79
+ STRIP_MARGIN = 6 # space above the first tab
80
+ SEPARATOR_INSET = 5 # how far a separator stops short of each edge
81
+
82
+ def __init__(self, master=None, side="left", tab_width=34,
83
+ tab_style="rounded", text_orientation="auto",
84
+ show_page_border=True, show_tab_separators=False,
85
+ state="normal", command=None, **kw):
86
+ """
87
+ Args:
88
+ master: Parent container.
89
+ side: Which edge the strip sits on, "left" or "right".
90
+ tab_width: Width of the strip in pixels. Explicit rather than
91
+ measured, because the strip takes space from the pages and a
92
+ layout that shifts when a tab is renamed is worse than one the
93
+ caller sets.
94
+ tab_style: "rounded" or "angled". Rounded matches the rest of the
95
+ library; angled is the shape of a real notebook divider.
96
+ text_orientation: How the labels sit on the tabs.
97
+ "auto" reads outside-in -- up a left strip, down a right one,
98
+ the way a book's spine is set. "up" and "down" force one
99
+ direction whichever side the strip is on. "horizontal" lays
100
+ the text flat, which needs a tab_width wide enough to hold
101
+ the longest label.
102
+ show_page_border: Whether to draw the outline around the page,
103
+ broken where the selected tab meets it.
104
+ show_tab_separators: Draws a line across the strip between
105
+ adjacent tabs. Useful with horizontal text, where stacked
106
+ labels can otherwise run together; less so with rotated text,
107
+ where the gap alone reads clearly. Off by default.
108
+ state: "normal" or "disabled".
109
+ **kw: Native CTkFrame arguments, or theme-key overrides.
110
+ """
111
+ self._side = self._check_side(side)
112
+ self._tab_width = int(tab_width)
113
+ self._tab_style = self._check_style(tab_style)
114
+ self._text_orientation = self._check_orientation(text_orientation)
115
+ self._show_page_border = bool(show_page_border)
116
+ self._show_tab_separators = bool(show_tab_separators)
117
+
118
+ ThemeableWidget.__init__(self, kw)
119
+
120
+ # Colours come from the RAW registry, not final_kw.
121
+ #
122
+ # ThemeableWidget strips its CUSTOM_VECTOR_KEYS -- text_color among
123
+ # them -- out of final_kw so they cannot reach a native constructor.
124
+ # Reading them back from final_kw finds nothing, which is the trap
125
+ # that left the whole dial family rendering in hardcoded fallbacks.
126
+ # See sCTkDial.md, "Reading theme colours".
127
+ raw_block = _tw.GLOBAL_THEME_REGISTRY.get(self.__class__.__name__) or {}
128
+ raw_colors = {k: v for k, v in raw_block.items()
129
+ if not isinstance(v, dict)}
130
+ self._local_defaults = ThemeableWidget._convert_lists_to_tuples(raw_colors)
131
+ self._local_defaults.update(self.final_kw)
132
+ self._custom_disabled_map = dict(self._widget_disabled_map)
133
+ self._validate_theme_keys()
134
+
135
+ native_kwargs = {k: v for k, v in self.final_kw.items()
136
+ if k in self._NATIVE_FRAME_KWARGS}
137
+ super().__init__(master, fg_color="transparent", border_width=0,
138
+ **native_kwargs)
139
+
140
+ self._state = "normal" if str(state).lower() == "normal" else "disabled"
141
+
142
+ # name -> sCTkFrame, in insertion order. Ordinary dicts preserve it,
143
+ # and tab order is insertion order.
144
+ self._pages = {}
145
+ self._current = None
146
+
147
+ # Called with the tab's name when the OPERATOR selects it. Not fired
148
+ # by set(): a programmatic selection is the application saying what
149
+ # it already knows, and firing there makes construction order
150
+ # load-bearing -- the lesson sCTkSpinbox.set() taught. Matches
151
+ # CTkTabview, whose command also fires only on a click.
152
+ self._command = command
153
+ self._hover = None # name under the pointer, or None
154
+ self._tab_bounds = {} # name -> (y_top, y_bottom), for hit-testing
155
+ self._scroll = 0 # strip scroll offset, in pixels
156
+
157
+ # NAMED self.canvas, NOT self._canvas.
158
+ #
159
+ # CTkFrame keeps its own background canvas in self._canvas, so
160
+ # assigning that name here replaces the frame's reference to it and
161
+ # its _draw() would then paint on this widget's canvas instead. The
162
+ # dials and the S-meters use the bare name for exactly this reason.
163
+ self.canvas = ctk.CTkCanvas(self, highlightthickness=0, bd=0)
164
+ # GRIDDED, not placed, and lowered behind the pages.
165
+ #
166
+ # place() takes a widget out of the geometry negotiation entirely: a
167
+ # placed page contributes nothing to what its parent requests, so the
168
+ # notebook asked for nothing and every size had to be set by hand.
169
+ # Grid keeps the propagation -- the cell takes the pages' requested
170
+ # size, the notebook takes the cell's -- while lower() keeps the
171
+ # canvas behind them.
172
+ self.canvas.grid(row=0, column=0, columnspan=2, sticky="nsew")
173
+ # NOT lowered.
174
+ #
175
+ # Lowering this canvas put it beneath CTkFrame's OWN background
176
+ # canvas, and "transparent" in CustomTkinter does not mean "do not
177
+ # paint" -- it means "paint the parent's colour". So the frame's
178
+ # background covered the tab strip completely and the widget rendered
179
+ # as an empty rectangle, while every print said the tabs were there.
180
+ #
181
+ # Nothing needs lowering anyway: this canvas is created before the
182
+ # page host, so it is already below it in the stacking order, which is
183
+ # the only relationship that matters.
184
+
185
+ # Pages sit ON TOP of the canvas, inside the outline.
186
+ # Created AFTER the canvas, so it stacks above it -- see the note
187
+ # there. lift() states it rather than relying on creation order.
188
+ self._page_host = ctk.CTkFrame(self, fg_color="transparent",
189
+ border_width=0)
190
+ self._page_host.lift()
191
+ self._page_host.grid_rowconfigure(0, weight=1)
192
+ self._page_host.grid_columnconfigure(0, weight=1)
193
+ self.grid_rowconfigure(0, weight=1)
194
+
195
+ self.canvas.bind("<Button-1>", self._on_click)
196
+ self.canvas.bind("<Motion>", self._on_motion)
197
+ self.canvas.bind("<Leave>", self._on_leave)
198
+ self.canvas.bind("<Configure>", lambda e: self._relayout())
199
+ for seq in ("<MouseWheel>", "<Button-4>", "<Button-5>"):
200
+ self.canvas.bind(seq, self._on_wheel)
201
+ # macOS trackpads deliver <TouchpadScroll>, a separate event from
202
+ # <MouseWheel>, with far more of them and far finer values. Binding
203
+ # only the wheel means a real mouse scrolls the strip and a trackpad
204
+ # does nothing at all. Same split ScrollBindingMixin handles for the
205
+ # scrolling containers.
206
+ # GUARDED: <TouchpadScroll> does not exist on every Tk build.
207
+ #
208
+ # It arrived in Tk 8.7, so a macOS running an older Tk raises here and
209
+ # the widget cannot be constructed at all:
210
+ #
211
+ # TclError: bad event type or keysym "TouchpadScroll"
212
+ #
213
+ # The wheel bindings above still work there; only the finer trackpad
214
+ # events are lost, which is a degradation rather than a failure.
215
+ # ScrollBindingMixin has guarded this all along, which is why the
216
+ # scrolling containers never showed the fault.
217
+ if sys.platform == "darwin":
218
+ try:
219
+ self.canvas.bind("<TouchpadScroll>", self._on_touchpad)
220
+ except tk.TclError:
221
+ pass
222
+
223
+ # Laid out ONCE at construction, not only when a tab is added.
224
+ #
225
+ # _relayout() is what configures the grid columns -- the strip's
226
+ # minsize and the page column's weight. Without this call an empty
227
+ # notebook had neither, so both columns collapsed and the canvas,
228
+ # gridded across them, had no width to draw in. The widget rendered
229
+ # as nothing at all until the first tab arrived.
230
+ self._relayout()
231
+
232
+ self._finalize_themeable_lifecycle()
233
+
234
+ # ------------------------------------------------------------------
235
+ # Argument checking
236
+ # ------------------------------------------------------------------
237
+ @staticmethod
238
+ def _check_side(value):
239
+ side = str(value).lower()
240
+ if side not in ("left", "right"):
241
+ raise ValueError(f'side must be "left" or "right", not "{value}"')
242
+ return side
243
+
244
+ @staticmethod
245
+ def _check_style(value):
246
+ style = str(value).lower()
247
+ if style not in ("rounded", "angled"):
248
+ raise ValueError(
249
+ f'tab_style must be "rounded" or "angled", not "{value}"')
250
+ return style
251
+
252
+ @staticmethod
253
+ def _check_orientation(value):
254
+ orientation = str(value).lower()
255
+ if orientation not in ("auto", "up", "down", "horizontal"):
256
+ raise ValueError(
257
+ 'text_orientation must be "auto", "up", "down" or '
258
+ f'"horizontal", not "{value}"')
259
+ return orientation
260
+
261
+ def _validate_theme_keys(self):
262
+ """
263
+ Hard-fails at construction on an incomplete theme block, naming the
264
+ missing key and where it belongs. The library-wide pattern: a missing
265
+ key is an error, never a silently substituted colour.
266
+ """
267
+ name = self.__class__.__name__
268
+ for key in self._REQUIRED_THEME_KEYS:
269
+ if self._local_defaults.get(key) is None:
270
+ raise KeyError(
271
+ f"'{name}' theme block is missing '{key}' at the top level "
272
+ f"of sCTkThemes.json.")
273
+ for key in self._REQUIRED_DISABLED_KEYS:
274
+ if self._custom_disabled_map.get(key) is None:
275
+ raise KeyError(
276
+ f"'{name}' theme block is missing '{key}' in disabled_map.")
277
+
278
+ # ------------------------------------------------------------------
279
+ # Scaling helpers
280
+ # ------------------------------------------------------------------
281
+ # Geometry keys the theme may override. Absent from a block, the class
282
+ # attribute of the same name in upper case applies -- so a theme need
283
+ # only mention the ones it wants to change, and none of them are
284
+ # required.
285
+ _THEMEABLE_GEOMETRY = {
286
+ "tab_corner": "TAB_CORNER",
287
+ "tab_chamfer_x": "TAB_CHAMFER_X",
288
+ "tab_chamfer_y": "TAB_CHAMFER_Y",
289
+ "tab_gap": "TAB_GAP",
290
+ "tab_pad": "TAB_PAD",
291
+ "page_corner": "PAGE_CORNER",
292
+ "page_inset": "PAGE_INSET",
293
+ "border_width": "BORDER_WIDTH",
294
+ "separator_inset": "SEPARATOR_INSET",
295
+ "strip_margin": "STRIP_MARGIN",
296
+ }
297
+
298
+ def _geom(self, key):
299
+ """
300
+ A geometry value, scaled, from the theme or the class attribute.
301
+
302
+ Theme first so a palette can carry its own proportions -- a squarer
303
+ theme wants a smaller tab_corner -- while the class attributes stay
304
+ the defaults and remain settable per instance for one-off tuning.
305
+ """
306
+ value = self._local_defaults.get(key)
307
+ if value is None:
308
+ value = getattr(self, self._THEMEABLE_GEOMETRY[key])
309
+ return self._apply_widget_scaling(float(value))
310
+
311
+ def _sx(self, value):
312
+ """Scales a dimension for drawing. Used on every geometry constant."""
313
+ return self._apply_widget_scaling(value)
314
+
315
+ def _scaled_font(self):
316
+ """
317
+ The theme font with its size scaled.
318
+
319
+ A canvas text item takes a plain font tuple, which CustomTkinter's own
320
+ font scaling never sees -- so without this the labels would stay the
321
+ same size while everything around them grew.
322
+
323
+ THE WHOLE TUPLE GOES IN, not the size. _apply_font_scaling takes a
324
+ font and returns it with its size scaled; handed a bare integer it
325
+ raises ValueError.
326
+
327
+ That mistake was here and invisible: the except below swallowed it
328
+ and returned the unscaled font, so the tab labels never scaled and
329
+ nothing ever said so. A try/except around a call you got wrong turns
330
+ a loud failure into a feature that quietly does not work.
331
+ """
332
+ font = self._local_defaults.get("font")
333
+ if not isinstance(font, (list, tuple)):
334
+ return font
335
+ try:
336
+ return self._apply_font_scaling(tuple(font))
337
+ except Exception:
338
+ # A genuine fallback now, rather than the normal path.
339
+ return tuple(font)
340
+
341
+ # ------------------------------------------------------------------
342
+ # Colour resolution
343
+ # ------------------------------------------------------------------
344
+ def _colour(self, key):
345
+ """The current value for a key, honouring the disabled state."""
346
+ if self._state == "disabled":
347
+ val = self._custom_disabled_map.get(key)
348
+ if val is not None:
349
+ return self._resolve_color(val)
350
+ return self._resolve_color(self._local_defaults.get(key))
351
+
352
+ def _page_background(self):
353
+ """
354
+ The colour behind the pages, resolved to something drawable.
355
+
356
+ A raw canvas cannot render CustomTkinter's "transparent" pseudo-value,
357
+ so a transparent theme falls back to whatever is actually behind the
358
+ widget -- the same accommodation sCTkFileExplorer's canvas makes.
359
+ """
360
+ bg = self._resolve_color(self._local_defaults.get("fg_color"))
361
+ if bg == "transparent":
362
+ detected = self._detect_color_of_master()
363
+ if detected in (None, "transparent"):
364
+ return self._resolve_color(
365
+ ctk.ThemeManager.theme["CTk"]["fg_color"])
366
+ return self._resolve_color(detected)
367
+ return bg
368
+
369
+ # ------------------------------------------------------------------
370
+ # Layout
371
+ # ------------------------------------------------------------------
372
+ def _content_rect(self):
373
+ """
374
+ (x0, y0, x1, y1) of the page area, in canvas coordinates.
375
+
376
+ The strip occupies tab_width on the chosen side; the page area is
377
+ everything else.
378
+ """
379
+ w = max(self.canvas.winfo_width(), 1)
380
+ h = max(self.canvas.winfo_height(), 1)
381
+ strip = self._sx(self._tab_width)
382
+ if self._side == "left":
383
+ return strip, 0, w, h
384
+ return 0, 0, w - strip, h
385
+
386
+ def _relayout(self):
387
+ """
388
+ Reserves the strip's column, insets the page host, then redraws.
389
+
390
+ The strip is a grid column with a minimum size rather than a placed
391
+ rectangle, so the pages keep contributing their requested size to the
392
+ notebook's own -- see the note where the canvas is gridded.
393
+ """
394
+ strip = int(self._sx(self._tab_width))
395
+ pad = int(self._geom("page_inset") + self._geom("border_width")) \
396
+ if self._show_page_border else 0
397
+
398
+ strip_col, page_col = (0, 1) if self._side == "left" else (1, 0)
399
+ self.grid_columnconfigure(strip_col, minsize=strip, weight=0)
400
+ self.grid_columnconfigure(page_col, weight=1)
401
+
402
+ self._page_host.grid(row=0, column=page_col, sticky="nsew",
403
+ padx=pad, pady=pad)
404
+ self._draw_notebook()
405
+
406
+ # ------------------------------------------------------------------
407
+ # Drawing
408
+ # ------------------------------------------------------------------
409
+ def _text_angle(self):
410
+ """
411
+ The canvas rotation for a label, in degrees.
412
+
413
+ "auto" reads outside-in: up a left-hand strip, down a right-hand one,
414
+ which is how a book's spine is set and stays readable whichever side
415
+ the strip moves to. The other three override that -- "up" and "down"
416
+ pin the direction regardless of side, which matters if a panel has
417
+ notebooks on both edges and you want their labels to match.
418
+ """
419
+ if self._text_orientation == "horizontal":
420
+ return 0
421
+ if self._text_orientation == "up":
422
+ return 90
423
+ if self._text_orientation == "down":
424
+ return 270
425
+ return 90 if self._side == "left" else 270
426
+
427
+ def _measure(self, text):
428
+ """
429
+ Length a label needs ALONG the strip, in pixels.
430
+
431
+ Measured rather than estimated from character count: a proportional
432
+ font makes "IIII" and "WWWW" differ by more than a factor of two, and
433
+ tabs sized by character count would collide or leave gaps.
434
+ """
435
+ try:
436
+ probe = tkfont.Font(font=self._scaled_font())
437
+ if self._text_orientation == "horizontal":
438
+ # Flat text runs ACROSS the strip, so what a tab needs along
439
+ # it is the line height, not the label's width. The width
440
+ # becomes tab_width's problem -- see the note in the
441
+ # constructor.
442
+ return probe.metrics("linespace") + self._geom("tab_pad")
443
+ return probe.measure(str(text)) + self._geom("tab_pad") * 2
444
+ except Exception:
445
+ # Before the widget is mapped, or with a font Tk cannot resolve.
446
+ # Eight pixels a character is wrong but bounded, and the next
447
+ # <Configure> redraw measures properly.
448
+ return self._sx((len(str(text)) * 8) + (self._geom("tab_pad") * 2))
449
+
450
+ def _tab_polygon(self, y0, y1, selected=False):
451
+ """
452
+ Points for one tab, in the current style.
453
+
454
+ The INNER edge -- the one against the page -- is always square, so a
455
+ selected tab reads as continuous with the page beside it. Only the
456
+ outer edge is shaped.
457
+ """
458
+ x_out = 0 if self._side == "left" else self.canvas.winfo_width()
459
+ strip = self._sx(self._tab_width)
460
+ # Only the SELECTED tab overlaps the page outline.
461
+ #
462
+ # It needs to: the outline is a stroked line centred on its path, so
463
+ # butting exactly against it leaves a hairline, and reaching past it
464
+ # closes the join.
465
+ #
466
+ # An unselected tab must NOT, and did at first. Every tab covered the
467
+ # border where it sat while the gaps between them left it showing,
468
+ # which turned a continuous edge into a dashed one.
469
+ # The selected tab crosses the outline; an unselected one reaches
470
+ # only to the middle of it.
471
+ #
472
+ # The outline is stroked, so it straddles its path -- half inside the
473
+ # page, half out. A tab stopping at the path's coordinate leaves that
474
+ # outer half uncovered, which reads as the border being a pixel
475
+ # thinner beside the tabs than it is elsewhere.
476
+ bw = self._geom("border_width")
477
+ overlap = bw if selected else (bw / 2.0)
478
+ if self._side == "left":
479
+ x_in = strip + overlap
480
+ direction = 1
481
+ else:
482
+ x_in = self.canvas.winfo_width() - strip - overlap
483
+ direction = -1
484
+
485
+ if self._tab_style == "angled":
486
+ # A rectangle with its two OUTER corners cut off.
487
+ #
488
+ # The cut runs between a point dx in from the corner along the
489
+ # outer edge and a point dy in from it along the top or bottom
490
+ # edge -- so the tab still reaches the edge of the strip, and only
491
+ # the corners are missing.
492
+ #
493
+ # This replaces a trapezium whose entire outer edge was inset by
494
+ # the slant, leaving the tab floating clear of the strip edge
495
+ # rather than sitting against it. At small slants the shape read
496
+ # as wrong without it being obvious why.
497
+ dx = min(self._geom("tab_chamfer_x"),
498
+ abs(x_in - x_out) * 0.4)
499
+ dy = min(self._geom("tab_chamfer_y"),
500
+ (y1 - y0) * self.TAB_CHAMFER_MAX_FRACTION)
501
+ return [x_in, y0,
502
+ x_out + (dx * direction), y0,
503
+ x_out, y0 + dy,
504
+ x_out, y1 - dy,
505
+ x_out + (dx * direction), y1,
506
+ x_in, y1]
507
+
508
+ # Genuine quarter circles, not a chamfer.
509
+ #
510
+ # Joining the two corner points with a straight line -- which is what
511
+ # this did first -- reads as a 45-degree cut, visibly different from
512
+ # the rounded corners on a CTkButton sitting beside it. Six segments
513
+ # per corner is enough to look like a curve at any sane tab width.
514
+ r = self._geom("tab_corner")
515
+ cx = x_out + (r * direction)
516
+ pts = [x_in, y0]
517
+ if direction > 0:
518
+ # Left strip: top corner sweeps 270 -> 180, bottom 180 -> 90.
519
+ pts += self._arc_points(cx, y0 + r, 270, 180, r)
520
+ pts += self._arc_points(cx, y1 - r, 180, 90, r)
521
+ else:
522
+ # Right strip: 270 -> 360, then 360 -> 450.
523
+ #
524
+ # 450, not 90. _arc_points interpolates linearly between the two
525
+ # angles, so 360 -> 90 sweeps BACKWARDS through 300, 200, 100 --
526
+ # 270 degrees the long way round instead of 90 the short way. The
527
+ # polygon looped back on itself and left a white notch at the
528
+ # corner of every right-hand tab.
529
+ pts += self._arc_points(cx, y0 + r, 270, 360, r)
530
+ pts += self._arc_points(cx, y1 - r, 360, 450, r)
531
+ pts += [x_in, y1]
532
+ return pts
533
+
534
+ @staticmethod
535
+ def _arc_points(cx, cy, start_deg, end_deg, radius, steps=6):
536
+ """Points along a circular arc, for building rounded polygons."""
537
+ pts = []
538
+ for i in range(steps + 1):
539
+ a = math.radians(start_deg + (end_deg - start_deg) * i / steps)
540
+ pts.extend([cx + radius * math.cos(a), cy + radius * math.sin(a)])
541
+ return pts
542
+
543
+ def _page_outline_points(self, gap):
544
+ """
545
+ An open path around the page area, broken where the selected tab is.
546
+
547
+ `gap` is (y_top, y_bottom) on the strip-side edge to leave out, or
548
+ None to close the rectangle. Returned as a single polyline starting at
549
+ one lip of the gap and running the long way round to the other, which
550
+ is what makes the outline appear to curve into the tab.
551
+ """
552
+ x0, y0, x1, y1 = self._content_rect()
553
+ r = self._geom("page_corner")
554
+ bw = self._geom("border_width")
555
+ # Half the stroke, so the drawn line sits inside the rectangle rather
556
+ # than straddling its edge and clipping at the widget boundary.
557
+ h = bw / 2.0
558
+ x0, y0, x1, y1 = x0 + h, y0 + h, x1 - h, y1 - h
559
+
560
+ arc = self._arc_points
561
+
562
+ if self._side == "left":
563
+ edge = x0 # the strip-side edge
564
+ top_start = [edge, y0 + r]
565
+ path = []
566
+ if gap:
567
+ path += [edge, gap[0]] # upper lip of the gap
568
+ path += [edge, y0 + r]
569
+ path += arc(x0 + r, y0 + r, 180, 270, r) # top-left
570
+ path += [x1 - r, y0]
571
+ path += arc(x1 - r, y0 + r, 270, 360, r) # top-right
572
+ path += [x1, y1 - r]
573
+ path += arc(x1 - r, y1 - r, 0, 90, r) # bottom-right
574
+ path += [x0 + r, y1]
575
+ path += arc(x0 + r, y1 - r, 90, 180, r) # bottom-left
576
+ if gap:
577
+ path += [edge, gap[1]] # lower lip of the gap
578
+ else:
579
+ path += [edge, y0 + r]
580
+ return path
581
+
582
+ edge = x1
583
+ path = []
584
+ if gap:
585
+ path += [edge, gap[0]]
586
+ path += [edge, y0 + r]
587
+ path += arc(x1 - r, y0 + r, 0, -90, r) # top-right
588
+ path += [x0 + r, y0]
589
+ path += arc(x0 + r, y0 + r, 270, 180, r) # top-left
590
+ path += [x0, y1 - r]
591
+ path += arc(x0 + r, y1 - r, 180, 90, r) # bottom-left
592
+ path += [x1 - r, y1]
593
+ path += arc(x1 - r, y1 - r, 90, 0, r) # bottom-right
594
+ if gap:
595
+ path += [edge, gap[1]]
596
+ else:
597
+ path += [edge, y0 + r]
598
+ return path
599
+
600
+ def _total_tab_length(self):
601
+ """Pixels the tabs need along the strip, including the gaps."""
602
+ gap = self._geom("tab_gap")
603
+ total = self._geom("strip_margin")
604
+ for name in self._pages:
605
+ total += self._measure(name) + gap
606
+ return total
607
+
608
+ def required_length(self):
609
+ """
610
+ The height this widget wants, so every tab is reachable without
611
+ scrolling.
612
+
613
+ Provided because the widget cannot insist: a parent packing it with
614
+ fill="both" decides its size regardless. Use it to set a minimum on
615
+ the containing window, or ignore it and let the strip scroll.
616
+ """
617
+ return int(self._total_tab_length() + self._geom("strip_margin"))
618
+
619
+ def _max_scroll(self):
620
+ """How far the strip can scroll before the last tab is flush."""
621
+ overflow = self._total_tab_length() - max(self.canvas.winfo_height(), 1)
622
+ return max(int(overflow), 0)
623
+
624
+ def _draw_notebook(self):
625
+ """
626
+ Repaints outline and tabs, and recomputes the hit-test bounds.
627
+
628
+ NOT named _draw(). CTkFrame has its own _draw(no_color_updates=...)
629
+ and calls it from its constructor, so a method of that name here
630
+ overrides it and the widget fails to build:
631
+
632
+ TypeError: _draw() got an unexpected keyword argument
633
+ 'no_color_updates'
634
+
635
+ Worth knowing for any widget that subclasses a CustomTkinter class
636
+ and wants a drawing method of its own.
637
+ """
638
+ if not hasattr(self, "_canvas"):
639
+ return
640
+ try:
641
+ if not self.canvas.winfo_exists():
642
+ return
643
+ except Exception:
644
+ return
645
+
646
+ self.canvas.delete("all")
647
+ self._tab_bounds.clear()
648
+ self.canvas.configure(bg=self._page_background())
649
+
650
+ font = self._scaled_font()
651
+ angle = self._text_angle()
652
+
653
+ # Pass one: work out where every tab sits, so the outline knows where
654
+ # to leave its gap before anything is drawn.
655
+ gap_amount = self._geom("tab_gap")
656
+ y = self._geom("strip_margin") - self._scroll
657
+ for name in self._pages:
658
+ length = self._measure(name)
659
+ self._tab_bounds[name] = (y, y + length)
660
+ y += length + gap_amount
661
+
662
+ # Separators go under the tabs, so a selected tab covers the two
663
+ # beside it and reads as sitting in front.
664
+ if self._show_tab_separators:
665
+ strip = self._sx(self._tab_width)
666
+ sx0 = 0 if self._side == "left" else self.canvas.winfo_width() - strip
667
+ sx1 = strip if self._side == "left" else self.canvas.winfo_width()
668
+ half = self._geom("tab_gap") / 2.0
669
+ # Stopped short at both ends. A separator running the full width
670
+ # of the strip reads as a division of the whole panel rather than
671
+ # a line between two tabs, and collides with the page border at
672
+ # its inner end.
673
+ # The OUTER end stops where the tab corners start -- matched to
674
+ # the shape actually being drawn, so it ends with the curve or the
675
+ # cut rather than a pixel or two past it.
676
+ #
677
+ # The INNER end runs all the way to the page border. Stopping
678
+ # short of it left the separator floating, most visibly on a
679
+ # right-hand strip: the tabs meet the frame, so a line between two
680
+ # of them should too.
681
+ shape_inset = self._geom("tab_corner") \
682
+ if self._tab_style == "rounded" else self._geom("tab_chamfer_x")
683
+ outer_inset = max(shape_inset, self._geom("separator_inset"))
684
+ inner_inset = 0
685
+ if self._side == "left":
686
+ sx0, sx1 = sx0 + outer_inset, sx1 - inner_inset
687
+ else:
688
+ sx0, sx1 = sx0 + inner_inset, sx1 - outer_inset
689
+ names = list(self._tab_bounds)
690
+ for i, name in enumerate(names[:-1]):
691
+ y_between = self._tab_bounds[name][1] + half
692
+ self.canvas.create_line(sx0, y_between, sx1, y_between,
693
+ fill=self._colour("border_color"),
694
+ width=max(self._sx(1), 1))
695
+
696
+ # DRAW ORDER MATTERS, and this is the third arrangement.
697
+ #
698
+ # Unselected tabs, then the page outline, then the selected tab. The
699
+ # outline running OVER the unselected tabs is what keeps it crisp
700
+ # along the strip edge: any attempt to have the tabs meet it exactly
701
+ # leaves a half-pixel of the stroke uncovered, because the stroke
702
+ # straddles its own path. Letting the border win is simpler than
703
+ # trying to butt against it.
704
+ #
705
+ # The selected tab goes last, so it covers the border and reads as
706
+ # sitting in front of the page -- and the outline is drawn with a gap
707
+ # there anyway, so there is nothing to cover but the lips.
708
+ for name, (y0, y1) in self._tab_bounds.items():
709
+ if name == self._current:
710
+ continue
711
+ self._paint_tab(name, y0, y1, font, angle)
712
+
713
+ if self._show_page_border:
714
+ gap = self._tab_bounds.get(self._current) if self._current else None
715
+ self.canvas.create_line(
716
+ *self._page_outline_points(gap),
717
+ fill=self._colour("border_color"),
718
+ width=self._geom("border_width"),
719
+ capstyle="round", joinstyle="round", smooth=False)
720
+
721
+ if self._current in self._tab_bounds:
722
+ y0, y1 = self._tab_bounds[self._current]
723
+ self._paint_tab(self._current, y0, y1, font, angle)
724
+
725
+ def _paint_tab(self, name, y0, y1, font, angle):
726
+ """Draws one tab and its label."""
727
+ if name == self._current:
728
+ fill = self._colour("tab_selected_color")
729
+ text_fill = self._colour("selected_text_color")
730
+ elif name == self._hover and self._state != "disabled":
731
+ fill = self._colour("tab_hover_color")
732
+ text_fill = self._colour("text_color")
733
+ else:
734
+ fill = self._colour("tab_fg_color")
735
+ text_fill = self._colour("text_color")
736
+
737
+ outline = self._colour("border_color") if name == self._current else fill
738
+ self.canvas.create_polygon(
739
+ self._tab_polygon(y0, y1, selected=(name == self._current)),
740
+ fill=fill, outline=outline,
741
+ width=self._geom("border_width") if name == self._current else 1)
742
+
743
+ strip = self._sx(self._tab_width)
744
+ cx = strip / 2 if self._side == "left" \
745
+ else self.canvas.winfo_width() - (strip / 2)
746
+ self.canvas.create_text(cx, (y0 + y1) / 2, text=str(name),
747
+ angle=angle, fill=text_fill, font=font)
748
+
749
+ # ------------------------------------------------------------------
750
+ # Interaction
751
+ # ------------------------------------------------------------------
752
+ def _on_strip(self, x):
753
+ """Whether an x coordinate falls on the tab strip."""
754
+ strip = self._sx(self._tab_width)
755
+ if self._side == "left":
756
+ return x <= strip
757
+ return x >= self.canvas.winfo_width() - strip
758
+
759
+ def _tab_at(self, x, y):
760
+ """The tab name at a canvas coordinate, or None."""
761
+ if not self._on_strip(x):
762
+ return None
763
+ for name, (y0, y1) in self._tab_bounds.items():
764
+ if y0 <= y <= y1:
765
+ return name
766
+ return None
767
+
768
+ def _on_click(self, event):
769
+ if self._state == "disabled":
770
+ return "break"
771
+ name = self._tab_at(event.x, event.y)
772
+ if name is not None and name != self._current:
773
+ self.set(name)
774
+ if callable(self._command):
775
+ self._command(name)
776
+ return "break"
777
+
778
+ def _on_motion(self, event):
779
+ if self._state == "disabled":
780
+ return
781
+ name = self._tab_at(event.x, event.y)
782
+ if name != self._hover:
783
+ self._hover = name
784
+ self._draw_notebook()
785
+
786
+ def _on_leave(self, event=None):
787
+ if self._hover is not None:
788
+ self._hover = None
789
+ self._draw_notebook()
790
+
791
+ def _on_wheel(self, event):
792
+ """
793
+ Scrolls the strip when the tabs do not fit.
794
+
795
+ Only over the strip, and only when there is something to scroll --
796
+ otherwise the wheel belongs to whatever is on the page.
797
+ """
798
+ if not self._on_strip(getattr(event, "x", 0)):
799
+ return
800
+ ceiling = self._max_scroll()
801
+ if ceiling <= 0:
802
+ return
803
+
804
+ if getattr(event, "num", None) == 4:
805
+ delta = -1
806
+ elif getattr(event, "num", None) == 5:
807
+ delta = 1
808
+ else:
809
+ delta = -1 if getattr(event, "delta", 0) > 0 else 1
810
+
811
+ self._scroll_by(delta)
812
+ return "break"
813
+
814
+ # Accumulated trackpad movement, and the threshold it must cross before
815
+ # the strip moves. Acting on every event is unusably fast -- the same
816
+ # reason ScrollBindingMixin accumulates.
817
+ TOUCHPAD_THRESHOLD = 12.0
818
+
819
+ @staticmethod
820
+ def _decode_touchpad_delta(raw_delta):
821
+ """
822
+ Pulls the signed Y component out of a packed <TouchpadScroll> delta.
823
+
824
+ Tk packs X and Y into one 32-bit value. The low sixteen bits are Y,
825
+ two's complement -- so the test is `>= 0x8000`, not `> 32768`. Those
826
+ differ at exactly 32768, the smallest NEGATIVE value, which the looser
827
+ test reads as positive and inverts the direction.
828
+ """
829
+ raw = raw_delta & 0xFFFFFFFF
830
+ delta_y = raw & 0xFFFF
831
+ if delta_y >= 0x8000:
832
+ delta_y -= 0x10000
833
+ return delta_y
834
+
835
+ def _on_touchpad(self, event):
836
+ """Trackpad equivalent of _on_wheel, gated by an accumulator."""
837
+ if not self._on_strip(getattr(event, "x", 0)):
838
+ return
839
+ if self._max_scroll() <= 0:
840
+ return
841
+
842
+ delta_y = self._decode_touchpad_delta(getattr(event, "delta", 0))
843
+ if delta_y == 0:
844
+ return
845
+
846
+ accumulated = getattr(self, "_touch_accum", 0.0)
847
+ # Reversing direction resets, so a change of direction responds at
848
+ # once rather than having to cancel out what built up going the other
849
+ # way.
850
+ if (accumulated > 0) != (delta_y > 0):
851
+ accumulated = 0.0
852
+ accumulated += delta_y
853
+
854
+ if abs(accumulated) >= self.TOUCHPAD_THRESHOLD:
855
+ self._scroll_by(-1 if accumulated > 0 else 1)
856
+ accumulated = 0.0
857
+ self._touch_accum = accumulated
858
+ return "break"
859
+
860
+ def _scroll_by(self, steps):
861
+ """Moves the strip, clamped to what there is to scroll."""
862
+ ceiling = self._max_scroll()
863
+ new = min(max(self._scroll + steps * self._sx(24), 0), ceiling)
864
+ if new != self._scroll:
865
+ self._scroll = new
866
+ self._draw_notebook()
867
+
868
+ # ------------------------------------------------------------------
869
+ # Public API -- mirrors sCTkTabview where the two overlap
870
+ # ------------------------------------------------------------------
871
+ def add(self, name):
872
+ """
873
+ Creates a tab and returns its content page, an sCTkFrame.
874
+
875
+ Returns the page directly rather than requiring a separate tab()
876
+ call, matching sCTkTabview.
877
+ """
878
+ name = str(name)
879
+ if name in self._pages:
880
+ raise ValueError(f"tab '{name}' already exists")
881
+ page = sCTkFrame(self._page_host, fg_color="transparent",
882
+ border_width=0)
883
+ page.grid(row=0, column=0, sticky="nsew")
884
+ # A page's own grid needs weights, or a child gridded into it lands in
885
+ # a cell with no room and collapses to nothing. A child frame survives
886
+ # that because it has a requested size of its own to insist on, which
887
+ # is why nesting one made the difference.
888
+ page.grid_rowconfigure(0, weight=1)
889
+ page.grid_columnconfigure(0, weight=1)
890
+ self._pages[name] = page
891
+ if self._current is None:
892
+ self._current = name
893
+
894
+ # The SELECTED page is re-raised after every addition.
895
+ #
896
+ # All the pages share one grid cell, and a widget created later stacks
897
+ # above one created earlier -- so each new tab quietly covered the
898
+ # selected one. Adding a widget to the first tab put it on a page that
899
+ # was already buried, and it vanished; adding to the last tab worked,
900
+ # because that page happened to be on top.
901
+ #
902
+ # Raising once when _current was first set was not enough: the
903
+ # stacking changes every time a page is added.
904
+ self._pages[self._current].tkraise()
905
+
906
+ self._relayout()
907
+ return page
908
+
909
+ def tab(self, name):
910
+ """The content page for a tab. Raises KeyError if it does not exist."""
911
+ name = str(name)
912
+ if name not in self._pages:
913
+ raise KeyError(f"no tab named '{name}'")
914
+ return self._pages[name]
915
+
916
+ def delete(self, name):
917
+ """Removes a tab and destroys its page."""
918
+ name = str(name)
919
+ if name not in self._pages:
920
+ raise KeyError(f"no tab named '{name}'")
921
+ page = self._pages.pop(name)
922
+ try:
923
+ page.destroy()
924
+ except Exception:
925
+ pass
926
+ if self._current == name:
927
+ self._current = next(iter(self._pages), None)
928
+ if self._current is not None:
929
+ self._pages[self._current].tkraise()
930
+ self._relayout()
931
+
932
+ def rename(self, old_name, new_name):
933
+ """
934
+ Renames a tab, keeping its position and its page.
935
+
936
+ Re-keys the page registry as well as the label. Renaming by deleting
937
+ and re-adding would move the tab to the end and destroy its children.
938
+ """
939
+ old_name, new_name = str(old_name), str(new_name)
940
+ if old_name not in self._pages:
941
+ raise KeyError(f"no tab named '{old_name}'")
942
+ if new_name in self._pages:
943
+ raise ValueError(f"tab '{new_name}' already exists")
944
+ # Rebuilt rather than mutated, to keep the insertion order.
945
+ self._pages = {(new_name if k == old_name else k): v
946
+ for k, v in self._pages.items()}
947
+ if self._current == old_name:
948
+ self._current = new_name
949
+ self._draw_notebook()
950
+
951
+ def set(self, name):
952
+ """Selects a tab and raises its page."""
953
+ name = str(name)
954
+ if name not in self._pages:
955
+ raise KeyError(f"no tab named '{name}'")
956
+ self._current = name
957
+ self._pages[name].tkraise()
958
+ self._draw_notebook()
959
+
960
+ def get(self):
961
+ """The selected tab's name, or None when there are no tabs."""
962
+ return self._current
963
+
964
+ def tabs(self):
965
+ """Tab names, in order."""
966
+ return list(self._pages)
967
+
968
+ # ------------------------------------------------------------------
969
+ # State
970
+ # ------------------------------------------------------------------
971
+ def get_state(self):
972
+ """Equivalent to state() with no argument."""
973
+ return self.state()
974
+
975
+ def state(self, mode=None):
976
+ """
977
+ Gets or sets "normal"/"disabled".
978
+
979
+ Disabling dims the strip and the outline and stops tab selection. It
980
+ does NOT cascade to the widgets on a page -- same as sCTkTabview, and
981
+ the caller's responsibility.
982
+ """
983
+ if mode is None:
984
+ return self._state
985
+ mode = str(mode).lower()
986
+ self._state = "disabled" if mode == "disabled" else "normal"
987
+ self._hover = None
988
+ self._draw_notebook()
989
+ return self._state
990
+
991
+ # ------------------------------------------------------------------
992
+ # Configuration
993
+ # ------------------------------------------------------------------
994
+ def configure(self, *args, **kwargs):
995
+ """Standard configuration, plus Pygubu's single-argument query form."""
996
+ if len(args) == 1:
997
+ if isinstance(args[0], dict):
998
+ kwargs = {**args[0], **kwargs}
999
+ else:
1000
+ pname = args[0]
1001
+ if pname == "state":
1002
+ return ("state", "state", "state", "normal", self._state)
1003
+ if pname == "side":
1004
+ return ("side", "side", "Side", "left", self._side)
1005
+ if pname == "tab_style":
1006
+ return ("tab_style", "tab_style", "TabStyle", "rounded",
1007
+ self._tab_style)
1008
+ if pname == "text_orientation":
1009
+ return ("text_orientation", "text_orientation",
1010
+ "TextOrientation", "auto", self._text_orientation)
1011
+ if pname == "show_tab_separators":
1012
+ return ("show_tab_separators", "show_tab_separators",
1013
+ "ShowTabSeparators", "False",
1014
+ str(self._show_tab_separators))
1015
+ if pname == "show_page_border":
1016
+ return ("show_page_border", "show_page_border",
1017
+ "ShowPageBorder", "True", str(self._show_page_border))
1018
+ if pname == "tab_width":
1019
+ return ("tab_width", "tab_width", "TabWidth", 34,
1020
+ self._tab_width)
1021
+ return self._configure_query(pname)
1022
+
1023
+ self._record_theme_overrides(kwargs)
1024
+
1025
+ if "state" in kwargs:
1026
+ self.state(kwargs.pop("state"))
1027
+
1028
+ if "command" in kwargs:
1029
+ self._command = kwargs.pop("command")
1030
+
1031
+ if "side" in kwargs:
1032
+ new_side = self._check_side(kwargs.pop("side"))
1033
+ if new_side != self._side:
1034
+ self._side = new_side
1035
+
1036
+ if "tab_style" in kwargs:
1037
+ self._tab_style = self._check_style(kwargs.pop("tab_style"))
1038
+
1039
+ if "text_orientation" in kwargs:
1040
+ self._text_orientation = self._check_orientation(
1041
+ kwargs.pop("text_orientation"))
1042
+
1043
+ if "show_tab_separators" in kwargs:
1044
+ val = kwargs.pop("show_tab_separators")
1045
+ if isinstance(val, str):
1046
+ val = val.strip().lower() in ("true", "1", "yes", "on")
1047
+ self._show_tab_separators = bool(val)
1048
+
1049
+ if "show_page_border" in kwargs:
1050
+ val = kwargs.pop("show_page_border")
1051
+ if isinstance(val, str):
1052
+ val = val.strip().lower() in ("true", "1", "yes", "on")
1053
+ self._show_page_border = bool(val)
1054
+
1055
+ if "tab_width" in kwargs:
1056
+ self._tab_width = int(kwargs.pop("tab_width"))
1057
+
1058
+ # Keys this widget draws with are consumed here: they are not native
1059
+ # CTkFrame options, and forwarding one raises
1060
+ # "['tab_fg_color'] are not supported arguments".
1061
+ for key in list(kwargs):
1062
+ if key in self._local_defaults and key not in self._NATIVE_FRAME_KWARGS:
1063
+ kwargs.pop(key)
1064
+
1065
+ self._drop_cleared(kwargs)
1066
+
1067
+ result = super().configure(**kwargs) if kwargs else None
1068
+ self._relayout()
1069
+ return result
1070
+
1071
+ config = configure
1072
+
1073
+ def cget(self, attribute_name):
1074
+ if attribute_name == "state":
1075
+ return self._state
1076
+ if attribute_name == "side":
1077
+ return self._side
1078
+ if attribute_name == "tab_style":
1079
+ return self._tab_style
1080
+ if attribute_name == "text_orientation":
1081
+ return self._text_orientation
1082
+ if attribute_name == "show_tab_separators":
1083
+ return self._show_tab_separators
1084
+ if attribute_name == "show_page_border":
1085
+ return self._show_page_border
1086
+ if attribute_name == "tab_width":
1087
+ return self._tab_width
1088
+ if attribute_name in self._local_defaults and \
1089
+ attribute_name not in self._NATIVE_FRAME_KWARGS:
1090
+ return self._local_defaults.get(attribute_name)
1091
+ return super().cget(attribute_name)
1092
+
1093
+ def _set_appearance_mode(self, mode_string):
1094
+ if hasattr(super(), "_set_appearance_mode"):
1095
+ try:
1096
+ super()._set_appearance_mode(mode_string)
1097
+ except Exception:
1098
+ pass
1099
+ self._draw_notebook()
1100
+
1101
+ def _update_current_visual_state(self):
1102
+ """Repaint hook, found by ThemeableWidget._repaint_after_override()."""
1103
+ self._draw_notebook()