scustomtkinter 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. scustomtkinter/__init__.py +81 -0
  2. scustomtkinter/assets/images/error.png +0 -0
  3. scustomtkinter/assets/images/info.png +0 -0
  4. scustomtkinter/assets/images/warning.png +0 -0
  5. scustomtkinter/assets/sCTkThemes.json +1963 -0
  6. scustomtkinter/license_morecustomtkinterwidgets.txt +22 -0
  7. scustomtkinter/sctk_button_primary.py +366 -0
  8. scustomtkinter/sctk_button_secondary.py +354 -0
  9. scustomtkinter/sctk_button_tertiary.py +375 -0
  10. scustomtkinter/sctk_checkbox.py +270 -0
  11. scustomtkinter/sctk_combobox.py +155 -0
  12. scustomtkinter/sctk_core.py +15 -0
  13. scustomtkinter/sctk_dial.py +1472 -0
  14. scustomtkinter/sctk_dialog.py +701 -0
  15. scustomtkinter/sctk_dialog_toplevel.py +268 -0
  16. scustomtkinter/sctk_entry_primary.py +400 -0
  17. scustomtkinter/sctk_entry_secondary.py +342 -0
  18. scustomtkinter/sctk_file_explorer.py +879 -0
  19. scustomtkinter/sctk_frame.py +210 -0
  20. scustomtkinter/sctk_frame_labeled_primary.py +667 -0
  21. scustomtkinter/sctk_frame_labeled_secondary.py +598 -0
  22. scustomtkinter/sctk_frequency_display.py +636 -0
  23. scustomtkinter/sctk_label_primary.py +283 -0
  24. scustomtkinter/sctk_label_secondary.py +275 -0
  25. scustomtkinter/sctk_label_tertiary.py +278 -0
  26. scustomtkinter/sctk_messagebox.py +275 -0
  27. scustomtkinter/sctk_notebook.py +1103 -0
  28. scustomtkinter/sctk_optionmenu_border_mixin.py +203 -0
  29. scustomtkinter/sctk_optionmenu_primary.py +378 -0
  30. scustomtkinter/sctk_optionmenu_secondary.py +346 -0
  31. scustomtkinter/sctk_path_chooser.py +516 -0
  32. scustomtkinter/sctk_progress_bar.py +274 -0
  33. scustomtkinter/sctk_radiobutton.py +360 -0
  34. scustomtkinter/sctk_scroll_mixin.py +546 -0
  35. scustomtkinter/sctk_scrollable_frame.py +795 -0
  36. scustomtkinter/sctk_scrollbar.py +313 -0
  37. scustomtkinter/sctk_segmentedbutton.py +424 -0
  38. scustomtkinter/sctk_selector.py +581 -0
  39. scustomtkinter/sctk_separator.py +308 -0
  40. scustomtkinter/sctk_slider.py +264 -0
  41. scustomtkinter/sctk_smeter.py +405 -0
  42. scustomtkinter/sctk_smeter_bar.py +557 -0
  43. scustomtkinter/sctk_spinbox.py +664 -0
  44. scustomtkinter/sctk_switch.py +463 -0
  45. scustomtkinter/sctk_tableview.py +869 -0
  46. scustomtkinter/sctk_tabview.py +409 -0
  47. scustomtkinter/sctk_textbox_primary.py +291 -0
  48. scustomtkinter/sctk_textbox_secondary.py +225 -0
  49. scustomtkinter/sctk_toplevel.py +153 -0
  50. scustomtkinter/themeable_widget.py +938 -0
  51. scustomtkinter-0.2.1.dist-info/METADATA +336 -0
  52. scustomtkinter-0.2.1.dist-info/RECORD +101 -0
  53. scustomtkinter-0.2.1.dist-info/WHEEL +5 -0
  54. scustomtkinter-0.2.1.dist-info/entry_points.txt +2 -0
  55. scustomtkinter-0.2.1.dist-info/licenses/LICENSE +21 -0
  56. scustomtkinter-0.2.1.dist-info/licenses/scustomtkinter/license_morecustomtkinterwidgets.txt +22 -0
  57. scustomtkinter-0.2.1.dist-info/top_level.txt +2 -0
  58. scustomtkinter_pygubu/__init__.py +1 -0
  59. scustomtkinter_pygubu/designer/__init__.py +0 -0
  60. scustomtkinter_pygubu/designer/plugin.py +1854 -0
  61. scustomtkinter_pygubu/designer/properties.py +247 -0
  62. scustomtkinter_pygubu/sCTkButtonPrimarybo.py +46 -0
  63. scustomtkinter_pygubu/sCTkButtonSecondarybo.py +45 -0
  64. scustomtkinter_pygubu/sCTkButtonTertiarybo.py +44 -0
  65. scustomtkinter_pygubu/sCTkCheckBoxbo.py +45 -0
  66. scustomtkinter_pygubu/sCTkComboBoxbo.py +45 -0
  67. scustomtkinter_pygubu/sCTkCorebo.py +120 -0
  68. scustomtkinter_pygubu/sCTkDialbo.py +371 -0
  69. scustomtkinter_pygubu/sCTkDialogbo.py +397 -0
  70. scustomtkinter_pygubu/sCTkEntryPrimarybo.py +100 -0
  71. scustomtkinter_pygubu/sCTkEntrySecondarybo.py +98 -0
  72. scustomtkinter_pygubu/sCTkFileExplorerbo.py +189 -0
  73. scustomtkinter_pygubu/sCTkFrameLabeledPrimarybo.py +69 -0
  74. scustomtkinter_pygubu/sCTkFrameLabeledSecondarybo.py +71 -0
  75. scustomtkinter_pygubu/sCTkFramebo.py +46 -0
  76. scustomtkinter_pygubu/sCTkFrequencyDisplaybo.py +175 -0
  77. scustomtkinter_pygubu/sCTkLabelPrimarybo.py +45 -0
  78. scustomtkinter_pygubu/sCTkLabelSecondarybo.py +44 -0
  79. scustomtkinter_pygubu/sCTkLabelTertiarybo.py +45 -0
  80. scustomtkinter_pygubu/sCTkNotebookbo.py +392 -0
  81. scustomtkinter_pygubu/sCTkOptionMenuPrimarybo.py +45 -0
  82. scustomtkinter_pygubu/sCTkOptionMenuSecondarybo.py +39 -0
  83. scustomtkinter_pygubu/sCTkPathChooserbo.py +204 -0
  84. scustomtkinter_pygubu/sCTkProgressBarbo.py +45 -0
  85. scustomtkinter_pygubu/sCTkRadioButtonbo.py +45 -0
  86. scustomtkinter_pygubu/sCTkSMeterBarbo.py +153 -0
  87. scustomtkinter_pygubu/sCTkSMeterbo.py +101 -0
  88. scustomtkinter_pygubu/sCTkScrollableFramebo.py +66 -0
  89. scustomtkinter_pygubu/sCTkScrollbarbo.py +48 -0
  90. scustomtkinter_pygubu/sCTkSegmentedButtonbo.py +47 -0
  91. scustomtkinter_pygubu/sCTkSelectorbo.py +167 -0
  92. scustomtkinter_pygubu/sCTkSeparatorbo.py +141 -0
  93. scustomtkinter_pygubu/sCTkSliderbo.py +45 -0
  94. scustomtkinter_pygubu/sCTkSpinboxbo.py +200 -0
  95. scustomtkinter_pygubu/sCTkSwitchbo.py +45 -0
  96. scustomtkinter_pygubu/sCTkTableviewbo.py +188 -0
  97. scustomtkinter_pygubu/sCTkTabviewbo.py +226 -0
  98. scustomtkinter_pygubu/sCTkTextboxPrimarybo.py +45 -0
  99. scustomtkinter_pygubu/sCTkTextboxSecondarybo.py +45 -0
  100. scustomtkinter_pygubu/sCTkToplevelbo.py +55 -0
  101. scustomtkinter_pygubu/sCTkWidgetSetForPygubuDesigner.py +141 -0
@@ -0,0 +1,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
+