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,1472 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ sCTkDial
4
+
5
+ Centralized Foundable Base Class for Theme-Adaptive Rotary Encoders.
6
+ Coordinates 3D knob geometry structures, vector math layers, and hardware delta masks.
7
+ """
8
+ import sys
9
+ import math
10
+ import time
11
+ import customtkinter as ctk
12
+ from . import themeable_widget as _tw
13
+ from .themeable_widget import ThemeableWidget, parse_list_property, defer
14
+ class sCTKDialBase(ctk.CTkFrame, ThemeableWidget):
15
+ """Abstract Base Class for theme-adaptive mechanical rotary encoder widgets."""
16
+
17
+ # Stops ScrollBindingMixin's descendant walk here, so an enclosing
18
+ # scrollable frame does not bind this widget's canvas to its own handler.
19
+ #
20
+ # FIX: without it, a dial placed inside an sCTkScrollableFrame scrolled the
21
+ # LIST when the pointer was over the knob -- the enclosing frame binds every
22
+ # descendant it finds with add="+", and its handler fires first. Pointing at
23
+ # a VFO knob and turning the wheel should turn the knob.
24
+ _CONSUMES_SCROLL = True
25
+
26
+ def __init__(self, master=None, divisions=24, state="normal", width=120, height=120, **kw):
27
+ # Pulled out BEFORE the theme pass, so it is not merged into final_kw
28
+ # and forwarded to the native constructor, which would reject it.
29
+ # None means "use the theme's label_font".
30
+ self._label_font_override = kw.pop("label_font", None)
31
+
32
+ # Same reason: these are this widget's own, not CTkFrame options.
33
+ # Parsed rather than bool()'d: the Designer passes strings, and
34
+ # bool("False") is True -- which would have made every dial placed in
35
+ # the Designer latch whatever the dropdown said.
36
+ def _as_bool(v, default=False):
37
+ if v is None:
38
+ return default
39
+ if isinstance(v, str):
40
+ return v.strip().lower() in ("true", "1", "yes", "on")
41
+ return bool(v)
42
+
43
+ _pressed_init = _as_bool(kw.pop("pressed", False))
44
+ _latching_init = _as_bool(kw.pop("latching", False))
45
+ _dbl_init = kw.pop("double_click_command", None)
46
+ _sdbl_init = kw.pop("shift_double_click_command", None)
47
+
48
+ ThemeableWidget.__init__(self, kw)
49
+ # THEME SOURCE -- read the RAW block, not final_kw.
50
+ #
51
+ # ThemeableWidget maintains a CUSTOM_VECTOR_KEYS set (dial_color,
52
+ # shadow_color, text_color, pointer_color, pointer_glow_color,
53
+ # diameter, ...) which it deliberately strips out of final_kw for
54
+ # vector widgets like this one, so they never reach the native
55
+ # CTkFrame constructor and raise ValueError. That stripping is
56
+ # correct. What was wrong was reading colours back out of final_kw
57
+ # afterwards: those keys were never in there.
58
+ #
59
+ # FIX: every `.get(key) or ("#hex", "#hex")` in the old draw code was
60
+ # therefore ALWAYS taking the hardcoded fallback -- this widget family
61
+ # has never rendered its configured theme colours at all. The values
62
+ # in sCTkThemes.json for dial_color, shadow_color, text_color and
63
+ # pointer_glow_color were decorative. Surfaced by _validate_theme_keys()
64
+ # the first time fail-loud checking was applied here.
65
+ #
66
+ # Module-attribute access rather than a direct name import, because
67
+ # load_initial_framework_themes() REBINDS GLOBAL_THEME_REGISTRY on
68
+ # load; a `from ... import GLOBAL_THEME_REGISTRY` would capture the
69
+ # empty dict that existed at import time.
70
+ # _THEME_BLOCK_NAME, not the class name.
71
+ #
72
+ # This resolved the block from self.__class__.__name__ directly,
73
+ # bypassing the override ThemeableWidget uses everywhere else -- so a
74
+ # subclass got an empty block and fail-loud validation raised:
75
+ #
76
+ # KeyError: "'sCTkDialContinuousForPreview' theme block is missing
77
+ # 'text_color' at the top level of sCTkThemes.json."
78
+ #
79
+ # naming a block that neither exists nor should. Hit by the Designer's
80
+ # preview subclasses, and by any user subclass of a dial.
81
+ raw_block = _tw.GLOBAL_THEME_REGISTRY.get(self._theme_block()) or {}
82
+ raw_colors = {k: v for k, v in raw_block.items() if not isinstance(v, dict)}
83
+ self._local_defaults = ThemeableWidget._convert_lists_to_tuples(raw_colors)
84
+ # final_kw still wins where it has a value, so constructor overrides
85
+ # and non-vector theme keys keep their existing precedence.
86
+ self._local_defaults.update(self.final_kw)
87
+
88
+ self._custom_disabled_map = dict(self._widget_disabled_map)
89
+ # pressed_map is already resolved by ThemeableWidget for every widget,
90
+ # so this needs nothing added there.
91
+ self._custom_pressed_map = dict(self._widget_pressed_map)
92
+ self._validate_theme_keys()
93
+
94
+ # knob_diameter names the KNOB. width and height name the CANVAS.
95
+ #
96
+ # It previously set both canvas dimensions to its own value, so a
97
+ # "diameter" of 300 produced a 300px WIDGET with a 244px knob inside
98
+ # it -- the name described neither. Renamed and separated: the knob is
99
+ # drawn at exactly this size, and the canvas defaults to leaving room
100
+ # for labels around it.
101
+ target_knob = self._local_defaults.get("knob_diameter")
102
+ self._knob_diameter = int(target_knob) if target_knob is not None else 120
103
+ width, height = self._default_canvas_size(width, height)
104
+
105
+ FRAME_VALID_KEYS = {"width", "height", "fg_color", "border_color", "border_width", "corner_radius", "bg_color"}
106
+ frame_kwargs = {k: self._local_defaults[k] for k in FRAME_VALID_KEYS if k in self._local_defaults and self._local_defaults[k] is not None}
107
+ frame_kwargs.setdefault("width", width)
108
+ frame_kwargs.setdefault("height", height)
109
+ frame_kwargs.setdefault("fg_color", "transparent")
110
+
111
+ super().__init__(master, **frame_kwargs)
112
+
113
+ self._divisions = int(divisions) if int(divisions) > 0 else 24
114
+ self._state = "normal" if state.lower() == "normal" else "disabled"
115
+
116
+ # LATCHING IS OPT-IN, and off by default.
117
+ #
118
+ # With latching=False -- the default -- a dial behaves exactly as it
119
+ # always has: always live, top-level colours, pressed_map ignored and
120
+ # not required. That matters because an armed/inert rotary control is
121
+ # an unusual pattern: someone reaching for sCTkDialRange as a volume
122
+ # knob would find it inert with nothing on screen to say why.
123
+ #
124
+ # With latching=True the dial starts switched off and ignores clicks,
125
+ # drags and the wheel until set_pressed(True). pressed_map then
126
+ # supplies the operational colours and the top-level block is the
127
+ # resting, dimmed look.
128
+ #
129
+ # Named for the mute or lock button it behaves like: pressed and it
130
+ # stays pressed. The button family's own pressed_map is momentary --
131
+ # a different meaning in a different block.
132
+ self._latching = _latching_init
133
+ self._pressed = _pressed_init if _latching_init else False
134
+
135
+ # Double-click is a PLAIN CALLBACK, not wired to the latch.
136
+ #
137
+ # Toggling on double-click is one use and a natural one, but it is the
138
+ # application's decision rather than the widget's -- see the example in
139
+ # sCTkDial.md, which wires exactly that. Called with the dial itself,
140
+ # so a handler can act on it without a closure over the name.
141
+ self._double_click_command = _dbl_init
142
+
143
+ # A SECOND double-click callback, on Shift.
144
+ #
145
+ # Every mouse button steps the dial, so a plain double-click competes
146
+ # with ordinary use: clicking left repeatedly to turn the knob trips it
147
+ # sooner or later. That only matters in one direction -- while the dial
148
+ # is switched off nothing is bound, so arming by double-click is always
149
+ # safe. It is DISARMING that collides.
150
+ #
151
+ # Shift is already the drag modifier, but a shift-double-click only
152
+ # fires <Shift-ButtonPress-1> twice, which records a drag origin and
153
+ # nothing else. So the gesture is free.
154
+ #
155
+ # The widget still decides nothing about meaning. The convention the
156
+ # examples follow is arm on double-click, disarm on shift-double-click.
157
+ self._shift_double_click_command = _sdbl_init
158
+ self._current_value = 0
159
+ self._scroll_cooldown_seconds = 0.060
160
+ self._last_scroll_time = 0.0
161
+ self._last_y = 0
162
+
163
+ self.canvas = ctk.CTkCanvas(self, highlightthickness=0, bd=0)
164
+ self.canvas.pack(fill="both", expand=True)
165
+ self.pack_propagate(False)
166
+ self.grid_propagate(False)
167
+
168
+ self.canvas.bind("<Enter>", lambda e: self._on_mouse_enter())
169
+ # Interactive bindings are NOT installed unconditionally any more --
170
+ # a dial starts switched off and must be inert until double-clicked.
171
+ # _apply_input_bindings() installs whatever the current state calls
172
+ # for, including the double-click that switches it on.
173
+ self.canvas.bind("<Shift-ButtonPress-1>", self._on_button_press)
174
+ self.canvas.bind("<Shift-B1-Motion>", self._on_button_motion)
175
+ self._apply_input_bindings()
176
+ self.canvas.bind("<Configure>", lambda e: self._draw_dial_base())
177
+ defer(self, 50, self._inject_private_layer_bindings)
178
+
179
+ # Keys every dial reads, required at the TOP LEVEL of its theme block.
180
+ _REQUIRED_THEME_KEYS = (
181
+ "fg_color", "text_color", "shadow_color", "dial_color",
182
+ "dial_highlight_color", "dial_shadow_color",
183
+ "dial_rim_light_color", "dial_rim_shadow_color",
184
+ )
185
+
186
+ # Keys required inside disabled_map. Deliberately excludes fg_color: the
187
+ # background does NOT dim when disabled, the knob face and text carry the
188
+ # signal. Matches the choice made for sCTkScrollableFrame.
189
+ _REQUIRED_DISABLED_KEYS = ("text_color", "dial_color")
190
+
191
+ # Required inside pressed_map -- the latched switched-on state. Same two
192
+ # keys as disabled_map: enough to tell the states apart at a glance,
193
+ # without forcing every shading key to be restated.
194
+ _REQUIRED_PRESSED_KEYS = ("text_color", "dial_color")
195
+
196
+ def _label_font(self):
197
+ """
198
+ The font for text drawn on the canvas.
199
+
200
+ A per-instance override set through the constructor or configure()
201
+ wins over the theme, so one dial can carry a different label size
202
+ without moving every dial in the application.
203
+
204
+ FIX: the two label-drawing variants hardcoded ("Arial", 9, "bold") in
205
+ their create_text() calls, while the colour on the same line was read
206
+ from the theme -- a partial job, and one the theme file had no way to
207
+ reach. Same pattern as the eight hardcoded fonts found in the S-meters.
208
+
209
+ Read from _local_defaults, which this widget builds from the RAW theme
210
+ registry block rather than from final_kw -- see __init__. That matters
211
+ for the same reason the dial colours do: keys this library adds are
212
+ stripped out of final_kw before the native constructor sees them, so a
213
+ widget reading them from there gets nothing.
214
+
215
+ Returns:
216
+ The tuple from the theme block.
217
+ """
218
+ font = (getattr(self, "_label_font_override", None)
219
+ or self._local_defaults.get("label_font"))
220
+
221
+ # Drop an empty style before it reaches the canvas. Tk rejects one --
222
+ # unknown font style "" -- and the Designer's font editor produces
223
+ # exactly that for a family with no style selected, as the literal
224
+ # "{}". Guarded here as well as in the builder object because a font
225
+ # can arrive from application code too.
226
+ if isinstance(font, (tuple, list)) and len(font) >= 3:
227
+ styles = [s for s in font[2:]
228
+ if s and str(s).strip() not in ("", "{}")]
229
+ font = (font[0], font[1], *styles) if styles else (font[0], font[1])
230
+ return font
231
+
232
+ # Room left around the knob for labels when only knob_diameter is given.
233
+ #
234
+ # 40, not the 28 the old fixed layout used. At 9pt a default Selector label
235
+ # like "POS 1" is around 40px wide, and anchoring puts half of that beyond
236
+ # the arc point -- about 20px -- on top of the gap _label_placement()
237
+ # leaves. 28 clipped the default labels of both Range and Selector at the
238
+ # canvas edge.
239
+ #
240
+ # Only applies when width and height are not given. An explicit canvas
241
+ # size always wins, so nothing laid out deliberately is affected.
242
+ DEFAULT_LABEL_MARGIN = 40
243
+
244
+ def _theme_block(self):
245
+ """
246
+ The theme block this widget reads, honouring _THEME_BLOCK_NAME.
247
+
248
+ Used for three things that all keyed off the raw class name before:
249
+ resolving the block, naming it in a validation error, and deciding
250
+ which variant's drawing code runs. A subclass -- the Designer's
251
+ preview classes, or a user's own -- broke all three.
252
+ """
253
+ return getattr(self, "_THEME_BLOCK_NAME", None) or self.__class__.__name__
254
+
255
+ def _default_canvas_size(self, width, height):
256
+ """
257
+ Fills in a canvas size the caller did not specify.
258
+
259
+ knob_diameter says how big the KNOB is; the canvas has to be at least
260
+ that plus somewhere for the labels. An explicit width or height always
261
+ wins -- this only supplies one where none was given, so a bare
262
+ knob_diameter still produces a usable dial without the caller having to
263
+ pass three numbers.
264
+
265
+ Labels longer or larger than the default margin allows are CLIPPED,
266
+ deliberately: widening the canvas is the caller's decision, and
267
+ shrinking the knob instead would make dials sharing a knob_diameter
268
+ look different from one another.
269
+
270
+ Args:
271
+ width: The width asked for, or the class default.
272
+ height: The height asked for.
273
+
274
+ Returns:
275
+ (width, height) to build the canvas with.
276
+ """
277
+ needed = self._knob_diameter + (2 * self.DEFAULT_LABEL_MARGIN)
278
+ explicit = self._local_defaults
279
+ if explicit.get("width") is None:
280
+ width = max(int(width or 0), needed)
281
+ if explicit.get("height") is None:
282
+ height = max(int(height or 0), needed)
283
+ return width, height
284
+
285
+ def _label_placement(self, angle_rad, knob_radius):
286
+ """
287
+ Where a label sits, and which way it grows from there.
288
+
289
+ TWO FIXES, both visible with a long label on an sCTkDialSelector:
290
+
291
+ ANCHORING. The labels were drawn CENTRED on their point, so half of
292
+ each one extended back toward the dial. A label on the left of the arc
293
+ therefore overhung the dial face, and a long one ran across it -- "Very
294
+ Long" intruded at any font size, and a bigger diameter did not help
295
+ because the offset is a fixed distance from the knob edge. Anchoring by
296
+ position makes a label grow AWAY from the dial: one on the left anchors
297
+ east, one on the right anchors west, one at the top or bottom anchors
298
+ to its near edge.
299
+
300
+ sCTkDialRange never showed this because its labels are numbers, one to
301
+ three characters, which barely overhang when centred. Same defect,
302
+ different content.
303
+
304
+ OFFSET SCALING. The gap was a hardcoded 18px, tuned for 9pt. Larger
305
+ text sat closer to the knob in proportion, and eventually touched it.
306
+ It now grows with the font size.
307
+
308
+ Args:
309
+ angle_rad: Where on the arc this label belongs.
310
+ knob_radius: Radius of the drawn knob.
311
+
312
+ Returns:
313
+ (offset, anchor) -- the distance from centre at which to place the
314
+ label, and the Tk anchor to place it with.
315
+ """
316
+ font = self._label_font()
317
+ try:
318
+ size = int(font[1])
319
+ except (TypeError, ValueError, IndexError):
320
+ size = 9
321
+
322
+ # 9pt keeps the original 18px gap, so existing dials are unchanged.
323
+ offset = knob_radius + 9 + size
324
+
325
+ cos_a, sin_a = math.cos(angle_rad), math.sin(angle_rad)
326
+
327
+ # 0.3 rather than 0: near the top and bottom of the arc a label is
328
+ # better anchored vertically, or a nearly-horizontal one flips side
329
+ # over a fraction of a degree and appears to jump.
330
+ if cos_a < -0.3:
331
+ anchor = "e"
332
+ elif cos_a > 0.3:
333
+ anchor = "w"
334
+ else:
335
+ anchor = "s" if sin_a > 0 else "n"
336
+
337
+ return offset, anchor
338
+
339
+ def _validate_theme_keys(self) -> None:
340
+ """
341
+ Hard-fails at construction on an incomplete theme block, naming the
342
+ missing key and where it belongs.
343
+
344
+ Replaces the previous pattern of `.get(key) or ("#hex", "#hex")`
345
+ throughout the draw routine, which silently substituted a plausible
346
+ guess whenever the theme was incomplete -- so a broken block looked
347
+ merely slightly-off rather than broken. Same fail-loud principle used
348
+ for sCTkTabview and sCTkScrollableFrame.
349
+
350
+ Subclasses extend the requirements via _EXTRA_THEME_KEYS and
351
+ _EXTRA_DISABLED_KEYS rather than overriding this, since each dial
352
+ variant reads a slightly different set: only Continuous draws a
353
+ dimple, and only Selector/Range draw a pointer line.
354
+
355
+ Raises:
356
+ KeyError: naming the first missing key found.
357
+ """
358
+ name = self._theme_block()
359
+ required = self._REQUIRED_THEME_KEYS + getattr(self, "_EXTRA_THEME_KEYS", ())
360
+ required_disabled = self._REQUIRED_DISABLED_KEYS + getattr(self, "_EXTRA_DISABLED_KEYS", ())
361
+
362
+ for key in required:
363
+ if self._local_defaults.get(key) is None:
364
+ raise KeyError(
365
+ f"'{name}' theme block is missing '{key}' at the top level "
366
+ f"of sCTkThemes.json."
367
+ )
368
+ for key in required_disabled:
369
+ if self._custom_disabled_map.get(key) is None:
370
+ raise KeyError(
371
+ f"'{name}' theme block is missing '{key}' in disabled_map."
372
+ )
373
+ # pressed_map is only required when latching is in use. A dial that
374
+ # is always live never reads it, so demanding it would force the key
375
+ # into every theme block for a feature most dials do not use.
376
+ if not getattr(self, "_latching", False):
377
+ return
378
+ for key in self._REQUIRED_PRESSED_KEYS + getattr(self, "_EXTRA_PRESSED_KEYS", ()):
379
+ if self._custom_pressed_map.get(key) is None:
380
+ raise KeyError(
381
+ f"'{name}' theme block is missing '{key}' in pressed_map."
382
+ )
383
+
384
+ # ------------------------------------------------------------------
385
+ # Latched "pressed" state
386
+ # ------------------------------------------------------------------
387
+ _SCROLL_EVENTS = ("<MouseWheel>", "<TouchpadScroll>", "<Button-4>",
388
+ "<Button-5>", "<Shift-ButtonPress-1>", "<Shift-B1-Motion>")
389
+
390
+ def _draws_active(self) -> bool:
391
+ """
392
+ Whether to draw the operational colours.
393
+
394
+ A NON-LATCHING dial is permanently on, so it draws from pressed_map
395
+ like an armed one. Without this, opting out of latching changed how a
396
+ dial looked: the bright values live in pressed_map now, and the
397
+ top-level block holds the resting, dimmed set -- so a plain dial
398
+ rendered dim, which is not what opting out should mean.
399
+
400
+ Disabled always wins over both.
401
+ """
402
+ if str(getattr(self, "_state", "normal")).lower() == "disabled":
403
+ return False
404
+ return True if not getattr(self, "_latching", False) else self.is_pressed()
405
+
406
+ def is_pressed(self) -> bool:
407
+ """True when the dial is switched on and will respond to input."""
408
+ return bool(getattr(self, "_pressed", False))
409
+
410
+ def set_pressed(self, pressed: bool) -> bool:
411
+ """
412
+ Switches the dial on or off, repainting and re-gating input.
413
+
414
+ Off is the resting state: the dial shows its top-level theme colours
415
+ and ignores clicks, drags and the wheel. On shows pressed_map and
416
+ responds normally.
417
+
418
+ Returns:
419
+ The resulting value, so a caller can act on what actually happened.
420
+ """
421
+ if not self._latching:
422
+ # Nothing to switch: the dial is always live. Silently ignored
423
+ # rather than raising, so generic code that arms every dial on a
424
+ # panel does not have to know which ones latch.
425
+ return False
426
+ pressed = bool(pressed)
427
+ if pressed == self.is_pressed():
428
+ return pressed
429
+ self._pressed = pressed
430
+ self._apply_input_bindings()
431
+ self._process_theme_repaint()
432
+ return pressed
433
+
434
+ def toggle_pressed(self) -> bool:
435
+ """Flips the switched-on state. What a double-click does."""
436
+ return self.set_pressed(not self.is_pressed())
437
+
438
+ def _on_shift_double_click(self, event=None):
439
+ """
440
+ Fires shift_double_click_command, if one was given.
441
+
442
+ See __init__ for why disarming wants its own gesture.
443
+ """
444
+ if str(self._state).lower() == "disabled":
445
+ return "break"
446
+ if callable(self._shift_double_click_command):
447
+ try:
448
+ self._shift_double_click_command(self)
449
+ except Exception:
450
+ pass
451
+ return "break"
452
+
453
+ def _on_double_click(self, event=None):
454
+ """
455
+ Fires double_click_command, if one was given.
456
+
457
+ Deliberately does NOT touch the latch. Wiring a double-click to
458
+ toggle_pressed() is the application's choice:
459
+
460
+ dial = sCTkDialRange(parent, latching=True,
461
+ double_click_command=lambda d: d.toggle_pressed())
462
+
463
+ Returns "break" so the click does not also reach anything bound
464
+ further up.
465
+ """
466
+ if str(self._state).lower() == "disabled":
467
+ return "break"
468
+ if callable(self._double_click_command):
469
+ try:
470
+ self._double_click_command(self)
471
+ except Exception:
472
+ # A raising callback must not leave the binding broken for
473
+ # the next click.
474
+ pass
475
+ return "break"
476
+
477
+ def _apply_input_bindings(self):
478
+ """
479
+ Installs or removes the interactive bindings for the current state.
480
+
481
+ Gating here rather than guarding inside each handler: there are six
482
+ handlers on each of the three variants, and a missed guard would be a
483
+ dial that still turns while switched off. Not binding at all cannot be
484
+ got wrong.
485
+
486
+ The double-click binding survives while the widget is enabled -- it is
487
+ how the dial gets switched on in the first place.
488
+ """
489
+ if not hasattr(self, "canvas"):
490
+ return
491
+ try:
492
+ if not self.canvas.winfo_exists():
493
+ return
494
+ except Exception:
495
+ return
496
+
497
+ enabled = str(self._state).lower() != "disabled"
498
+ # Without latching the dial is live whenever it is enabled, which is
499
+ # how it has always behaved.
500
+ live = enabled and (self.is_pressed() if self._latching else True)
501
+
502
+ try:
503
+ if live:
504
+ self.canvas.bind("<Button-1>", self._on_left_click_step)
505
+ self.canvas.bind("<Button-2>", self._on_right_click_step)
506
+ self.canvas.bind("<Button-3>", self._on_right_click_step)
507
+ # The shift-drag handlers are part of the interactive set and
508
+ # have to come back with it. _inject_private_layer_bindings()
509
+ # covers only the scroll events.
510
+ self.canvas.bind("<Shift-ButtonPress-1>", self._on_button_press)
511
+ self.canvas.bind("<Shift-B1-Motion>", self._on_button_motion)
512
+ self._inject_private_layer_bindings()
513
+ else:
514
+ self.canvas.unbind("<Button-1>")
515
+ self.canvas.unbind("<Button-2>")
516
+ self.canvas.unbind("<Button-3>")
517
+ # Unbound on EVERY layer the injection binds, not just the
518
+ # canvas. _inject_private_layer_bindings() installs the scroll
519
+ # handlers on the canvas, the widget and CTkFrame's own canvas;
520
+ # clearing one of the three left a dial that still scrolled
521
+ # while switched off. The old disabled path had the same gap.
522
+ for _layer in (self.canvas, self, getattr(self, "_canvas", None)):
523
+ if _layer is None:
524
+ continue
525
+ for ev in self._SCROLL_EVENTS:
526
+ try:
527
+ _layer.unbind(ev)
528
+ except Exception:
529
+ pass
530
+
531
+ # The plain double-click is NOT bound on an armed latching dial.
532
+ #
533
+ # Tk gives <Double-Button-1> precedence over the second
534
+ # <Button-1>, so while both are bound an accidental double-click
535
+ # while clicking to step the dial costs a step -- two clicks, one
536
+ # step. Harmless but irritating on a control you click repeatedly.
537
+ #
538
+ # On a latching dial the plain double-click means "arm", which is
539
+ # only ever needed while it is off. Not binding it once armed
540
+ # removes the collision outright rather than compensating for it.
541
+ # Disarming has its own gesture and stays bound throughout.
542
+ #
543
+ # A non-latching dial keeps it bound always: there
544
+ # double_click_command is a general-purpose callback with no
545
+ # arming role, and silently dropping it would be wrong.
546
+ if enabled and not (self._latching and self.is_pressed()):
547
+ self.canvas.bind("<Double-Button-1>", self._on_double_click)
548
+ else:
549
+ self.canvas.unbind("<Double-Button-1>")
550
+
551
+ if enabled:
552
+ self.canvas.bind("<Shift-Double-Button-1>",
553
+ self._on_shift_double_click)
554
+ else:
555
+ self.canvas.unbind("<Shift-Double-Button-1>")
556
+ except Exception:
557
+ pass
558
+
559
+ def _inject_private_layer_bindings(self):
560
+ """
561
+ Installs the scroll handlers on every layer that can receive them.
562
+
563
+ Guarded on the dial actually being live. __init__ schedules this with
564
+ after(50, ...), so it fires AFTER _apply_input_bindings() has decided
565
+ what should be bound -- and without this check it put the scroll
566
+ handlers back on a dial that was meant to be inert. The symptom was a
567
+ freshly built latching dial that still responded to the wheel, while
568
+ one cycled on and off behaved correctly, because by then the gating
569
+ had run again with nothing scheduled behind it.
570
+ """
571
+ if getattr(self, "_latching", False) and not self.is_pressed():
572
+ return
573
+ if str(getattr(self, "_state", "normal")).lower() == "disabled":
574
+ return
575
+
576
+ layers_to_bind = [self.canvas, self]
577
+ if hasattr(self, "_canvas") and self._canvas is not None:
578
+ layers_to_bind.append(self._canvas)
579
+ for target_layer in layers_to_bind:
580
+ if sys.platform == "darwin":
581
+ # GUARDED: <TouchpadScroll> arrived in Tk 8.7, so a macOS on an
582
+ # older Tk raises here and the dial cannot be built at all:
583
+ #
584
+ # TclError: bad event type or keysym "TouchpadScroll"
585
+ #
586
+ # The wheel bindings below still work; only the finer trackpad
587
+ # events are lost. ScrollBindingMixin guards the same bind.
588
+ # Exception, not tk.TclError: this module does not import
589
+ # tkinter, so naming the specific class raised NameError from
590
+ # inside the handler -- which aborted the whole injection and
591
+ # took the <MouseWheel> bindings below with it. The dial then
592
+ # responded to clicks and not to the wheel.
593
+ try:
594
+ target_layer.bind("<TouchpadScroll>",
595
+ self._process_mac_touchpad_scroll, add="+")
596
+ except Exception:
597
+ pass
598
+ target_layer.bind("<MouseWheel>", self._process_scroll_wheel, add="+")
599
+ target_layer.bind("<Button-4>", self._process_scroll_wheel, add="+")
600
+ target_layer.bind("<Button-5>", self._process_scroll_wheel, add="+")
601
+
602
+ def _on_mouse_enter(self):
603
+ if self._state == "normal": self.canvas.focus_set()
604
+
605
+ def _set_appearance_mode(self, mode_string):
606
+ if hasattr(super(), "_set_appearance_mode"):
607
+ try: super()._set_appearance_mode(mode_string)
608
+ except Exception: pass
609
+ if hasattr(self, "canvas") and self.canvas.winfo_exists():
610
+ defer(self, 20, self._process_theme_repaint)
611
+
612
+ def _process_theme_repaint(self):
613
+ """🔑 TRANSPARENCY SHIELD FIXED: Resolves transparent settings to parent hex values."""
614
+ theme_bg_raw = self._local_defaults.get("fg_color", "transparent")
615
+ if self._state == "disabled":
616
+ theme_bg_raw = self._custom_disabled_map.get("fg_color", "transparent")
617
+
618
+ resolved_bg = self._resolve_color(theme_bg_raw)
619
+
620
+ if resolved_bg == "transparent":
621
+ current_parent = self.master
622
+ while current_parent is not None:
623
+ if hasattr(current_parent, "cget"):
624
+ try:
625
+ p_color = current_parent.cget("fg_color")
626
+ if p_color and p_color not in ("transparent", ""):
627
+ resolved_bg = self._resolve_color(p_color)
628
+ break
629
+ except Exception:
630
+ pass
631
+ current_parent = getattr(current_parent, "master", None)
632
+
633
+ if resolved_bg == "transparent":
634
+ resolved_bg = self._opaque_fallback()
635
+
636
+ self.canvas.configure(bg=resolved_bg)
637
+ if hasattr(self, "_draw_dial_base"): self._draw_dial_base()
638
+
639
+ def _opaque_fallback(self):
640
+ """A drawable colour for when everything above resolves transparent."""
641
+ return ("#1C1C1C" if str(ctk.get_appearance_mode()).lower() == "dark"
642
+ else "#F1F5F9")
643
+
644
+ def _canvas_background(self):
645
+ """
646
+ A colour the CANVAS can actually be set to.
647
+
648
+ fg_color may legitimately be "transparent" -- it is how a widget says
649
+ it should take its parent's colour, and a dial dropped into a themed
650
+ panel wants exactly that. A raw canvas cannot render the word, so it
651
+ has to be turned into whatever is really behind the widget:
652
+
653
+ TclError: unknown color name "transparent"
654
+
655
+ The same accommodation sCTkNotebook and sCTkFileExplorer make for
656
+ their canvases.
657
+ """
658
+ colour = self._resolve_color(self._local_defaults.get("fg_color"))
659
+ if colour != "transparent":
660
+ return colour
661
+
662
+ parent = getattr(self, "master", None)
663
+ for _ in range(6):
664
+ if parent is None:
665
+ break
666
+ try:
667
+ candidate = self._resolve_color(parent.cget("fg_color"))
668
+ if candidate and candidate != "transparent":
669
+ return candidate
670
+ except Exception:
671
+ pass
672
+ parent = getattr(parent, "master", None)
673
+ return self._opaque_fallback()
674
+
675
+ # ------------------------------------------------------------------
676
+ # Clicks, drags, the wheel and the trackpad -- shared by all three dials
677
+ # ------------------------------------------------------------------
678
+ # FIX (2026-11-11): these were copied into each of sCTkDialContinuous,
679
+ # sCTkDialSelector and sCTkDialRange, and had already drifted apart
680
+ # once. Each dial now says only how it takes one step (_step) and how far
681
+ # a drag must travel for one (_DRAG_THRESHOLD_PX); the handling is here.
682
+
683
+ # Pixels of vertical drag per step. The selector's is larger: its
684
+ # positions are few and far apart, so a small drag should not skip one.
685
+ _DRAG_THRESHOLD_PX = 2
686
+
687
+ def _step(self, delta: int):
688
+ """Moves the dial by `delta` positions (+1 or -1). Each dial defines it."""
689
+ raise NotImplementedError
690
+
691
+ def _on_left_click_step(self, event):
692
+ if self._state == "disabled": return
693
+ if self._left_click_callback is not None: self._left_click_callback()
694
+ else: self._step(-1)
695
+
696
+ def _on_right_click_step(self, event):
697
+ if self._state == "disabled": return
698
+ if self._right_click_callback is not None: self._right_click_callback()
699
+ else: self._step(1)
700
+
701
+ def _on_button_press(self, event): self._last_y = event.y
702
+
703
+ def _on_button_motion(self, event):
704
+ if self._state == "disabled": return
705
+ delta_y = self._last_y - event.y
706
+ if abs(delta_y) > self._DRAG_THRESHOLD_PX:
707
+ self._step(1 if delta_y > 0 else -1)
708
+ self._last_y = event.y
709
+
710
+ def _process_mac_touchpad_scroll(self, event):
711
+ if self._state == "disabled": return "break"
712
+ current_time = time.time()
713
+ if current_time - self._last_scroll_time < self._scroll_cooldown_seconds: return "break"
714
+ delta_y = self._decode_mac_touchpad_delta(event.delta)
715
+ if delta_y != 0:
716
+ self._last_scroll_time = current_time
717
+ self._step(1 if delta_y > 0 else -1)
718
+ return "break"
719
+
720
+ def _process_scroll_wheel(self, event):
721
+ """
722
+ One wheel detent, once.
723
+
724
+ RETURNS "break", and must. The scroll handlers are installed on THREE
725
+ layers -- the drawing canvas, the widget, and CTkFrame's own
726
+ background canvas -- so an event that is not consumed propagates to
727
+ each in turn and the dial steps two or three times for one notch.
728
+
729
+ The symptom is subtle where a caller coalesces its commands and
730
+ obvious where it does not: a receive frequency ran away at roughly
731
+ 2.5 steps per detent while a transmit one, whose writes were being
732
+ merged, looked correct.
733
+ """
734
+ if self._state == "disabled":
735
+ return "break"
736
+ if getattr(event, "num", 0) == 4 or (hasattr(event, "delta") and event.delta > 0):
737
+ d = 1
738
+ elif getattr(event, "num", 0) == 5 or (hasattr(event, "delta") and event.delta < 0):
739
+ d = -1
740
+ else:
741
+ return "break"
742
+ self._step(d)
743
+ return "break"
744
+
745
+ def _decode_mac_touchpad_delta(self, raw_delta):
746
+ raw = raw_delta & 0xFFFFFFFF
747
+ delta_y = raw & 0xFFFF
748
+ if delta_y >= 0x8000: delta_y -= 0x10000
749
+ return delta_y
750
+
751
+ def configure(self, *args, **kwargs):
752
+ """Handles Pygubu designer queries and manages composite state updates safely."""
753
+ if args and len(args) == 1:
754
+ # FIX: was
755
+ # pname = args if isinstance(args, (list, tuple)) else args
756
+ # -- BOTH branches assigned `args`, which is always a tuple. Every
757
+ # comparison below therefore tested a tuple against a string and
758
+ # failed, so the entire query block was dead and Pygubu could read
759
+ # none of these properties. The ternary made it look deliberate.
760
+ #
761
+ # Same one-character bug found in sCTkFileExplorer, sCTkSMeterBar
762
+ # and sCTkPathChooser.
763
+ pname = args[0]
764
+ if pname == "latching":
765
+ return ("latching", "latching", "Latching", "False",
766
+ str(bool(getattr(self, "_latching", False))))
767
+
768
+ if pname == "pressed":
769
+ return ("pressed", "pressed", "Pressed", "False",
770
+ str(self.is_pressed()))
771
+
772
+ if pname == "width":
773
+ return ('width', 'width', 'Width', 120, super().cget("width") if hasattr(self, "cget") else 120)
774
+ if pname == "height":
775
+ return ('height', 'height', 'Height', 120, super().cget("height") if hasattr(self, "cget") else 120)
776
+ if pname == "state":
777
+ return ('state', 'state', 'State', 'normal', getattr(self, "_state", "normal"))
778
+ if pname == "labels":
779
+ # JSON, not a comma-joined string: pygubu parses list
780
+ # properties with a strict json.loads(). _query_value() renders
781
+ # it, given the property name -- see themeable_widget.py.
782
+ default_labels = ["POS 1", "POS 2", "POS 3"]
783
+ return ('labels', 'labels', 'Labels',
784
+ self._query_value(default_labels, "labels"),
785
+ self._query_value(
786
+ list(getattr(self, "_labels", default_labels)),
787
+ "labels"))
788
+
789
+ if pname == "label_font":
790
+ # The theme's label_font is the default, so clearing the field
791
+ # in the Designer returns to it rather than to nothing.
792
+ return ('label_font', 'label_font', 'Label font',
793
+ self._query_value(
794
+ self._local_defaults.get("label_font"), "label_font"),
795
+ self._query_value(self._label_font(), "label_font"))
796
+ if pname in ["knob_diameter", "divisions", "arc_angle", "from_", "to", "command", "left_click_callback",
797
+ "right_click_callback"]:
798
+ return (pname, pname, pname, "", "")
799
+ try:
800
+ return self._configure_query(pname)
801
+ except Exception:
802
+ return (pname, pname, pname, "", "")
803
+
804
+ # FIX: was `if args and isinstance(args, dict)`. args is ALWAYS a
805
+ # tuple, so this never fired and the dict form of configure() was
806
+ # dead code. Same tautology fixed across the batch-one widgets.
807
+ if len(args) == 1 and isinstance(args[0], dict):
808
+ kwargs = {**args[0], **kwargs}
809
+
810
+ if "width" in kwargs:
811
+ w = kwargs["width"]
812
+ kwargs["width"] = int(w) if (w and str(w).strip()) else 120
813
+ if "height" in kwargs:
814
+ h = kwargs["height"]
815
+ kwargs["height"] = int(h) if (h and str(h).strip()) else 120
816
+
817
+ if "label_font" in kwargs:
818
+ # Empty restores the theme's label_font, matching the query above
819
+ # and what generated code does when the property is omitted.
820
+ new_font = kwargs.pop("label_font")
821
+ self._label_font_override = new_font if new_font else None
822
+ # Full redraw, not _redraw_indicator(): the labels are part of the
823
+ # dial body, and the cheap indicator-only path does not touch them.
824
+ self._draw_dial_base()
825
+
826
+ # Runtime overrides have to reach the map a repaint reads, or the
827
+ # repaint puts the theme value straight back -- see
828
+ # ThemeableWidget._record_theme_overrides().
829
+ self._record_theme_overrides(kwargs)
830
+
831
+ if "double_click_command" in kwargs:
832
+ self._double_click_command = kwargs.pop("double_click_command")
833
+
834
+ if "shift_double_click_command" in kwargs:
835
+ self._shift_double_click_command = kwargs.pop("shift_double_click_command")
836
+
837
+ if "latching" in kwargs:
838
+ _l = kwargs.pop("latching")
839
+ if isinstance(_l, str):
840
+ _l = _l.strip().lower() in ("true", "1", "yes")
841
+ self._latching = bool(_l)
842
+ if not self._latching:
843
+ self._pressed = False
844
+ self._apply_input_bindings()
845
+ self._process_theme_repaint()
846
+
847
+ if "pressed" in kwargs:
848
+ _p = kwargs.pop("pressed")
849
+ # The Designer sends strings.
850
+ if isinstance(_p, str):
851
+ _p = _p.strip().lower() in ("true", "1", "yes")
852
+ self.set_pressed(bool(_p))
853
+
854
+ if "state" in kwargs:
855
+ self.state(kwargs.pop("state"))
856
+
857
+ # This library's own theme keys are consumed here.
858
+ #
859
+ # They are not native CTkFrame options -- text_color, dial_color,
860
+ # pointer_color and the rest describe things this widget draws on its
861
+ # own canvas -- so forwarding them raises:
862
+ #
863
+ # ['text_color'] are not supported arguments.
864
+ #
865
+ # _record_theme_overrides() above has already stored the value and
866
+ # triggered a redraw, so there is nothing left to do with them.
867
+ for _own_key in list(kwargs):
868
+ if _own_key in self._local_defaults:
869
+ kwargs.pop(_own_key)
870
+
871
+ if kwargs:
872
+ self._drop_cleared(kwargs)
873
+ if kwargs: return super().configure(**kwargs)
874
+ return None
875
+
876
+
877
+ # Tkinter/CTk convention binds .config to .configure as a SEPARATE class
878
+ # attribute -- it does NOT track whichever configure() a subclass defines.
879
+ # Without this, .config(...) skips every override in this class and lands
880
+ # on the native widget, bypassing divisions/command/diameter handling and
881
+ # the theme repaint entirely. Confirmed as a critical bug on
882
+ # sCTkSegmentedButton earlier in this project's audit. Each subclass needs
883
+ # its own line -- inheriting the alias would point at the PARENT's
884
+ # configure(), not the subclass's.
885
+ config = configure
886
+ def get_state(self) -> str:
887
+ return str(self._state).lower()
888
+
889
+ def state(self, mode: str = None) -> str:
890
+ """Dedicated rotary dial operational interaction lock and visibility state controller."""
891
+ if mode is None:
892
+ return str(self._state).lower()
893
+
894
+ mode = mode.lower()
895
+ if mode in ("normal", "enabled", "active"):
896
+ self._state = "normal"
897
+ self._custom_current_state = "normal"
898
+
899
+ # 🔑 1. REPAINT CANVAS LAYERS FIRST: Let Tkinter settle its color configurations
900
+ self._process_theme_repaint()
901
+
902
+ # Bindings follow BOTH conditions: enabling a dial that is
903
+ # switched off must not make it responsive. Disabling cleared the
904
+ # latch, so a dial always comes back from a lock switched off.
905
+ self._apply_input_bindings()
906
+
907
+ elif mode == "disabled":
908
+ self._state = "disabled"
909
+ self._custom_current_state = "disabled"
910
+ # Disabling switches the dial OFF as well as locking it, so
911
+ # re-enabling leaves it inert until deliberately double-clicked.
912
+ #
913
+ # This is what makes a panel lock useful: unlocking gives you a
914
+ # screen of dials that cannot be nudged by accident, rather than
915
+ # one that comes back live because it was live before the lock.
916
+ self._pressed = False
917
+ self._apply_input_bindings()
918
+ self._process_theme_repaint()
919
+
920
+ return str(self._state).lower()
921
+
922
+ def cget(self, attribute_name: str) -> any:
923
+ if attribute_name == "pressed": return self.is_pressed()
924
+ if attribute_name == "latching": return bool(getattr(self, "_latching", False))
925
+ if attribute_name == "double_click_command": return self._double_click_command
926
+ if attribute_name == "shift_double_click_command": return self._shift_double_click_command
927
+ if attribute_name == "state": return getattr(self, "_state", "normal")
928
+ if attribute_name == "knob_diameter": return self._knob_diameter
929
+ if attribute_name == "divisions": return getattr(self, "_divisions", 24)
930
+ return super().cget(attribute_name)
931
+
932
+ # ------------------------------------------------------------------
933
+ # Knob shading
934
+ # ------------------------------------------------------------------
935
+ # Number of concentric ovals used to fake a radial gradient. Tk's canvas
936
+ # has no gradient primitive and no alpha channel, so the only way to get a
937
+ # domed surface is to stack solid-filled ovals, each slightly smaller and
938
+ # shifted toward the light source. Below ~12 the steps read as visible
939
+ # contour bands; above ~24 the extra items cost more than they show.
940
+ KNOB_SHADE_STEPS = 18
941
+
942
+ # How far the ring stack shrinks from edge to centre, and how far each
943
+ # step drifts toward the light. Both as fractions of the knob radius.
944
+ KNOB_SHADE_SHRINK = 0.55
945
+ KNOB_LIGHT_OFFSET = 0.55
946
+
947
+ # Finger dimple, as fractions of knob radius -- NOT absolute pixels. The
948
+ # previous code used a fixed 14px inset with a fixed 14.5px radius, so the
949
+ # dimple was lost on a large dial and swallowed a small one.
950
+ DIMPLE_RADIUS_FRAC = 0.36
951
+ DIMPLE_RIM_CLEARANCE_FRAC = 0.06
952
+
953
+ # Pointer line for Selector/Range: width in px, and how far short of the
954
+ # rim it stops.
955
+ POINTER_WIDTH = 3.0
956
+ POINTER_RIM_INSET = 3
957
+
958
+ @staticmethod
959
+ def _lerp_color(color_a: str, color_b: str, t: float) -> str:
960
+ """
961
+ Blends two resolved "#RRGGBB" strings.
962
+
963
+ Both inputs must already be resolved to a single colour by
964
+ _resolve_color() -- this cannot accept (light, dark) tuples.
965
+
966
+ Args:
967
+ color_a: Start colour at t=0.
968
+ color_b: End colour at t=1.
969
+ t: Position between them, 0.0 to 1.0.
970
+
971
+ Returns:
972
+ The blended colour as "#RRGGBB".
973
+ """
974
+ a = tuple(int(color_a[i:i + 2], 16) for i in (1, 3, 5))
975
+ b = tuple(int(color_b[i:i + 2], 16) for i in (1, 3, 5))
976
+ return "#%02X%02X%02X" % tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
977
+
978
+ def _draw_shaded_disc(self, cx, cy, radius, edge_color, centre_color, tag,
979
+ rim_light=None, rim_shadow=None, recessed=False):
980
+ """
981
+ Draws a domed (or recessed) disc as a stack of concentric ovals.
982
+
983
+ The light source is the upper left. For a DOME each successive ring
984
+ shrinks and drifts toward that light, so the bright end of the ramp
985
+ lands off-centre where a highlight would fall. For a RECESS the drift
986
+ is inverted -- a hole lit from the upper left is shadowed on its
987
+ upper-left interior wall and lit on the lower right. Getting that
988
+ backwards is what makes a dimple pop outward as a bump instead of
989
+ sinking in.
990
+
991
+ Args:
992
+ cx, cy: Centre of the disc.
993
+ radius: Outer radius.
994
+ edge_color: Resolved colour at the outer edge.
995
+ centre_color: Resolved colour at the lit end of the ramp.
996
+ tag: Canvas tag applied to every item, so the whole disc can be
997
+ deleted as a unit.
998
+ rim_light: Optional resolved colour for the lit rim arc.
999
+ rim_shadow: Optional resolved colour for the shaded rim arc.
1000
+ recessed: True to invert the light direction (see above).
1001
+ """
1002
+ direction = -1.0 if recessed else 1.0
1003
+ for i in range(self.KNOB_SHADE_STEPS):
1004
+ t = i / (self.KNOB_SHADE_STEPS - 1)
1005
+ r = radius * (1.0 - self.KNOB_SHADE_SHRINK * t)
1006
+ drift = (radius - r) * self.KNOB_LIGHT_OFFSET * direction
1007
+ x, y = cx - drift, cy - drift
1008
+ self.canvas.create_oval(x - r, y - r, x + r, y + r,
1009
+ fill=self._lerp_color(edge_color, centre_color, t),
1010
+ outline="", tags=tag)
1011
+
1012
+ # Rim lighting. On a dark knob this does most of the work: the body
1013
+ # ramp is clamped at the bottom (you can't go darker than black), so
1014
+ # the bright upper-left arc is what reads as a curved edge rather than
1015
+ # a flat cut-out.
1016
+ if rim_light:
1017
+ lit_start, shade_start = (225, 45) if not recessed else (45, 225)
1018
+ self.canvas.create_arc(cx - radius, cy - radius, cx + radius, cy + radius,
1019
+ start=lit_start, extent=180, style="arc",
1020
+ outline=rim_light, width=2.5, tags=tag)
1021
+ if rim_shadow:
1022
+ self.canvas.create_arc(cx - radius, cy - radius, cx + radius, cy + radius,
1023
+ start=shade_start, extent=180, style="arc",
1024
+ outline=rim_shadow, width=2.5, tags=tag)
1025
+
1026
+ def _redraw_indicator(self):
1027
+ """
1028
+ Redraws only the moving indicator -- the dimple or the pointer line --
1029
+ leaving the knob body, ticks and labels alone.
1030
+
1031
+ Call this on a VALUE change. Call _draw_dial_base() only when the
1032
+ geometry, theme or state changes.
1033
+
1034
+ The body is now a stack of ~20 shaded ovals plus ticks and labels, and
1035
+ none of it changes as the dial turns. Rebuilding all of it per detent
1036
+ made the shading cost real; this makes it free while tuning. Same
1037
+ pattern as sCTkSMeter._execute_needle_draw(), which redraws its needle
1038
+ against a static face.
1039
+ """
1040
+ if not hasattr(self, "canvas") or not self.canvas.winfo_exists(): return
1041
+ if not self.canvas.find_withtag("knob_body"):
1042
+ # Body was never drawn (first paint, or a resize wiped it), so a
1043
+ # partial update would leave the dial blank. Do the full pass.
1044
+ self._draw_dial_base()
1045
+ return
1046
+ self._draw_dial_base(indicator_only=True)
1047
+
1048
+ def _draw_dial_base(self, indicator_only: bool = False):
1049
+ if not hasattr(self, "canvas") or not self.canvas.winfo_exists(): return
1050
+ if not indicator_only:
1051
+ self.canvas.delete("all")
1052
+ width, height = self.canvas.winfo_width(), self.canvas.winfo_height()
1053
+ if width < 10 or height < 10: width = height = int(self.cget("width") if hasattr(self, "cget") else 120)
1054
+
1055
+ # Resolved, not the class name: a preview or user subclass must draw
1056
+ # as the variant it derives from, or it renders as the generic base.
1057
+ child_classname = self._theme_block()
1058
+ # No `or ("#hex", "#hex")` fallbacks: _validate_theme_keys() hard-failed
1059
+ # at construction if any of these were missing, so every lookup here is
1060
+ # guaranteed to resolve. A fallback would only reintroduce the silent
1061
+ # substitution that made an incomplete theme block invisible.
1062
+ bg_color = self._canvas_background()
1063
+ shadow_paint = self._resolve_color(self._local_defaults.get("shadow_color"))
1064
+ text_color = self._resolve_color(self._local_defaults.get("text_color"))
1065
+ dial_color = self._resolve_color(self._local_defaults.get("dial_color"))
1066
+ is_dark_mode = (ctk.get_appearance_mode() == "Dark")
1067
+
1068
+ # Knob shading colours. Explicit rather than derived from dial_color:
1069
+ # a fixed lighten/darken percentage produces a nearly invisible rim on
1070
+ # a black knob and a blown-out one on aluminium, because the available
1071
+ # range differs enormously between them.
1072
+ knob_highlight = self._resolve_color(self._local_defaults.get("dial_highlight_color"))
1073
+ knob_shadow = self._resolve_color(self._local_defaults.get("dial_shadow_color"))
1074
+ rim_light = self._resolve_color(self._local_defaults.get("dial_rim_light_color"))
1075
+ rim_shadow = self._resolve_color(self._local_defaults.get("dial_rim_shadow_color"))
1076
+
1077
+ if self._draws_active():
1078
+ # SWITCHED ON. pressed_map holds the operational look; the top
1079
+ # level above is the resting, dimmed one. Each key falls back to
1080
+ # the top-level value, so a pressed_map that names only the few
1081
+ # colours that should brighten is a valid block.
1082
+ _pm = self._custom_pressed_map
1083
+
1084
+ def _pressed_or(key, current):
1085
+ val = _pm.get(key)
1086
+ return self._resolve_color(val) if val is not None else current
1087
+
1088
+ text_color = _pressed_or("text_color", text_color)
1089
+ dial_color = _pressed_or("dial_color", dial_color)
1090
+ shadow_paint = _pressed_or("shadow_color", shadow_paint)
1091
+ knob_highlight = _pressed_or("dial_highlight_color", knob_highlight)
1092
+ knob_shadow = _pressed_or("dial_shadow_color", knob_shadow)
1093
+ rim_light = _pressed_or("dial_rim_light_color", rim_light)
1094
+ rim_shadow = _pressed_or("dial_rim_shadow_color", rim_shadow)
1095
+ # fg_color deliberately not switched: the background stays put and
1096
+ # the knob face carries the signal, as it does when disabled.
1097
+
1098
+ if self._state == "disabled":
1099
+ text_color = self._resolve_color(self._custom_disabled_map.get("text_color"))
1100
+ # FIX: this previously read "fg_color" for BOTH the dial face and
1101
+ # the background, so once disabled_map actually contained fg_color
1102
+ # the knob would render the same colour as the surface behind it
1103
+ # and vanish. The two only looked different before because the map
1104
+ # was empty and their hardcoded fallbacks happened to differ.
1105
+ dial_color = self._resolve_color(self._custom_disabled_map.get("dial_color"))
1106
+ knob_highlight = self._resolve_color(
1107
+ self._custom_disabled_map.get("dial_highlight_color") or self._custom_disabled_map.get("dial_color"))
1108
+ knob_shadow = self._resolve_color(
1109
+ self._custom_disabled_map.get("dial_shadow_color") or self._custom_disabled_map.get("dial_color"))
1110
+ rim_light = self._resolve_color(
1111
+ self._custom_disabled_map.get("dial_rim_light_color") or self._custom_disabled_map.get("dial_color"))
1112
+ rim_shadow = self._resolve_color(
1113
+ self._custom_disabled_map.get("dial_rim_shadow_color") or self._custom_disabled_map.get("dial_color"))
1114
+ # Background deliberately NOT dimmed -- the knob face and border
1115
+ # carry the disabled signal, matching sCTkScrollableFrame.
1116
+
1117
+ self.canvas.configure(bg=bg_color)
1118
+ center_x, center_y = width / 2, height / 2
1119
+ # knob_diameter is the KNOB, not the widget. The canvas is width x
1120
+ # height, and the knob is drawn at the size asked for regardless -- so
1121
+ # three dials sharing a knob_diameter have identical faces however long
1122
+ # their labels are.
1123
+ #
1124
+ # An earlier version measured the labels and shrank the knob to fit
1125
+ # them inside the widget. That kept the layout predictable but made the
1126
+ # knobs differ between dials whose labels differed, which is wrong in
1127
+ # the case that matters most: a row of controls on a panel.
1128
+ #
1129
+ # Labels that do not fit are clipped. Widen the canvas -- see
1130
+ # _default_canvas_size() for what a bare knob_diameter reserves.
1131
+ knob_radius = self._knob_diameter / 2.0
1132
+
1133
+ # Never larger than the canvas: a knob bigger than its widget would
1134
+ # draw outside the visible area with no indication why.
1135
+ knob_radius = min(knob_radius, min(center_x, center_y))
1136
+
1137
+ has_arc_constraints = hasattr(self, "_arc_angle")
1138
+ arc_sweep = float(self._arc_angle) if has_arc_constraints else 360.0
1139
+ start_deg = -90.0 - (arc_sweep / 2.0) if has_arc_constraints else 0.0
1140
+
1141
+ if child_classname == "sCTkDialSelector" and hasattr(self, "_labels"):
1142
+ if not self._labels or len(self._labels) == 0: self._labels = list(getattr(self, "_default_labels", ["POS 1", "POS 2", "POS 3"]))
1143
+ total_ticks = len(self._labels)
1144
+ self._divisions = total_ticks
1145
+ elif child_classname == "sCTkDialRange" and hasattr(self, "_divisions"):
1146
+ total_ticks = int(self._divisions) if (self._divisions and int(self._divisions) > 0) else 5
1147
+ else:
1148
+ total_ticks = int(self._divisions) if (hasattr(self, "_divisions") and self._divisions) else 24
1149
+
1150
+ if not indicator_only:
1151
+ for i in range(total_ticks):
1152
+ fraction = (i / (total_ticks - 1)) if (total_ticks > 1 and has_arc_constraints) else (i / total_ticks)
1153
+ angle_deg = start_deg + (fraction * arc_sweep)
1154
+ angle_rad = math.radians(-angle_deg)
1155
+ x1, y1 = center_x + knob_radius * math.cos(angle_rad), center_y - knob_radius * math.sin(angle_rad)
1156
+ x2, y2 = center_x + (knob_radius + 6) * math.cos(angle_rad), center_y - (knob_radius + 6) * math.sin(angle_rad)
1157
+ self.canvas.create_line(x1, y1, x2, y2, fill=text_color, width=2.0)
1158
+
1159
+ if child_classname == "sCTkDialSelector" and i < len(self._labels):
1160
+ label_offset, label_anchor = self._label_placement(angle_rad, knob_radius)
1161
+ self.canvas.create_text(
1162
+ center_x + label_offset * math.cos(angle_rad),
1163
+ center_y - label_offset * math.sin(angle_rad),
1164
+ text=str(self._labels[i]), fill=text_color,
1165
+ font=self._label_font(), anchor=label_anchor)
1166
+ elif child_classname == "sCTkDialRange":
1167
+ from_val, to_val = getattr(self, "_from", 0), getattr(self, "_to", 100)
1168
+ range_val = int(from_val + (to_val - from_val) * fraction)
1169
+ label_offset, label_anchor = self._label_placement(angle_rad, knob_radius)
1170
+ self.canvas.create_text(
1171
+ center_x + label_offset * math.cos(angle_rad),
1172
+ center_y - label_offset * math.sin(angle_rad),
1173
+ text=str(range_val), fill=text_color,
1174
+ font=self._label_font(), anchor=label_anchor)
1175
+
1176
+ self.canvas.create_oval(center_x - knob_radius + 1, center_y - knob_radius + 4, center_x + knob_radius + 4, center_y + knob_radius + 4, fill=shadow_paint, outline="")
1177
+
1178
+ if child_classname == "sCTkDialContinuous":
1179
+ num_side_teeth = 72
1180
+ teeth_shadow = "#000000" if is_dark_mode else "#334155"
1181
+ for k in range(num_side_teeth):
1182
+ k_angle = math.radians(-(k * (360.0 / num_side_teeth)))
1183
+ self.canvas.create_line(center_x + knob_radius * math.cos(k_angle), center_y - knob_radius * math.sin(k_angle), center_x + (knob_radius - 3) * math.cos(k_angle), center_y - (knob_radius - 3) * math.sin(k_angle), fill=teeth_shadow, width=1.5)
1184
+
1185
+ # The knob body: a stack of concentric ovals stepping from
1186
+ # dial_shadow_color at the rim to dial_highlight_color off-centre,
1187
+ # replacing what used to be a single flat fill with two hardcoded
1188
+ # outline rings. See _draw_shaded_disc().
1189
+ self._draw_shaded_disc(center_x, center_y, knob_radius - 2,
1190
+ knob_shadow, knob_highlight, "knob_body",
1191
+ rim_light=rim_light, rim_shadow=rim_shadow)
1192
+
1193
+ val_pct = self._get_value_fraction() if hasattr(self, "_get_value_fraction") else 0.0
1194
+ pointer_rad = math.radians(-(start_deg + (val_pct * arc_sweep)))
1195
+
1196
+ # Indicator. Tagged separately from the body so a value change can
1197
+ # redraw ONLY this -- see _redraw_indicator(). The body is expensive
1198
+ # now and doesn't change while the dial is turned.
1199
+ self.canvas.delete("indicator")
1200
+
1201
+ if child_classname in ["sCTkDialSelector", "sCTkDialRange"]:
1202
+ # A plain straight line from dead centre out to just short of the
1203
+ # rim, which is how these knobs are actually marked. The previous
1204
+ # version drew an arrowhead and a centre cap; both are gone, along
1205
+ # with the cap's two hardcoded outline colours.
1206
+ px = center_x + (knob_radius - self.POINTER_RIM_INSET) * math.cos(pointer_rad)
1207
+ py = center_y - (knob_radius - self.POINTER_RIM_INSET) * math.sin(pointer_rad)
1208
+ pointer_key = "pointer_color"
1209
+ if self._state == "disabled":
1210
+ raw_pointer = (self._custom_disabled_map.get(pointer_key)
1211
+ or self._custom_disabled_map.get("text_color"))
1212
+ elif self._draws_active():
1213
+ # Switched on, or never latching: pressed_map first, then the
1214
+ # resting value.
1215
+ raw_pointer = (self._custom_pressed_map.get(pointer_key)
1216
+ or self._custom_pressed_map.get("text_color")
1217
+ or self._local_defaults.get(pointer_key)
1218
+ or self._local_defaults.get("text_color"))
1219
+ else:
1220
+ raw_pointer = (self._local_defaults.get(pointer_key)
1221
+ or self._local_defaults.get("text_color"))
1222
+ self.canvas.create_line(center_x, center_y, px, py,
1223
+ fill=self._resolve_color(raw_pointer),
1224
+ width=self.POINTER_WIDTH, capstyle="round",
1225
+ tags="indicator")
1226
+ else:
1227
+ # Continuous only: the finger dimple a VFO operator spins the dial
1228
+ # by. Sized as a FRACTION of knob_radius so it holds its
1229
+ # proportions at any dial size.
1230
+ # Resolved HERE rather than above, because only this branch draws
1231
+ # a dimple. The previous code resolved it for every dial, so
1232
+ # Selector and Range looked up a key they never used -- which is
1233
+ # why pointer_glow_color appeared "missing" from their theme
1234
+ # blocks. It belongs to Continuous alone.
1235
+ pointer_glow = self._resolve_color(
1236
+ self._custom_disabled_map.get("pointer_glow_color") if self._state == "disabled"
1237
+ else self._local_defaults.get("pointer_glow_color"))
1238
+
1239
+ ind_radius = knob_radius * self.DIMPLE_RADIUS_FRAC
1240
+ clearance = knob_radius * self.DIMPLE_RIM_CLEARANCE_FRAC
1241
+ dimple_center_radius = knob_radius - ind_radius - clearance
1242
+ dx = center_x + dimple_center_radius * math.cos(pointer_rad)
1243
+ dy = center_y - dimple_center_radius * math.sin(pointer_rad)
1244
+
1245
+ # recessed=True inverts the light direction: a hole lit from the
1246
+ # upper left is shadowed on its upper-left interior wall. Drawn
1247
+ # with the body's direction it would read as a raised bump.
1248
+ self._draw_shaded_disc(dx, dy, ind_radius, knob_highlight, knob_shadow,
1249
+ "indicator", rim_light=rim_shadow,
1250
+ rim_shadow=rim_light, recessed=True)
1251
+ self.canvas.create_oval(dx - ind_radius, dy - ind_radius, dx + ind_radius, dy + ind_radius,
1252
+ fill="", outline=pointer_glow, width=1.5, tags="indicator")
1253
+ class sCTkDialContinuous(sCTKDialBase):
1254
+ # Only this variant draws the finger dimple, so only it requires the glow
1255
+ # ring colour -- see _draw_dial_base()'s indicator branch.
1256
+ _EXTRA_THEME_KEYS = ("pointer_glow_color",)
1257
+ _EXTRA_DISABLED_KEYS = ("pointer_glow_color",)
1258
+
1259
+ def __init__(self, master=None, divisions=24, command=None, left_click_callback=None, right_click_callback=None, knob_diameter=120, **kw):
1260
+ super().__init__(master, divisions=divisions, knob_diameter=knob_diameter, **kw)
1261
+ self._command = command
1262
+ self._left_click_callback = left_click_callback if (left_click_callback and str(left_click_callback).strip()) else None
1263
+ self._right_click_callback = right_click_callback if (right_click_callback and str(right_click_callback).strip()) else None
1264
+ self._current_value = 0
1265
+ self._finalize_themeable_lifecycle()
1266
+
1267
+ def _get_value_fraction(self): return self._current_value / self._divisions
1268
+ def configure(self, *args, **kwargs):
1269
+ if args: return super().configure(*args, **kwargs)
1270
+ if "divisions" in kwargs: self._divisions = int(kwargs.pop("divisions"))
1271
+ if "command" in kwargs: self._command = kwargs.pop("command")
1272
+ if "left_click_callback" in kwargs: self._left_click_callback = kwargs.pop("left_click_callback")
1273
+ if "right_click_callback" in kwargs: self._right_click_callback = kwargs.pop("right_click_callback")
1274
+ if "knob_diameter" in kwargs:
1275
+ # Sets the KNOB only. The canvas keeps whatever width and height it
1276
+ # has -- resizing the widget when the knob changes would move
1277
+ # everything else in the layout.
1278
+ self._knob_diameter = int(kwargs.pop("knob_diameter"))
1279
+ result = super().configure(**kwargs)
1280
+ if hasattr(self, "canvas") and self.canvas.winfo_exists(): self._draw_dial_base()
1281
+ return result
1282
+
1283
+
1284
+ # Tkinter/CTk convention binds .config to .configure as a SEPARATE class
1285
+ # attribute -- it does NOT track whichever configure() a subclass defines.
1286
+ # Without this, .config(...) skips every override in this class and lands
1287
+ # on the native widget, bypassing divisions/command/diameter handling and
1288
+ # the theme repaint entirely. Confirmed as a critical bug on
1289
+ # sCTkSegmentedButton earlier in this project's audit. Each subclass needs
1290
+ # its own line -- inheriting the alias would point at the PARENT's
1291
+ # configure(), not the subclass's.
1292
+ config = configure
1293
+ def cget(self, attribute_name: str) -> any:
1294
+ if attribute_name == "command": return self._command
1295
+ if attribute_name == "left_click_callback": return self._left_click_callback
1296
+ if attribute_name == "right_click_callback": return self._right_click_callback
1297
+ return super().cget(attribute_name)
1298
+
1299
+ def set_position_index(self, step_delta):
1300
+ self._current_value = (self._current_value + int(step_delta)) % self._divisions
1301
+ if self.canvas.winfo_exists(): self._redraw_indicator()
1302
+ if self._command is not None and self._state == "normal": self._command(int(step_delta))
1303
+
1304
+ def _step(self, delta: int):
1305
+ self.set_position_index(delta)
1306
+
1307
+
1308
+ class sCTkDialSelector(sCTKDialBase):
1309
+ # This variant draws a plain line pointer rather than a dimple, so it
1310
+ # requires pointer_color instead of pointer_glow_color. pointer_color was
1311
+ # present in the theme file but never read by any code path until now.
1312
+ # label_font is required on the two variants that DRAW text. Continuous
1313
+ # draws none, so it does not carry the key.
1314
+ _EXTRA_THEME_KEYS = ("pointer_color", "label_font")
1315
+
1316
+ """
1317
+ Rotary switch selector module. Constrained to custom arc angles (default 270).
1318
+ Loops infinitely past outer limits and reports the active integer item index position.
1319
+ """
1320
+ def __init__(self, master=None, labels=None, arc_angle=270, command=None, left_click_callback=None, right_click_callback=None, knob_diameter=120, **kw):
1321
+ if isinstance(labels, str) and labels.strip():
1322
+ # Shared parser -- see parse_list_property(). This site used
1323
+ # literal_eval with a manual fallback; the configure() site
1324
+ # below used a bare split that did NOT strip quotes, so the
1325
+ # same value parsed differently depending on when it was set.
1326
+ labels = parse_list_property(labels)
1327
+ self._default_labels = ["POS 1", "POS 2", "POS 3"]
1328
+ self._labels = labels if labels is not None else list(self._default_labels)
1329
+ self._arc_angle = float(arc_angle)
1330
+ super().__init__(master, divisions=len(self._labels), knob_diameter=knob_diameter, **kw)
1331
+ self._scroll_cooldown_seconds = 0.150
1332
+ self._command = command
1333
+ self._left_click_callback = left_click_callback if (left_click_callback and str(left_click_callback).strip()) else None
1334
+ self._right_click_callback = right_click_callback if (right_click_callback and str(right_click_callback).strip()) else None
1335
+ self._current_value = 0
1336
+ self._custom_current_state = "normal" if self._state == "normal" else "disabled"
1337
+ self._finalize_themeable_lifecycle()
1338
+
1339
+ def _get_value_fraction(self):
1340
+ t = len(self._labels) - 1
1341
+ return self._current_value / t if t > 0 else 0.0
1342
+
1343
+ def configure(self, *args, **kwargs):
1344
+ if args: return super().configure(*args, **kwargs)
1345
+ if "labels" in kwargs:
1346
+ lbls = kwargs.pop("labels")
1347
+ if isinstance(lbls, str):
1348
+ s = lbls.strip().strip("[]\"'")
1349
+ lbls = parse_list_property(s, default=list(self._default_labels))
1350
+ self._labels = list(self._default_labels) if not lbls else lbls
1351
+ self._divisions = len(self._labels)
1352
+ if "arc_angle" in kwargs: self._arc_angle = float(kwargs.pop("arc_angle"))
1353
+ if "command" in kwargs: self._command = kwargs.pop("command")
1354
+ if "left_click_callback" in kwargs: self._left_click_callback = kwargs.pop("left_click_callback")
1355
+ if "right_click_callback" in kwargs: self._right_click_callback = kwargs.pop("right_click_callback")
1356
+ if "knob_diameter" in kwargs:
1357
+ # Sets the KNOB only. The canvas keeps whatever width and height it
1358
+ # has -- resizing the widget when the knob changes would move
1359
+ # everything else in the layout.
1360
+ self._knob_diameter = int(kwargs.pop("knob_diameter"))
1361
+ result = super().configure(**kwargs)
1362
+ if hasattr(self, "canvas") and self.canvas.winfo_exists(): self._draw_dial_base()
1363
+ return result
1364
+
1365
+
1366
+ # Tkinter/CTk convention binds .config to .configure as a SEPARATE class
1367
+ # attribute -- it does NOT track whichever configure() a subclass defines.
1368
+ # Without this, .config(...) skips every override in this class and lands
1369
+ # on the native widget, bypassing divisions/command/diameter handling and
1370
+ # the theme repaint entirely. Confirmed as a critical bug on
1371
+ # sCTkSegmentedButton earlier in this project's audit. Each subclass needs
1372
+ # its own line -- inheriting the alias would point at the PARENT's
1373
+ # configure(), not the subclass's.
1374
+ config = configure
1375
+ def cget(self, attribute_name: str) -> any:
1376
+ if attribute_name == "labels": return self._labels
1377
+ if attribute_name == "arc_angle": return self._arc_angle
1378
+ if attribute_name == "command": return self._command
1379
+ return super().cget(attribute_name)
1380
+
1381
+ def set(self, value):
1382
+ t = len(self._labels)
1383
+ if t == 0: return
1384
+ target = int(value)
1385
+ if target >= t: target = 0
1386
+ elif target < 0: target = t - 1
1387
+ self._current_value = target
1388
+ if self.canvas.winfo_exists(): self._redraw_indicator()
1389
+ if self._command is not None and self._state == "normal": self._command(self._current_value)
1390
+
1391
+ def get(self): return self._current_value
1392
+ # Few positions, far apart: a small drag should not skip one.
1393
+ _DRAG_THRESHOLD_PX = 25
1394
+
1395
+ def _step(self, delta: int):
1396
+ self.set(self._current_value + delta)
1397
+
1398
+
1399
+ class sCTkDialRange(sCTKDialBase):
1400
+ # This variant draws a plain line pointer rather than a dimple, so it
1401
+ # requires pointer_color instead of pointer_glow_color. pointer_color was
1402
+ # present in the theme file but never read by any code path until now.
1403
+ # label_font is required on the two variants that DRAW text. Continuous
1404
+ # draws none, so it does not carry the key.
1405
+ _EXTRA_THEME_KEYS = ("pointer_color", "label_font")
1406
+
1407
+ """
1408
+ Ranged potentiometer module tracking discrete integer boundaries.
1409
+ Enforces absolute dead stops (does not loop at thresholds) and reports absolute integer states.
1410
+ """
1411
+ def __init__(self, master=None, from_=0, to=100, arc_angle=270, command=None, left_click_callback=None, right_click_callback=None, knob_diameter=120, divisions=5, **kw):
1412
+ self._from = int(from_)
1413
+ self._to = int(to)
1414
+ self._arc_angle = float(arc_angle)
1415
+ super().__init__(master, divisions=divisions, knob_diameter=knob_diameter, **kw)
1416
+ self._command = command
1417
+ self._left_click_callback = left_click_callback if (left_click_callback and str(left_click_callback).strip()) else None
1418
+ self._right_click_callback = right_click_callback if (right_click_callback and str(right_click_callback).strip()) else None
1419
+ self._current_value = self._from
1420
+ self._custom_current_state = "normal" if self._state == "normal" else "disabled"
1421
+ self._finalize_themeable_lifecycle()
1422
+
1423
+ def _get_value_fraction(self):
1424
+ r = self._to - self._from
1425
+ return (self._current_value - self._from) / r if r > 0 else 0.0
1426
+
1427
+ def configure(self, *args, **kwargs):
1428
+ if args: return super().configure(*args, **kwargs)
1429
+ if "from_" in kwargs or "min_value" in kwargs: self._from = int(kwargs.pop("from_", kwargs.pop("min_value", 0)))
1430
+ if "to" in kwargs or "max_value" in kwargs: self._to = int(kwargs.pop("to", kwargs.pop("max_value", 100)))
1431
+ if "arc_angle" in kwargs: self._arc_angle = float(kwargs.pop("arc_angle"))
1432
+ if "divisions" in kwargs: self._divisions = int(kwargs.pop("divisions"))
1433
+ if "command" in kwargs: self._command = kwargs.pop("command")
1434
+ if "left_click_callback" in kwargs: self._left_click_callback = kwargs.pop("left_click_callback")
1435
+ if "right_click_callback" in kwargs: self._right_click_callback = kwargs.pop("right_click_callback")
1436
+ if "knob_diameter" in kwargs:
1437
+ # Sets the KNOB only. The canvas keeps whatever width and height it
1438
+ # has -- resizing the widget when the knob changes would move
1439
+ # everything else in the layout.
1440
+ self._knob_diameter = int(kwargs.pop("knob_diameter"))
1441
+ result = super().configure(**kwargs)
1442
+ if hasattr(self, "canvas") and self.canvas.winfo_exists(): self._draw_dial_base()
1443
+ return result
1444
+
1445
+
1446
+ # Tkinter/CTk convention binds .config to .configure as a SEPARATE class
1447
+ # attribute -- it does NOT track whichever configure() a subclass defines.
1448
+ # Without this, .config(...) skips every override in this class and lands
1449
+ # on the native widget, bypassing divisions/command/diameter handling and
1450
+ # the theme repaint entirely. Confirmed as a critical bug on
1451
+ # sCTkSegmentedButton earlier in this project's audit. Each subclass needs
1452
+ # its own line -- inheriting the alias would point at the PARENT's
1453
+ # configure(), not the subclass's.
1454
+ config = configure
1455
+ def cget(self, attribute_name: str) -> any:
1456
+ if attribute_name in ["from_", "min_value"]: return self._from
1457
+ if attribute_name in ["to", "max_value"]: return self._to
1458
+ if attribute_name == "arc_angle": return self._arc_angle
1459
+ if attribute_name == "command": return self._command
1460
+ if attribute_name == "divisions": return self._divisions
1461
+ return super().cget(attribute_name)
1462
+
1463
+ def set(self, value):
1464
+ target = max(self._from, min(self._to, int(value)))
1465
+ if target != self._current_value:
1466
+ self._current_value = target
1467
+ if self.canvas.winfo_exists(): self._redraw_indicator()
1468
+ if self._command is not None and self._state == "normal": self._command(self._current_value)
1469
+
1470
+ def get(self): return self._current_value
1471
+ def _step(self, delta: int):
1472
+ self.set(self._current_value + delta)