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