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,664 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
"""
|
|
3
|
+
sCTkSpinbox
|
|
4
|
+
|
|
5
|
+
A theme-compliant, highly configurable custom spinbox wrapper component.
|
|
6
|
+
Operates entirely programmatically via get() and set() methods, bypassing
|
|
7
|
+
textvariable trace conflicts to guarantee pristine placeholder rendering.
|
|
8
|
+
|
|
9
|
+
Supports a genuine three-state model: normal / readonly / disabled, matching
|
|
10
|
+
real ttk.Spinbox semantics. In readonly mode, the entry can't be typed into
|
|
11
|
+
directly, but the increment/decrement buttons stay fully clickable -- an
|
|
12
|
+
earlier version collapsed anything that wasn't literally "disabled" into
|
|
13
|
+
"normal", silently discarding any readonly request (confirmed: two separate
|
|
14
|
+
mechanisms both erased it -- the state-cascade applied one value uniformly to
|
|
15
|
+
entry and buttons, which can't express the distinction readonly needs, and
|
|
16
|
+
_apply_custom_theme_colors() ran a hardcoded disabled-or-normal binary check
|
|
17
|
+
unconditionally after every configure() call, overwriting whatever the
|
|
18
|
+
cascade had done). Readonly routes through sCTkEntryPrimary's own three-state
|
|
19
|
+
_update_current_visual_state(), which is where the actual readonly colors and
|
|
20
|
+
required-key validation live -- see that widget for the full detail.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import customtkinter as ctk
|
|
24
|
+
from .themeable_widget import ThemeableWidget, parse_list_property
|
|
25
|
+
|
|
26
|
+
from .sctk_entry_primary import sCTkEntryPrimary
|
|
27
|
+
|
|
28
|
+
class sCTkSpinbox(ctk.CTkFrame, ThemeableWidget):
|
|
29
|
+
def __init__(self, master=None, from_=0.0, to=100.0, step_size=1.0, command=None,
|
|
30
|
+
state="normal", wrap=False, justify="left", show=None,
|
|
31
|
+
placeholder_text=None, exportselection=True, width=140, height=32, **kw):
|
|
32
|
+
|
|
33
|
+
ThemeableWidget.__init__(self, kw)
|
|
34
|
+
self._local_defaults = dict(self.final_kw)
|
|
35
|
+
# FIX: an earlier version read "disabled_map" out of self._local_defaults
|
|
36
|
+
# (== dict(self.final_kw)), but ThemeableWidget.__init__ deliberately
|
|
37
|
+
# excludes "disabled_map" from final_kw -- this always evaluated to the
|
|
38
|
+
# empty-dict default, meaning EVERY disabled-state color lookup below
|
|
39
|
+
# silently fell back to its hardcoded literal instead of the real
|
|
40
|
+
# theme. Confirmed identical bug, same fix, as sCTkSwitch and
|
|
41
|
+
# sCTkTableview elsewhere in this project.
|
|
42
|
+
self._custom_disabled_map = dict(self._widget_disabled_map)
|
|
43
|
+
# readonly_map: mirrors sCTkEntryPrimary's own pattern -- required
|
|
44
|
+
# only when readonly is actually requested, validated in
|
|
45
|
+
# _apply_custom_theme_colors().
|
|
46
|
+
self._custom_readonly_map = dict(self._widget_readonly_map)
|
|
47
|
+
|
|
48
|
+
button_width = self._local_defaults.pop("button_width", 22)
|
|
49
|
+
button_height = self._local_defaults.pop("button_height", None)
|
|
50
|
+
button_side = self._local_defaults.pop("button_side", "right")
|
|
51
|
+
orientation = self._local_defaults.pop("orientation", "vertical")
|
|
52
|
+
# FIX: an earlier version only ever looked for "arrow_font_size" (a
|
|
53
|
+
# bare number) here, but the real theme key is "arrow_font" -- a full
|
|
54
|
+
# (family, size, weight) tuple, matching the convention used for
|
|
55
|
+
# every other font in this theme file. Since "arrow_font_size" never
|
|
56
|
+
# actually existed in the theme, this always silently fell back to
|
|
57
|
+
# its hardcoded default (11), and the font family/weight were
|
|
58
|
+
# separately hardcoded to "Arial"/"normal" a few lines down --
|
|
59
|
+
# meaning the real theme's arrow_font value was never applied at
|
|
60
|
+
# all. Now reads arrow_font properly, while still allowing
|
|
61
|
+
# arrow_font_size to independently override just the size (e.g. via
|
|
62
|
+
# configure(arrow_font_size=14)) without needing to respecify the
|
|
63
|
+
# full tuple.
|
|
64
|
+
arrow_font_theme = self._local_defaults.pop("arrow_font", None)
|
|
65
|
+
if isinstance(arrow_font_theme, (list, tuple)) and len(arrow_font_theme) >= 2:
|
|
66
|
+
default_arrow_family = str(arrow_font_theme[0])
|
|
67
|
+
default_arrow_size = arrow_font_theme[1]
|
|
68
|
+
default_arrow_weight = str(arrow_font_theme[2]) if len(arrow_font_theme) >= 3 else "normal"
|
|
69
|
+
else:
|
|
70
|
+
default_arrow_family, default_arrow_size, default_arrow_weight = "Arial", 11, "normal"
|
|
71
|
+
arrow_font_size = self._local_defaults.pop("arrow_font_size", default_arrow_size)
|
|
72
|
+
format_str = self._local_defaults.pop("format", None)
|
|
73
|
+
values = self._local_defaults.pop("values", None)
|
|
74
|
+
wrap_val = kw.pop("wrap", wrap)
|
|
75
|
+
|
|
76
|
+
self.final_kw.pop("state", None)
|
|
77
|
+
for pop_key in ["fg_color", "text_color", "entry_color", "border_color", "border_width",
|
|
78
|
+
"corner_radius", "font", "placeholder_text_color", "button_color",
|
|
79
|
+
"button_hover_color", "disabled_text_color", "disabled_entry_color",
|
|
80
|
+
"disabled_border_color", "disabled_button_color",
|
|
81
|
+
"arrow_font", "arrow_up_char", "arrow_down_char", "arrow_right_char", "arrow_left_char",
|
|
82
|
+
"button_width", "button_height", "button_side", "orientation", "format", "values", "wrap"]:
|
|
83
|
+
self.final_kw.pop(pop_key, None)
|
|
84
|
+
|
|
85
|
+
super().__init__(master, width=width, height=height, fg_color="transparent", **self.final_kw)
|
|
86
|
+
|
|
87
|
+
self._arrow_font_family = default_arrow_family
|
|
88
|
+
self._arrow_font_weight = default_arrow_weight
|
|
89
|
+
self._arrow_font_size = int(arrow_font_size)
|
|
90
|
+
self._from, self._to, self._step_size = float(from_), float(to), float(step_size)
|
|
91
|
+
self._wrap = wrap_val if isinstance(wrap_val, bool) else (str(wrap_val).lower() in ("true", "1", "yes"))
|
|
92
|
+
self._command, self._placeholder_text, self._format = command, placeholder_text, (str(format_str) if format_str else "")
|
|
93
|
+
self._current_numeric_value = self._from
|
|
94
|
+
|
|
95
|
+
self._values = self._parse_string_list(values) if values else []
|
|
96
|
+
self._current_index = 0 if self._values else -1
|
|
97
|
+
self._button_width, self._button_side, self._orientation = int(button_width), str(button_side).lower(), str(orientation).lower()
|
|
98
|
+
# FIX: an earlier version collapsed anything that wasn't literally
|
|
99
|
+
# "normal" into "disabled" -- meaning state="readonly" passed to the
|
|
100
|
+
# constructor was silently treated as disabled. Now supports a
|
|
101
|
+
# genuine three-state model matching ttk.Spinbox's real semantics
|
|
102
|
+
# (normal/readonly/disabled); see class docstring.
|
|
103
|
+
_initial_state = str(state).lower()
|
|
104
|
+
self._state = _initial_state if _initial_state in ("normal", "readonly", "disabled") else "normal"
|
|
105
|
+
self._button_height = int(button_height) if button_height is not None else ((height // 2) - 1 if self._orientation == "vertical" else height)
|
|
106
|
+
|
|
107
|
+
used_buttons = 2 if self._button_side == "split" or self._orientation == "horizontal" else 1
|
|
108
|
+
self.entry = sCTkEntryPrimary(self, width=width - (self._button_width * used_buttons), height=height, justify=justify, show=show, placeholder_text=self._placeholder_text, exportselection=exportselection)
|
|
109
|
+
self.up_button = ctk.CTkButton(self, text="â–²" if self._orientation == "vertical" else "â–¶", width=self._button_width, height=self._button_height, corner_radius=2, command=self._increment_callback)
|
|
110
|
+
self.down_button = ctk.CTkButton(self, text="â–¼" if self._orientation == "vertical" else "â—€", width=self._button_width, height=self._button_height, corner_radius=2, command=self._decrement_callback)
|
|
111
|
+
|
|
112
|
+
if not self._placeholder_text or str(self._placeholder_text).strip() == "":
|
|
113
|
+
# FIX: was str(self._values) -- the whole LIST, so the entry showed
|
|
114
|
+
# "['Porsche', 'VW', 'Tesla']" instead of the first value. In list
|
|
115
|
+
# mode a spinbox displays one value at a time; _current_index tracks
|
|
116
|
+
# which. Same mistake at both set_values() and configure() below.
|
|
117
|
+
self.entry.insert(0, str(self._values[0]) if self._values else self._format_value(self._from))
|
|
118
|
+
|
|
119
|
+
self.entry.bind("<FocusOut>", lambda e: self._validate_and_sanitize_input())
|
|
120
|
+
self.entry.bind("<Return>", lambda e: self._validate_and_sanitize_input())
|
|
121
|
+
|
|
122
|
+
self._rebuild_grid_layout()
|
|
123
|
+
self._apply_custom_theme_colors()
|
|
124
|
+
|
|
125
|
+
# FIX: this used to be
|
|
126
|
+
# if self._state == "disabled": super().configure(state="disabled")
|
|
127
|
+
# -- but this widget is a CTkFrame subclass, and CTkFrame has no
|
|
128
|
+
# `state` option at all:
|
|
129
|
+
#
|
|
130
|
+
# ValueError: ['state'] are not supported arguments.
|
|
131
|
+
#
|
|
132
|
+
# It only fired when the spinbox was CONSTRUCTED already disabled,
|
|
133
|
+
# which is what a Designer preview does -- the .ui carries the state,
|
|
134
|
+
# so it reaches the constructor rather than a later configure() call.
|
|
135
|
+
# Building one disabled in application code failed the same way.
|
|
136
|
+
#
|
|
137
|
+
# Nothing is needed here: _apply_custom_theme_colors() above already
|
|
138
|
+
# reads self._state and disables the entry and the arrow buttons, which
|
|
139
|
+
# is where this widget's interactivity actually lives. The frame itself
|
|
140
|
+
# has no state to set.
|
|
141
|
+
self._finalize_themeable_lifecycle()
|
|
142
|
+
|
|
143
|
+
def _parse_string_list(self, input_data) -> list:
|
|
144
|
+
"""
|
|
145
|
+
Parses this widget's `values` property.
|
|
146
|
+
|
|
147
|
+
Delegates to the library-wide parse_list_property() so `values`
|
|
148
|
+
behaves identically to sCTkTableview's `columns`, sCTkSelector's
|
|
149
|
+
`items` and sCTkDialSelector's `labels`. Retained as a method rather
|
|
150
|
+
than replaced at each call site because set_values() and configure()
|
|
151
|
+
both use it.
|
|
152
|
+
|
|
153
|
+
FIX: this previously used shlex.split() whenever the input contained
|
|
154
|
+
no comma, making SPACE a value separator here and nowhere else in the
|
|
155
|
+
library -- so "Meat Loaf" was two values in a spinbox and one value in
|
|
156
|
+
every other widget. Space separation is deliberately dropped rather
|
|
157
|
+
than propagated; a value containing a comma should be quoted.
|
|
158
|
+
"""
|
|
159
|
+
return parse_list_property(input_data)
|
|
160
|
+
|
|
161
|
+
def set_values(self, list_of_strings):
|
|
162
|
+
self._values = self._parse_string_list(list_of_strings)
|
|
163
|
+
self._current_index = 0 if self._values else -1
|
|
164
|
+
# set() takes a SINGLE value; in list mode it renders str(value)
|
|
165
|
+
# directly, so passing the list here printed its repr into the entry.
|
|
166
|
+
self.set(self._values[0] if self._values else getattr(self, "_from", 0.0))
|
|
167
|
+
|
|
168
|
+
def _rebuild_grid_layout(self):
|
|
169
|
+
if not hasattr(self, "entry") or not self.entry.winfo_exists(): return
|
|
170
|
+
for i in range(3): self.grid_columnconfigure(i, weight=0, minsize=0)
|
|
171
|
+
for i in range(2): self.grid_rowconfigure(i, weight=0, minsize=0)
|
|
172
|
+
self.entry.grid_forget(); self.up_button.grid_forget(); self.down_button.grid_forget()
|
|
173
|
+
|
|
174
|
+
up_char = self._local_defaults.get("arrow_right_char", "â–¶") if self._orientation == "horizontal" else self._local_defaults.get("arrow_up_char", "â–²")
|
|
175
|
+
down_char = self._local_defaults.get("arrow_left_char", "â—€") if self._orientation == "horizontal" else self._local_defaults.get("arrow_down_char", "â–¼")
|
|
176
|
+
self.up_button.configure(text=up_char, font=(self._arrow_font_family, self._arrow_font_size, self._arrow_font_weight))
|
|
177
|
+
self.down_button.configure(text=down_char, font=(self._arrow_font_family, self._arrow_font_size, self._arrow_font_weight))
|
|
178
|
+
|
|
179
|
+
if self._orientation == "horizontal":
|
|
180
|
+
self.grid_rowconfigure(0, weight=1); self.grid_columnconfigure(1, weight=1)
|
|
181
|
+
if self._button_side == "left":
|
|
182
|
+
self.down_button.grid(row=0, column=0, padx=(0, 1), sticky="nsew"); self.up_button.grid(row=0, column=1, padx=(1, 1), sticky="nsew"); self.entry.grid(row=0, column=2, padx=(1, 0), sticky="nsew")
|
|
183
|
+
elif self._button_side == "split":
|
|
184
|
+
self.down_button.grid(row=0, column=0, padx=(0, 2), sticky="nsew"); self.entry.grid(row=0, column=1, padx=(2, 2), sticky="nsew"); self.up_button.grid(row=0, column=2, padx=(2, 0), sticky="nsew")
|
|
185
|
+
else:
|
|
186
|
+
self.entry.grid(row=0, column=0, padx=(0, 1), sticky="nsew"); self.down_button.grid(row=0, column=1, padx=(1, 1), sticky="nsew"); self.up_button.grid(row=0, column=2, padx=(1, 0), sticky="nsew")
|
|
187
|
+
else:
|
|
188
|
+
self.grid_rowconfigure((0, 1), weight=1); self.grid_columnconfigure(0, weight=1)
|
|
189
|
+
if self._button_side == "left":
|
|
190
|
+
self.grid_columnconfigure(1, weight=1); self.up_button.grid(row=0, column=0, padx=(0, 1), pady=(0, 1), sticky="nsew"); self.down_button.grid(row=1, column=0, padx=(0, 1), pady=(1, 0), sticky="nsew"); self.entry.grid(row=0, column=1, rowspan=2, padx=(1, 0), sticky="nsew")
|
|
191
|
+
elif self._button_side == "split":
|
|
192
|
+
self.grid_columnconfigure(1, weight=1); self.down_button.grid(row=0, column=0, rowspan=2, padx=(0, 2), sticky="nsew"); self.entry.grid(row=0, column=1, rowspan=2, padx=(2, 2), sticky="nsew"); self.up_button.grid(row=0, column=2, rowspan=2, padx=(2, 0), sticky="nsew")
|
|
193
|
+
else:
|
|
194
|
+
self.entry.grid(row=0, column=0, rowspan=2, padx=(0, 2), sticky="nsew"); self.up_button.grid(row=0, column=1, padx=(1, 0), pady=(0, 1), sticky="nsew"); self.down_button.grid(row=1, column=1, padx=(1, 0), pady=(1, 0), sticky="nsew")
|
|
195
|
+
self.update_idletasks()
|
|
196
|
+
# Native constructor defaults, for the single-argument query below.
|
|
197
|
+
# Anything not listed reports its current value as its default.
|
|
198
|
+
_NATIVE_QUERY_DEFAULTS = {"width": 140, "height": 32}
|
|
199
|
+
|
|
200
|
+
def configure(self, require_redraw=None, **kwargs):
|
|
201
|
+
"""Standardized configuration handler supporting Pygubu workspace properties switches."""
|
|
202
|
+
if require_redraw is not None and not kwargs and isinstance(require_redraw, str):
|
|
203
|
+
mapping = {
|
|
204
|
+
"state": ("state", "state", "state", "normal", str(getattr(self, "_state", "normal"))),
|
|
205
|
+
"from_": ("from_", "from_", "from_", "0.0", str(getattr(self, "_from", 0.0))),
|
|
206
|
+
"to": ("to", "to", "to", "100.0", str(getattr(self, "_to", 100.0))),
|
|
207
|
+
"step_size": ("step_size", "step_size", "step_size", "1.0", str(getattr(self, "_step_size", 1.0))),
|
|
208
|
+
"button_width": ("button_width", "button_width", "button_width", "22", str(getattr(self, "_button_width", 22))),
|
|
209
|
+
"button_height": ("button_height", "button_height", "button_height", "", str(getattr(self, "_button_height", ""))),
|
|
210
|
+
"button_side": ("button_side", "button_side", "button_side", "right", str(getattr(self, "_button_side", "right"))),
|
|
211
|
+
"orientation": ("orientation", "orientation", "orientation", "vertical", str(getattr(self, "_orientation", "vertical"))),
|
|
212
|
+
"justify": ("justify", "justify", "justify", "left", str(self.entry.cget("justify"))),
|
|
213
|
+
"placeholder_text": ("placeholder_text", "placeholder_text", "placeholder_text", "", str(getattr(self, "_placeholder_text", ""))),
|
|
214
|
+
"format": ("format", "format", "format", "", str(getattr(self, "_format", ""))),
|
|
215
|
+
"wrap": ("wrap", "wrap", "wrap", "False", str(getattr(self, "_wrap", False))),
|
|
216
|
+
# FIX: was a hand-rolled space-joined string, quoting only the
|
|
217
|
+
# values that contained a space:
|
|
218
|
+
#
|
|
219
|
+
# 160m 80m "Meat Loaf" 40m
|
|
220
|
+
#
|
|
221
|
+
# Nothing else in the library uses that shape, and nothing
|
|
222
|
+
# reads it back -- parse_list_property() dropped space as a
|
|
223
|
+
# separator precisely so `Meat Loaf` would not become two
|
|
224
|
+
# values here and one everywhere else. So the Designer showed
|
|
225
|
+
# a format it could not itself accept.
|
|
226
|
+
#
|
|
227
|
+
# _query_value() already knows how: `values` is in
|
|
228
|
+
# ThemeableWidget._LIST_PROPERTIES, and it emits the bracketed
|
|
229
|
+
# JSON form the inspector's own help text recommends.
|
|
230
|
+
"values": ("values", "values", "values", "",
|
|
231
|
+
self._query_value(getattr(self, "_values", []), "values"))
|
|
232
|
+
}
|
|
233
|
+
if require_redraw in mapping: return mapping[require_redraw]
|
|
234
|
+
# FIX: this used to be `return super().configure(require_redraw)`.
|
|
235
|
+
#
|
|
236
|
+
# The native configure() is declared
|
|
237
|
+
# configure(self, require_redraw=False, **kwargs), so the property
|
|
238
|
+
# NAME was passed as require_redraw and the call returned None.
|
|
239
|
+
# Pygubu's _get_default_value() expects a five-element tuple, got
|
|
240
|
+
# None, and handed that None back to _set_property() -- where
|
|
241
|
+
# CustomTkinter raised "float() argument must be a string or a real
|
|
242
|
+
# number, not 'NoneType'".
|
|
243
|
+
#
|
|
244
|
+
# Reached whenever a field is blanked in the Designer inspector,
|
|
245
|
+
# which calls configure(name) to read the property's default.
|
|
246
|
+
#
|
|
247
|
+
# A proper Tkinter-style tuple is returned instead, built from
|
|
248
|
+
# cget() so the reported current value is real. Properties with no
|
|
249
|
+
# known native default report their current value as the default,
|
|
250
|
+
# making a blank a no-op rather than a jump to a guessed value.
|
|
251
|
+
try:
|
|
252
|
+
_current = self.cget(require_redraw)
|
|
253
|
+
except Exception:
|
|
254
|
+
_current = None
|
|
255
|
+
# FIX: the default came from _NATIVE_QUERY_DEFAULTS or, failing
|
|
256
|
+
# that, from cget() -- the CURRENT value. So clearing a themed
|
|
257
|
+
# property in the Designer reported whatever override was in place
|
|
258
|
+
# as its own default and put it straight back.
|
|
259
|
+
#
|
|
260
|
+
# The THEME's value is the right answer, taken from an untouched
|
|
261
|
+
# copy so a runtime override cannot corrupt it -- see
|
|
262
|
+
# ThemeableWidget._theme_default().
|
|
263
|
+
_default = self._NATIVE_QUERY_DEFAULTS.get(require_redraw)
|
|
264
|
+
if _default is None:
|
|
265
|
+
_default = self._theme_default(require_redraw)
|
|
266
|
+
if _default is None:
|
|
267
|
+
_default = _current
|
|
268
|
+
return (require_redraw, require_redraw, require_redraw,
|
|
269
|
+
self._query_value(_default, require_redraw),
|
|
270
|
+
self._query_value(_current, require_redraw))
|
|
271
|
+
|
|
272
|
+
if isinstance(require_redraw, dict): kwargs.update(require_redraw)
|
|
273
|
+
if "wrap" in kwargs: self._wrap = kwargs.pop("wrap") if isinstance(kwargs["wrap"], bool) else (str(kwargs.pop("wrap")).lower() in ("true", "1", "yes"))
|
|
274
|
+
if "values" in kwargs:
|
|
275
|
+
self._values = self._parse_string_list(kwargs.pop("values"))
|
|
276
|
+
self._current_index = 0 if self._values else -1
|
|
277
|
+
if self._values: self.set(self._values[0])
|
|
278
|
+
|
|
279
|
+
for key in ["from_", "to", "step_size"]:
|
|
280
|
+
if key in kwargs: setattr(self, f"_{key}", float(kwargs.pop(key) or (0.0 if key == 'from_' else 100.0 if key == 'to' else 1.0)))
|
|
281
|
+
|
|
282
|
+
if "command" in kwargs: self._command = kwargs.pop("command")
|
|
283
|
+
if "format" in kwargs:
|
|
284
|
+
self._format = str(kwargs.pop("format") or "")
|
|
285
|
+
if not self._values: self.set(self._current_numeric_value)
|
|
286
|
+
|
|
287
|
+
rebuild_grid = False
|
|
288
|
+
if "button_width" in kwargs: self._button_width = int(kwargs.pop("button_width")); self.up_button.configure(width=self._button_width); self.down_button.configure(width=self._button_width); rebuild_grid = True
|
|
289
|
+
if "button_height" in kwargs: self._button_height = int(kwargs.pop("button_height")); self.up_button.configure(width=self._button_width, height=self._button_height); self.down_button.configure(width=self._button_width, height=self._button_height); rebuild_grid = True
|
|
290
|
+
if "button_side" in kwargs: self._button_side = str(kwargs.pop("button_side") or "right").strip().lower(); rebuild_grid = True
|
|
291
|
+
if "orientation" in kwargs:
|
|
292
|
+
self._orientation = str(kwargs.pop("orientation") or "vertical").strip().lower()
|
|
293
|
+
if not kwargs.get("button_height") and hasattr(self, "cget"):
|
|
294
|
+
self._button_height = (int(self.cget("height")) // 2) - 1 if self._orientation == "vertical" else int(self.cget("height"))
|
|
295
|
+
self.up_button.configure(height=self._button_height); self.down_button.configure(height=self._button_height)
|
|
296
|
+
rebuild_grid = True
|
|
297
|
+
|
|
298
|
+
if "arrow_font_size" in kwargs: self._arrow_font_size = int(kwargs.pop("arrow_font_size")); rebuild_grid = True
|
|
299
|
+
# New: allows setting the full (family, size, weight) tuple at once,
|
|
300
|
+
# now that these attributes are genuinely used rather than
|
|
301
|
+
# hardcoded. arrow_font_size above remains available too, for
|
|
302
|
+
# changing just the size without respecifying family/weight.
|
|
303
|
+
if "arrow_font" in kwargs:
|
|
304
|
+
new_arrow_font = kwargs.pop("arrow_font")
|
|
305
|
+
if isinstance(new_arrow_font, (list, tuple)) and len(new_arrow_font) >= 2:
|
|
306
|
+
self._arrow_font_family = str(new_arrow_font[0])
|
|
307
|
+
self._arrow_font_size = int(new_arrow_font[1])
|
|
308
|
+
self._arrow_font_weight = str(new_arrow_font[2]) if len(new_arrow_font) >= 3 else "normal"
|
|
309
|
+
rebuild_grid = True
|
|
310
|
+
if "placeholder_text" in kwargs:
|
|
311
|
+
self._placeholder_text = kwargs["placeholder_text"]
|
|
312
|
+
if self._placeholder_text and str(self._placeholder_text).strip() != "" and hasattr(self, "entry") and self.entry.winfo_exists():
|
|
313
|
+
# FIX: this compared the entry text against str(self._from) --
|
|
314
|
+
# "0.0" -- but __init__ inserts self._format_value(self._from),
|
|
315
|
+
# which with a format string produces something like "0.00".
|
|
316
|
+
# The strings never matched, so setting placeholder_text at
|
|
317
|
+
# runtime left the initial value in place and the placeholder
|
|
318
|
+
# never showed. The formatted form is now compared too.
|
|
319
|
+
_auto_initial = {"", "0", "0.0", str(getattr(self, "_from", 0.0))}
|
|
320
|
+
try:
|
|
321
|
+
_auto_initial.add(self._format_value(self._from))
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
if str(self.entry.get()).strip() in _auto_initial:
|
|
325
|
+
old = self.entry.cget("state"); self.entry.configure(state="normal"); self.entry.delete(0, "end"); self.entry.configure(state=old)
|
|
326
|
+
|
|
327
|
+
for entry_attr in ["justify", "show", "placeholder_text", "exportselection"]:
|
|
328
|
+
if entry_attr in kwargs:
|
|
329
|
+
if hasattr(self, "entry") and self.entry.winfo_exists(): self.entry.configure(**{entry_attr: kwargs[entry_attr]})
|
|
330
|
+
kwargs.pop(entry_attr)
|
|
331
|
+
|
|
332
|
+
if rebuild_grid and hasattr(self, "cget") and hasattr(self, "entry"):
|
|
333
|
+
self.entry.configure(width=int(self.cget("width")) - (self._button_width * (2 if self._button_side == "split" or self._orientation == "horizontal" else 1)))
|
|
334
|
+
self._rebuild_grid_layout()
|
|
335
|
+
|
|
336
|
+
# Runtime overrides have to reach the map a repaint reads, or the
|
|
337
|
+
# repaint puts the theme value straight back -- see
|
|
338
|
+
# ThemeableWidget._record_theme_overrides().
|
|
339
|
+
self._record_theme_overrides(kwargs)
|
|
340
|
+
|
|
341
|
+
if "state" in kwargs:
|
|
342
|
+
self._state = str(kwargs.pop("state")).lower()
|
|
343
|
+
if self._state not in ("normal", "readonly", "disabled"):
|
|
344
|
+
self._state = "normal"
|
|
345
|
+
# Entry gets the full three-way state -- routed through its own
|
|
346
|
+
# state() so sCTkEntryPrimary's own readonly_map/disabled_map
|
|
347
|
+
# color logic applies correctly. Buttons only ever get normal/
|
|
348
|
+
# disabled -- "readonly" is not a real native CTkButton state,
|
|
349
|
+
# and per ttk.Spinbox's own readonly semantics, the
|
|
350
|
+
# increment/decrement arrows stay fully clickable in readonly
|
|
351
|
+
# mode; only the entry becomes non-typable.
|
|
352
|
+
if hasattr(self.entry, "winfo_exists") and self.entry.winfo_exists():
|
|
353
|
+
if hasattr(self.entry, "state"):
|
|
354
|
+
self.entry.state(self._state)
|
|
355
|
+
else:
|
|
356
|
+
self.entry.configure(state=self._state)
|
|
357
|
+
button_state = "disabled" if self._state == "disabled" else "normal"
|
|
358
|
+
for child in [self.up_button, self.down_button]:
|
|
359
|
+
if hasattr(child, "winfo_exists") and child.winfo_exists(): child.configure(state=button_state)
|
|
360
|
+
|
|
361
|
+
for pop_custom_key in ["from_", "to", "step_size", "button_width", "button_height", "button_side", "orientation", "arrow_font_size", "arrow_font", "format", "values", "wrap"]: kwargs.pop(pop_custom_key, None)
|
|
362
|
+
self._drop_cleared(kwargs)
|
|
363
|
+
|
|
364
|
+
if kwargs: super().configure(**kwargs)
|
|
365
|
+
self._apply_custom_theme_colors()
|
|
366
|
+
|
|
367
|
+
config = configure
|
|
368
|
+
|
|
369
|
+
def cget(self, attribute_name):
|
|
370
|
+
pname = str(attribute_name).lower()
|
|
371
|
+
if pname == "arrow_font":
|
|
372
|
+
# Special case: no single self._arrow_font attribute exists --
|
|
373
|
+
# family/size/weight are tracked separately (see __init__), so
|
|
374
|
+
# the generic dynamic-lookup pattern below can't be reused here.
|
|
375
|
+
return (self._arrow_font_family, self._arrow_font_size, self._arrow_font_weight)
|
|
376
|
+
if pname in ["state", "from_", "to", "step_size", "button_width", "button_height", "button_side", "orientation",
|
|
377
|
+
"arrow_font_size", "format", "wrap", "values"]: return getattr(self, f"_{pname}")
|
|
378
|
+
return super().cget(attribute_name)
|
|
379
|
+
|
|
380
|
+
def _format_value(self, val: float) -> str:
|
|
381
|
+
fmt = self._format.strip() if hasattr(self, "_format") and self._format else ""
|
|
382
|
+
if fmt:
|
|
383
|
+
if "{" in fmt and "}" in fmt:
|
|
384
|
+
try:
|
|
385
|
+
return fmt.format(val)
|
|
386
|
+
except Exception:
|
|
387
|
+
pass
|
|
388
|
+
elif ":" in fmt and not "{" in fmt:
|
|
389
|
+
try:
|
|
390
|
+
return ("{" + fmt + "}").format(val)
|
|
391
|
+
except Exception:
|
|
392
|
+
pass
|
|
393
|
+
elif "%" in fmt:
|
|
394
|
+
try:
|
|
395
|
+
return fmt % val
|
|
396
|
+
except Exception:
|
|
397
|
+
pass
|
|
398
|
+
dec_places = len(str(self._step_size).split('.')) if '.' in str(self._step_size) else 0
|
|
399
|
+
return f"{val:.{dec_places}f}"
|
|
400
|
+
|
|
401
|
+
def get(self) -> str:
|
|
402
|
+
return str(self.entry.get()) if (hasattr(self, "entry") and self.entry.winfo_exists()) else str(
|
|
403
|
+
getattr(self, "_from", "0.0"))
|
|
404
|
+
|
|
405
|
+
def set(self, value, _emit=False):
|
|
406
|
+
"""
|
|
407
|
+
Sets the value. Does NOT fire the command.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
value: The new value.
|
|
411
|
+
_emit: Internal. True only for the paths driven by the USER --
|
|
412
|
+
the arrows, typing, the wheel -- which do fire the command.
|
|
413
|
+
Not part of the public interface.
|
|
414
|
+
"""
|
|
415
|
+
try:
|
|
416
|
+
if not getattr(self, "_values", None):
|
|
417
|
+
num = float(value)
|
|
418
|
+
if num < self._from: num = self._from
|
|
419
|
+
if num > self._to: num = self._to
|
|
420
|
+
self._current_numeric_value = num
|
|
421
|
+
display_text, callback_val = self._format_value(num), num
|
|
422
|
+
else:
|
|
423
|
+
display_text = str(value)
|
|
424
|
+
if display_text in self._values: self._current_index = self._values.index(display_text)
|
|
425
|
+
callback_val = display_text
|
|
426
|
+
|
|
427
|
+
old = self.entry.cget("state");
|
|
428
|
+
self.entry.configure(state="normal");
|
|
429
|
+
self.entry.delete(0, "end");
|
|
430
|
+
self.entry.insert(0, display_text);
|
|
431
|
+
self.entry.configure(state=old)
|
|
432
|
+
|
|
433
|
+
# DELIBERATELY DOES NOT FIRE THE COMMAND.
|
|
434
|
+
#
|
|
435
|
+
# This used to call self._command(callback_val), and it made
|
|
436
|
+
# construction order load-bearing: setting an initial value while
|
|
437
|
+
# building an interface called back into one that did not exist
|
|
438
|
+
# yet, and the traceback named a widget rather than the real
|
|
439
|
+
# cause. It also risks a loop where a command sets the value back.
|
|
440
|
+
#
|
|
441
|
+
# CustomTkinter's own widgets behave this way for the same reason
|
|
442
|
+
# -- CTkSlider.set(), CTkSegmentedButton.set() and
|
|
443
|
+
# CTkOptionMenu.set() all update quietly. A programmatic set is
|
|
444
|
+
# the application saying what the value is, and the application
|
|
445
|
+
# already knows.
|
|
446
|
+
#
|
|
447
|
+
# The command still fires for anything the USER does: the arrows,
|
|
448
|
+
# typing in the field, the mouse wheel. Those paths call this
|
|
449
|
+
# method with _emit=True.
|
|
450
|
+
if _emit and self._command:
|
|
451
|
+
try:
|
|
452
|
+
self._command(callback_val)
|
|
453
|
+
except TypeError:
|
|
454
|
+
self._command()
|
|
455
|
+
except ValueError:
|
|
456
|
+
if getattr(self, "_values", None):
|
|
457
|
+
display_text = str(value)
|
|
458
|
+
if display_text in self._values: self._current_index = self._values.index(display_text)
|
|
459
|
+
# FIX: an earlier version hardcoded state="normal" if self._state
|
|
460
|
+
# == "normal" else "readonly" here -- meaning a disabled spinbox
|
|
461
|
+
# would incorrectly end up "readonly" instead of staying
|
|
462
|
+
# disabled. Uses the same save/restore pattern already correct
|
|
463
|
+
# in the success path above, which works regardless of which
|
|
464
|
+
# of the three real states (normal/readonly/disabled) was
|
|
465
|
+
# actually active.
|
|
466
|
+
old = self.entry.cget("state")
|
|
467
|
+
self.entry.configure(state="normal")
|
|
468
|
+
self.entry.delete(0, "end")
|
|
469
|
+
self.entry.insert(0, display_text)
|
|
470
|
+
self.entry.configure(state=old)
|
|
471
|
+
|
|
472
|
+
def _increment_callback(self):
|
|
473
|
+
if self._state == "disabled": return
|
|
474
|
+
if hasattr(self, "_values") and self._values:
|
|
475
|
+
i = self._current_index + 1
|
|
476
|
+
if i >= len(self._values): i = 0 if self._wrap else (len(self._values) - 1)
|
|
477
|
+
self._current_index = i;
|
|
478
|
+
self.set(self._values[i], _emit=True);
|
|
479
|
+
return
|
|
480
|
+
try:
|
|
481
|
+
n = self._current_numeric_value + self._step_size
|
|
482
|
+
if n > self._to: n = self._from if self._wrap else self._to
|
|
483
|
+
self.set(n, _emit=True)
|
|
484
|
+
except Exception:
|
|
485
|
+
self.set(self._from, _emit=True)
|
|
486
|
+
|
|
487
|
+
def _decrement_callback(self):
|
|
488
|
+
if self._state == "disabled": return
|
|
489
|
+
if hasattr(self, "_values") and self._values:
|
|
490
|
+
i = self._current_index - 1
|
|
491
|
+
if i < 0: i = (len(self._values) - 1) if self._wrap else 0
|
|
492
|
+
self._current_index = i;
|
|
493
|
+
self.set(self._values[i], _emit=True);
|
|
494
|
+
return
|
|
495
|
+
try:
|
|
496
|
+
n = self._current_numeric_value - self._step_size
|
|
497
|
+
if n < self._from: n = self._to if self._wrap else self._from
|
|
498
|
+
self.set(n, _emit=True)
|
|
499
|
+
except Exception:
|
|
500
|
+
self.set(self._from, _emit=True)
|
|
501
|
+
|
|
502
|
+
def _validate_and_sanitize_input(self):
|
|
503
|
+
if getattr(self, "_values", None):
|
|
504
|
+
if self.get() in self._values: self._current_index = self._values.index(self.get())
|
|
505
|
+
return
|
|
506
|
+
try:
|
|
507
|
+
raw = self.get().strip()
|
|
508
|
+
# _emit=True throughout: this runs when the USER has typed
|
|
509
|
+
# something and committed it, which is exactly the case the
|
|
510
|
+
# command exists for.
|
|
511
|
+
if not raw: self.set(self._from, _emit=True); return
|
|
512
|
+
for clean_token in [self._format, "kHz", "MHz", "dB", "%", "Hz", "{", "}", " "]:
|
|
513
|
+
if clean_token: raw = raw.replace(clean_token, "")
|
|
514
|
+
self.set(float(raw), _emit=True)
|
|
515
|
+
except ValueError:
|
|
516
|
+
self.set(self._current_numeric_value, _emit=True)
|
|
517
|
+
|
|
518
|
+
def _set_appearance_mode(self, mode_string: str):
|
|
519
|
+
if hasattr(super(), "_set_appearance_mode"):
|
|
520
|
+
try:
|
|
521
|
+
super()._set_appearance_mode(mode_string)
|
|
522
|
+
except Exception:
|
|
523
|
+
pass
|
|
524
|
+
self._apply_custom_theme_colors()
|
|
525
|
+
|
|
526
|
+
def state(self, mode: str = None) -> str:
|
|
527
|
+
"""
|
|
528
|
+
Gets or sets the widget's normal/readonly/disabled visual state.
|
|
529
|
+
|
|
530
|
+
Args:
|
|
531
|
+
mode: If None, returns the current state without changing
|
|
532
|
+
anything. Otherwise forwarded to configure(state=mode), which
|
|
533
|
+
validates against ("normal", "readonly", "disabled") and
|
|
534
|
+
cascades correctly: the entry gets the full three-way state
|
|
535
|
+
(routed through its own state()), while the up/down buttons
|
|
536
|
+
only ever receive "normal" or "disabled" -- "readonly" is not
|
|
537
|
+
a real native CTkButton state, and per ttk.Spinbox's own
|
|
538
|
+
semantics, the arrows stay clickable in readonly mode anyway.
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
If setting: echoes back `mode` exactly as given (not re-queried
|
|
542
|
+
afterward -- may not match self._state if configure() rejected
|
|
543
|
+
an invalid value). If querying: the current state string.
|
|
544
|
+
"""
|
|
545
|
+
if mode is None: return str(getattr(self, "_state", "normal")).lower()
|
|
546
|
+
self.configure(state=mode)
|
|
547
|
+
return mode
|
|
548
|
+
|
|
549
|
+
def get_state(self) -> str:
|
|
550
|
+
return self.state()
|
|
551
|
+
|
|
552
|
+
def _apply_custom_theme_colors(self):
|
|
553
|
+
"""
|
|
554
|
+
Applies colors and native state to the entry and both buttons, based
|
|
555
|
+
on self._state (normal/readonly/disabled).
|
|
556
|
+
|
|
557
|
+
The entry's own colors are handled by its own state() call, which
|
|
558
|
+
triggers sCTkEntryPrimary's own three-state _update_current_visual_state()
|
|
559
|
+
-- see that widget for the full readonly-color logic and its required-
|
|
560
|
+
key validation. This method THEN overrides the entry's fg_color/
|
|
561
|
+
border_color/text_color with Spinbox's own theme keys ("entry_color"/
|
|
562
|
+
"border_color"/"text_color" from Spinbox's own theme block) for all
|
|
563
|
+
three states -- a deliberate, pre-existing design choice: Spinbox
|
|
564
|
+
controls its own entry's look via Spinbox-specific theme keys, not
|
|
565
|
+
the entry's own defaults. entry_color, border_color, and text_color
|
|
566
|
+
are required to be present in readonly_map specifically when readonly
|
|
567
|
+
is the current state -- if any are missing, this raises immediately
|
|
568
|
+
rather than silently falling back to normal/disabled colors, matching
|
|
569
|
+
the same principle used in sCTkEntryPrimary's own readonly validation.
|
|
570
|
+
|
|
571
|
+
Buttons only ever receive "normal" or "disabled" -- never "readonly",
|
|
572
|
+
which is not a real native CTkButton state, and which ttk.Spinbox's
|
|
573
|
+
own semantics require to stay clickable anyway. Button colors are
|
|
574
|
+
always read from self._local_defaults (normal) unless disabled, so
|
|
575
|
+
buttons look completely ordinary in readonly mode -- no readonly-
|
|
576
|
+
specific button color key exists or is needed.
|
|
577
|
+
"""
|
|
578
|
+
if not hasattr(self, "entry") or not self.entry.winfo_exists(): return
|
|
579
|
+
current = self._state
|
|
580
|
+
is_disabled = current == "disabled"
|
|
581
|
+
is_readonly = current == "readonly"
|
|
582
|
+
|
|
583
|
+
# FIX: an earlier version used .get(key, hardcoded_literal) throughout
|
|
584
|
+
# this method for entry_color/border_color/text_color/button_color/
|
|
585
|
+
# button_hover_color -- silently substituting a guessed value whenever
|
|
586
|
+
# the real theme was incomplete. Matches the same hard-fail principle
|
|
587
|
+
# applied to sCTkSwitch, the label family, and sCTkTableview elsewhere
|
|
588
|
+
# in this project.
|
|
589
|
+
for required_key in ("entry_color", "border_color", "button_color", "button_hover_color", "text_color"):
|
|
590
|
+
if self._local_defaults.get(required_key) is None:
|
|
591
|
+
raise KeyError(
|
|
592
|
+
f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing '{required_key}' "
|
|
593
|
+
f"at the top level of sCTkThemes.json."
|
|
594
|
+
)
|
|
595
|
+
for required_key in ("entry_color", "border_color", "text_color", "button_color"):
|
|
596
|
+
if self._custom_disabled_map.get(required_key) is None:
|
|
597
|
+
raise KeyError(
|
|
598
|
+
f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing '{required_key}' in disabled_map."
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
if is_disabled:
|
|
602
|
+
m = self._custom_disabled_map
|
|
603
|
+
elif is_readonly:
|
|
604
|
+
m = self._custom_readonly_map
|
|
605
|
+
for required_key in ("entry_color", "border_color", "text_color"):
|
|
606
|
+
if m.get(required_key) is None:
|
|
607
|
+
raise KeyError(
|
|
608
|
+
f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing '{required_key}' "
|
|
609
|
+
f"in readonly_map -- required because state 'readonly' was requested."
|
|
610
|
+
)
|
|
611
|
+
else:
|
|
612
|
+
m = self._local_defaults
|
|
613
|
+
|
|
614
|
+
dm = self._custom_disabled_map
|
|
615
|
+
d_b_text = dm.get("text_color")
|
|
616
|
+
|
|
617
|
+
# 🔑 NATIVE ACTION ROUTING:
|
|
618
|
+
# Route the full three-way state through sCTkEntryPrimary's own
|
|
619
|
+
# public state tracker, so its own readonly_map/disabled_map color
|
|
620
|
+
# logic (and required-key validation for readonly) applies correctly.
|
|
621
|
+
if hasattr(self.entry, "state"):
|
|
622
|
+
self.entry.state(current)
|
|
623
|
+
else:
|
|
624
|
+
self.entry.configure(state=current)
|
|
625
|
+
|
|
626
|
+
# Spinbox-specific color override, applied for all three states now.
|
|
627
|
+
entry_override = {
|
|
628
|
+
"fg_color": m.get("entry_color"),
|
|
629
|
+
"border_color": m.get("border_color"),
|
|
630
|
+
}
|
|
631
|
+
# text_color is applied for ALL THREE states, not just readonly.
|
|
632
|
+
#
|
|
633
|
+
# It sat behind `if is_readonly:` and was delegated to
|
|
634
|
+
# sCTkEntryPrimary's own state() for normal and disabled -- so this
|
|
635
|
+
# widget's own text_color, including its disabled_map value, never
|
|
636
|
+
# reached the entry. Setting disabled_map.text_color on an sCTkSpinbox
|
|
637
|
+
# coloured the ARROWS, which take it a few lines below, while the
|
|
638
|
+
# number in the box kept sCTkEntryPrimary's colour. One key appearing
|
|
639
|
+
# to affect the wrong part of the widget.
|
|
640
|
+
#
|
|
641
|
+
# The sCTkSpinbox block is the source of truth for a spinbox, so it
|
|
642
|
+
# wins for every state. Note this can change the NORMAL appearance too,
|
|
643
|
+
# where the two blocks disagree -- which is the point: the widget now
|
|
644
|
+
# honours its own theme.
|
|
645
|
+
entry_override["text_color"] = m.get("text_color")
|
|
646
|
+
self.entry.configure(**entry_override)
|
|
647
|
+
|
|
648
|
+
b_color = self._local_defaults.get("button_color")
|
|
649
|
+
b_hover = self._local_defaults.get("button_hover_color")
|
|
650
|
+
b_text = self._local_defaults.get("text_color")
|
|
651
|
+
d_b_color = dm.get("button_color")
|
|
652
|
+
|
|
653
|
+
# Buttons only ever get normal/disabled -- never readonly.
|
|
654
|
+
button_state = "disabled" if is_disabled else "normal"
|
|
655
|
+
|
|
656
|
+
for b in [self.up_button, self.down_button]:
|
|
657
|
+
if hasattr(b, "winfo_exists") and b.winfo_exists():
|
|
658
|
+
b.configure(
|
|
659
|
+
fg_color=d_b_color if is_disabled else b_color,
|
|
660
|
+
hover_color=d_b_color if is_disabled else b_hover,
|
|
661
|
+
text_color=d_b_text if is_disabled else b_text,
|
|
662
|
+
text_color_disabled=d_b_text,
|
|
663
|
+
state=button_state
|
|
664
|
+
)
|