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,879 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
"""
|
|
3
|
+
sCTkFileExplorer
|
|
4
|
+
|
|
5
|
+
A theme-compliant, highly configurable custom file explorer wrapper component.
|
|
6
|
+
Inherits cleanly and directly from ctk.CTkFrame to preserve native features.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
import tkinter as tk
|
|
11
|
+
import tkinter.ttk as ttk
|
|
12
|
+
|
|
13
|
+
import customtkinter as ctk
|
|
14
|
+
from .themeable_widget import ThemeableWidget, parse_list_property, defer
|
|
15
|
+
from .sctk_scroll_mixin import ScrollBindingMixin
|
|
16
|
+
|
|
17
|
+
from typing import Literal, Optional, Union, Tuple
|
|
18
|
+
|
|
19
|
+
from .sctk_button_primary import sCTkButtonPrimary
|
|
20
|
+
from .sctk_button_secondary import sCTkButtonSecondary
|
|
21
|
+
from .sctk_label_secondary import sCTkLabelSecondary
|
|
22
|
+
from .sctk_entry_primary import sCTkEntryPrimary
|
|
23
|
+
|
|
24
|
+
class sCTkFileExplorer(ctk.CTkFrame, ScrollBindingMixin, ThemeableWidget):
|
|
25
|
+
# NOTE: an earlier version declared a _MANAGED_PROPERTIES frozenset here,
|
|
26
|
+
# never referenced anywhere else in this file -- dead code, removed. Same
|
|
27
|
+
# vestigial pattern found and removed elsewhere in this project.
|
|
28
|
+
|
|
29
|
+
def __init__(self,
|
|
30
|
+
master: any,
|
|
31
|
+
type: Literal["file", "directory"] = "directory",
|
|
32
|
+
filetypes: list[str] = None,
|
|
33
|
+
initialdir: str = None,
|
|
34
|
+
initialfile: str = None,
|
|
35
|
+
command: Optional[callable] = None,
|
|
36
|
+
double_click_command: Optional[callable] = None,
|
|
37
|
+
width: int = 400,
|
|
38
|
+
height: int = 300,
|
|
39
|
+
corner_radius: Optional[Union[int, str]] = None,
|
|
40
|
+
border_width: Optional[Union[int, str]] = None,
|
|
41
|
+
bg_color: Union[str, Tuple[str, str]] = "transparent",
|
|
42
|
+
fg_color: Optional[Union[str, Tuple[str, str]]] = None,
|
|
43
|
+
border_color: Optional[Union[str, Tuple[str, str]]] = None,
|
|
44
|
+
background_corner_colors: Union[Tuple[Union[str, Tuple[str, str]]], None] = None,
|
|
45
|
+
overwrite_preferred_drawing_method: Union[str, None] = None,
|
|
46
|
+
**kwargs):
|
|
47
|
+
|
|
48
|
+
kwargs.pop("initialdir", None)
|
|
49
|
+
kwargs.pop("initialfile", None)
|
|
50
|
+
kwargs.pop("type", None)
|
|
51
|
+
kwargs.pop("filetypes", None)
|
|
52
|
+
kwargs.pop("defaultextension", None)
|
|
53
|
+
kwargs.pop("title", None)
|
|
54
|
+
|
|
55
|
+
self._initial_state_seed = str(kwargs.pop("state", "normal")).lower()
|
|
56
|
+
|
|
57
|
+
# Captured BEFORE the theme pass. selection_color is this library's
|
|
58
|
+
# own key -- native CTkFrame rejects any keyword it does not
|
|
59
|
+
# recognise, so leaving it in kwargs would raise at construction.
|
|
60
|
+
# None means "use the theme's selection_color".
|
|
61
|
+
self._selection_color_override = kwargs.pop("selection_color", None)
|
|
62
|
+
|
|
63
|
+
ThemeableWidget.__init__(self, kwargs)
|
|
64
|
+
|
|
65
|
+
self._local_defaults = dict(self.final_kw)
|
|
66
|
+
self._custom_disabled_map = dict(self._widget_disabled_map)
|
|
67
|
+
|
|
68
|
+
# fg_color from the theme block when the caller did not pass one.
|
|
69
|
+
#
|
|
70
|
+
# The named constructor parameter defaults to None and was handed
|
|
71
|
+
# straight to CTkFrame, so a theme value never reached the widget and
|
|
72
|
+
# the background came from native CustomTkinter's own default. That
|
|
73
|
+
# also left fg_color absent from _local_defaults, so a Designer query
|
|
74
|
+
# reported the CURRENT colour as the default -- and clearing the field
|
|
75
|
+
# set it to what it already was.
|
|
76
|
+
if fg_color is None:
|
|
77
|
+
fg_color = self.final_kw.get("fg_color")
|
|
78
|
+
|
|
79
|
+
super().__init__(master, width=width, height=height, corner_radius=corner_radius,
|
|
80
|
+
border_width=border_width, bg_color=bg_color, fg_color=fg_color,
|
|
81
|
+
border_color=border_color, background_corner_colors=background_corner_colors,
|
|
82
|
+
overwrite_preferred_drawing_method=overwrite_preferred_drawing_method, **kwargs)
|
|
83
|
+
|
|
84
|
+
self._state = "normal" if self._initial_state_seed not in ("normal", "disabled") else self._initial_state_seed
|
|
85
|
+
self.response_type = type.lower()
|
|
86
|
+
self.change_path = True
|
|
87
|
+
self.item_labels = {}
|
|
88
|
+
self.command = command
|
|
89
|
+
self.double_click_command = double_click_command
|
|
90
|
+
self._last_double_click_time = 0.0
|
|
91
|
+
|
|
92
|
+
self._desired_width = width
|
|
93
|
+
self._desired_height = height
|
|
94
|
+
|
|
95
|
+
self.filetypes = []
|
|
96
|
+
if filetypes:
|
|
97
|
+
# Complaint, not correction.
|
|
98
|
+
#
|
|
99
|
+
# An earlier attempt had this switch the mode to "file" silently,
|
|
100
|
+
# on the reasoning that asking for a filter is unambiguous about
|
|
101
|
+
# intent. It was wrong for the Designer: a builder object cannot
|
|
102
|
+
# write the companion value back to the live tree node, so the
|
|
103
|
+
# inspector went on showing "directory" -- and the .ui file SAVED
|
|
104
|
+
# that combination, making the divergence permanent rather than
|
|
105
|
+
# cosmetic.
|
|
106
|
+
#
|
|
107
|
+
# Refusing is better than correcting something the surrounding
|
|
108
|
+
# tooling cannot be told about.
|
|
109
|
+
if self.response_type != "file":
|
|
110
|
+
raise ValueError("Cannot provide 'filetypes' filters when widget mode is 'directory'.")
|
|
111
|
+
if isinstance(filetypes, str):
|
|
112
|
+
cleaned_str = filetypes.strip()
|
|
113
|
+
if not (cleaned_str.startswith("[") and cleaned_str.endswith("]")):
|
|
114
|
+
raise ValueError(f"Malformed filetypes sequence parsed: '{filetypes}'.")
|
|
115
|
+
try: processed_types = parse_list_property(cleaned_str)
|
|
116
|
+
except Exception as err: raise ValueError(f"Malformed syntax evaluating filetypes configuration: {err}")
|
|
117
|
+
else: processed_types = filetypes
|
|
118
|
+
|
|
119
|
+
if not isinstance(processed_types, list):
|
|
120
|
+
raise ValueError(f"Invalid filetypes configuration format context: {type(processed_types)}.")
|
|
121
|
+
for f in processed_types:
|
|
122
|
+
clean_f = str(f).lower().replace("*", "").strip()
|
|
123
|
+
if clean_f:
|
|
124
|
+
if not clean_f.startswith("."): clean_f = "." + clean_f
|
|
125
|
+
self.filetypes.append(clean_f)
|
|
126
|
+
else:
|
|
127
|
+
self.filetypes = None
|
|
128
|
+
raw_dir = os.path.expanduser(str(initialdir)) if initialdir else None
|
|
129
|
+
|
|
130
|
+
# A bare initialfile resolves against initialdir, not against the
|
|
131
|
+
# process's working directory -- see _resolve_initial_file(). Without
|
|
132
|
+
# this, initialdir="~/Downloads" with initialfile="doc.txt" produced
|
|
133
|
+
# <cwd>/doc.txt, and since the path entry displays selected_path the
|
|
134
|
+
# widget looked as though it had opened the working directory.
|
|
135
|
+
raw_file = None
|
|
136
|
+
if initialfile:
|
|
137
|
+
raw_file = os.path.expanduser(str(initialfile))
|
|
138
|
+
if not os.path.isabs(raw_file) and raw_dir:
|
|
139
|
+
raw_file = os.path.join(raw_dir, raw_file)
|
|
140
|
+
|
|
141
|
+
if self.response_type == "directory" and raw_file:
|
|
142
|
+
raw_dir = os.path.dirname(raw_file)
|
|
143
|
+
raw_file = None
|
|
144
|
+
|
|
145
|
+
if raw_dir is not None:
|
|
146
|
+
init_p = raw_dir
|
|
147
|
+
elif raw_file is not None:
|
|
148
|
+
init_p = os.path.dirname(raw_file)
|
|
149
|
+
else:
|
|
150
|
+
init_p = os.getcwd()
|
|
151
|
+
|
|
152
|
+
self.path_to_show = ctk.StringVar(self, value=os.path.normpath(os.path.abspath(init_p)))
|
|
153
|
+
self.selected_path = ctk.StringVar(self,
|
|
154
|
+
value=os.path.normpath(os.path.abspath(raw_file if raw_file else init_p)))
|
|
155
|
+
|
|
156
|
+
self.folder_icon = "📁 "
|
|
157
|
+
self.file_icon = "📄 "
|
|
158
|
+
|
|
159
|
+
self.top_frame = ctk.CTkFrame(self, width=self._desired_width, fg_color="transparent")
|
|
160
|
+
self.top_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
|
|
161
|
+
self.top_frame.columnconfigure(1, weight=1)
|
|
162
|
+
|
|
163
|
+
self.back_button = sCTkButtonPrimary(self.top_frame, text="▲ Up", width=45)
|
|
164
|
+
self.back_button.grid(row=0, column=0, padx=(0, 5), sticky="nw")
|
|
165
|
+
|
|
166
|
+
self.path_entry = sCTkEntryPrimary(self.top_frame, textvariable=self.selected_path)
|
|
167
|
+
self.path_entry.grid(row=0, column=1, sticky="ew")
|
|
168
|
+
|
|
169
|
+
self.main_container = ctk.CTkFrame(self, width=self._desired_width, height=self._desired_height - 60)
|
|
170
|
+
self.main_container.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
|
|
171
|
+
self.main_container.columnconfigure(0, weight=1)
|
|
172
|
+
self.main_container.rowconfigure(0, weight=1)
|
|
173
|
+
|
|
174
|
+
self.canvas = ctk.CTkCanvas(self.main_container, highlightthickness=0, width=self._desired_width - 30,
|
|
175
|
+
height=self._desired_height - 70)
|
|
176
|
+
self.canvas.grid(row=0, column=0, sticky="nsew")
|
|
177
|
+
|
|
178
|
+
self.explorer_frame = ctk.CTkFrame(self.canvas, fg_color="transparent")
|
|
179
|
+
self.explorer_frame.columnconfigure(0, weight=1)
|
|
180
|
+
|
|
181
|
+
self.y_scrollbar = ctk.CTkScrollbar(self.main_container, command=self.canvas.yview)
|
|
182
|
+
self.y_scrollbar.grid(row=0, column=1, sticky="ns")
|
|
183
|
+
self.canvas.configure(yscrollcommand=self.y_scrollbar.set)
|
|
184
|
+
|
|
185
|
+
self.canvas.create_window((0, 0), window=self.explorer_frame, anchor="nw", tags="inner_window")
|
|
186
|
+
|
|
187
|
+
# Scroll state and activation, owned by ScrollBindingMixin.
|
|
188
|
+
# _init_scroll_state() must run before any binding happens.
|
|
189
|
+
#
|
|
190
|
+
# FIX: this was previously deferred with defer(self, 10, ...) -- an
|
|
191
|
+
# arbitrary delay chosen to let the widget hierarchy settle. The mixin
|
|
192
|
+
# activates via after_idle() instead, which fires when Tk is actually
|
|
193
|
+
# idle rather than after a guessed interval, plus <Map> on this widget
|
|
194
|
+
# and a debounced <Configure> rebind. The delay is retained below only
|
|
195
|
+
# for the NON-scroll wiring _finalize_split_bindings() also does.
|
|
196
|
+
self._init_scroll_state()
|
|
197
|
+
self._install_scroll_activation(content_widget=self.explorer_frame)
|
|
198
|
+
|
|
199
|
+
defer(self, 10, self._finalize_split_bindings)
|
|
200
|
+
self._process_live_theme_repaint()
|
|
201
|
+
|
|
202
|
+
# 🔑 REGISTER LIFECYCLE HANDSHAKE HOOK: Maps registration signals back up to Pygubu windows cleanly
|
|
203
|
+
self._finalize_themeable_lifecycle()
|
|
204
|
+
|
|
205
|
+
def _resolve_canvas_bg_color(self):
|
|
206
|
+
"""
|
|
207
|
+
Determines what color to give the internal raw Canvas's background,
|
|
208
|
+
since a raw Tkinter Canvas cannot render CTk's "transparent"
|
|
209
|
+
pseudo-value at all.
|
|
210
|
+
|
|
211
|
+
FIX: an earlier version reached into ctk.ThemeManager.theme["CTkFrame"]
|
|
212
|
+
-- CustomTkinter's own native theme registry -- as an intermediate
|
|
213
|
+
fallback before reaching the hardcoded literal below. Every other
|
|
214
|
+
widget in this project exclusively uses sCTkThemes.json or a
|
|
215
|
+
documented literal; this was the only place reaching into native
|
|
216
|
+
CTk's own theme as an additional fallback layer. Removed -- goes
|
|
217
|
+
directly to the documented hardcoded pair instead, matching the same
|
|
218
|
+
precedent already established in sCTkFrameLabeledPrimary's
|
|
219
|
+
_hide_internal_scrollbars(): this isn't a "theme is incomplete"
|
|
220
|
+
situation (this widget's own fg_color being "transparent" is a
|
|
221
|
+
legitimate, common choice), it's "a raw canvas needs an actual
|
|
222
|
+
renderable color, and transparent isn't one" -- a different problem
|
|
223
|
+
with a different, accepted solution.
|
|
224
|
+
"""
|
|
225
|
+
canvas_bg_raw = self.cget("fg_color")
|
|
226
|
+
if canvas_bg_raw == "transparent" or canvas_bg_raw is None:
|
|
227
|
+
return "#1C1C1C" if str(ctk.get_appearance_mode()).lower() == "dark" else "#F3F4F6"
|
|
228
|
+
resolved_hex = self._resolve_color(canvas_bg_raw)
|
|
229
|
+
if resolved_hex == "transparent":
|
|
230
|
+
return "#1C1C1C" if str(ctk.get_appearance_mode()).lower() == "dark" else "#F3F4F6"
|
|
231
|
+
return resolved_hex
|
|
232
|
+
|
|
233
|
+
def _selection_color(self):
|
|
234
|
+
"""
|
|
235
|
+
The colour of the selected row.
|
|
236
|
+
|
|
237
|
+
Split from `btn_fg`, which also colours the navigation buttons -- so
|
|
238
|
+
exposing that key directly would have recoloured Home, Up and Refresh
|
|
239
|
+
along with the selection. `selection_color` names one thing.
|
|
240
|
+
|
|
241
|
+
A per-instance override wins over the theme, so one explorer can
|
|
242
|
+
highlight differently without moving every explorer in the
|
|
243
|
+
application.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
A resolved colour string.
|
|
247
|
+
"""
|
|
248
|
+
value = (self._selection_color_override
|
|
249
|
+
or self._local_defaults.get("selection_color")
|
|
250
|
+
or self._local_defaults.get("btn_fg"))
|
|
251
|
+
return self._resolve_color(value)
|
|
252
|
+
|
|
253
|
+
def _set_appearance_mode(self, mode_string):
|
|
254
|
+
"""Intercepts appearance-mode changes and forces a valid hex string
|
|
255
|
+
onto the internal raw Canvas, which cannot render CTk's "transparent"."""
|
|
256
|
+
super()._set_appearance_mode(mode_string)
|
|
257
|
+
if hasattr(self, "canvas") and self.canvas.winfo_exists():
|
|
258
|
+
self.canvas.configure(bg=self._resolve_canvas_bg_color())
|
|
259
|
+
if hasattr(self, "path_to_show"): self._fill_explorer()
|
|
260
|
+
|
|
261
|
+
def _process_live_theme_repaint(self):
|
|
262
|
+
theme, d_map = self._local_defaults, self._custom_disabled_map
|
|
263
|
+
current_state = getattr(self, "_state", "normal")
|
|
264
|
+
|
|
265
|
+
# FIX: an earlier version used hardcoded fallback literals for
|
|
266
|
+
# button_color (the scrollbar's color) -- and, in the enabled branch,
|
|
267
|
+
# reached into ctk.ThemeManager.theme["CTkScrollbar"] (native CTk's
|
|
268
|
+
# own theme registry) as an additional fallback layer, the only place
|
|
269
|
+
# in this project besides the canvas-background case above doing
|
|
270
|
+
# that. Both replaced with hard-fail validation, matching the
|
|
271
|
+
# principle established for sCTkSwitch, the label family, and
|
|
272
|
+
# sCTkTableview elsewhere in this project.
|
|
273
|
+
if theme.get("button_color") is None:
|
|
274
|
+
raise KeyError(f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing 'button_color' at the top level.")
|
|
275
|
+
if d_map.get("button_color") is None:
|
|
276
|
+
raise KeyError(f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing 'button_color' in disabled_map.")
|
|
277
|
+
|
|
278
|
+
if current_state == "disabled":
|
|
279
|
+
btn_fg = d_map.get("btn_fg", theme.get("btn_fg"))
|
|
280
|
+
btn_border = d_map.get("btn_border_color", theme.get("btn_border_color"))
|
|
281
|
+
btn_text = d_map.get("btn_text_color", theme.get("btn_text_color"))
|
|
282
|
+
btn_hover, entry_fg = btn_fg, d_map.get("entry_fg", theme.get("entry_fg"))
|
|
283
|
+
entry_border = d_map.get("entry_border_color", theme.get("entry_border_color"))
|
|
284
|
+
entry_text = d_map.get("entry_text_color", theme.get("entry_text_color"))
|
|
285
|
+
sb_btn_color, sb_command = d_map.get("button_color"), None
|
|
286
|
+
else:
|
|
287
|
+
btn_fg, btn_border = theme.get("btn_fg"), theme.get("btn_border_color")
|
|
288
|
+
btn_text, btn_hover = theme.get("btn_text_color"), theme.get("btn_hover")
|
|
289
|
+
entry_fg, entry_border = theme.get("entry_fg"), theme.get("entry_border_color")
|
|
290
|
+
entry_text = theme.get("entry_text_color")
|
|
291
|
+
sb_btn_color = theme.get("button_color")
|
|
292
|
+
sb_command = self.canvas.yview
|
|
293
|
+
|
|
294
|
+
self.canvas.configure(bg=self._resolve_canvas_bg_color())
|
|
295
|
+
|
|
296
|
+
if hasattr(self, "back_button") and self.back_button.winfo_exists():
|
|
297
|
+
self.back_button.configure(state=current_state, font=theme.get("btn_font"),
|
|
298
|
+
fg_color=self._resolve_color(btn_fg), hover_color=self._resolve_color(btn_hover),
|
|
299
|
+
text_color=self._resolve_color(btn_text),
|
|
300
|
+
border_color=self._resolve_color(btn_border))
|
|
301
|
+
if hasattr(self, "path_entry") and self.path_entry.winfo_exists():
|
|
302
|
+
self.path_entry.configure(state=current_state, font=theme.get("entry_font"),
|
|
303
|
+
fg_color=self._resolve_color(entry_fg),
|
|
304
|
+
border_color=self._resolve_color(entry_border),
|
|
305
|
+
text_color=self._resolve_color(entry_text))
|
|
306
|
+
if hasattr(self, "y_scrollbar") and self.y_scrollbar.winfo_exists():
|
|
307
|
+
self.y_scrollbar.configure(command=sb_command, button_color=self._resolve_color(sb_btn_color),
|
|
308
|
+
button_hover_color=self._resolve_color(sb_btn_color))
|
|
309
|
+
if hasattr(self, "path_to_show"): self._fill_explorer()
|
|
310
|
+
|
|
311
|
+
def _configure_frame(self, event=None):
|
|
312
|
+
defer(self, 10, self._update_scrollregion)
|
|
313
|
+
|
|
314
|
+
def _update_scrollregion(self):
|
|
315
|
+
"""
|
|
316
|
+
Sets the canvas scroll region to the content bounds, expanded to at
|
|
317
|
+
least the visible canvas height.
|
|
318
|
+
|
|
319
|
+
FIX: this previously set scrollregion straight from bbox("all"). When
|
|
320
|
+
the files don't fill the frame that region is SHORTER than the visible
|
|
321
|
+
canvas, and Tk will still scroll within it -- so dragging the
|
|
322
|
+
scrollbar pushed the rows down to the bottom of the frame with empty
|
|
323
|
+
space above them, instead of doing nothing. Growing the region to the
|
|
324
|
+
canvas height when content is shorter leaves yview with nowhere to go,
|
|
325
|
+
which is the intended "content fits, so scrolling does nothing"
|
|
326
|
+
behavior.
|
|
327
|
+
"""
|
|
328
|
+
try:
|
|
329
|
+
if not self.canvas.winfo_exists():
|
|
330
|
+
return
|
|
331
|
+
bounds = self.canvas.bbox("all")
|
|
332
|
+
if not bounds:
|
|
333
|
+
return
|
|
334
|
+
x0, y0, x1, y1 = bounds
|
|
335
|
+
visible_height = self.canvas.winfo_height()
|
|
336
|
+
if (y1 - y0) < visible_height:
|
|
337
|
+
y1 = y0 + visible_height
|
|
338
|
+
self.canvas.configure(scrollregion=(x0, y0, x1, y1))
|
|
339
|
+
except Exception:
|
|
340
|
+
pass
|
|
341
|
+
|
|
342
|
+
def _user_path_changed(self, *args):
|
|
343
|
+
if not self.change_path: return
|
|
344
|
+
target = self.selected_path.get()
|
|
345
|
+
if os.path.isdir(target):
|
|
346
|
+
self.path_to_show.set(target)
|
|
347
|
+
self._fill_explorer()
|
|
348
|
+
|
|
349
|
+
def _apply_entry_view(self, *args):
|
|
350
|
+
"""
|
|
351
|
+
Scrolls the path entry so the END of a long path stays visible.
|
|
352
|
+
|
|
353
|
+
The entry displays `selected_path` through a textvariable, so a path
|
|
354
|
+
longer than the field showed its start and the filename ran off the
|
|
355
|
+
right -- selecting a file gave no visible feedback in the entry.
|
|
356
|
+
|
|
357
|
+
CTkEntry does not reliably forward xview_moveto to the real tk.Entry it
|
|
358
|
+
wraps, so the inner widget is used when reachable. Same helper as
|
|
359
|
+
sCTkPathChooser, which has a `justify` property to choose the end; this
|
|
360
|
+
widget has none, and the filename is the useful end.
|
|
361
|
+
"""
|
|
362
|
+
entry = getattr(self, "path_entry", None)
|
|
363
|
+
if entry is None:
|
|
364
|
+
return
|
|
365
|
+
target = getattr(entry, "_entry", None) or entry
|
|
366
|
+
|
|
367
|
+
def move():
|
|
368
|
+
try:
|
|
369
|
+
target.xview_moveto(1.0)
|
|
370
|
+
except Exception:
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
# Twice: now, and again once Tk is idle.
|
|
374
|
+
#
|
|
375
|
+
# A trace fires while the variable is being written, before the entry
|
|
376
|
+
# has recomputed the pixel width of its new text -- so the immediate
|
|
377
|
+
# move lands short and a long path stopped part-way through the
|
|
378
|
+
# filename instead of at the end.
|
|
379
|
+
move()
|
|
380
|
+
try:
|
|
381
|
+
defer(entry, "idle", move)
|
|
382
|
+
except Exception:
|
|
383
|
+
pass
|
|
384
|
+
|
|
385
|
+
def _on_entry_return(self, event=None):
|
|
386
|
+
"""
|
|
387
|
+
Applies a path typed into the entry.
|
|
388
|
+
|
|
389
|
+
Bound to Return, keypad Enter and focus-out, matching
|
|
390
|
+
sCTkPathChooser -- the two are the same control, one with a button.
|
|
391
|
+
|
|
392
|
+
Three fixes here. `~` was never expanded, so typing "~/Documents"
|
|
393
|
+
silently did nothing. Only directories were accepted, regardless of
|
|
394
|
+
this widget's own `type` -- so a file explorer would not let you type a
|
|
395
|
+
file path. And there was no focus-out binding, so tabbing away left the
|
|
396
|
+
text unapplied.
|
|
397
|
+
|
|
398
|
+
A value is accepted only if it EXISTS: focus-out fires whenever the
|
|
399
|
+
user clicks elsewhere, and a half-typed path must not replace a good
|
|
400
|
+
one.
|
|
401
|
+
"""
|
|
402
|
+
raw = (self.path_entry.get() or "").strip()
|
|
403
|
+
if not raw:
|
|
404
|
+
return
|
|
405
|
+
target = os.path.normpath(os.path.abspath(os.path.expanduser(raw)))
|
|
406
|
+
if not os.path.exists(target):
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
if os.path.isdir(target):
|
|
410
|
+
self.path_to_show.set(target)
|
|
411
|
+
self.change_path = False
|
|
412
|
+
self.selected_path.set(target)
|
|
413
|
+
self.change_path = True
|
|
414
|
+
self._fill_explorer()
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
# A file. Navigate to its folder either way; select it only when this
|
|
418
|
+
# explorer is actually choosing files.
|
|
419
|
+
self.path_to_show.set(os.path.dirname(target))
|
|
420
|
+
self.change_path = False
|
|
421
|
+
self.selected_path.set(target if self.response_type == "file"
|
|
422
|
+
else os.path.dirname(target))
|
|
423
|
+
self.change_path = True
|
|
424
|
+
self._fill_explorer()
|
|
425
|
+
|
|
426
|
+
def _empty_explorer(self):
|
|
427
|
+
for widget in self.explorer_frame.winfo_children(): widget.destroy()
|
|
428
|
+
self.item_labels.clear()
|
|
429
|
+
|
|
430
|
+
def _move_back(self):
|
|
431
|
+
p = os.path.dirname(self.path_to_show.get())
|
|
432
|
+
if p != self.path_to_show.get():
|
|
433
|
+
self.path_to_show.set(p)
|
|
434
|
+
self.change_path = False
|
|
435
|
+
self.selected_path.set(p)
|
|
436
|
+
self.change_path = True
|
|
437
|
+
self._fill_explorer()
|
|
438
|
+
|
|
439
|
+
# ------------------------------------------------------------------
|
|
440
|
+
# ScrollBindingMixin contract
|
|
441
|
+
# ------------------------------------------------------------------
|
|
442
|
+
def _scroll_target(self):
|
|
443
|
+
"""
|
|
444
|
+
The widget to scroll. Unlike sCTkScrollableFrame -- which is wrapped
|
|
445
|
+
by a native CTkScrollableFrame owning the canvas, and so has to look
|
|
446
|
+
it up via winfo_parent() -- this widget builds its own canvas
|
|
447
|
+
explicitly, so no lookup is needed.
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
self.canvas, or None if it doesn't exist yet.
|
|
451
|
+
"""
|
|
452
|
+
canvas = getattr(self, "canvas", None)
|
|
453
|
+
try:
|
|
454
|
+
if canvas is not None and canvas.winfo_exists():
|
|
455
|
+
return canvas
|
|
456
|
+
except Exception:
|
|
457
|
+
pass
|
|
458
|
+
return None
|
|
459
|
+
|
|
460
|
+
def _scroll_layers(self):
|
|
461
|
+
"""
|
|
462
|
+
Every widget that should respond to a scroll event over this explorer:
|
|
463
|
+
the widget itself, its canvas, the scrollbar, and the full row tree.
|
|
464
|
+
|
|
465
|
+
FIX: an earlier version walked only ONE level into explorer_frame
|
|
466
|
+
(`for child in self.explorer_frame.winfo_children()`), so anything
|
|
467
|
+
nested inside a row -- its label, its icon -- was never bound, and
|
|
468
|
+
the wheel did nothing while the pointer was over those. The mixin's
|
|
469
|
+
collector recurses to full depth, stopping only at nested
|
|
470
|
+
CTkScrollableFrame boundaries.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
An ordered, deduplicated list of widgets.
|
|
474
|
+
"""
|
|
475
|
+
layers = [self]
|
|
476
|
+
|
|
477
|
+
canvas = self._scroll_target()
|
|
478
|
+
if canvas is not None:
|
|
479
|
+
layers.append(canvas)
|
|
480
|
+
|
|
481
|
+
# The scrollbar is a sibling of the canvas inside main_container, not
|
|
482
|
+
# a descendant of explorer_frame, so the content walk below would
|
|
483
|
+
# never reach it -- without this the wheel does nothing while the
|
|
484
|
+
# pointer is over the scrollbar itself.
|
|
485
|
+
bar = getattr(self, "y_scrollbar", None)
|
|
486
|
+
if bar is not None:
|
|
487
|
+
self._collect_scroll_descendants(bar, layers)
|
|
488
|
+
|
|
489
|
+
frame = getattr(self, "explorer_frame", None)
|
|
490
|
+
if frame is not None:
|
|
491
|
+
try:
|
|
492
|
+
if frame.winfo_exists():
|
|
493
|
+
self._collect_scroll_descendants(frame, layers)
|
|
494
|
+
except Exception:
|
|
495
|
+
pass
|
|
496
|
+
|
|
497
|
+
return layers
|
|
498
|
+
|
|
499
|
+
def _scroll_permitted(self) -> bool:
|
|
500
|
+
"""
|
|
501
|
+
Whether this explorer should currently respond to scroll input.
|
|
502
|
+
|
|
503
|
+
Tied to the widget's state, so a disabled explorer is genuinely
|
|
504
|
+
inert -- wheel, trackpad, and scrollbar dragging all stop -- rather
|
|
505
|
+
than merely looking dimmed while still scrolling. Consistent with
|
|
506
|
+
sCTkScrollableFrame, where disabling does the same.
|
|
507
|
+
|
|
508
|
+
The mixin consults this before every bind, so state changes take
|
|
509
|
+
effect through the existing configure(state=...) path with no extra
|
|
510
|
+
wiring. See ScrollBindingMixin._toggle_scroll_bindings() for how
|
|
511
|
+
blocking is implemented.
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
True only while state is "normal".
|
|
515
|
+
"""
|
|
516
|
+
return str(getattr(self, "_state", "normal")).lower() == "normal"
|
|
517
|
+
|
|
518
|
+
def _scroll_drag_targets(self):
|
|
519
|
+
"""
|
|
520
|
+
The internal scrollbar, whose click-and-drag is blocked while this
|
|
521
|
+
widget is disabled -- otherwise a disabled explorer could still be
|
|
522
|
+
scrolled by grabbing the bar, contradicting its own reported state.
|
|
523
|
+
"""
|
|
524
|
+
bar = getattr(self, "y_scrollbar", None)
|
|
525
|
+
return [bar] if bar is not None else []
|
|
526
|
+
|
|
527
|
+
def _finalize_split_bindings(self):
|
|
528
|
+
if hasattr(self, "back_button"): self.back_button.configure(command=self._move_back)
|
|
529
|
+
if hasattr(self, "path_entry"):
|
|
530
|
+
self.path_entry.bind("<Return>", self._on_entry_return)
|
|
531
|
+
self.path_entry.bind("<KP_Enter>", self._on_entry_return)
|
|
532
|
+
self.path_entry.bind("<FocusOut>", self._on_entry_return)
|
|
533
|
+
if hasattr(self, "selected_path"):
|
|
534
|
+
self.selected_path.trace_add("write", self._user_path_changed)
|
|
535
|
+
# Separate trace, deliberately: _user_path_changed is gated by
|
|
536
|
+
# change_path, which is switched off whenever the widget sets the
|
|
537
|
+
# path itself -- exactly the case where the view needs moving.
|
|
538
|
+
self.selected_path.trace_add("write", self._apply_entry_view)
|
|
539
|
+
if hasattr(self, "explorer_frame"): self.explorer_frame.bind("<Configure>", self._configure_frame)
|
|
540
|
+
if hasattr(self, "canvas"):
|
|
541
|
+
self.canvas.bind("<Configure>", lambda e: self.canvas.itemconfig("inner_window", width=e.width))
|
|
542
|
+
self._activate_scroll_bindings()
|
|
543
|
+
self.bind("<Visibility>", lambda e: self._process_live_theme_repaint())
|
|
544
|
+
self._fill_explorer()
|
|
545
|
+
def configure(self, *args, **kwargs):
|
|
546
|
+
if args and len(args) == 1:
|
|
547
|
+
# FIX: was `pname = args`, leaving pname as a TUPLE -- every
|
|
548
|
+
# comparison below failed, so all six single-argument queries
|
|
549
|
+
# were dead and fell through to super(). Pygubu could not read
|
|
550
|
+
# any of them.
|
|
551
|
+
pname = args[0]
|
|
552
|
+
if pname == "state": return ("state", "state", "state", "normal", getattr(self, "_state", "normal"))
|
|
553
|
+
if pname == "type": return ("type", "type", "type", "directory", self.response_type)
|
|
554
|
+
if pname == "initialdir": return ("initialdir", "initialdir", "initialdir", "", self.path_to_show.get())
|
|
555
|
+
if pname == "initialfile": return ("initialfile", "initialfile", "initialfile", "", self.selected_path.get())
|
|
556
|
+
if pname == "filetypes": return ("filetypes", "filetypes", "filetypes", "", str(self.filetypes) if self.filetypes else "")
|
|
557
|
+
if pname == "selection_color":
|
|
558
|
+
return ("selection_color", "selection_color", "selection_color",
|
|
559
|
+
self._query_value(self._local_defaults.get("selection_color")
|
|
560
|
+
or self._local_defaults.get("btn_fg")),
|
|
561
|
+
self._query_value(self._selection_color_override
|
|
562
|
+
or self._local_defaults.get("selection_color")
|
|
563
|
+
or self._local_defaults.get("btn_fg")))
|
|
564
|
+
|
|
565
|
+
if pname == "double_click_command": return ("double_click_command", "double_click_command", "double_click_command", "", str(self.double_click_command))
|
|
566
|
+
# FIX: was `return super().configure(*args, **kwargs)`.
|
|
567
|
+
#
|
|
568
|
+
# Native CTkFrame.configure() takes the property NAME as
|
|
569
|
+
# require_redraw and returns None, which pygubu then hands back to
|
|
570
|
+
# _set_property() -- see themeable_widget._configure_query().
|
|
571
|
+
# The library-wide sweep matched on `pname` and `require_redraw`
|
|
572
|
+
# and missed the *args form used here.
|
|
573
|
+
return self._configure_query(pname)
|
|
574
|
+
|
|
575
|
+
# FIX: was `if args and isinstance(args, dict)`. args is ALWAYS a
|
|
576
|
+
# tuple, so this never fired and the dict-merge form of configure()
|
|
577
|
+
# was dead. Same tautology fixed across the batch-one widgets.
|
|
578
|
+
if len(args) == 1 and isinstance(args[0], dict): kwargs = {**args[0], **kwargs}
|
|
579
|
+
# fg_color has to reach the internal CANVAS as well as the frame.
|
|
580
|
+
#
|
|
581
|
+
# _resolve_canvas_bg_color() was called only at construction and from
|
|
582
|
+
# _set_appearance_mode(), so configure(fg_color=...) repainted the
|
|
583
|
+
# outer frame and left the scrolling area on its old colour. And on the
|
|
584
|
+
# unset path -- where something did trigger a refresh -- the canvas
|
|
585
|
+
# picked up the new value while the frame did not, so clearing the
|
|
586
|
+
# field appeared to paint the WHOLE widget instead of resetting it.
|
|
587
|
+
#
|
|
588
|
+
# One symptom each way, one cause: the canvas was never refreshed at
|
|
589
|
+
# the point the colour actually changed.
|
|
590
|
+
if "selection_color" in kwargs:
|
|
591
|
+
# Empty restores the theme's selection_color, matching the query
|
|
592
|
+
# branch and what generated code does when the property is omitted.
|
|
593
|
+
_sel = kwargs.pop("selection_color")
|
|
594
|
+
self._selection_color_override = _sel if _sel else None
|
|
595
|
+
if hasattr(self, "path_to_show"):
|
|
596
|
+
self._fill_explorer()
|
|
597
|
+
|
|
598
|
+
_fg_changed = "fg_color" in kwargs
|
|
599
|
+
# Runtime overrides have to reach the map a repaint reads, or the
|
|
600
|
+
# repaint puts the theme value straight back -- see
|
|
601
|
+
# ThemeableWidget._record_theme_overrides().
|
|
602
|
+
self._record_theme_overrides(kwargs)
|
|
603
|
+
|
|
604
|
+
if "state" in kwargs:
|
|
605
|
+
self._state = str(kwargs.pop("state")).lower()
|
|
606
|
+
if self._state not in ("normal", "disabled"): self._state = "normal"
|
|
607
|
+
# Scroll handling is tied to state via _scroll_permitted(), so
|
|
608
|
+
# rebuild the bindings. state() does the same; both entry points
|
|
609
|
+
# have to, since neither delegates to the other.
|
|
610
|
+
self._activate_scroll_bindings()
|
|
611
|
+
if "type" in kwargs:
|
|
612
|
+
# Through set_mode(), so there is ONE rule rather than two.
|
|
613
|
+
#
|
|
614
|
+
# This set response_type directly and left filetypes alone, so
|
|
615
|
+
# switching to "directory" with a filter already set left the two
|
|
616
|
+
# disagreeing -- and the fill routine drew its "UI Mismatch" label
|
|
617
|
+
# instead of the listing. set_mode() clears the filter, which is
|
|
618
|
+
# what choosing directories means.
|
|
619
|
+
_mode = str(kwargs.pop("type")).lower()
|
|
620
|
+
if _mode not in ("file", "directory"):
|
|
621
|
+
_mode = "directory"
|
|
622
|
+
self.set_mode(_mode)
|
|
623
|
+
if "filetypes" in kwargs:
|
|
624
|
+
ft_val = kwargs.pop("filetypes")
|
|
625
|
+
if ft_val:
|
|
626
|
+
raw_types = parse_list_property(ft_val)
|
|
627
|
+
self.filetypes = []
|
|
628
|
+
for f in raw_types:
|
|
629
|
+
clean_f = str(f).lower().replace("*", "").strip()
|
|
630
|
+
if clean_f:
|
|
631
|
+
if not clean_f.startswith("."): clean_f = "." + clean_f
|
|
632
|
+
self.filetypes.append(clean_f)
|
|
633
|
+
else: self.filetypes = None
|
|
634
|
+
|
|
635
|
+
if "width" in kwargs:
|
|
636
|
+
self._desired_width = int(kwargs.pop("width"))
|
|
637
|
+
kwargs["width"] = self._desired_width
|
|
638
|
+
if hasattr(self, "top_frame"): self.top_frame.configure(width=self._desired_width)
|
|
639
|
+
if hasattr(self, "main_container"): self.main_container.configure(width=self._desired_width)
|
|
640
|
+
if hasattr(self, "canvas"): self.canvas.configure(width=self._desired_width - 30)
|
|
641
|
+
if "height" in kwargs:
|
|
642
|
+
self._desired_height = int(kwargs.pop("height"))
|
|
643
|
+
kwargs["height"] = self._desired_height
|
|
644
|
+
if hasattr(self, "main_container"): self.main_container.configure(height=self._desired_height - 60)
|
|
645
|
+
if hasattr(self, "canvas"): self.canvas.configure(height=self._desired_height - 70)
|
|
646
|
+
|
|
647
|
+
if "command" in kwargs: self.command = kwargs.pop("command")
|
|
648
|
+
if "double_click_command" in kwargs: self.double_click_command = kwargs.pop("double_click_command")
|
|
649
|
+
if "initialdir" in kwargs:
|
|
650
|
+
r = kwargs.pop("initialdir")
|
|
651
|
+
if r:
|
|
652
|
+
init_dir = os.path.normpath(os.path.abspath(os.path.expanduser(str(r))))
|
|
653
|
+
self.path_to_show.set(init_dir)
|
|
654
|
+
self.change_path = False
|
|
655
|
+
self.selected_path.set(init_dir)
|
|
656
|
+
self.change_path = True
|
|
657
|
+
if "initialfile" in kwargs:
|
|
658
|
+
r = kwargs.pop("initialfile")
|
|
659
|
+
if r:
|
|
660
|
+
resolved = self._resolve_initial_file(r)
|
|
661
|
+
if resolved:
|
|
662
|
+
self.selected_path.set(resolved)
|
|
663
|
+
|
|
664
|
+
self._drop_cleared(kwargs)
|
|
665
|
+
if hasattr(self, "final_kw"):
|
|
666
|
+
for custom_key in ["type", "filetypes", "double_click_command", "initialdir", "initialfile", "state"]:
|
|
667
|
+
self.final_kw.pop(custom_key, None)
|
|
668
|
+
|
|
669
|
+
self._process_live_theme_repaint()
|
|
670
|
+
result = super().configure(**kwargs)
|
|
671
|
+
|
|
672
|
+
# The internal CANVAS, after the frame has taken its new colour.
|
|
673
|
+
#
|
|
674
|
+
# _resolve_canvas_bg_color() reads back cget("fg_color"), so it has to
|
|
675
|
+
# run AFTER super().configure() or it re-reads the old value.
|
|
676
|
+
if _fg_changed and hasattr(self, "canvas") and self.canvas.winfo_exists():
|
|
677
|
+
self.canvas.configure(bg=self._resolve_canvas_bg_color())
|
|
678
|
+
if hasattr(self, "path_to_show"):
|
|
679
|
+
self._fill_explorer()
|
|
680
|
+
|
|
681
|
+
return result
|
|
682
|
+
|
|
683
|
+
config = configure
|
|
684
|
+
def get_state(self) -> str: return self.state()
|
|
685
|
+
def state(self, mode: str = None) -> str:
|
|
686
|
+
if mode is None: return str(getattr(self, "_state", "normal")).lower()
|
|
687
|
+
mode = mode.lower()
|
|
688
|
+
if mode in ("normal", "enabled", "active"): self._state = "normal"
|
|
689
|
+
elif mode == "disabled": self._state = "disabled"
|
|
690
|
+
self._process_live_theme_repaint()
|
|
691
|
+
# Scroll handling is tied to state via _scroll_permitted(), so the
|
|
692
|
+
# bindings have to be rebuilt here -- without this the change wouldn't
|
|
693
|
+
# take effect until the next content rebind happened to fire.
|
|
694
|
+
self._activate_scroll_bindings()
|
|
695
|
+
return self._state
|
|
696
|
+
|
|
697
|
+
def set_mode(self, type_str: Literal["file", "directory"]):
|
|
698
|
+
target_mode = str(type_str).lower().strip()
|
|
699
|
+
if target_mode in ("file", "directory"):
|
|
700
|
+
self.response_type = target_mode
|
|
701
|
+
if self.response_type == "directory": self.filetypes = None
|
|
702
|
+
self._process_live_theme_repaint()
|
|
703
|
+
|
|
704
|
+
def set_initial_dir(self, path_str: str):
|
|
705
|
+
if path_str:
|
|
706
|
+
clean_dir = os.path.normpath(os.path.abspath(os.path.expanduser(str(path_str))))
|
|
707
|
+
if os.path.isdir(clean_dir):
|
|
708
|
+
self.path_to_show.set(clean_dir)
|
|
709
|
+
self.change_path = False
|
|
710
|
+
self.selected_path.set(clean_dir)
|
|
711
|
+
self.change_path = True
|
|
712
|
+
self._process_live_theme_repaint()
|
|
713
|
+
|
|
714
|
+
def set_initial_file(self, path_str: str):
|
|
715
|
+
if path_str:
|
|
716
|
+
self.selected_path.set(os.path.normpath(os.path.abspath(os.path.expanduser(str(path_str)))))
|
|
717
|
+
self._process_live_theme_repaint()
|
|
718
|
+
|
|
719
|
+
def set_filetypes(self, filetypes_data: Union[list, str]):
|
|
720
|
+
# Complaint, not correction -- see the constructor for why.
|
|
721
|
+
if self.response_type != "file":
|
|
722
|
+
raise ValueError("Cannot apply 'filetypes' when mode is 'directory'.")
|
|
723
|
+
if not filetypes_data:
|
|
724
|
+
self.filetypes = None
|
|
725
|
+
self._process_live_theme_repaint()
|
|
726
|
+
return
|
|
727
|
+
if isinstance(filetypes_data, str):
|
|
728
|
+
s = filetypes_data.strip().strip("[]\"'")
|
|
729
|
+
raw_types = parse_list_property(s)
|
|
730
|
+
else: raw_types = filetypes_data
|
|
731
|
+
|
|
732
|
+
self.filetypes = []
|
|
733
|
+
for f in raw_types:
|
|
734
|
+
clean_f = str(f).lower().replace("*", "").strip()
|
|
735
|
+
if clean_f:
|
|
736
|
+
if not clean_f.startswith("."): clean_f = "." + clean_f
|
|
737
|
+
self.filetypes.append(clean_f)
|
|
738
|
+
self._process_live_theme_repaint()
|
|
739
|
+
def _resolve_initial_file(self, raw_file, base_dir=None):
|
|
740
|
+
"""
|
|
741
|
+
Resolves an `initialfile` against the explorer's directory, not the
|
|
742
|
+
process's working directory.
|
|
743
|
+
|
|
744
|
+
FIX: this used os.path.abspath() alone, which resolves a BARE filename
|
|
745
|
+
against the current working directory. So
|
|
746
|
+
|
|
747
|
+
configure(initialdir="~/Downloads", initialfile="doc.txt")
|
|
748
|
+
|
|
749
|
+
produced <cwd>/doc.txt -- a path that usually does not exist. The
|
|
750
|
+
listing was correct, but the path entry displays selected_path, so the
|
|
751
|
+
widget appeared to have opened the working directory.
|
|
752
|
+
|
|
753
|
+
An absolute path, or one starting with ~, is honoured as given.
|
|
754
|
+
|
|
755
|
+
Args:
|
|
756
|
+
raw_file: The value passed for initialfile.
|
|
757
|
+
base_dir: Directory to resolve a bare name against. Defaults to
|
|
758
|
+
whatever the explorer is currently showing.
|
|
759
|
+
|
|
760
|
+
Returns:
|
|
761
|
+
A normalised absolute path, or None.
|
|
762
|
+
"""
|
|
763
|
+
if not raw_file:
|
|
764
|
+
return None
|
|
765
|
+
text = str(raw_file)
|
|
766
|
+
expanded = os.path.expanduser(text)
|
|
767
|
+
if not os.path.isabs(expanded):
|
|
768
|
+
base = base_dir or (self.path_to_show.get()
|
|
769
|
+
if hasattr(self, "path_to_show") else None)
|
|
770
|
+
if base:
|
|
771
|
+
expanded = os.path.join(os.path.expanduser(str(base)), expanded)
|
|
772
|
+
return os.path.normpath(os.path.abspath(expanded))
|
|
773
|
+
|
|
774
|
+
def _fill_explorer(self):
|
|
775
|
+
self._empty_explorer()
|
|
776
|
+
current_dir = self.path_to_show.get()
|
|
777
|
+
current_selected = os.path.normpath(os.path.abspath(self.selected_path.get()))
|
|
778
|
+
|
|
779
|
+
if self.filetypes and self.response_type != "file":
|
|
780
|
+
sCTkLabelSecondary(self.explorer_frame, text="⚠️ UI Mismatch: Cannot filter extension when mode is 'directory'.", text_color="red").grid(row=0, column=0, padx=10, pady=10, sticky="w")
|
|
781
|
+
self._activate_scroll_bindings()
|
|
782
|
+
return
|
|
783
|
+
try: items = sorted(os.listdir(current_dir))
|
|
784
|
+
except Exception:
|
|
785
|
+
sCTkLabelSecondary(self.explorer_frame, text="⚠️ Directory unreadable or permission denied", text_color="red").grid(row=0, column=0, padx=10, pady=10, sticky="w")
|
|
786
|
+
self._activate_scroll_bindings()
|
|
787
|
+
return
|
|
788
|
+
|
|
789
|
+
row_idx = 0
|
|
790
|
+
current_state = getattr(self, "_state", "normal")
|
|
791
|
+
theme, d_map = self._local_defaults, self._custom_disabled_map
|
|
792
|
+
|
|
793
|
+
for item in items:
|
|
794
|
+
if item.startswith('.'): continue
|
|
795
|
+
full_path = os.path.normpath(os.path.join(current_dir, item))
|
|
796
|
+
is_dir = os.path.isdir(full_path)
|
|
797
|
+
|
|
798
|
+
is_valid_row = True
|
|
799
|
+
if self.response_type == "directory" and not is_dir: is_valid_row = False
|
|
800
|
+
elif self.response_type == "file" and not is_dir and self.filetypes:
|
|
801
|
+
_, ext = os.path.splitext(item.lower())
|
|
802
|
+
if ext not in self.filetypes: is_valid_row = False
|
|
803
|
+
|
|
804
|
+
icon = self.folder_icon if is_dir else self.file_icon
|
|
805
|
+
is_currently_highlighted = (full_path == current_selected)
|
|
806
|
+
|
|
807
|
+
# FIX: an earlier version used the hardcoded literal "gray50" for
|
|
808
|
+
# row_dimmed_text (in both branches below), and reached into
|
|
809
|
+
# ctk.ThemeManager.theme["CTkLabel"] (native CTk's own theme
|
|
810
|
+
# registry) as a fallback for row_active_text. Both replaced with
|
|
811
|
+
# hard-fail validation on first use, matching the principle
|
|
812
|
+
# established for sCTkSwitch, the label family, and
|
|
813
|
+
# sCTkTableview elsewhere in this project.
|
|
814
|
+
if current_state == "disabled":
|
|
815
|
+
if d_map.get("row_dimmed_text") is None:
|
|
816
|
+
raise KeyError(f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing 'row_dimmed_text' in disabled_map.")
|
|
817
|
+
txt_color, row_widget_state, btn_bg = self._resolve_color(d_map.get("row_dimmed_text")), "disabled", "transparent"
|
|
818
|
+
elif is_valid_row:
|
|
819
|
+
if theme.get("row_active_text") is None:
|
|
820
|
+
raise KeyError(f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing 'row_active_text' at the top level.")
|
|
821
|
+
txt_color, row_widget_state = self._resolve_color(theme.get("row_active_text")), "normal"
|
|
822
|
+
btn_bg = self._selection_color() if is_currently_highlighted else "transparent"
|
|
823
|
+
else:
|
|
824
|
+
if theme.get("row_dimmed_text") is None:
|
|
825
|
+
raise KeyError(f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing 'row_dimmed_text' at the top level.")
|
|
826
|
+
txt_color, row_widget_state, btn_bg = self._resolve_color(theme.get("row_dimmed_text")), "disabled", "transparent"
|
|
827
|
+
|
|
828
|
+
item_btn = sCTkButtonSecondary(self.explorer_frame, text=f"{icon}{item}", anchor="w", fg_color=btn_bg, text_color=txt_color, state=row_widget_state, hover_color=self._resolve_color(theme.get("btn_hover")), command=lambda p=full_path: self._on_item_clicked(p))
|
|
829
|
+
item_btn.grid(row=row_idx, column=0, sticky="ew", padx=2, pady=1)
|
|
830
|
+
|
|
831
|
+
if is_valid_row and current_state != "disabled":
|
|
832
|
+
item_btn.bind("<Double-Button-1>", lambda e, p=full_path: self._on_item_double_clicked(p))
|
|
833
|
+
self.item_labels[full_path] = item_btn
|
|
834
|
+
row_idx += 1
|
|
835
|
+
self.canvas.yview_moveto(0)
|
|
836
|
+
# FIX: navigating to a new folder replaces every row widget in
|
|
837
|
+
# explorer_frame -- re-bind scroll events so the newly-created rows
|
|
838
|
+
# get their own handlers too, not just whatever existed at the last
|
|
839
|
+
# binding pass. See _toggle_scroll_bindings()'s docstring.
|
|
840
|
+
self._activate_scroll_bindings()
|
|
841
|
+
|
|
842
|
+
def _on_item_clicked(self, target_path):
|
|
843
|
+
now = time.time()
|
|
844
|
+
if (now - self._last_double_click_time) < 0.3: return
|
|
845
|
+
target_path = os.path.normpath(target_path)
|
|
846
|
+
if self.response_type == "directory" and os.path.isfile(target_path): target_path = os.path.dirname(target_path)
|
|
847
|
+
|
|
848
|
+
self.change_path = False
|
|
849
|
+
self.selected_path.set(target_path)
|
|
850
|
+
self.change_path = True
|
|
851
|
+
|
|
852
|
+
for path, btn in self.item_labels.items():
|
|
853
|
+
if path == target_path: btn.configure(fg_color=self._selection_color())
|
|
854
|
+
else: btn.configure(fg_color="transparent")
|
|
855
|
+
# FIX: an earlier version called self.command(self) here, passing this
|
|
856
|
+
# FileExplorer widget instance instead of the clicked path. sCTkPathChooser's
|
|
857
|
+
# command=lambda p: self.set(p) expects p to be a path string -- with the
|
|
858
|
+
# old code, a single click would call self.set(<widget instance>), which
|
|
859
|
+
# would then try to treat str(widget) as a filesystem path. Confirmed by
|
|
860
|
+
# the maintainer: command should receive the path, matching what every
|
|
861
|
+
# caller of this widget actually expects. double_click_command is
|
|
862
|
+
# unaffected -- it already correctly passes (self, target_path).
|
|
863
|
+
if self.command and callable(self.command): self.command(target_path)
|
|
864
|
+
|
|
865
|
+
def _on_item_double_clicked(self, target_path):
|
|
866
|
+
target_path = os.path.normpath(target_path)
|
|
867
|
+
if os.path.isdir(target_path):
|
|
868
|
+
self.path_to_show.set(target_path)
|
|
869
|
+
self.change_path = False
|
|
870
|
+
self.selected_path.set(target_path)
|
|
871
|
+
self.change_path = True
|
|
872
|
+
self._fill_explorer()
|
|
873
|
+
else:
|
|
874
|
+
if self.response_type == "directory": target_path = os.path.dirname(target_path)
|
|
875
|
+
now = time.time()
|
|
876
|
+
if (now - self._last_double_click_time) < 0.3: return
|
|
877
|
+
self._last_double_click_time = now
|
|
878
|
+
if self.double_click_command and callable(self.double_click_command): self.double_click_command(self, target_path)
|
|
879
|
+
|