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,516 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
"""
|
|
3
|
+
sCTkPathChooser
|
|
4
|
+
|
|
5
|
+
A custom, compound theme-compliant path selection tool pairing an entry line with a browse button.
|
|
6
|
+
Inherits cleanly and directly from ctk.CTkFrame to preserve 100% of native CustomTkinter loops.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import tkinter as tk
|
|
11
|
+
|
|
12
|
+
import customtkinter as ctk
|
|
13
|
+
from .themeable_widget import ThemeableWidget, parse_list_property, defer
|
|
14
|
+
|
|
15
|
+
from .sctk_file_explorer import sCTkFileExplorer
|
|
16
|
+
|
|
17
|
+
# Framework-compliant component imports
|
|
18
|
+
from .sctk_button_primary import sCTkButtonPrimary
|
|
19
|
+
from .sctk_entry_primary import sCTkEntryPrimary
|
|
20
|
+
from .sctk_button_secondary import sCTkButtonSecondary
|
|
21
|
+
from .sctk_frame import sCTkFrame
|
|
22
|
+
from .sctk_file_explorer import sCTkFileExplorer
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class sCTkPathChooser(ctk.CTkFrame, ThemeableWidget):
|
|
26
|
+
# NOTE: an earlier version declared a _MANAGED_PROPERTIES frozenset here,
|
|
27
|
+
# never referenced anywhere else in this file -- dead code, removed. Same
|
|
28
|
+
# vestigial pattern found and removed elsewhere in this project
|
|
29
|
+
# (sCTkFrame's original "properties = frozenset()", sCTkButtonPrimary's
|
|
30
|
+
# original "_MANAGED_PROPERTIES = frozenset({'state'})").
|
|
31
|
+
|
|
32
|
+
def __init__(self, master=None, **kwargs):
|
|
33
|
+
# Forcefully extract custom properties out so ctk.CTkFrame never throws a kwargs error
|
|
34
|
+
self.type = str(kwargs.pop("type", "directory") or "directory").lower()
|
|
35
|
+
self.title = str(kwargs.pop("title", "Select Path"))
|
|
36
|
+
self.command = kwargs.pop("command", None)
|
|
37
|
+
|
|
38
|
+
# Pull standard layout sizing constraints explicitly, cleansing them from kwargs pass
|
|
39
|
+
desired_width = kwargs.pop("width", 350)
|
|
40
|
+
desired_height = kwargs.pop("height", 32)
|
|
41
|
+
|
|
42
|
+
# Pop text alignment parameters or fall back to left orientation
|
|
43
|
+
self.justify = str(kwargs.pop("justify", "left")).lower()
|
|
44
|
+
if self.justify not in ("left", "right", "center"):
|
|
45
|
+
self.justify = "left"
|
|
46
|
+
|
|
47
|
+
# Pop entry custom height dimensions or fall back to global layout height
|
|
48
|
+
self.entry_height = int(kwargs.pop("entry_height", desired_height))
|
|
49
|
+
|
|
50
|
+
# Pop button custom dimensions or fall back to defaults
|
|
51
|
+
self.btn_width = int(kwargs.pop("btn_width", 110))
|
|
52
|
+
self.btn_height = int(kwargs.pop("btn_height", desired_height))
|
|
53
|
+
|
|
54
|
+
# Pop custom button label override text parameter
|
|
55
|
+
self.btn_text = kwargs.pop("btn_text", None)
|
|
56
|
+
if self.btn_text is not None:
|
|
57
|
+
self.btn_text = str(self.btn_text)
|
|
58
|
+
|
|
59
|
+
# Pop modal document viewer window geometry attributes safely
|
|
60
|
+
self.browser_width = int(kwargs.pop("browser_width", 500))
|
|
61
|
+
self.browser_height = int(kwargs.pop("browser_height", 450))
|
|
62
|
+
|
|
63
|
+
# SAFE INTERCEPTION FIX: Clean and extract state out of kwargs before calling base class constructor
|
|
64
|
+
self._initial_state_seed = str(kwargs.pop("state", "normal")).lower()
|
|
65
|
+
|
|
66
|
+
# Clean out other potential custom property parameter leaks
|
|
67
|
+
kwargs.pop("defaultextension", None)
|
|
68
|
+
kwargs.pop("entry_width", None)
|
|
69
|
+
|
|
70
|
+
raw_file = kwargs.pop("initialfile", None)
|
|
71
|
+
raw_dir = kwargs.pop("initialdir", None)
|
|
72
|
+
ft_raw = kwargs.pop("filetypes", None)
|
|
73
|
+
|
|
74
|
+
self.initialdir = os.path.normpath(os.path.expanduser(str(raw_dir))) if raw_dir else os.getcwd()
|
|
75
|
+
|
|
76
|
+
# A bare initialfile resolves against initialdir, NOT against the
|
|
77
|
+
# process's working directory. os.path.expanduser() alone left
|
|
78
|
+
# initialdir="~/Downloads" with initialfile="doc.txt" pointing at
|
|
79
|
+
# <cwd>/doc.txt -- a path that usually does not exist. Same fix as
|
|
80
|
+
# sCTkFileExplorer.
|
|
81
|
+
# An initialfile that does not EXIST is still displayed.
|
|
82
|
+
#
|
|
83
|
+
# Deliberate: this widget names a path the user is choosing, and naming
|
|
84
|
+
# a file that has yet to be created is a legitimate thing to do -- a
|
|
85
|
+
# "save as" field being the obvious case. The browse popup opens on
|
|
86
|
+
# initialdir regardless, so a nonexistent seed costs nothing.
|
|
87
|
+
#
|
|
88
|
+
# The visible consequence is that the entry shows a file while the
|
|
89
|
+
# browser behaves as though only the directory were set.
|
|
90
|
+
self.initialfile = None
|
|
91
|
+
if raw_file:
|
|
92
|
+
candidate = os.path.expanduser(str(raw_file))
|
|
93
|
+
if not os.path.isabs(candidate):
|
|
94
|
+
candidate = os.path.join(self.initialdir, candidate)
|
|
95
|
+
self.initialfile = os.path.normpath(candidate)
|
|
96
|
+
|
|
97
|
+
if self.type == "directory" and self.initialfile:
|
|
98
|
+
self.initialdir = os.path.dirname(self.initialfile)
|
|
99
|
+
self.initialfile = None
|
|
100
|
+
|
|
101
|
+
self.filetypes = []
|
|
102
|
+
if ft_raw:
|
|
103
|
+
if self.type != "file":
|
|
104
|
+
raise ValueError("Cannot configure filetypes filter attributes when widget type matches 'directory'.")
|
|
105
|
+
if isinstance(ft_raw, str):
|
|
106
|
+
cleaned_ft = ft_raw.strip()
|
|
107
|
+
if not (cleaned_ft.startswith("[") and cleaned_ft.endswith("]")):
|
|
108
|
+
raise ValueError(f"Malformed filetypes string array sequence: '{ft_raw}'.")
|
|
109
|
+
try:
|
|
110
|
+
self.filetypes = parse_list_property(cleaned_ft)
|
|
111
|
+
except Exception as err:
|
|
112
|
+
raise ValueError(f"Malformed syntax encountered processing filetypes configuration string: {err}")
|
|
113
|
+
else:
|
|
114
|
+
self.filetypes = ft_raw
|
|
115
|
+
else:
|
|
116
|
+
self.filetypes = None
|
|
117
|
+
# Enforce name introspection by passing kwargs directly up into ThemeableWidget
|
|
118
|
+
ThemeableWidget.__init__(self, kwargs)
|
|
119
|
+
|
|
120
|
+
# 🛠️ THE MUTATION SAFEGUARD DEEP COPY SHIELD:
|
|
121
|
+
self._local_defaults = dict(self.final_kw)
|
|
122
|
+
self._custom_disabled_map = dict(self._widget_disabled_map)
|
|
123
|
+
|
|
124
|
+
# Initialize base container passing standard frame parameters safely with no extra keyword arguments leaking
|
|
125
|
+
super().__init__(master, width=desired_width, height=desired_height, **kwargs)
|
|
126
|
+
|
|
127
|
+
self._state = "normal" if self._initial_state_seed not in ("normal", "disabled") else self._initial_state_seed
|
|
128
|
+
|
|
129
|
+
# Enforce strict pixel dimensional footprints to withstand parent scale bounds
|
|
130
|
+
self.grid_propagate(False)
|
|
131
|
+
self.columnconfigure(0, weight=1) # Column 0 (Entry) gets all available stretch space
|
|
132
|
+
self.columnconfigure(1, weight=0) # Column 1 (Button) stays fixed to btn_width
|
|
133
|
+
self.rowconfigure(0, weight=1)
|
|
134
|
+
|
|
135
|
+
self.entry = sCTkEntryPrimary(self, justify=self.justify, width=0, height=self.entry_height)
|
|
136
|
+
entry_v_padding = max(0, (desired_height - self.entry_height) // 2)
|
|
137
|
+
self.entry.grid(row=0, column=0, sticky="ew", padx=(0, 8), pady=entry_v_padding)
|
|
138
|
+
|
|
139
|
+
default_seed = self.initialfile if self.initialfile else self.initialdir
|
|
140
|
+
self.set(default_seed)
|
|
141
|
+
|
|
142
|
+
# Re-applied once Tk is idle. At this point the entry is not mapped and
|
|
143
|
+
# has no width, so xview_moveto() has nothing to scroll within -- a
|
|
144
|
+
# right-justified long path came up showing its start regardless.
|
|
145
|
+
defer(self, "idle", self._apply_entry_view)
|
|
146
|
+
|
|
147
|
+
# Typed input has to be acted on. The entry had NO bindings at all, so
|
|
148
|
+
# a path typed straight into it was never applied: it stayed as loose
|
|
149
|
+
# text, nothing navigated, and the justify view was never re-applied
|
|
150
|
+
# because set() is what applies it. Pressing the browse button then
|
|
151
|
+
# showed the real value and the typed text appeared to vanish.
|
|
152
|
+
self.entry.bind("<Return>", self._on_entry_commit)
|
|
153
|
+
self.entry.bind("<KP_Enter>", self._on_entry_commit)
|
|
154
|
+
self.entry.bind("<FocusOut>", self._on_entry_commit)
|
|
155
|
+
|
|
156
|
+
self.btn = sCTkButtonPrimary(self, width=self.btn_width, height=self.btn_height, command=self._launch_browser)
|
|
157
|
+
btn_v_padding = max(0, (desired_height - self.btn_height) // 2)
|
|
158
|
+
self.btn.grid(row=0, column=1, sticky="ew", pady=btn_v_padding)
|
|
159
|
+
|
|
160
|
+
# Force structural tracking state initialization loops
|
|
161
|
+
self._process_live_theme_repaint()
|
|
162
|
+
if self._state == "disabled":
|
|
163
|
+
self.state("disabled")
|
|
164
|
+
|
|
165
|
+
# 🔑 REGISTER LIFECYCLE HANDSHAKE HOOK: Pushes notifications up to Pygubu systems cleanly.
|
|
166
|
+
self._finalize_themeable_lifecycle()
|
|
167
|
+
|
|
168
|
+
def _set_appearance_mode(self, mode_string: str):
|
|
169
|
+
"""Native look catcher ensuring active or desaturated elements follow theme shifts fluidly."""
|
|
170
|
+
if hasattr(super(), "_set_appearance_mode"):
|
|
171
|
+
try: super()._set_appearance_mode(mode_string)
|
|
172
|
+
except Exception: pass
|
|
173
|
+
self._update_current_visual_state()
|
|
174
|
+
|
|
175
|
+
def _update_current_visual_state(self):
|
|
176
|
+
"""Helper to catch theme broadcasts and refresh internal components seamlessly."""
|
|
177
|
+
self._process_live_theme_repaint()
|
|
178
|
+
def _process_live_theme_repaint(self):
|
|
179
|
+
"""Centralized theme-repaint pipeline resolving aesthetic look parameters."""
|
|
180
|
+
theme = self._local_defaults
|
|
181
|
+
btn_txt = self.btn_text if self.btn_text is not None else ("Browse Folders..." if self.type == "directory" else "Browse Files...")
|
|
182
|
+
|
|
183
|
+
# FIX: an earlier version had no validation at all here -- every key
|
|
184
|
+
# below was a plain, unguarded .get(key), meaning a missing key would
|
|
185
|
+
# silently resolve to None and get passed straight to a native
|
|
186
|
+
# configure() call, likely producing a confusing native error rather
|
|
187
|
+
# than a clear message naming what's missing. Matches the same
|
|
188
|
+
# hard-fail principle established for sCTkFileExplorer, sCTkSwitch,
|
|
189
|
+
# the label family, sCTkTableview, sCTkSpinbox, and sCTkSelector
|
|
190
|
+
# elsewhere in this project. disabled_map is deliberately NOT given
|
|
191
|
+
# the same hard-fail treatment -- its existing graceful fallback to
|
|
192
|
+
# the top-level/normal value when a disabled-specific override is
|
|
193
|
+
# missing is intentional and already correct, matching the same
|
|
194
|
+
# lenient pattern used for most of sCTkFileExplorer's disabled_map
|
|
195
|
+
# keys.
|
|
196
|
+
for required_key in ("entry_fg", "entry_border_color", "entry_text_color", "entry_font",
|
|
197
|
+
"btn_fg", "btn_border_color", "btn_text_color", "btn_hover", "btn_font"):
|
|
198
|
+
if theme.get(required_key) is None:
|
|
199
|
+
raise KeyError(
|
|
200
|
+
f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing '{required_key}' "
|
|
201
|
+
f"at the top level of sCTkThemes.json."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
current_state = getattr(self, "_state", "normal")
|
|
205
|
+
if current_state == "disabled":
|
|
206
|
+
d_map = self._custom_disabled_map
|
|
207
|
+
entry_bg = d_map.get("entry_fg", theme.get("entry_fg"))
|
|
208
|
+
entry_border = d_map.get("entry_border_color", theme.get("entry_border_color"))
|
|
209
|
+
entry_text = d_map.get("entry_text_color", theme.get("entry_text_color"))
|
|
210
|
+
btn_bg = d_map.get("btn_fg", theme.get("btn_fg"))
|
|
211
|
+
btn_border = d_map.get("btn_border_color", theme.get("btn_border_color"))
|
|
212
|
+
btn_text = d_map.get("btn_text_color", theme.get("btn_text_color"))
|
|
213
|
+
btn_hover = btn_bg
|
|
214
|
+
else:
|
|
215
|
+
entry_bg = theme.get("entry_fg")
|
|
216
|
+
entry_border = theme.get("entry_border_color")
|
|
217
|
+
entry_text = theme.get("entry_text_color")
|
|
218
|
+
btn_bg = theme.get("btn_fg")
|
|
219
|
+
btn_border = theme.get("btn_border_color")
|
|
220
|
+
btn_text = theme.get("btn_text_color")
|
|
221
|
+
btn_hover = theme.get("btn_hover")
|
|
222
|
+
|
|
223
|
+
if hasattr(self, "entry") and self.entry.winfo_exists():
|
|
224
|
+
self.entry.configure(
|
|
225
|
+
font=theme.get("entry_font"),
|
|
226
|
+
fg_color=self._resolve_color(entry_bg),
|
|
227
|
+
border_color=self._resolve_color(entry_border),
|
|
228
|
+
text_color=self._resolve_color(entry_text)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if hasattr(self, "btn") and self.btn.winfo_exists():
|
|
232
|
+
self.btn.configure(
|
|
233
|
+
text=btn_txt,
|
|
234
|
+
font=theme.get("btn_font"),
|
|
235
|
+
fg_color=self._resolve_color(btn_bg),
|
|
236
|
+
hover_color=self._resolve_color(btn_hover),
|
|
237
|
+
text_color=self._resolve_color(btn_text),
|
|
238
|
+
border_color=self._resolve_color(btn_border)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def configure(self, *args, **kwargs):
|
|
242
|
+
"""Extended configure to handle Pygubu queries and dynamic look modifications."""
|
|
243
|
+
if args and len(args) == 1:
|
|
244
|
+
# FIX: was `pname = args`, leaving pname as a TUPLE -- so every
|
|
245
|
+
# comparison below tested a tuple against a string and the whole
|
|
246
|
+
# query block was dead. Pygubu could read none of these seven
|
|
247
|
+
# properties. Same one-character bug found in sCTkFileExplorer,
|
|
248
|
+
# sCTkSMeterBar and the dial family.
|
|
249
|
+
pname = args[0]
|
|
250
|
+
if pname == "state": return ("state", "state", "state", "normal", getattr(self, "_state", "normal"))
|
|
251
|
+
if pname == "type": return ("type", "type", "type", "directory", self.type)
|
|
252
|
+
if pname == "justify": return ("justify", "justify", "justify", "left", self.justify)
|
|
253
|
+
if pname == "btn_text": return ("btn_text", "btn_text", "btn_text", "", str(self.btn_text) if self.btn_text else "")
|
|
254
|
+
if pname == "title": return ("title", "title", "title", "Select Path", self.title)
|
|
255
|
+
if pname == "entry_height": return ("entry_height", "entry_height", "entry_height", "32", self.entry_height)
|
|
256
|
+
if pname == "btn_width": return ("btn_width", "btn_width", "btn_width", "110", self.btn_width)
|
|
257
|
+
if pname == "btn_height": return ("btn_height", "btn_height", "btn_height", "32", self.btn_height)
|
|
258
|
+
# FIX: forwarding the property NAME to native configure() passes
|
|
259
|
+
# it as require_redraw and returns None, which pygubu hands back to
|
|
260
|
+
# _set_property() -- see themeable_widget._configure_query().
|
|
261
|
+
return self._configure_query(pname)
|
|
262
|
+
|
|
263
|
+
# FIX: was `isinstance(args, dict)`. args is ALWAYS a tuple, so this
|
|
264
|
+
# never fired and the dict form of configure() was dead code. The value
|
|
265
|
+
# itself is args[0].
|
|
266
|
+
if args and len(args) == 1 and isinstance(args[0], dict):
|
|
267
|
+
kwargs = {**args[0], **kwargs}
|
|
268
|
+
|
|
269
|
+
if "btn_text" in kwargs: self.btn_text = str(kwargs.pop("btn_text")) if kwargs["btn_text"] is not None else None
|
|
270
|
+
if "type" in kwargs: self.type = str(kwargs.pop("type")).lower()
|
|
271
|
+
if "title" in kwargs: self.title = str(kwargs.pop("title"))
|
|
272
|
+
|
|
273
|
+
# FIX: initialdir, initialfile and filetypes had NO configure()
|
|
274
|
+
# branches at all -- they were constructor-only. The Designer applies
|
|
275
|
+
# every property through configure(), so setting any of the three there
|
|
276
|
+
# did nothing at all.
|
|
277
|
+
if "initialdir" in kwargs:
|
|
278
|
+
raw_dir = kwargs.pop("initialdir")
|
|
279
|
+
if raw_dir:
|
|
280
|
+
self.initialdir = os.path.normpath(
|
|
281
|
+
os.path.expanduser(str(raw_dir)))
|
|
282
|
+
if not (self.entry.get() or "").strip():
|
|
283
|
+
self.set(self.initialdir)
|
|
284
|
+
|
|
285
|
+
if "initialfile" in kwargs:
|
|
286
|
+
raw_file = kwargs.pop("initialfile")
|
|
287
|
+
if raw_file:
|
|
288
|
+
# A bare name resolves against initialdir, not the working
|
|
289
|
+
# directory -- same rule as the constructor.
|
|
290
|
+
candidate = os.path.expanduser(str(raw_file))
|
|
291
|
+
if not os.path.isabs(candidate):
|
|
292
|
+
candidate = os.path.join(self.initialdir or os.getcwd(),
|
|
293
|
+
candidate)
|
|
294
|
+
self.initialfile = os.path.normpath(candidate)
|
|
295
|
+
self.set(self.initialfile)
|
|
296
|
+
|
|
297
|
+
if "filetypes" in kwargs:
|
|
298
|
+
ft_raw = kwargs.pop("filetypes")
|
|
299
|
+
self.filetypes = parse_list_property(ft_raw) if ft_raw else None
|
|
300
|
+
|
|
301
|
+
if "justify" in kwargs:
|
|
302
|
+
self.justify = str(kwargs.pop("justify")).lower()
|
|
303
|
+
if self.justify not in ("left", "right", "center"): self.justify = "left"
|
|
304
|
+
if hasattr(self, "entry"):
|
|
305
|
+
self.entry.configure(justify=self.justify)
|
|
306
|
+
# Not set(), which fires `command` -- changing alignment is not
|
|
307
|
+
# a path selection.
|
|
308
|
+
self._apply_entry_view()
|
|
309
|
+
|
|
310
|
+
if "entry_height" in kwargs:
|
|
311
|
+
self.entry_height = int(kwargs.pop("entry_height"))
|
|
312
|
+
if hasattr(self, "entry"):
|
|
313
|
+
self.entry.configure(height=self.entry_height)
|
|
314
|
+
self.entry.grid_configure(pady=max(0, (self.cget("height") - self.entry_height) // 2))
|
|
315
|
+
|
|
316
|
+
if "btn_width" in kwargs:
|
|
317
|
+
self.btn_width = int(kwargs.pop("btn_width"))
|
|
318
|
+
if hasattr(self, "btn"): self.btn.configure(width=self.btn_width)
|
|
319
|
+
|
|
320
|
+
if "btn_height" in kwargs:
|
|
321
|
+
self.btn_height = int(kwargs.pop("btn_height"))
|
|
322
|
+
if hasattr(self, "btn"):
|
|
323
|
+
self.btn.configure(height=self.btn_height)
|
|
324
|
+
self.btn.grid_configure(pady=max(0, (self.cget("height") - self.btn_height) // 2))
|
|
325
|
+
|
|
326
|
+
if "browser_width" in kwargs: self.browser_width = int(kwargs.pop("browser_width"))
|
|
327
|
+
if "browser_height" in kwargs: self.browser_height = int(kwargs.pop("browser_height"))
|
|
328
|
+
if "command" in kwargs: self.command = kwargs.pop("command")
|
|
329
|
+
|
|
330
|
+
if "width" in kwargs: super().configure(width=int(kwargs.pop("width")))
|
|
331
|
+
if "height" in kwargs:
|
|
332
|
+
h_val = int(kwargs.pop("height"))
|
|
333
|
+
super().configure(height=h_val)
|
|
334
|
+
if hasattr(self, "entry"):
|
|
335
|
+
te = min(self.entry_height, h_val)
|
|
336
|
+
self.entry.configure(height=te)
|
|
337
|
+
self.entry.grid_configure(pady=max(0, (h_val - te) // 2))
|
|
338
|
+
if hasattr(self, "btn"):
|
|
339
|
+
tb = min(self.btn_height, h_val)
|
|
340
|
+
self.btn.configure(height=tb)
|
|
341
|
+
self.btn.grid_configure(pady=max(0, (h_val - tb) // 2))
|
|
342
|
+
|
|
343
|
+
# Runtime overrides have to reach the map a repaint reads, or the
|
|
344
|
+
# repaint puts the theme value straight back -- see
|
|
345
|
+
# ThemeableWidget._record_theme_overrides().
|
|
346
|
+
self._record_theme_overrides(kwargs)
|
|
347
|
+
|
|
348
|
+
if "state" in kwargs: self.state(kwargs.pop("state"))
|
|
349
|
+
|
|
350
|
+
if hasattr(self, "final_kw"):
|
|
351
|
+
for custom_key in ["type", "justify", "btn_text", "title", "entry_height", "btn_width", "btn_height", "browser_width", "browser_height"]:
|
|
352
|
+
self.final_kw.pop(custom_key, None)
|
|
353
|
+
|
|
354
|
+
self.grid_propagate(False)
|
|
355
|
+
self.columnconfigure(0, weight=1)
|
|
356
|
+
self.columnconfigure(1, weight=0)
|
|
357
|
+
|
|
358
|
+
kwargs.pop("defaultextension", None)
|
|
359
|
+
kwargs.pop("entry_width", None)
|
|
360
|
+
kwargs.pop("initialdir", None)
|
|
361
|
+
kwargs.pop("initialfile", None)
|
|
362
|
+
kwargs.pop("filetypes", None)
|
|
363
|
+
|
|
364
|
+
self._process_live_theme_repaint()
|
|
365
|
+
if kwargs: return super().configure(**kwargs)
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
config = configure
|
|
369
|
+
def _launch_browser(self):
|
|
370
|
+
"""Launches the theme-compliant popup modal browser window without layout stutters."""
|
|
371
|
+
popup = ctk.CTkToplevel(self.winfo_toplevel())
|
|
372
|
+
popup.withdraw()
|
|
373
|
+
|
|
374
|
+
final_title = self.title
|
|
375
|
+
if self.type == "file" and self.filetypes:
|
|
376
|
+
formatted_exts = [f"*{ext}" for ext in self.filetypes] if isinstance(self.filetypes, list) else [f"*{self.filetypes}"]
|
|
377
|
+
final_title = f"{self.title} ({', '.join(formatted_exts)})"
|
|
378
|
+
|
|
379
|
+
popup.title(final_title)
|
|
380
|
+
popup.geometry(f"{self.browser_width}x{self.browser_height}")
|
|
381
|
+
popup.resizable(False, False)
|
|
382
|
+
popup.transient(self.winfo_toplevel())
|
|
383
|
+
popup.grab_set()
|
|
384
|
+
|
|
385
|
+
popup.columnconfigure(0, weight=1)
|
|
386
|
+
popup.rowconfigure(0, weight=1)
|
|
387
|
+
|
|
388
|
+
entry_val = self.entry.get().strip()
|
|
389
|
+
seed_dir, seed_file = self.initialdir, self.initialfile
|
|
390
|
+
|
|
391
|
+
if entry_val: entry_val = os.path.normpath(os.path.expanduser(entry_val))
|
|
392
|
+
if entry_val and os.path.exists(entry_val):
|
|
393
|
+
if os.path.isdir(entry_val): seed_dir, seed_file = entry_val, None
|
|
394
|
+
else: seed_dir, seed_file = os.path.dirname(entry_val), entry_val
|
|
395
|
+
|
|
396
|
+
explorer = sCTkFileExplorer(
|
|
397
|
+
popup, type=self.type, filetypes=self.filetypes, initialdir=seed_dir, initialfile=seed_file,
|
|
398
|
+
width=self.browser_width - 25, height=self.browser_height - 60,
|
|
399
|
+
command=lambda p: self.set(p),
|
|
400
|
+
double_click_command=lambda *args: (self.set(args[-1]), popup.grab_release(), popup.destroy())
|
|
401
|
+
)
|
|
402
|
+
explorer.grid(row=0, column=0, sticky="nsew", padx=10, pady=10)
|
|
403
|
+
|
|
404
|
+
bottom_bar = sCTkFrame(popup, fg_color="transparent")
|
|
405
|
+
bottom_bar.grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 10))
|
|
406
|
+
|
|
407
|
+
def submit():
|
|
408
|
+
chosen = explorer.selected_path.get()
|
|
409
|
+
if self.type == "file" and os.path.isdir(chosen): return
|
|
410
|
+
self.set(chosen)
|
|
411
|
+
close()
|
|
412
|
+
|
|
413
|
+
def close():
|
|
414
|
+
popup.grab_release()
|
|
415
|
+
popup.destroy()
|
|
416
|
+
|
|
417
|
+
sCTkButtonSecondary(bottom_bar, text="Cancel", fg_color="transparent", border_width=2, command=close).pack(side="left")
|
|
418
|
+
sCTkButtonPrimary(bottom_bar, text="Select", command=submit).pack(side="right")
|
|
419
|
+
|
|
420
|
+
popup.update_idletasks()
|
|
421
|
+
width, height = self.browser_width, self.browser_height
|
|
422
|
+
parent = self.winfo_toplevel()
|
|
423
|
+
if parent and hasattr(parent, "winfo_x"):
|
|
424
|
+
x = parent.winfo_x() + (parent.winfo_width() // 2) - (width // 2)
|
|
425
|
+
y = parent.winfo_y() + (parent.winfo_height() // 2) - (height // 2)
|
|
426
|
+
else:
|
|
427
|
+
x = (popup.winfo_screenwidth() // 2) - (width // 2)
|
|
428
|
+
y = (popup.winfo_screenheight() // 2) - (height // 2)
|
|
429
|
+
|
|
430
|
+
popup.geometry(f"{width}x{height}+{max(0, x)}+{max(0, y)}")
|
|
431
|
+
popup.deiconify()
|
|
432
|
+
|
|
433
|
+
def _on_entry_commit(self, event=None):
|
|
434
|
+
"""
|
|
435
|
+
Applies a path typed into the entry.
|
|
436
|
+
|
|
437
|
+
Bound to Return and focus-out. Expands `~`, and accepts the value only
|
|
438
|
+
if it exists -- a half-typed path should not replace a good one, and
|
|
439
|
+
focus-out fires whenever the user clicks elsewhere.
|
|
440
|
+
|
|
441
|
+
A directory becomes the new `initialdir`, so browsing afterwards opens
|
|
442
|
+
there; anything else is treated as a file selection.
|
|
443
|
+
"""
|
|
444
|
+
raw = (self.entry.get() or "").strip()
|
|
445
|
+
if not raw:
|
|
446
|
+
return
|
|
447
|
+
expanded = os.path.normpath(os.path.abspath(os.path.expanduser(raw)))
|
|
448
|
+
if not os.path.exists(expanded):
|
|
449
|
+
return
|
|
450
|
+
if os.path.isdir(expanded):
|
|
451
|
+
self.initialdir = expanded
|
|
452
|
+
self.initialfile = None
|
|
453
|
+
else:
|
|
454
|
+
self.initialdir = os.path.dirname(expanded)
|
|
455
|
+
self.initialfile = expanded
|
|
456
|
+
self.set(expanded)
|
|
457
|
+
|
|
458
|
+
def _apply_entry_view(self):
|
|
459
|
+
"""
|
|
460
|
+
Scrolls the entry so the requested end of a long path is visible.
|
|
461
|
+
|
|
462
|
+
A Tk entry only honours `justify` when the text is SHORTER than the
|
|
463
|
+
field. A long path scrolls instead, and what you see is governed by the
|
|
464
|
+
view position -- so "right" has to move the view explicitly.
|
|
465
|
+
|
|
466
|
+
CTkEntry does not reliably forward xview_moveto to the real tk.Entry it
|
|
467
|
+
wraps, so the inner widget is used when it is reachable. Without that
|
|
468
|
+
the call either raised or silently did nothing, depending on the
|
|
469
|
+
CustomTkinter version, which is why justify appeared to have no effect
|
|
470
|
+
on long paths.
|
|
471
|
+
"""
|
|
472
|
+
target = getattr(self.entry, "_entry", None) or self.entry
|
|
473
|
+
|
|
474
|
+
def move():
|
|
475
|
+
try:
|
|
476
|
+
target.xview_moveto(1.0 if self.justify == "right" else 0.0)
|
|
477
|
+
except Exception:
|
|
478
|
+
pass
|
|
479
|
+
|
|
480
|
+
# Twice: now, and again once Tk is idle. The immediate call runs before
|
|
481
|
+
# the entry has recomputed the pixel width of its new text, so a long
|
|
482
|
+
# path stopped part-way through the filename rather than at the end.
|
|
483
|
+
move()
|
|
484
|
+
try:
|
|
485
|
+
defer(self.entry, "idle", move)
|
|
486
|
+
except Exception:
|
|
487
|
+
pass
|
|
488
|
+
|
|
489
|
+
def set(self, path_string: str):
|
|
490
|
+
"""Forces fully absolute tilde user expansion when paths are applied via button selections."""
|
|
491
|
+
self.entry.configure(state="normal")
|
|
492
|
+
self.entry.delete(0, tk.END)
|
|
493
|
+
expanded_path = os.path.normpath(os.path.abspath(os.path.expanduser(str(path_string))))
|
|
494
|
+
self.entry.insert(0, expanded_path)
|
|
495
|
+
self._apply_entry_view()
|
|
496
|
+
if getattr(self, "_state", "normal") == "disabled": self.entry.configure(state="disabled")
|
|
497
|
+
if self.command and callable(self.command):
|
|
498
|
+
try: self.command(expanded_path)
|
|
499
|
+
except TypeError: self.command()
|
|
500
|
+
|
|
501
|
+
def get(self) -> str: return self.entry.get()
|
|
502
|
+
def get_state(self) -> str: return self.state()
|
|
503
|
+
def state(self, mode: str = None) -> str:
|
|
504
|
+
if mode is None: return str(getattr(self, "_state", "normal")).lower()
|
|
505
|
+
mode = mode.lower()
|
|
506
|
+
if mode in ("normal", "enabled", "active"):
|
|
507
|
+
self._state = "normal"
|
|
508
|
+
if hasattr(self, "entry"): self.entry.configure(state="normal")
|
|
509
|
+
if hasattr(self, "btn"): self.btn.configure(state="normal")
|
|
510
|
+
self._process_live_theme_repaint()
|
|
511
|
+
elif mode == "disabled":
|
|
512
|
+
self._state = "disabled"
|
|
513
|
+
if hasattr(self, "entry"): self.entry.configure(state="disabled")
|
|
514
|
+
if hasattr(self, "btn"): self.btn.configure(state="disabled")
|
|
515
|
+
self._process_live_theme_repaint()
|
|
516
|
+
return self._state
|