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,268 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ sCTkDialogToplevel
4
+
5
+ The window that holds an sCTkDialog. Owns everything to do with the
6
+ WINDOW -- title, size, placement, modality -- so that sCTkDialog can
7
+ concern itself only with the content inside it.
8
+
9
+ Previously this class was an empty subclass of CTkToplevel and all of the
10
+ window behaviour lived in a separate sCTkDialogMixin, which callers had to
11
+ add by hand to their generated class. Folding it in here removes that step:
12
+ see Dialogs.md.
13
+ """
14
+ import tkinter as tk
15
+
16
+ import customtkinter as ctk
17
+
18
+
19
+ class sCTkDialogToplevel(ctk.CTkToplevel):
20
+ """
21
+ A top-level window sized, placed and (optionally) made modal on creation.
22
+
23
+ Placement is expressed RELATIVE to another window rather than in screen
24
+ coordinates, because that is what dialogs actually want: appear over the
25
+ window that opened me, nudged down and right so the parent is still
26
+ visible behind. Absolute coordinates break the moment the user moves the
27
+ application window.
28
+ """
29
+
30
+ # Floor for a content-sized window. Explicit width/height below these are
31
+ # honoured -- a caller asking for 200x100 gets it; these only apply when
32
+ # the size is being derived from the content.
33
+ MIN_WIDTH = 320
34
+ MIN_HEIGHT = 180
35
+
36
+ def __init__(self, master=None, *, title=None, width=None, height=None,
37
+ locate_over=None, offset_x=40, offset_y=40, modal=False,
38
+ transient=True, **kw):
39
+ """
40
+ Args:
41
+ master: The Tk parent. Required for the window to belong to the
42
+ right interpreter -- see the note below.
43
+ title: Window title bar text.
44
+ width: Window width in pixels. Omit to size to content, subject
45
+ to MIN_WIDTH.
46
+ height: Window height in pixels. Omit to size to content, subject
47
+ to MIN_HEIGHT.
48
+ locate_over: The window this dialog should appear over, and be
49
+ transient to. Defaults to master's own toplevel.
50
+
51
+ Deliberately separate from `master`: a dialog is often
52
+ parented to a frame or a controller widget while needing to
53
+ position itself over the main application window. Passing the
54
+ two separately avoids having to choose.
55
+ offset_x: Pixels right of locate_over's top-left corner.
56
+ offset_y: Pixels below it.
57
+ modal: True to grab input and block interaction with the rest of
58
+ the application. See _apply_modal() for the ordering this
59
+ requires.
60
+ transient: True to tie the window to locate_over -- it stays above
61
+ that window, minimises with it, and usually keeps out of the
62
+ taskbar. False gives an independent window, which is what a
63
+ long-lived tool panel wants. Placement is unaffected either
64
+ way; only the window-manager relationship changes.
65
+ **kw: Any native CTkToplevel argument.
66
+ """
67
+ # FIX: an extra blank window appeared when master was None.
68
+ #
69
+ # tkinter creates a default root implicitly the first time any widget
70
+ # is made without one -- and that root is a real, visible, empty
71
+ # window. A dialog constructed with no master therefore produced TWO
72
+ # windows: the dialog, and a blank one behind it.
73
+ #
74
+ # If a root already exists we adopt it. If not, one is created and
75
+ # immediately withdrawn, so it services the Tcl interpreter without
76
+ # ever being seen. Withdrawing rather than avoiding it is necessary:
77
+ # Tk requires a root, and letting tkinter make one silently is exactly
78
+ # what caused the problem.
79
+ self._implicit_root = None
80
+ if master is None:
81
+ existing = getattr(tk, "_default_root", None)
82
+ if existing is None:
83
+ self._implicit_root = ctk.CTk()
84
+ self._implicit_root.withdraw()
85
+ master = self._implicit_root
86
+ else:
87
+ master = existing
88
+
89
+ super().__init__(master, **kw)
90
+
91
+ # locate_over defaults to whichever window owns master. winfo_toplevel()
92
+ # walks up for us, so a dialog parented to a deeply nested frame still
93
+ # positions itself over the right window.
94
+ if locate_over is None and master is not None:
95
+ try:
96
+ locate_over = master.winfo_toplevel()
97
+ except Exception:
98
+ locate_over = None
99
+ self._locate_over = locate_over
100
+ self._offset_x = int(offset_x)
101
+ self._offset_y = int(offset_y)
102
+ self._requested_width = width
103
+ self._requested_height = height
104
+
105
+ if title:
106
+ self.title(title)
107
+
108
+ # transient() ties the dialog to its parent for the window manager:
109
+ # it stays above that window and usually skips the taskbar. Done
110
+ # before placement so the WM has the relationship when the window maps.
111
+ if transient and self._locate_over is not None:
112
+ try:
113
+ self.transient(self._locate_over)
114
+ except Exception:
115
+ pass
116
+
117
+ self.place_over()
118
+
119
+ if modal:
120
+ self._apply_modal()
121
+
122
+ def place_over(self, locate_over=None, offset_x=None, offset_y=None):
123
+ """
124
+ Positions the window relative to another one.
125
+
126
+ Callable again later -- if the parent window moves and the dialog
127
+ should follow, or to re-place after a resize.
128
+
129
+ Falls back to centring on screen when there is no reference window,
130
+ which is better than landing at whatever coordinates the window
131
+ manager chose.
132
+
133
+ Args:
134
+ locate_over: Reference window. Defaults to the one given at
135
+ construction.
136
+ offset_x: Pixels right of its top-left corner.
137
+ offset_y: Pixels below it.
138
+ """
139
+ if locate_over is not None:
140
+ self._locate_over = locate_over
141
+ if offset_x is not None:
142
+ self._offset_x = int(offset_x)
143
+ if offset_y is not None:
144
+ self._offset_y = int(offset_y)
145
+
146
+ # Required before any winfo_* geometry query: without it the values
147
+ # are whatever they were before the pending layout ran, which for a
148
+ # freshly created window is 1x1 at 0,0.
149
+ self.update_idletasks()
150
+
151
+ # The minimum applies ONLY to a dimension being derived from content.
152
+ # An explicit size is honoured exactly: a caller asking for 200x100
153
+ # gets 200x100, because they had a reason to ask.
154
+ if self._requested_width:
155
+ width = self._requested_width
156
+ else:
157
+ width = max(self.winfo_reqwidth(), self.MIN_WIDTH)
158
+
159
+ if self._requested_height:
160
+ height = self._requested_height
161
+ else:
162
+ height = max(self.winfo_reqheight(), self.MIN_HEIGHT)
163
+
164
+ ref = self._locate_over
165
+ try:
166
+ usable_ref = ref is not None and ref.winfo_exists()
167
+ except Exception:
168
+ usable_ref = False
169
+
170
+ if usable_ref:
171
+ # rootx/rooty rather than x/y: these are screen coordinates, which
172
+ # is what geometry() expects. x/y would be relative to the parent's
173
+ # own parent and place the dialog somewhere unrelated.
174
+ x = ref.winfo_rootx() + self._offset_x
175
+ y = ref.winfo_rooty() + self._offset_y
176
+ else:
177
+ x = (self.winfo_screenwidth() // 2) - (width // 2)
178
+ y = (self.winfo_screenheight() // 2) - (height // 2)
179
+
180
+ # Keep the window on screen. A large offset from a parent near the
181
+ # right edge would otherwise put the dialog partly or wholly off it.
182
+ x = max(0, min(x, self.winfo_screenwidth() - width))
183
+ y = max(0, min(y, self.winfo_screenheight() - height))
184
+
185
+ # Always set both dimensions. Setting only the position and letting Tk
186
+ # size the window works only while nothing has been requested; once a
187
+ # width is given, Tk keeps whatever height it last computed -- which
188
+ # was the empty-window height if this ran before the content existed.
189
+ self.geometry(f"{width}x{height}+{x}+{y}")
190
+
191
+ def set_size(self, width=None, height=None):
192
+ """
193
+ Changes the window size, keeping it where it is.
194
+
195
+ Args:
196
+ width: New width in pixels, or None to leave it. Pass 0 or an
197
+ empty value to go back to sizing to content.
198
+ height: Same, for height.
199
+ """
200
+ if width is not None:
201
+ self._requested_width = int(width) if width else None
202
+ if height is not None:
203
+ self._requested_height = int(height) if height else None
204
+ self.place_over()
205
+
206
+ def get_size(self):
207
+ """
208
+ Returns the window's (width, height).
209
+
210
+ Reports the REQUESTED size where one was given, and the actual size
211
+ otherwise -- so a dialog sized to its content reports what it really
212
+ is rather than None.
213
+ """
214
+ try:
215
+ self.update_idletasks()
216
+ actual_w, actual_h = self.winfo_width(), self.winfo_height()
217
+ except Exception:
218
+ actual_w = actual_h = 0
219
+ return (self._requested_width or actual_w,
220
+ self._requested_height or actual_h)
221
+
222
+ def _apply_modal(self):
223
+ """
224
+ Takes an input grab, blocking interaction with the rest of the
225
+ application.
226
+
227
+ wait_visibility() first is NOT optional: on X11 a grab on a window
228
+ that is not yet viewable raises TclError. It is harmless on macOS and
229
+ Windows, so it is done unconditionally rather than platform-tested.
230
+ """
231
+ try:
232
+ self.wait_visibility()
233
+ self.grab_set()
234
+ except Exception:
235
+ # Window destroyed between construction and this call.
236
+ pass
237
+
238
+ def destroy(self):
239
+ """
240
+ Destroys the window, and the implicit root if this widget created one.
241
+
242
+ Without this, a dialog built with no master would leave the withdrawn
243
+ root alive after closing and the interpreter would never exit.
244
+ """
245
+ implicit = getattr(self, "_implicit_root", None)
246
+ super().destroy()
247
+ if implicit is not None:
248
+ try:
249
+ implicit.destroy()
250
+ except Exception:
251
+ pass
252
+
253
+ def release_modal(self):
254
+ """Releases the input grab, leaving the window open but non-blocking."""
255
+ try:
256
+ self.grab_release()
257
+ except Exception:
258
+ pass
259
+
260
+
261
+ if __name__ == "__main__":
262
+ root = ctk.CTk()
263
+ ctk.CTkButton(
264
+ root, text="Open dialog window",
265
+ command=lambda: sCTkDialogToplevel(
266
+ root, title="Example", width=320, height=200, modal=True),
267
+ ).pack(padx=40, pady=40)
268
+ root.mainloop()
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ sCTkEntryPrimary
4
+
5
+ A theme-compliant, high-emphasis single-line text entry widget -- the more
6
+ prominent of the library's two entry tiers (see also sCTkEntrySecondary).
7
+ Inherits directly from ctk.CTkEntry so CustomTkinter handles native rendering
8
+ and text editing; this class layers automatic light/dark theme resolution and
9
+ a distinct enabled/disabled visual state on top.
10
+
11
+ Base class order matters here: `class sCTkEntryPrimary(ctk.CTkEntry,
12
+ ThemeableWidget)` puts the native CTk class first, so every `super()` call in
13
+ this file's own methods resolves to ctk.CTkEntry -- and, beneath it,
14
+ tkinter.Misc -- never to ThemeableWidget. ThemeableWidget's own
15
+ configure()/cget()/_set_appearance_mode() overrides have been removed entirely
16
+ for this reason (see themeable_widget.py's docstring); this widget owns all of
17
+ its own runtime color-swapping logic.
18
+
19
+ Disabling uses CTk's native state="disabled", consistent with every other
20
+ widget in this library confirmed to correctly block interaction this way (the
21
+ button family, sCTkSegmentedButton, sCTkOptionMenuPrimary). An earlier version
22
+ used native "readonly" instead, on an unverified claim that "disabled" caused
23
+ CustomTkinter rendering issues -- tested directly and confirmed unnecessary
24
+ for the disabled/normal distinction.
25
+
26
+ Now supports a genuine three-state model: normal / readonly / disabled,
27
+ matching real ttk.Spinbox semantics (readonly means the field can't be typed
28
+ into directly, but stays focusable/selectable -- distinct from disabled,
29
+ which blocks all interaction). Added specifically to support sCTkSpinbox's
30
+ own readonly mode correctly, which previously collapsed everything
31
+ non-disabled into "normal", silently discarding any readonly request. Uses
32
+ CTk's native state="readonly", the third of the three real Tk Entry states
33
+ (normal/readonly/disabled) -- not independently confirmed by direct testing
34
+ the way disabled/normal were; test readonly specifically, including the
35
+ disable/enable-cycle cursor-placeholder scenario already confirmed and fixed
36
+ for the normal<->disabled transition, since readonly hasn't been checked
37
+ against that same failure mode.
38
+ """
39
+ from typing import Any, Optional
40
+ import customtkinter as ctk
41
+ from .themeable_widget import ThemeableWidget, defer
42
+
43
+
44
+ class sCTkEntryPrimary(ctk.CTkEntry, ThemeableWidget):
45
+ """Themeable, high-emphasis text entry field.
46
+
47
+ Adds to native ctk.CTkEntry:
48
+ - Automatic light/dark theme resolution from sCTkThemes.json (via
49
+ ThemeableWidget.__init__ -- see that class's docstring for what it does,
50
+ and just as importantly, what it no longer does).
51
+ - A genuine three-state visual model: normal / readonly / disabled,
52
+ using CTk's native state option for all three (state="normal"
53
+ confirmed correct by direct testing; state="disabled" confirmed
54
+ correct by direct testing; state="readonly" not independently
55
+ confirmed -- see module docstring).
56
+ - Pygubu Designer property introspection for `state`, `fg_color`,
57
+ `text_color`, `border_color`, and `placeholder_text_color` via a
58
+ single-argument configure() call.
59
+
60
+ Colors are passed through to configure() as raw (light, dark) tuples rather
61
+ than pre-resolved to a single value, so CustomTkinter's own appearance-mode
62
+ tracking repaints them automatically on a light/dark switch -- the same
63
+ approach validated on sCTkComboBox, sCTkSegmentedButton, and the button
64
+ family. Not separately re-confirmed for this specific widget.
65
+
66
+ placeholder_text_color is a real, themed key (not a fallback to
67
+ text_color) -- confirmed against CustomTkinter's own shipped themes, where
68
+ it's deliberately more muted than the main text color (e.g. gray52/gray62
69
+ vs. gray14/gray84 in the stock dark-blue theme). Note CTkEntry has no
70
+ separate font for placeholder text vs. typed text -- both always share the
71
+ single `font` property; this is a real ceiling in the underlying widget,
72
+ not a gap in this theme file.
73
+ """
74
+
75
+ def __init__(self, master: Optional[Any] = None, **kw: Any) -> None:
76
+ """
77
+ Args:
78
+ master: Parent container.
79
+ **kw: Any native CTkEntry argument (e.g. `placeholder_text`,
80
+ `width`), or a theme-key override (see the "sCTkEntryPrimary"
81
+ block in sCTkThemes.json, including its disabled_map).
82
+ """
83
+ # 1. Fire our shared theme logic first. This resolves final_kw
84
+ # (construction-time properties) and the disabled color map. See
85
+ # ThemeableWidget.__init__ for what actually happens here.
86
+ ThemeableWidget.__init__(self, kw)
87
+
88
+ # 2. Deep-copy the resolved map onto this instance, so later changes
89
+ # here never leak back into the shared theme registry.
90
+ self._local_defaults = dict(self.final_kw)
91
+ self._custom_disabled_map = dict(self._widget_disabled_map)
92
+ # readonly_map: see module docstring for the three-state model this
93
+ # supports. Required only when readonly is actually requested -- see
94
+ # _update_current_visual_state()'s validation.
95
+ self._custom_readonly_map = dict(self._widget_readonly_map)
96
+
97
+ # Extract "state" from final_kw (after ThemeableWidget's merge, unlike
98
+ # the label widgets which extract it from kw before that merge) --
99
+ # this means an explicit state= kwarg correctly overrides any
100
+ # "state" the theme JSON might define at the top level, since kwargs
101
+ # are merged into final_kw after the theme defaults. Not a bug, just
102
+ # a different valid approach from the label family's.
103
+ state_init = self.final_kw.pop("state", "normal")
104
+
105
+ # 3. Initialize CustomTkinter natively with the clean final kwargs array.
106
+ super().__init__(master, **self.final_kw)
107
+
108
+ # 4. Apply the requested initial state now that the native widget exists.
109
+ self._custom_current_state = "normal"
110
+ self.state(state_init)
111
+
112
+ # 5. Register lifecycle handshake hook, notifying Pygubu-style consumers
113
+ # that construction is complete.
114
+ self._finalize_themeable_lifecycle()
115
+
116
+ def configure(self, *args: Any, **kwargs: Any) -> Any:
117
+ """
118
+ Standard widget configuration, with Pygubu/positional-argument handling.
119
+
120
+ Args:
121
+ *args: At most one positional argument is meaningful:
122
+ - a dict: merged into kwargs and processed normally below.
123
+ - the literal string "state": returns a Tkinter-style
124
+ (name, name, name, default, current) tuple.
125
+ - one of "fg_color"/"text_color"/"border_color"/
126
+ "placeholder_text_color": returns the same style of tuple,
127
+ with `current` reflecting the disabled or normal value as
128
+ appropriate. Note the returned value is str(value), where
129
+ value may itself be a (light, dark) tuple rather than a
130
+ single resolved color -- a known limitation shared with the
131
+ wider Pygubu-query investigation set aside elsewhere in this
132
+ project, not fixed here.
133
+ - anything else: forwarded directly to the native widget's
134
+ configure(), which does not support single-argument property
135
+ queries for arbitrary properties (same limitation).
136
+ **kwargs: Standard CTkEntry configuration options, plus: passing
137
+ `state=...` routes through self.state() rather than being
138
+ forwarded as-is.
139
+
140
+ Returns:
141
+ The query tuple described above for the single-argument case, or
142
+ whatever super().configure() returns for the keyword-argument case
143
+ (typically None).
144
+ """
145
+ # args is always a tuple -- args[0] is the actual value passed, whether
146
+ # that's a string or a dict. An earlier version of this method compared
147
+ # the wrapped tuple directly (`pname = args`), so the query branches
148
+ # below never matched anything, and the fallback forwarded the wrapped
149
+ # tuple itself to super().configure() -- not a valid call shape for the
150
+ # native widget. Don't reintroduce that.
151
+ if len(args) == 1:
152
+ if isinstance(args[0], dict):
153
+ kwargs = {**args[0], **kwargs}
154
+ else:
155
+ pname = args[0]
156
+ if pname == "state":
157
+ return ("state", "state", "state", "normal", str(self.state()))
158
+
159
+ if pname in ["fg_color", "text_color", "border_color", "placeholder_text_color"]:
160
+ current = self._custom_current_state
161
+ if current == "disabled":
162
+ val = self._custom_disabled_map.get(pname)
163
+ elif current == "readonly":
164
+ val = self._custom_readonly_map.get(pname)
165
+ else:
166
+ val = self._local_defaults.get(pname)
167
+ return (pname, pname, pname, self._query_value(self._theme_default(pname)), self._query_value(val))
168
+
169
+ return self._configure_query(pname)
170
+
171
+ # Runtime overrides have to reach the map a repaint reads, or the
172
+ # repaint puts the theme value straight back -- see
173
+ # ThemeableWidget._record_theme_overrides().
174
+ self._record_theme_overrides(kwargs)
175
+
176
+ if "state" in kwargs:
177
+ self.state(kwargs.pop("state"))
178
+
179
+ self._drop_cleared(kwargs)
180
+
181
+ if kwargs:
182
+ return super().configure(**kwargs)
183
+ return None
184
+
185
+ # Tkinter/CTk convention binds .config to .configure as a SEPARATE class
186
+ # attribute -- it does not automatically track whichever configure() a
187
+ # subclass defines. Without this line, calling .config(...) on an instance
188
+ # would silently skip this entire override and land on the native widget's
189
+ # configure() directly, bypassing theming and state handling entirely.
190
+ config = configure
191
+
192
+ def _set_appearance_mode(self, mode_string: str) -> None:
193
+ """
194
+ Forwards CustomTkinter's internal light/dark mode change notification to
195
+ the native widget.
196
+
197
+ No longer manually re-triggers _update_current_visual_state(). That
198
+ method now passes raw (light, dark) tuples straight through to
199
+ configure() instead of pre-resolving to a single color, so CTk's own
200
+ appearance-mode tracking should repaint correctly on its own -- the
201
+ same approach validated on sCTkComboBox, sCTkSegmentedButton, and the
202
+ button family.
203
+
204
+ Args:
205
+ mode_string: The new appearance mode ("Light" or "Dark"), as passed
206
+ by CustomTkinter's internal appearance-mode change machinery.
207
+ """
208
+ if hasattr(super(), "_set_appearance_mode"):
209
+ try:
210
+ super()._set_appearance_mode(mode_string)
211
+ except Exception:
212
+ pass
213
+
214
+ def _write_through_state(self, action, *args, **kwargs):
215
+ """
216
+ Runs a text-modifying call even when the field is disabled.
217
+
218
+ A disabled Tk entry silently ignores insert() and delete() -- no error,
219
+ no text. That bites in generated code, because pygubu emits the state
220
+ before the content:
221
+
222
+ sctkentryprimary1.configure(state="disabled")
223
+ sctkentryprimary1.delete(0, "end")
224
+ sctkentryprimary1.insert(0, 'mark')
225
+
226
+ which produced a permanently empty disabled field. Setting text on a
227
+ disabled widget is a normal thing for an application to do -- the state
228
+ is about what the USER may edit, not about what the program may write.
229
+
230
+ The native state is lifted for the write and restored afterwards, which
231
+ is the same approach sCTkPathChooser.set() already uses. The widget's
232
+ own _custom_current_state is untouched, so the colours do not flicker.
233
+
234
+ Args:
235
+ action: The bound native method to call.
236
+ *args, **kwargs: Passed through to it.
237
+
238
+ Returns:
239
+ Whatever the native method returns.
240
+ """
241
+ was_disabled = self._custom_current_state == "disabled"
242
+ if was_disabled:
243
+ try:
244
+ super().configure(state="normal")
245
+ except Exception:
246
+ was_disabled = False
247
+ try:
248
+ return action(*args, **kwargs)
249
+ finally:
250
+ if was_disabled:
251
+ try:
252
+ super().configure(state="disabled")
253
+ except Exception:
254
+ pass
255
+
256
+ def insert(self, *args, **kwargs):
257
+ """Inserts text, even while disabled -- see _write_through_state()."""
258
+ return self._write_through_state(super().insert, *args, **kwargs)
259
+
260
+ def delete(self, *args, **kwargs):
261
+ """Deletes text, even while disabled -- see _write_through_state()."""
262
+ return self._write_through_state(super().delete, *args, **kwargs)
263
+
264
+ def get_state(self) -> str:
265
+ """Equivalent to calling state() with no argument."""
266
+ return self.state()
267
+
268
+ def state(self, state_string: Optional[str] = None) -> str:
269
+ """
270
+ Gets or sets the widget's normal/readonly/disabled visual state.
271
+
272
+ Args:
273
+ state_string: If None, returns the current state without changing
274
+ anything. Otherwise: "normal"/"enabled"/"active" all map to
275
+ "normal"; "readonly" (case-insensitive) maps to "readonly";
276
+ "disabled" (case-insensitive) maps to "disabled". Any other
277
+ value matches none of these and leaves the internal state flag
278
+ unchanged, though _update_current_visual_state() still runs
279
+ (harmlessly re-applying the same colors).
280
+
281
+ Returns:
282
+ The resulting state ("normal", "readonly", or "disabled", lowercase).
283
+ """
284
+ if state_string is None:
285
+ return getattr(self, "_custom_current_state", "normal")
286
+
287
+ mode = str(state_string).lower()
288
+ if mode in ("normal", "enabled", "active"):
289
+ self._custom_current_state = "normal"
290
+ elif mode == "readonly":
291
+ self._custom_current_state = "readonly"
292
+ elif mode == "disabled":
293
+ self._custom_current_state = "disabled"
294
+
295
+ self._update_current_visual_state()
296
+ return self._custom_current_state
297
+
298
+ def _update_current_visual_state(self) -> None:
299
+ """
300
+ Recomputes and applies this widget's colors from the theme file, based
301
+ on the current state, then sets the native interactive lock.
302
+
303
+ Called after construction and on every state() change.
304
+
305
+ Passes raw (light, dark) tuples straight through to configure() instead
306
+ of resolving to a single color first, so CTk's native tracking can
307
+ handle appearance-mode repaints without help from _set_appearance_mode.
308
+ Every value here traces back to sCTkThemes.json; there are no
309
+ hardcoded colors in this method.
310
+
311
+ fg_color, border_color, text_color, and placeholder_text_color are
312
+ required to be present in readonly_map specifically when readonly is
313
+ the current state -- if any are missing, this raises immediately
314
+ rather than substituting a hardcoded color or silently falling back
315
+ to normal/disabled colors. This check only runs when readonly is
316
+ actually requested, so existing code that never uses readonly is
317
+ unaffected regardless of whether readonly_map exists in the theme
318
+ file yet.
319
+ """
320
+ current = self._custom_current_state
321
+
322
+ if current == "disabled":
323
+ target_map = self._custom_disabled_map
324
+ elif current == "readonly":
325
+ target_map = self._custom_readonly_map
326
+ for required_key in ("fg_color", "border_color", "text_color", "placeholder_text_color"):
327
+ if target_map.get(required_key) is None:
328
+ raise KeyError(
329
+ f"'{self.__class__.__name__}' theme block is missing '{required_key}' "
330
+ f"in readonly_map -- required because state 'readonly' was requested."
331
+ )
332
+ else:
333
+ target_map = self._local_defaults
334
+
335
+ config_payload = {}
336
+ for key in ("fg_color", "border_color", "text_color", "placeholder_text_color"):
337
+ val = target_map.get(key)
338
+ if val is not None:
339
+ config_payload[key] = val
340
+
341
+ if config_payload:
342
+ super().configure(**config_payload)
343
+
344
+ # Confirmed correct by direct testing: native "disabled" properly
345
+ # blocks interaction. state="readonly" is the third real native Tk
346
+ # Entry state (normal/readonly/disabled) but is NOT independently
347
+ # confirmed here -- see module docstring. Deferred cursor-reset is
348
+ # applied for both normal and readonly, since both leave the field
349
+ # focusable/showable; whether readonly specifically needs it (i.e.
350
+ # whether the same disable/enable-cycle bug applies to a
351
+ # disabled<->readonly or normal<->readonly transition) has not been
352
+ # tested.
353
+ if current == "disabled":
354
+ super().configure(state="disabled")
355
+ else:
356
+ super().configure(state=current)
357
+ defer(self, "idle", self._reset_cursor_if_showing_placeholder)
358
+
359
+ def _reset_cursor_if_showing_placeholder(self) -> None:
360
+ """
361
+ Resets the cursor to position 0, but only if the field is currently
362
+ showing its placeholder text -- never touches the cursor if the field
363
+ holds real user-typed content.
364
+
365
+ FIX: after a disable -> enable cycle, clicking the field placed the
366
+ cursor at the END of the placeholder text instead of position 0 --
367
+ confirmed by direct testing. Likely cause: CustomTkinter's own
368
+ placeholder system re-inserts the placeholder string when the widget
369
+ regains a non-focused, empty state (which disabling appears to
370
+ trigger via focus loss), and a plain text insert leaves the cursor
371
+ positioned after whatever was just inserted -- i.e. at the end --
372
+ unless something explicitly resets it afterward. Deferred via
373
+ after_idle so this runs after CTk's own internal refresh, the same
374
+ reasoning as the after_idle fix on sCTkButtonPrimary's state-
375
+ transition race.
376
+
377
+ Now also called on transitions into "readonly", not just "normal" --
378
+ this was confirmed and fixed specifically for the disabled<->normal
379
+ transition; whether the same underlying mechanism (or something else
380
+ entirely) affects a disabled<->readonly or normal<->readonly
381
+ transition has not been independently tested. Calling this
382
+ unconditionally on both non-disabled states is a precaution, not a
383
+ confirmed necessity for readonly specifically.
384
+
385
+ Deliberately does NOT use CTkEntry's private, underscore-prefixed
386
+ internal attributes (e.g. self._is_focused, self._activate_placeholder())
387
+ to detect placeholder state, even though those exist -- relying on
388
+ undocumented internals would break silently on a future CustomTkinter
389
+ version. Since the placeholder is implemented by genuinely inserting
390
+ the placeholder string as the field's real text content (confirmed
391
+ from CustomTkinter's own source), comparing self.get() against the
392
+ configured placeholder_text via public, documented API achieves the
393
+ same result without that risk. The only false-positive case is a user
394
+ typing content that exactly matches the placeholder text verbatim, in
395
+ which case resetting the cursor to 0 is a harmless, barely-noticeable
396
+ side effect, not a real problem.
397
+ """
398
+ placeholder = self.cget("placeholder_text")
399
+ if placeholder and self.get() == placeholder:
400
+ self.icursor(0)