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,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)
|