scustomtkinter 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. scustomtkinter/__init__.py +81 -0
  2. scustomtkinter/assets/images/error.png +0 -0
  3. scustomtkinter/assets/images/info.png +0 -0
  4. scustomtkinter/assets/images/warning.png +0 -0
  5. scustomtkinter/assets/sCTkThemes.json +1963 -0
  6. scustomtkinter/license_morecustomtkinterwidgets.txt +22 -0
  7. scustomtkinter/sctk_button_primary.py +366 -0
  8. scustomtkinter/sctk_button_secondary.py +354 -0
  9. scustomtkinter/sctk_button_tertiary.py +375 -0
  10. scustomtkinter/sctk_checkbox.py +270 -0
  11. scustomtkinter/sctk_combobox.py +155 -0
  12. scustomtkinter/sctk_core.py +15 -0
  13. scustomtkinter/sctk_dial.py +1472 -0
  14. scustomtkinter/sctk_dialog.py +701 -0
  15. scustomtkinter/sctk_dialog_toplevel.py +268 -0
  16. scustomtkinter/sctk_entry_primary.py +400 -0
  17. scustomtkinter/sctk_entry_secondary.py +342 -0
  18. scustomtkinter/sctk_file_explorer.py +879 -0
  19. scustomtkinter/sctk_frame.py +210 -0
  20. scustomtkinter/sctk_frame_labeled_primary.py +667 -0
  21. scustomtkinter/sctk_frame_labeled_secondary.py +598 -0
  22. scustomtkinter/sctk_frequency_display.py +636 -0
  23. scustomtkinter/sctk_label_primary.py +283 -0
  24. scustomtkinter/sctk_label_secondary.py +275 -0
  25. scustomtkinter/sctk_label_tertiary.py +278 -0
  26. scustomtkinter/sctk_messagebox.py +275 -0
  27. scustomtkinter/sctk_notebook.py +1103 -0
  28. scustomtkinter/sctk_optionmenu_border_mixin.py +203 -0
  29. scustomtkinter/sctk_optionmenu_primary.py +378 -0
  30. scustomtkinter/sctk_optionmenu_secondary.py +346 -0
  31. scustomtkinter/sctk_path_chooser.py +516 -0
  32. scustomtkinter/sctk_progress_bar.py +274 -0
  33. scustomtkinter/sctk_radiobutton.py +360 -0
  34. scustomtkinter/sctk_scroll_mixin.py +546 -0
  35. scustomtkinter/sctk_scrollable_frame.py +795 -0
  36. scustomtkinter/sctk_scrollbar.py +313 -0
  37. scustomtkinter/sctk_segmentedbutton.py +424 -0
  38. scustomtkinter/sctk_selector.py +581 -0
  39. scustomtkinter/sctk_separator.py +308 -0
  40. scustomtkinter/sctk_slider.py +264 -0
  41. scustomtkinter/sctk_smeter.py +405 -0
  42. scustomtkinter/sctk_smeter_bar.py +557 -0
  43. scustomtkinter/sctk_spinbox.py +664 -0
  44. scustomtkinter/sctk_switch.py +463 -0
  45. scustomtkinter/sctk_tableview.py +869 -0
  46. scustomtkinter/sctk_tabview.py +409 -0
  47. scustomtkinter/sctk_textbox_primary.py +291 -0
  48. scustomtkinter/sctk_textbox_secondary.py +225 -0
  49. scustomtkinter/sctk_toplevel.py +153 -0
  50. scustomtkinter/themeable_widget.py +938 -0
  51. scustomtkinter-0.2.1.dist-info/METADATA +336 -0
  52. scustomtkinter-0.2.1.dist-info/RECORD +101 -0
  53. scustomtkinter-0.2.1.dist-info/WHEEL +5 -0
  54. scustomtkinter-0.2.1.dist-info/entry_points.txt +2 -0
  55. scustomtkinter-0.2.1.dist-info/licenses/LICENSE +21 -0
  56. scustomtkinter-0.2.1.dist-info/licenses/scustomtkinter/license_morecustomtkinterwidgets.txt +22 -0
  57. scustomtkinter-0.2.1.dist-info/top_level.txt +2 -0
  58. scustomtkinter_pygubu/__init__.py +1 -0
  59. scustomtkinter_pygubu/designer/__init__.py +0 -0
  60. scustomtkinter_pygubu/designer/plugin.py +1854 -0
  61. scustomtkinter_pygubu/designer/properties.py +247 -0
  62. scustomtkinter_pygubu/sCTkButtonPrimarybo.py +46 -0
  63. scustomtkinter_pygubu/sCTkButtonSecondarybo.py +45 -0
  64. scustomtkinter_pygubu/sCTkButtonTertiarybo.py +44 -0
  65. scustomtkinter_pygubu/sCTkCheckBoxbo.py +45 -0
  66. scustomtkinter_pygubu/sCTkComboBoxbo.py +45 -0
  67. scustomtkinter_pygubu/sCTkCorebo.py +120 -0
  68. scustomtkinter_pygubu/sCTkDialbo.py +371 -0
  69. scustomtkinter_pygubu/sCTkDialogbo.py +397 -0
  70. scustomtkinter_pygubu/sCTkEntryPrimarybo.py +100 -0
  71. scustomtkinter_pygubu/sCTkEntrySecondarybo.py +98 -0
  72. scustomtkinter_pygubu/sCTkFileExplorerbo.py +189 -0
  73. scustomtkinter_pygubu/sCTkFrameLabeledPrimarybo.py +69 -0
  74. scustomtkinter_pygubu/sCTkFrameLabeledSecondarybo.py +71 -0
  75. scustomtkinter_pygubu/sCTkFramebo.py +46 -0
  76. scustomtkinter_pygubu/sCTkFrequencyDisplaybo.py +175 -0
  77. scustomtkinter_pygubu/sCTkLabelPrimarybo.py +45 -0
  78. scustomtkinter_pygubu/sCTkLabelSecondarybo.py +44 -0
  79. scustomtkinter_pygubu/sCTkLabelTertiarybo.py +45 -0
  80. scustomtkinter_pygubu/sCTkNotebookbo.py +392 -0
  81. scustomtkinter_pygubu/sCTkOptionMenuPrimarybo.py +45 -0
  82. scustomtkinter_pygubu/sCTkOptionMenuSecondarybo.py +39 -0
  83. scustomtkinter_pygubu/sCTkPathChooserbo.py +204 -0
  84. scustomtkinter_pygubu/sCTkProgressBarbo.py +45 -0
  85. scustomtkinter_pygubu/sCTkRadioButtonbo.py +45 -0
  86. scustomtkinter_pygubu/sCTkSMeterBarbo.py +153 -0
  87. scustomtkinter_pygubu/sCTkSMeterbo.py +101 -0
  88. scustomtkinter_pygubu/sCTkScrollableFramebo.py +66 -0
  89. scustomtkinter_pygubu/sCTkScrollbarbo.py +48 -0
  90. scustomtkinter_pygubu/sCTkSegmentedButtonbo.py +47 -0
  91. scustomtkinter_pygubu/sCTkSelectorbo.py +167 -0
  92. scustomtkinter_pygubu/sCTkSeparatorbo.py +141 -0
  93. scustomtkinter_pygubu/sCTkSliderbo.py +45 -0
  94. scustomtkinter_pygubu/sCTkSpinboxbo.py +200 -0
  95. scustomtkinter_pygubu/sCTkSwitchbo.py +45 -0
  96. scustomtkinter_pygubu/sCTkTableviewbo.py +188 -0
  97. scustomtkinter_pygubu/sCTkTabviewbo.py +226 -0
  98. scustomtkinter_pygubu/sCTkTextboxPrimarybo.py +45 -0
  99. scustomtkinter_pygubu/sCTkTextboxSecondarybo.py +45 -0
  100. scustomtkinter_pygubu/sCTkToplevelbo.py +55 -0
  101. scustomtkinter_pygubu/sCTkWidgetSetForPygubuDesigner.py +141 -0
@@ -0,0 +1,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
+ )