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.
- scustomtkinter/__init__.py +81 -0
- scustomtkinter/assets/images/error.png +0 -0
- scustomtkinter/assets/images/info.png +0 -0
- scustomtkinter/assets/images/warning.png +0 -0
- scustomtkinter/assets/sCTkThemes.json +1963 -0
- scustomtkinter/license_morecustomtkinterwidgets.txt +22 -0
- scustomtkinter/sctk_button_primary.py +366 -0
- scustomtkinter/sctk_button_secondary.py +354 -0
- scustomtkinter/sctk_button_tertiary.py +375 -0
- scustomtkinter/sctk_checkbox.py +270 -0
- scustomtkinter/sctk_combobox.py +155 -0
- scustomtkinter/sctk_core.py +15 -0
- scustomtkinter/sctk_dial.py +1472 -0
- scustomtkinter/sctk_dialog.py +701 -0
- scustomtkinter/sctk_dialog_toplevel.py +268 -0
- scustomtkinter/sctk_entry_primary.py +400 -0
- scustomtkinter/sctk_entry_secondary.py +342 -0
- scustomtkinter/sctk_file_explorer.py +879 -0
- scustomtkinter/sctk_frame.py +210 -0
- scustomtkinter/sctk_frame_labeled_primary.py +667 -0
- scustomtkinter/sctk_frame_labeled_secondary.py +598 -0
- scustomtkinter/sctk_frequency_display.py +636 -0
- scustomtkinter/sctk_label_primary.py +283 -0
- scustomtkinter/sctk_label_secondary.py +275 -0
- scustomtkinter/sctk_label_tertiary.py +278 -0
- scustomtkinter/sctk_messagebox.py +275 -0
- scustomtkinter/sctk_notebook.py +1103 -0
- scustomtkinter/sctk_optionmenu_border_mixin.py +203 -0
- scustomtkinter/sctk_optionmenu_primary.py +378 -0
- scustomtkinter/sctk_optionmenu_secondary.py +346 -0
- scustomtkinter/sctk_path_chooser.py +516 -0
- scustomtkinter/sctk_progress_bar.py +274 -0
- scustomtkinter/sctk_radiobutton.py +360 -0
- scustomtkinter/sctk_scroll_mixin.py +546 -0
- scustomtkinter/sctk_scrollable_frame.py +795 -0
- scustomtkinter/sctk_scrollbar.py +313 -0
- scustomtkinter/sctk_segmentedbutton.py +424 -0
- scustomtkinter/sctk_selector.py +581 -0
- scustomtkinter/sctk_separator.py +308 -0
- scustomtkinter/sctk_slider.py +264 -0
- scustomtkinter/sctk_smeter.py +405 -0
- scustomtkinter/sctk_smeter_bar.py +557 -0
- scustomtkinter/sctk_spinbox.py +664 -0
- scustomtkinter/sctk_switch.py +463 -0
- scustomtkinter/sctk_tableview.py +869 -0
- scustomtkinter/sctk_tabview.py +409 -0
- scustomtkinter/sctk_textbox_primary.py +291 -0
- scustomtkinter/sctk_textbox_secondary.py +225 -0
- scustomtkinter/sctk_toplevel.py +153 -0
- scustomtkinter/themeable_widget.py +938 -0
- scustomtkinter-0.2.1.dist-info/METADATA +336 -0
- scustomtkinter-0.2.1.dist-info/RECORD +101 -0
- scustomtkinter-0.2.1.dist-info/WHEEL +5 -0
- scustomtkinter-0.2.1.dist-info/entry_points.txt +2 -0
- scustomtkinter-0.2.1.dist-info/licenses/LICENSE +21 -0
- scustomtkinter-0.2.1.dist-info/licenses/scustomtkinter/license_morecustomtkinterwidgets.txt +22 -0
- scustomtkinter-0.2.1.dist-info/top_level.txt +2 -0
- scustomtkinter_pygubu/__init__.py +1 -0
- scustomtkinter_pygubu/designer/__init__.py +0 -0
- scustomtkinter_pygubu/designer/plugin.py +1854 -0
- scustomtkinter_pygubu/designer/properties.py +247 -0
- scustomtkinter_pygubu/sCTkButtonPrimarybo.py +46 -0
- scustomtkinter_pygubu/sCTkButtonSecondarybo.py +45 -0
- scustomtkinter_pygubu/sCTkButtonTertiarybo.py +44 -0
- scustomtkinter_pygubu/sCTkCheckBoxbo.py +45 -0
- scustomtkinter_pygubu/sCTkComboBoxbo.py +45 -0
- scustomtkinter_pygubu/sCTkCorebo.py +120 -0
- scustomtkinter_pygubu/sCTkDialbo.py +371 -0
- scustomtkinter_pygubu/sCTkDialogbo.py +397 -0
- scustomtkinter_pygubu/sCTkEntryPrimarybo.py +100 -0
- scustomtkinter_pygubu/sCTkEntrySecondarybo.py +98 -0
- scustomtkinter_pygubu/sCTkFileExplorerbo.py +189 -0
- scustomtkinter_pygubu/sCTkFrameLabeledPrimarybo.py +69 -0
- scustomtkinter_pygubu/sCTkFrameLabeledSecondarybo.py +71 -0
- scustomtkinter_pygubu/sCTkFramebo.py +46 -0
- scustomtkinter_pygubu/sCTkFrequencyDisplaybo.py +175 -0
- scustomtkinter_pygubu/sCTkLabelPrimarybo.py +45 -0
- scustomtkinter_pygubu/sCTkLabelSecondarybo.py +44 -0
- scustomtkinter_pygubu/sCTkLabelTertiarybo.py +45 -0
- scustomtkinter_pygubu/sCTkNotebookbo.py +392 -0
- scustomtkinter_pygubu/sCTkOptionMenuPrimarybo.py +45 -0
- scustomtkinter_pygubu/sCTkOptionMenuSecondarybo.py +39 -0
- scustomtkinter_pygubu/sCTkPathChooserbo.py +204 -0
- scustomtkinter_pygubu/sCTkProgressBarbo.py +45 -0
- scustomtkinter_pygubu/sCTkRadioButtonbo.py +45 -0
- scustomtkinter_pygubu/sCTkSMeterBarbo.py +153 -0
- scustomtkinter_pygubu/sCTkSMeterbo.py +101 -0
- scustomtkinter_pygubu/sCTkScrollableFramebo.py +66 -0
- scustomtkinter_pygubu/sCTkScrollbarbo.py +48 -0
- scustomtkinter_pygubu/sCTkSegmentedButtonbo.py +47 -0
- scustomtkinter_pygubu/sCTkSelectorbo.py +167 -0
- scustomtkinter_pygubu/sCTkSeparatorbo.py +141 -0
- scustomtkinter_pygubu/sCTkSliderbo.py +45 -0
- scustomtkinter_pygubu/sCTkSpinboxbo.py +200 -0
- scustomtkinter_pygubu/sCTkSwitchbo.py +45 -0
- scustomtkinter_pygubu/sCTkTableviewbo.py +188 -0
- scustomtkinter_pygubu/sCTkTabviewbo.py +226 -0
- scustomtkinter_pygubu/sCTkTextboxPrimarybo.py +45 -0
- scustomtkinter_pygubu/sCTkTextboxSecondarybo.py +45 -0
- scustomtkinter_pygubu/sCTkToplevelbo.py +55 -0
- 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)
|