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,701 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
"""
|
|
3
|
+
sCTkDialog
|
|
4
|
+
|
|
5
|
+
A consistent popup dialog: a heading, a content area you fill, and a row of
|
|
6
|
+
Apply / Cancel / Reset buttons.
|
|
7
|
+
|
|
8
|
+
WINDOW OWNERSHIP. This widget creates its own sCTkDialogToplevel and packs
|
|
9
|
+
itself into it, so a dialog is always its own window. Callers do not create
|
|
10
|
+
one, and there is no mixin to add.
|
|
11
|
+
|
|
12
|
+
That replaces an earlier arrangement in which sCTkDialog was a plain frame
|
|
13
|
+
and a separate sCTkDialogMixin supplied the window, which required every user
|
|
14
|
+
to edit their generated class by hand -- adding an import and changing the
|
|
15
|
+
inheritance line. That step is gone.
|
|
16
|
+
|
|
17
|
+
The layout below was originally a Pygubu-generated base class,
|
|
18
|
+
sCTkDialogui.py, that this file inherited. It has been folded in, matching
|
|
19
|
+
every other widget in this library: a widget builds its own children in
|
|
20
|
+
__init__ rather than depending on a generated file shipped inside the package.
|
|
21
|
+
That file also carried imports which only resolved in the directory it was
|
|
22
|
+
generated in ("from sCTkFrame import sCTkFrame"), so it could not be imported
|
|
23
|
+
at all once installed.
|
|
24
|
+
|
|
25
|
+
The content area is `contentFrame`. In Pygubu Designer, widgets dropped onto an
|
|
26
|
+
sCTkDialog land there automatically -- the builder object's
|
|
27
|
+
get_child_master() returns it.
|
|
28
|
+
"""
|
|
29
|
+
import tkinter as tk
|
|
30
|
+
|
|
31
|
+
import customtkinter as ctk
|
|
32
|
+
|
|
33
|
+
from .sctk_frame import sCTkFrame
|
|
34
|
+
from .sctk_label_primary import sCTkLabelPrimary
|
|
35
|
+
from .sctk_button_primary import sCTkButtonPrimary
|
|
36
|
+
from .sctk_button_secondary import sCTkButtonSecondary
|
|
37
|
+
from .sctk_dialog_toplevel import sCTkDialogToplevel
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class sCTkDialog(sCTkFrame):
|
|
41
|
+
"""Popup dialog with a heading, a content area and an action button row."""
|
|
42
|
+
|
|
43
|
+
# Set False on a subclass to suppress window creation. Used by the Pygubu
|
|
44
|
+
# Designer preview, which renders the dialog inline on its canvas and must
|
|
45
|
+
# not spawn a real window on every redraw -- a modal one would seize input
|
|
46
|
+
# and leave the Designer unusable.
|
|
47
|
+
_MAKE_WINDOW = True
|
|
48
|
+
|
|
49
|
+
# Required in the "sCTkDialog" block of sCTkThemes.json.
|
|
50
|
+
#
|
|
51
|
+
# NOTE the CustomTkinter naming: fg_color is the BACKGROUND fill, not the
|
|
52
|
+
# text colour. It is a native frame option and reaches CTkFrame on its own.
|
|
53
|
+
# text_color is the dialog's foreground -- used for the heading, and
|
|
54
|
+
# available to caller content through cget("text_color"). heading_font is
|
|
55
|
+
# read in _build_layout().
|
|
56
|
+
_REQUIRED_THEME_KEYS = ("fg_color", "text_color", "heading_font")
|
|
57
|
+
|
|
58
|
+
# Subclasses read THIS block unless they say otherwise.
|
|
59
|
+
#
|
|
60
|
+
# ThemeableWidget resolves a theme block from the class name, which is
|
|
61
|
+
# right for widgets nobody subclasses. A dialog is the opposite case:
|
|
62
|
+
# subclassing is how it is meant to be used -- the Designer generates
|
|
63
|
+
# `class SettingsDialog(sCTkDialog)`, and the documented example does the
|
|
64
|
+
# same -- so resolving by class name demanded a "SettingsDialog" block that
|
|
65
|
+
# no one would think to write, and construction failed with
|
|
66
|
+
#
|
|
67
|
+
# KeyError: "'SettingsDialog' theme block is missing 'fg_color' ..."
|
|
68
|
+
#
|
|
69
|
+
# naming a block that should not need to exist.
|
|
70
|
+
#
|
|
71
|
+
# A subclass that genuinely wants its own styling overrides this with its
|
|
72
|
+
# own block name.
|
|
73
|
+
_THEME_BLOCK_NAME = "sCTkDialog"
|
|
74
|
+
|
|
75
|
+
# Natural width of the content area, and so of the dialog when no explicit
|
|
76
|
+
# width is given. Override on a subclass for a consistently wider or
|
|
77
|
+
# narrower dialog.
|
|
78
|
+
CONTENT_WIDTH = 500
|
|
79
|
+
|
|
80
|
+
# How many buttons each setting shows, in order. Apply is always present:
|
|
81
|
+
# a dialog with no way to accept is a message box, not a dialog.
|
|
82
|
+
_BUTTON_ORDER = ("apply", "cancel", "reset")
|
|
83
|
+
|
|
84
|
+
def __init__(self, master=None, *, title=None, width=None, height=None,
|
|
85
|
+
locate_over=None, offset_x=40, offset_y=40, modal=False,
|
|
86
|
+
transient=True, heading="Heading Title",
|
|
87
|
+
heading_anchor="center", heading_font=None,
|
|
88
|
+
heading_color=None,
|
|
89
|
+
buttons=3, apply_text="Apply", cancel_text="Cancel",
|
|
90
|
+
reset_text="Reset", apply_command=None, cancel_command=None,
|
|
91
|
+
reset_command=None, toplevel=None, **kw):
|
|
92
|
+
"""
|
|
93
|
+
Args:
|
|
94
|
+
master: Parent widget. Also the default for locate_over.
|
|
95
|
+
title: Window title bar text.
|
|
96
|
+
width / height: WINDOW size in pixels. Omit to size to content.
|
|
97
|
+
Note these size the window, not the frame: the frame fills its
|
|
98
|
+
window, so its own width and height would have no effect.
|
|
99
|
+
configure() and cget() treat them the same way.
|
|
100
|
+
locate_over: The window to appear over and be transient to.
|
|
101
|
+
Defaults to master's own toplevel. Kept separate from master
|
|
102
|
+
because a dialog is often parented to a frame or a controller
|
|
103
|
+
while needing to position over the main application window --
|
|
104
|
+
see sCTkDialogToplevel.
|
|
105
|
+
offset_x / offset_y: Pixels right of and below locate_over's
|
|
106
|
+
top-left corner.
|
|
107
|
+
modal: True to block interaction with the rest of the application
|
|
108
|
+
while the dialog is open. run_and_wait() additionally blocks
|
|
109
|
+
the calling code until it closes.
|
|
110
|
+
transient: True to tie the window to its parent -- it stays above
|
|
111
|
+
that window, minimises with it, and usually keeps out of the
|
|
112
|
+
taskbar. False gives an independent window, which is what a
|
|
113
|
+
long-lived tool panel wants.
|
|
114
|
+
heading: Text shown above the content area.
|
|
115
|
+
heading_anchor: "w", "e" or "center".
|
|
116
|
+
heading_font: Overrides the theme's heading_font for this instance.
|
|
117
|
+
A (family, size) or (family, size, style) tuple, or a CTkFont.
|
|
118
|
+
None uses the theme value.
|
|
119
|
+
heading_color: Overrides the theme's text_color for the heading on
|
|
120
|
+
this instance. None uses the theme value.
|
|
121
|
+
buttons: How many action buttons to show -- 3 (Apply, Cancel,
|
|
122
|
+
Reset), 2 (Apply, Cancel) or 1 (Apply). Apply is always
|
|
123
|
+
present: a dialog with no way to accept is a message box.
|
|
124
|
+
Buttons not shown are never created, so `reset_Button` does
|
|
125
|
+
not exist when buttons is 2 or 1 -- use has_button() to test.
|
|
126
|
+
apply_text / cancel_text / reset_text: Button labels.
|
|
127
|
+
apply_command / cancel_command / reset_command: Click callbacks.
|
|
128
|
+
A callback given here takes precedence over the corresponding
|
|
129
|
+
apply_CB / cancel_CB / reset_CB method, which remain available
|
|
130
|
+
to override in a subclass.
|
|
131
|
+
toplevel: An existing window to use instead of creating one.
|
|
132
|
+
Rarely needed; present so a caller can supply a pre-configured
|
|
133
|
+
window.
|
|
134
|
+
**kw: Any native sCTkFrame argument.
|
|
135
|
+
"""
|
|
136
|
+
self.dialog_parent = master
|
|
137
|
+
self._heading_text = heading
|
|
138
|
+
self._heading_anchor = heading_anchor
|
|
139
|
+
self._heading_font_override = heading_font
|
|
140
|
+
self._heading_color_override = heading_color
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
self._button_count = max(1, min(3, int(buttons)))
|
|
144
|
+
except (TypeError, ValueError):
|
|
145
|
+
self._button_count = 3
|
|
146
|
+
self._button_text = {"apply": apply_text, "cancel": cancel_text,
|
|
147
|
+
"reset": reset_text}
|
|
148
|
+
self._button_command = {"apply": apply_command,
|
|
149
|
+
"cancel": cancel_command,
|
|
150
|
+
"reset": reset_command}
|
|
151
|
+
|
|
152
|
+
if self._MAKE_WINDOW:
|
|
153
|
+
if toplevel is None:
|
|
154
|
+
# master is passed explicitly. Creating a Toplevel with no
|
|
155
|
+
# master attaches it to whichever root Tkinter considers
|
|
156
|
+
# default, which is wrong in any application with more than one
|
|
157
|
+
# window -- and a real source of silently orphaned Tk variables.
|
|
158
|
+
toplevel = sCTkDialogToplevel(
|
|
159
|
+
master, title=title, width=width, height=height,
|
|
160
|
+
locate_over=locate_over, offset_x=offset_x,
|
|
161
|
+
offset_y=offset_y, modal=modal, transient=transient,
|
|
162
|
+
)
|
|
163
|
+
self.dialog_toplevel = toplevel
|
|
164
|
+
self.dialog_toplevel.protocol(
|
|
165
|
+
"WM_DELETE_WINDOW", self.on_delete_window)
|
|
166
|
+
# The content is built INSIDE the new window, so the toplevel has
|
|
167
|
+
# to exist before this call.
|
|
168
|
+
super().__init__(self.dialog_toplevel, **kw)
|
|
169
|
+
self.pack(expand=True, fill="both")
|
|
170
|
+
else:
|
|
171
|
+
self.dialog_toplevel = None
|
|
172
|
+
super().__init__(master, **kw)
|
|
173
|
+
|
|
174
|
+
self._validate_theme_keys()
|
|
175
|
+
self._build_layout()
|
|
176
|
+
|
|
177
|
+
# Re-measure and re-place now that the content exists.
|
|
178
|
+
#
|
|
179
|
+
# sCTkDialogToplevel sizes itself in its own __init__, which runs
|
|
180
|
+
# BEFORE this widget builds anything into it -- so winfo_reqheight()
|
|
181
|
+
# was measuring an empty window. A dialog that omitted `height` (or
|
|
182
|
+
# gave only a width) came up too short, with the button row squashed
|
|
183
|
+
# to slivers at the bottom.
|
|
184
|
+
#
|
|
185
|
+
# Doing it here rather than moving the sizing out of the toplevel keeps
|
|
186
|
+
# that class usable on its own: it still sizes itself correctly when
|
|
187
|
+
# given explicit dimensions.
|
|
188
|
+
if self.dialog_toplevel is not None:
|
|
189
|
+
self.dialog_toplevel.place_over()
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
# Theme
|
|
193
|
+
# ------------------------------------------------------------------
|
|
194
|
+
def _validate_theme_keys(self):
|
|
195
|
+
"""
|
|
196
|
+
Hard-fails on an incomplete theme block, naming the missing key.
|
|
197
|
+
|
|
198
|
+
Follows the library-wide fail-loud rule: substituting a plausible
|
|
199
|
+
default hides a broken block behind output that merely looks slightly
|
|
200
|
+
wrong.
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
KeyError: naming the first missing key.
|
|
204
|
+
"""
|
|
205
|
+
# FIX: this read self.__class__.__name__, which for a subclass is the
|
|
206
|
+
# SUBCLASS's name -- so the Designer preview class reported
|
|
207
|
+
# "'sCTkDialogForPreview' theme block is missing 'heading_font'" while
|
|
208
|
+
# the block it actually reads is named by _THEME_BLOCK_NAME. Validation
|
|
209
|
+
# must resolve the block the same way ThemeableWidget does, or it
|
|
210
|
+
# reports a block that was never consulted.
|
|
211
|
+
name = getattr(self, "_THEME_BLOCK_NAME", None) or self.__class__.__name__
|
|
212
|
+
for key in self._REQUIRED_THEME_KEYS:
|
|
213
|
+
if self.final_kw.get(key) is None:
|
|
214
|
+
raise KeyError(
|
|
215
|
+
f"'{name}' theme block is missing '{key}' at the top "
|
|
216
|
+
f"level of sCTkThemes.json."
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
# Layout
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
def _build_layout(self):
|
|
223
|
+
"""
|
|
224
|
+
Builds the three regions: heading, content, action row.
|
|
225
|
+
|
|
226
|
+
Every StringVar is given an explicit master. A variable created without
|
|
227
|
+
one attaches to whichever root Tkinter considers default, which in an
|
|
228
|
+
application with more than one window silently binds it to the wrong
|
|
229
|
+
interpreter -- the widget updates one variable while the application
|
|
230
|
+
reads another, and the symptom is a value that is always empty.
|
|
231
|
+
"""
|
|
232
|
+
# --- heading -------------------------------------------------------
|
|
233
|
+
self.titleFrame = sCTkFrame(self)
|
|
234
|
+
self.heading_VAR = tk.StringVar(master=self, value=self._heading_text)
|
|
235
|
+
self.heading_Label = sCTkLabelPrimary(self.titleFrame)
|
|
236
|
+
# font and colour come from THIS widget's theme block, not from
|
|
237
|
+
# sCTkLabelPrimary's. A dialog heading is a distinct role and should be
|
|
238
|
+
# restyleable without moving every primary label in the application.
|
|
239
|
+
self.heading_Label.configure(
|
|
240
|
+
anchor=self._heading_anchor,
|
|
241
|
+
textvariable=self.heading_VAR,
|
|
242
|
+
# A font passed to the constructor wins over the theme, so a
|
|
243
|
+
# single dialog can be restyled without touching the theme file.
|
|
244
|
+
font=self._heading_font_override or self.final_kw.get("heading_font"),
|
|
245
|
+
text_color=(self._heading_color_override
|
|
246
|
+
or self.final_kw.get("text_color")),
|
|
247
|
+
)
|
|
248
|
+
self.heading_Label.pack(expand=True, fill="x", side="top")
|
|
249
|
+
# expand=False: only the CONTENT area should take spare vertical
|
|
250
|
+
# space. With all three regions expanding, the extra height was shared
|
|
251
|
+
# between them and the dialog came up taller than its content, with a
|
|
252
|
+
# gap above the button row.
|
|
253
|
+
self.titleFrame.pack(anchor="n", expand=False, fill="x",
|
|
254
|
+
padx=10, pady="20 10", side="top")
|
|
255
|
+
|
|
256
|
+
# --- content area --------------------------------------------------
|
|
257
|
+
# Where caller content goes. The Designer's builder object returns this
|
|
258
|
+
# from get_child_master(), so widgets dropped on the dialog land here
|
|
259
|
+
# rather than beside the heading or the buttons.
|
|
260
|
+
self.contentFrame = sCTkFrame(self)
|
|
261
|
+
# Width sets the dialog's natural width; height is collapsed to 1 so
|
|
262
|
+
# the content area asks for no more room than its children need.
|
|
263
|
+
#
|
|
264
|
+
# CTkFrame defaults to 200x200, and a frame with an explicit height
|
|
265
|
+
# requests THAT height regardless of what is inside it -- so leaving
|
|
266
|
+
# the default in place made every dialog at least 200px of content
|
|
267
|
+
# area, with the surplus showing as a gap above the button row. Two
|
|
268
|
+
# entries need about 80.
|
|
269
|
+
#
|
|
270
|
+
# An empty content area collapsing to nothing is fine: the window has
|
|
271
|
+
# its own floor, sCTkDialogToplevel.MIN_HEIGHT.
|
|
272
|
+
self.contentFrame.configure(width=self.CONTENT_WIDTH, height=1)
|
|
273
|
+
|
|
274
|
+
# --- action row ----------------------------------------------------
|
|
275
|
+
# Built BEFORE the content area is packed: it packs to the bottom, and
|
|
276
|
+
# pack processes side="bottom" claims in order, so the content area has
|
|
277
|
+
# to come afterwards to fill what is left rather than push the buttons
|
|
278
|
+
# off the edge.
|
|
279
|
+
self._build_action_row()
|
|
280
|
+
|
|
281
|
+
self.contentFrame.pack(expand=True, fill="both", padx=5, side="top")
|
|
282
|
+
|
|
283
|
+
def _build_action_row(self):
|
|
284
|
+
"""
|
|
285
|
+
Builds the button row from the current button count.
|
|
286
|
+
|
|
287
|
+
Separate from _build_layout() so set_buttons() can rebuild just this
|
|
288
|
+
part. Only the requested buttons are created; attributes for the others
|
|
289
|
+
are set to None rather than left undefined, so `self.reset_Button` is
|
|
290
|
+
always a valid expression -- code reaching for a button that was not
|
|
291
|
+
requested gets None instead of AttributeError.
|
|
292
|
+
"""
|
|
293
|
+
self.actionFrame = sCTkFrame(self)
|
|
294
|
+
|
|
295
|
+
self.apply_Button = self.cancel_Button = self.reset_Button = None
|
|
296
|
+
self.applyText_VAR = self.cancelText_VAR = self.resetText_VAR = None
|
|
297
|
+
|
|
298
|
+
button_classes = {"apply": sCTkButtonPrimary,
|
|
299
|
+
"cancel": sCTkButtonSecondary,
|
|
300
|
+
"reset": sCTkButtonSecondary}
|
|
301
|
+
|
|
302
|
+
for column, name in enumerate(self._BUTTON_ORDER[:self._button_count]):
|
|
303
|
+
var = tk.StringVar(master=self, value=self._button_text[name])
|
|
304
|
+
button = button_classes[name](self.actionFrame)
|
|
305
|
+
# A command passed to the constructor wins; otherwise the
|
|
306
|
+
# overridable method is used, so a subclass that only defines
|
|
307
|
+
# apply_CB() still works.
|
|
308
|
+
command = self._button_command[name] or getattr(self, f"{name}_CB")
|
|
309
|
+
button.configure(textvariable=var, command=command)
|
|
310
|
+
button.grid(column=column, padx=10, row=0)
|
|
311
|
+
setattr(self, f"{name}_Button", button)
|
|
312
|
+
setattr(self, f"{name}Text_VAR", var)
|
|
313
|
+
|
|
314
|
+
# expand=False, and packed to the BOTTOM so it stays against the edge
|
|
315
|
+
# while the content area grows into whatever is left.
|
|
316
|
+
self.actionFrame.pack(anchor="s", expand=False, fill="x",
|
|
317
|
+
padx=5, pady="10 20", side="bottom")
|
|
318
|
+
self.actionFrame.grid_anchor("s")
|
|
319
|
+
|
|
320
|
+
def set_buttons(self, count):
|
|
321
|
+
"""
|
|
322
|
+
Changes how many action buttons the dialog shows, rebuilding the row.
|
|
323
|
+
|
|
324
|
+
The row is built once at construction, so changing the count means
|
|
325
|
+
destroying and rebuilding it -- there is no way to add a button that
|
|
326
|
+
was never created. Labels and commands set through the constructor are
|
|
327
|
+
preserved, since they live in _button_text and _button_command rather
|
|
328
|
+
than on the widgets.
|
|
329
|
+
|
|
330
|
+
Exists so the Pygubu Designer can honour a change to `buttons` on a
|
|
331
|
+
live widget. Relying on the Designer to rebuild the whole widget did
|
|
332
|
+
not work: the canvas kept showing the old count while the preview and
|
|
333
|
+
the generated code showed the new one.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
count: 1, 2 or 3. Clamped into that range.
|
|
337
|
+
"""
|
|
338
|
+
try:
|
|
339
|
+
count = max(1, min(3, int(count)))
|
|
340
|
+
except (TypeError, ValueError):
|
|
341
|
+
return
|
|
342
|
+
if count == self._button_count:
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
self._button_count = count
|
|
346
|
+
if getattr(self, "actionFrame", None) is not None:
|
|
347
|
+
self.actionFrame.destroy()
|
|
348
|
+
self._build_action_row()
|
|
349
|
+
|
|
350
|
+
# The new row packs to the bottom, which puts it BELOW the content area
|
|
351
|
+
# in pack order. Re-packing the content area restores the intended
|
|
352
|
+
# arrangement: heading, content, buttons.
|
|
353
|
+
content = getattr(self, "contentFrame", None)
|
|
354
|
+
if content is not None:
|
|
355
|
+
content.pack_forget()
|
|
356
|
+
content.pack(expand=True, fill="both", padx=5, side="top")
|
|
357
|
+
|
|
358
|
+
def set_button_text(self, name, text):
|
|
359
|
+
"""
|
|
360
|
+
Sets one button's label, remembering it across a rebuild.
|
|
361
|
+
|
|
362
|
+
set_buttons() recreates the buttons, so a label set only on the widget
|
|
363
|
+
would be lost. This records it too.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
name: "apply", "cancel" or "reset".
|
|
367
|
+
text: The label.
|
|
368
|
+
"""
|
|
369
|
+
if name not in self._button_text:
|
|
370
|
+
return False
|
|
371
|
+
self._button_text[name] = text
|
|
372
|
+
var = getattr(self, f"{name}Text_VAR", None)
|
|
373
|
+
if var is not None:
|
|
374
|
+
var.set(text)
|
|
375
|
+
return True
|
|
376
|
+
|
|
377
|
+
# ------------------------------------------------------------------
|
|
378
|
+
# Configuration
|
|
379
|
+
# ------------------------------------------------------------------
|
|
380
|
+
# Properties this widget owns. None of them are native CTkFrame options,
|
|
381
|
+
# so each is consumed by configure() below before the rest is forwarded.
|
|
382
|
+
# width and height are listed here deliberately.
|
|
383
|
+
#
|
|
384
|
+
# sCTkFrame, this widget's base, also has width and height -- meaning the
|
|
385
|
+
# FRAME's size. For a dialog that meaning is empty: the frame is packed
|
|
386
|
+
# with expand=True, fill="both", so the geometry manager overrides whatever
|
|
387
|
+
# size it is given and the value has no visible effect.
|
|
388
|
+
#
|
|
389
|
+
# Left alone, that produced three answers for one name: the constructor's
|
|
390
|
+
# width sized the window, configure(width=...) sized the frame and did
|
|
391
|
+
# nothing, and cget("width") reported the frame. Routing both to the window
|
|
392
|
+
# makes width and height mean the same thing everywhere -- the size of the
|
|
393
|
+
# dialog, which is what anyone asking for a dialog's width means.
|
|
394
|
+
_OWN_PROPERTIES = frozenset({
|
|
395
|
+
"width", "height",
|
|
396
|
+
"title", "heading", "heading_anchor", "heading_font", "heading_color",
|
|
397
|
+
"buttons", "apply_text", "cancel_text", "reset_text",
|
|
398
|
+
"apply_command", "cancel_command", "reset_command",
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
# Constructor defaults for this widget's own properties, so a
|
|
402
|
+
# single-argument query can report one. Pygubu calls configure(name) to
|
|
403
|
+
# discover a default when a field is cleared in the inspector, then applies
|
|
404
|
+
# what it gets back -- so an accurate default here is what makes clearing a
|
|
405
|
+
# label restore "Cancel" rather than leaving it blank.
|
|
406
|
+
_PROPERTY_DEFAULTS = {
|
|
407
|
+
"title": "", "heading": "Heading Title", "heading_anchor": "center",
|
|
408
|
+
"heading_font": "", "heading_color": "", "buttons": 3,
|
|
409
|
+
"apply_text": "Apply", "cancel_text": "Cancel", "reset_text": "Reset",
|
|
410
|
+
"apply_command": "", "cancel_command": "", "reset_command": "",
|
|
411
|
+
"width": "", "height": "",
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
def configure(self, cnf=None, **kwargs):
|
|
415
|
+
"""
|
|
416
|
+
Standard configuration, extended to accept this widget's own
|
|
417
|
+
properties.
|
|
418
|
+
|
|
419
|
+
FIX: without this, generated code raised
|
|
420
|
+
|
|
421
|
+
ValueError: ['apply_command'] are not supported arguments.
|
|
422
|
+
|
|
423
|
+
Pygubu emits command properties as configure() arguments through its
|
|
424
|
+
own code path, so intercepting them in the builder object was not
|
|
425
|
+
enough -- they still reached CTkFrame.configure(), which rejects any
|
|
426
|
+
keyword it does not recognise. Handling them here works whichever path
|
|
427
|
+
supplies them, and gives callers a runtime API that matches the
|
|
428
|
+
Designer's inspector.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
cnf: Optional dict of options, merged into kwargs.
|
|
432
|
+
**kwargs: Any native sCTkFrame option, or any of _OWN_PROPERTIES.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
Whatever the native configure() returns for the remaining options.
|
|
436
|
+
"""
|
|
437
|
+
# FIX: a single positional STRING is a query, not a dict of options.
|
|
438
|
+
# Pygubu calls configure(name) to read a property's default when the
|
|
439
|
+
# field is cleared in the inspector, and this method previously did
|
|
440
|
+
# {**cnf}, which raises TypeError on a string -- so clearing a label
|
|
441
|
+
# blew up the unset path and the label stayed as it was.
|
|
442
|
+
if isinstance(cnf, str):
|
|
443
|
+
name = cnf
|
|
444
|
+
if name in self._OWN_PROPERTIES:
|
|
445
|
+
default = self._PROPERTY_DEFAULTS.get(name, "")
|
|
446
|
+
current = self.cget(name)
|
|
447
|
+
return (name, name, name, default, current)
|
|
448
|
+
return super().cget(name)
|
|
449
|
+
|
|
450
|
+
if cnf:
|
|
451
|
+
kwargs = {**cnf, **kwargs}
|
|
452
|
+
|
|
453
|
+
for key in list(kwargs):
|
|
454
|
+
if key not in self._OWN_PROPERTIES:
|
|
455
|
+
continue
|
|
456
|
+
value = kwargs.pop(key)
|
|
457
|
+
|
|
458
|
+
if key in ("width", "height"):
|
|
459
|
+
if self.dialog_toplevel is not None:
|
|
460
|
+
self.dialog_toplevel.set_size(**{key: value})
|
|
461
|
+
else:
|
|
462
|
+
# No window of its own -- the Designer preview. Fall back
|
|
463
|
+
# to the frame's own size so the canvas shows something
|
|
464
|
+
# that reflects the setting.
|
|
465
|
+
super().configure(**{key: value})
|
|
466
|
+
elif key == "title":
|
|
467
|
+
self.set_title(value or "")
|
|
468
|
+
elif key == "heading":
|
|
469
|
+
self.set_heading(
|
|
470
|
+
heading=value if value else
|
|
471
|
+
self._PROPERTY_DEFAULTS["heading"])
|
|
472
|
+
elif key == "heading_anchor":
|
|
473
|
+
self.set_heading(anchor=value)
|
|
474
|
+
elif key == "heading_font":
|
|
475
|
+
self.set_heading_font(value)
|
|
476
|
+
elif key == "heading_color":
|
|
477
|
+
self.set_heading_color(value)
|
|
478
|
+
elif key == "buttons":
|
|
479
|
+
self.set_buttons(value)
|
|
480
|
+
elif key.endswith("_text"):
|
|
481
|
+
# An empty value means "use the default", matching what the
|
|
482
|
+
# generated code does -- the property is omitted and the
|
|
483
|
+
# constructor default applies.
|
|
484
|
+
self.set_button_text(
|
|
485
|
+
key[:-len("_text")],
|
|
486
|
+
value if value else self._PROPERTY_DEFAULTS[key])
|
|
487
|
+
elif key.endswith("_command"):
|
|
488
|
+
name = key[:-len("_command")]
|
|
489
|
+
# Recorded as well as applied, so it survives the rebuild that
|
|
490
|
+
# a `buttons` change triggers.
|
|
491
|
+
self._button_command[name] = value
|
|
492
|
+
button = getattr(self, f"{name}_Button", None)
|
|
493
|
+
if button is not None and value:
|
|
494
|
+
button.configure(command=value)
|
|
495
|
+
|
|
496
|
+
if kwargs:
|
|
497
|
+
return super().configure(**kwargs)
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
config = configure
|
|
501
|
+
|
|
502
|
+
def cget(self, key):
|
|
503
|
+
"""Extends native cget() to this widget's own properties."""
|
|
504
|
+
if key == "buttons":
|
|
505
|
+
return self._button_count
|
|
506
|
+
if key in ("apply_text", "cancel_text", "reset_text"):
|
|
507
|
+
return self._button_text[key[:-len("_text")]]
|
|
508
|
+
if key in ("apply_command", "cancel_command", "reset_command"):
|
|
509
|
+
return self._button_command[key[:-len("_command")]]
|
|
510
|
+
if key == "heading":
|
|
511
|
+
return self.heading_VAR.get()
|
|
512
|
+
if key == "heading_anchor":
|
|
513
|
+
return self._heading_anchor
|
|
514
|
+
if key == "heading_font":
|
|
515
|
+
return (self._heading_font_override
|
|
516
|
+
or self.final_kw.get("heading_font"))
|
|
517
|
+
if key == "heading_color":
|
|
518
|
+
return (self._heading_color_override
|
|
519
|
+
or self.final_kw.get("text_color"))
|
|
520
|
+
if key == "title":
|
|
521
|
+
return (self.dialog_toplevel.title()
|
|
522
|
+
if self.dialog_toplevel is not None else "")
|
|
523
|
+
if key in ("width", "height"):
|
|
524
|
+
if self.dialog_toplevel is None:
|
|
525
|
+
return super().cget(key)
|
|
526
|
+
size = self.dialog_toplevel.get_size()
|
|
527
|
+
return size[0] if key == "width" else size[1]
|
|
528
|
+
return super().cget(key)
|
|
529
|
+
|
|
530
|
+
# ------------------------------------------------------------------
|
|
531
|
+
# Button callbacks -- override in a subclass
|
|
532
|
+
# ------------------------------------------------------------------
|
|
533
|
+
def apply_CB(self):
|
|
534
|
+
"""Called when Apply is clicked. Override."""
|
|
535
|
+
pass
|
|
536
|
+
|
|
537
|
+
def cancel_CB(self):
|
|
538
|
+
"""Called when Cancel is clicked. Override."""
|
|
539
|
+
pass
|
|
540
|
+
|
|
541
|
+
def reset_CB(self):
|
|
542
|
+
"""Called when Reset is clicked. Override."""
|
|
543
|
+
pass
|
|
544
|
+
|
|
545
|
+
# ------------------------------------------------------------------
|
|
546
|
+
# Window
|
|
547
|
+
# ------------------------------------------------------------------
|
|
548
|
+
def on_delete_window(self):
|
|
549
|
+
"""Bound to the window manager's close button. Override to intercept."""
|
|
550
|
+
self.dialog_close()
|
|
551
|
+
|
|
552
|
+
def dialog_close(self):
|
|
553
|
+
"""Closes and destroys the dialog window."""
|
|
554
|
+
if self.dialog_toplevel is not None:
|
|
555
|
+
self.dialog_toplevel.destroy()
|
|
556
|
+
|
|
557
|
+
def run_and_wait(self):
|
|
558
|
+
"""
|
|
559
|
+
Makes the dialog modal and blocks until it closes.
|
|
560
|
+
|
|
561
|
+
Use when the calling code needs the result before continuing. A dialog
|
|
562
|
+
constructed with modal=True already blocks *interaction*; this also
|
|
563
|
+
blocks *execution*, which is the difference between a dialog you can
|
|
564
|
+
ignore and one you must answer.
|
|
565
|
+
"""
|
|
566
|
+
if self.dialog_toplevel is None:
|
|
567
|
+
return
|
|
568
|
+
self.dialog_toplevel._apply_modal()
|
|
569
|
+
parent = self.dialog_parent or self.dialog_toplevel
|
|
570
|
+
parent.wait_window(self.dialog_toplevel)
|
|
571
|
+
|
|
572
|
+
def set_title(self, title):
|
|
573
|
+
"""Sets the window title bar text."""
|
|
574
|
+
if self.dialog_toplevel is not None:
|
|
575
|
+
self.dialog_toplevel.title(title)
|
|
576
|
+
|
|
577
|
+
# ------------------------------------------------------------------
|
|
578
|
+
# Content
|
|
579
|
+
# ------------------------------------------------------------------
|
|
580
|
+
def set_heading(self, heading=None, anchor=None):
|
|
581
|
+
"""
|
|
582
|
+
Sets the heading text above the content area, and optionally its
|
|
583
|
+
alignment.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
heading: New text. None leaves it unchanged.
|
|
587
|
+
anchor: "w", "e" or "center". Any other value is ignored. None
|
|
588
|
+
leaves it unchanged.
|
|
589
|
+
"""
|
|
590
|
+
if heading is not None:
|
|
591
|
+
self.heading_VAR.set(heading)
|
|
592
|
+
if anchor is not None and str(anchor).lower() in ("w", "e", "center"):
|
|
593
|
+
self._heading_anchor = str(anchor).lower()
|
|
594
|
+
self.heading_Label.configure(anchor=self._heading_anchor)
|
|
595
|
+
|
|
596
|
+
def set_heading_font(self, font):
|
|
597
|
+
"""Overrides the heading font for this instance."""
|
|
598
|
+
self.heading_Label.configure(font=font)
|
|
599
|
+
|
|
600
|
+
def set_heading_color(self, text_color):
|
|
601
|
+
"""
|
|
602
|
+
Overrides the heading colour for this instance.
|
|
603
|
+
|
|
604
|
+
Passing None restores the theme's text_color.
|
|
605
|
+
"""
|
|
606
|
+
self.heading_Label.configure(
|
|
607
|
+
text_color=text_color or self.final_kw.get("text_color"))
|
|
608
|
+
|
|
609
|
+
def has_button(self, name):
|
|
610
|
+
"""
|
|
611
|
+
Whether a given button exists on this dialog.
|
|
612
|
+
|
|
613
|
+
Args:
|
|
614
|
+
name: "apply", "cancel" or "reset".
|
|
615
|
+
|
|
616
|
+
Returns:
|
|
617
|
+
True if that button was created and still exists.
|
|
618
|
+
"""
|
|
619
|
+
button = getattr(self, f"{name}_Button", None)
|
|
620
|
+
if button is None:
|
|
621
|
+
return False
|
|
622
|
+
try:
|
|
623
|
+
return bool(button.winfo_exists())
|
|
624
|
+
except Exception:
|
|
625
|
+
return False
|
|
626
|
+
|
|
627
|
+
def set_two_button(self):
|
|
628
|
+
"""
|
|
629
|
+
Reduces the action row to Apply and Cancel by removing Reset.
|
|
630
|
+
|
|
631
|
+
Retained from the original API. Prefer buttons=2 at construction --
|
|
632
|
+
this destroys a button that was built a moment earlier. Irreversible;
|
|
633
|
+
set_reset_button() returns False afterwards rather than raising.
|
|
634
|
+
"""
|
|
635
|
+
if self.has_button("reset"):
|
|
636
|
+
self.reset_Button.destroy()
|
|
637
|
+
self._button_count = min(self._button_count, 2)
|
|
638
|
+
|
|
639
|
+
def set_apply_button(self, button_name=None, button_command=None):
|
|
640
|
+
"""
|
|
641
|
+
Sets the Apply button's label and callback.
|
|
642
|
+
|
|
643
|
+
Args:
|
|
644
|
+
button_name: New label. None leaves it unchanged.
|
|
645
|
+
button_command: New callback. None leaves it unchanged.
|
|
646
|
+
|
|
647
|
+
Returns:
|
|
648
|
+
True, or False if the button does not exist on this dialog.
|
|
649
|
+
"""
|
|
650
|
+
if not self.has_button("apply"):
|
|
651
|
+
return False
|
|
652
|
+
if button_name is not None:
|
|
653
|
+
self.applyText_VAR.set(button_name)
|
|
654
|
+
if button_command is not None:
|
|
655
|
+
self.apply_Button.configure(command=button_command)
|
|
656
|
+
return True
|
|
657
|
+
|
|
658
|
+
def set_cancel_button(self, button_name=None, button_command=None):
|
|
659
|
+
"""Sets the Cancel button's label and callback. See set_apply_button()."""
|
|
660
|
+
if not self.has_button("cancel"):
|
|
661
|
+
return False
|
|
662
|
+
if button_name is not None:
|
|
663
|
+
self.cancelText_VAR.set(button_name)
|
|
664
|
+
if button_command is not None:
|
|
665
|
+
self.cancel_Button.configure(command=button_command)
|
|
666
|
+
return True
|
|
667
|
+
|
|
668
|
+
def set_reset_button(self, button_name=None, button_command=None):
|
|
669
|
+
"""
|
|
670
|
+
Sets the Reset button's label and callback.
|
|
671
|
+
|
|
672
|
+
Returns:
|
|
673
|
+
False if the button has been removed by set_two_button(), in which
|
|
674
|
+
case nothing is changed. True otherwise.
|
|
675
|
+
"""
|
|
676
|
+
if not self.has_button("reset"):
|
|
677
|
+
return False
|
|
678
|
+
if button_name is not None:
|
|
679
|
+
self.resetText_VAR.set(button_name)
|
|
680
|
+
if button_command is not None:
|
|
681
|
+
self.reset_Button.configure(command=button_command)
|
|
682
|
+
return True
|
|
683
|
+
|
|
684
|
+
# ------------------------------------------------------------------
|
|
685
|
+
# Backwards-compatible aliases.
|
|
686
|
+
#
|
|
687
|
+
# The original methods were camelCase and inconsistent with each other:
|
|
688
|
+
# setApplyButton took `buttonCommand` while setCancelButton and
|
|
689
|
+
# setResetButton took `ButtonCommand`, so the documented call failed on one
|
|
690
|
+
# of the three. The snake_case methods above are the supported API; these
|
|
691
|
+
# keep existing code working.
|
|
692
|
+
# ------------------------------------------------------------------
|
|
693
|
+
setTitle = set_title
|
|
694
|
+
setHeading = set_heading
|
|
695
|
+
setTwoButton = set_two_button
|
|
696
|
+
setApplyButton = set_apply_button
|
|
697
|
+
setCancelButton = set_cancel_button
|
|
698
|
+
setResetButton = set_reset_button
|
|
699
|
+
onDeleteWindow = on_delete_window
|
|
700
|
+
dialogClose = dialog_close
|
|
701
|
+
runAndWait = run_and_wait
|