bootstack 0.3.0__py3-none-any.whl → 0.3.2__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.
- bootstack/dialogs/_impl/datedialog.py +27 -2
- bootstack/dialogs/_impl/dialog.py +293 -7
- bootstack/dialogs/_impl/query.py +12 -10
- bootstack/widgets/_core/base.py +1 -1
- bootstack/widgets/_core/container.py +63 -6
- bootstack/widgets/_impl/composites/selectbox.py +151 -32
- bootstack/widgets/appshell.py +1 -1
- bootstack/widgets/card.py +1 -1
- bootstack/widgets/expander.py +2 -2
- bootstack/widgets/grid.py +1 -1
- bootstack/widgets/groupbox.py +1 -1
- bootstack/widgets/pagestack.py +1 -1
- bootstack/widgets/select.py +20 -7
- bootstack/widgets/splitview.py +1 -1
- bootstack/widgets/tabs.py +1 -1
- bootstack/widgets/timefield.py +23 -7
- {bootstack-0.3.0.dist-info → bootstack-0.3.2.dist-info}/METADATA +1 -1
- {bootstack-0.3.0.dist-info → bootstack-0.3.2.dist-info}/RECORD +23 -23
- {bootstack-0.3.0.dist-info → bootstack-0.3.2.dist-info}/WHEEL +0 -0
- {bootstack-0.3.0.dist-info → bootstack-0.3.2.dist-info}/entry_points.txt +0 -0
- {bootstack-0.3.0.dist-info → bootstack-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {bootstack-0.3.0.dist-info → bootstack-0.3.2.dist-info}/licenses/NOTICE +0 -0
- {bootstack-0.3.0.dist-info → bootstack-0.3.2.dist-info}/top_level.txt +0 -0
|
@@ -16,7 +16,8 @@ from bootstack.widgets._impl.primitives import Frame
|
|
|
16
16
|
from bootstack.constants import BOTH, YES
|
|
17
17
|
from bootstack.events import Subscription
|
|
18
18
|
from bootstack.dialogs._impl.dialog import (
|
|
19
|
-
DIALOG_RESULT, Dialog, DialogButton, emit_dialog_result,
|
|
19
|
+
DIALOG_RESULT, Dialog, DialogButton, capture_grab, emit_dialog_result, restore_grab,
|
|
20
|
+
result_target,
|
|
20
21
|
)
|
|
21
22
|
from bootstack._runtime.window_utilities import AnchorPoint
|
|
22
23
|
from bootstack.widgets._impl.composites.calendar import Calendar
|
|
@@ -78,6 +79,22 @@ class _ChromeDialog(Dialog):
|
|
|
78
79
|
self._build_content()
|
|
79
80
|
self._build_footer()
|
|
80
81
|
|
|
82
|
+
# This override duplicates `Dialog.show()` rather than extending it, so
|
|
83
|
+
# the initial-focus resolution has to be repeated here — see
|
|
84
|
+
# `Dialog._focus_when_mapped` (issue #439).
|
|
85
|
+
#
|
|
86
|
+
# This reaches only RANGE mode. Single-date mode commits the moment a
|
|
87
|
+
# date is clicked and so builds no footer buttons (see `__init__`),
|
|
88
|
+
# leaving `_default_button` None; nothing sets `_focus_target` for the
|
|
89
|
+
# raw calendar content either. So `ask_date()` still opens with focus on
|
|
90
|
+
# the toplevel itself, exactly as before — measured, not assumed. Giving
|
|
91
|
+
# the single-date calendar a real focus target is a separate change,
|
|
92
|
+
# since it would put a focus ring on a day cell and make the arrow keys
|
|
93
|
+
# move the selection.
|
|
94
|
+
focus_target = self._focus_target or self._default_button
|
|
95
|
+
if focus_target is not None:
|
|
96
|
+
self._focus_when_mapped(focus_target)
|
|
97
|
+
|
|
81
98
|
self._position_dialog(
|
|
82
99
|
position=position,
|
|
83
100
|
anchor_to=anchor_to,
|
|
@@ -110,9 +127,17 @@ class _ChromeDialog(Dialog):
|
|
|
110
127
|
if modal:
|
|
111
128
|
# transient() is already called in Dialog._create_toplevel()
|
|
112
129
|
# Don't call it again here as it breaks mica effect on Windows
|
|
130
|
+
previous_grab = None
|
|
113
131
|
if self._mode == "modal":
|
|
132
|
+
# Remember who held the grab, and how, so it can be handed back
|
|
133
|
+
# — a nested modal must not leave its opener non-modal (#440),
|
|
134
|
+
# nor narrow an app-modal window to a local grab.
|
|
135
|
+
previous_grab = capture_grab(self._toplevel)
|
|
114
136
|
self._toplevel.grab_set()
|
|
115
|
-
|
|
137
|
+
try:
|
|
138
|
+
self._master.wait_window(self._toplevel)
|
|
139
|
+
finally:
|
|
140
|
+
restore_grab(previous_grab)
|
|
116
141
|
|
|
117
142
|
def _on_focus_out(self, event: tkinter.Event):
|
|
118
143
|
if self._suppress_focus_out:
|
|
@@ -182,6 +182,187 @@ def emit_dialog_result(target: Optional[tkinter.Misc], payload: dict) -> None:
|
|
|
182
182
|
|
|
183
183
|
# --- Dialog ----------------------------------------------------------------
|
|
184
184
|
|
|
185
|
+
def capture_grab(widget: Any) -> tuple[Any, str | None] | None:
|
|
186
|
+
"""Record who holds the modal grab and HOW, for `restore_grab` to hand back.
|
|
187
|
+
|
|
188
|
+
Returns `None` when nothing holds the grab — the outermost case, which needs
|
|
189
|
+
no restore. Otherwise returns the holder paired with its grab KIND.
|
|
190
|
+
|
|
191
|
+
⚠ Call this BEFORE taking the grab. Once another window grabs, the previous
|
|
192
|
+
holder's `grab_status()` reads `None`, so a kind read afterwards is always
|
|
193
|
+
wrong — measured, and the reason this pairing exists as one function rather
|
|
194
|
+
than two steps a caller has to sequence correctly.
|
|
195
|
+
|
|
196
|
+
The kind matters because Tk has two: `bs.Window(modal="app")` takes a GLOBAL
|
|
197
|
+
grab, and restoring that as a local one silently narrows the window's
|
|
198
|
+
modality (it would block only this application). Reading the kind back from
|
|
199
|
+
Tk rather than assuming it keeps this correct on every window system without
|
|
200
|
+
a platform branch — whatever Tk reported, we hand back.
|
|
201
|
+
"""
|
|
202
|
+
holder = widget.grab_current()
|
|
203
|
+
if holder is None:
|
|
204
|
+
return None
|
|
205
|
+
try:
|
|
206
|
+
kind = holder.grab_status()
|
|
207
|
+
except (AttributeError, tkinter.TclError):
|
|
208
|
+
# Fall back to the narrower grab rather than guessing global.
|
|
209
|
+
kind = "local"
|
|
210
|
+
return (holder, kind)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def restore_grab(previous: tuple[Any, str | None] | None) -> None:
|
|
214
|
+
"""Hand the modal grab back to whatever held it before this dialog took it.
|
|
215
|
+
|
|
216
|
+
Tk releases a grab when the window holding it is destroyed, but it does NOT
|
|
217
|
+
restore the grab that window displaced. So a modal opened from inside
|
|
218
|
+
another modal — `bs.alert()` from a dialog button command, or
|
|
219
|
+
`QueryDialog._on_submit` — took the grab over and then dropped it on the
|
|
220
|
+
floor when it closed. The OUTER dialog was left on screen and still
|
|
221
|
+
blocking its caller inside `show()`, yet holding no grab at all: the user
|
|
222
|
+
could click straight back into the main window and drive the app
|
|
223
|
+
underneath it, against a dialog that was modal in appearance only
|
|
224
|
+
(issue #440).
|
|
225
|
+
|
|
226
|
+
Pass the token `capture_grab()` returned. `None` means nothing held the
|
|
227
|
+
grab, which is the outermost case and needs no restore.
|
|
228
|
+
|
|
229
|
+
A failure here is deliberately swallowed. This runs on a teardown path,
|
|
230
|
+
where the previous holder may itself have been destroyed while the inner
|
|
231
|
+
dialog was up, or the whole interpreter may be going down — and a dialog
|
|
232
|
+
that has already closed must not raise on its way out. It is LOGGED rather
|
|
233
|
+
than passed over in silence, because a failed restore is the very defect
|
|
234
|
+
this function exists to prevent (#440): the outer dialog stays on screen
|
|
235
|
+
holding nothing.
|
|
236
|
+
|
|
237
|
+
⚠ A global restore can fail where a local one cannot — `grab set -global`
|
|
238
|
+
is the call Tk's viewability rule guards, and on X11 it can also lose to
|
|
239
|
+
another client. That is the one way this is riskier than always restoring
|
|
240
|
+
a local grab, so a failed global restore DEGRADES TO LOCAL rather than to
|
|
241
|
+
nothing: modal within the application is imperfect, but it is not the #440
|
|
242
|
+
symptom.
|
|
243
|
+
"""
|
|
244
|
+
if previous is None:
|
|
245
|
+
return
|
|
246
|
+
holder, kind = previous
|
|
247
|
+
try:
|
|
248
|
+
if not holder.winfo_exists():
|
|
249
|
+
return
|
|
250
|
+
if kind == "global":
|
|
251
|
+
try:
|
|
252
|
+
holder.grab_set_global()
|
|
253
|
+
except tkinter.TclError:
|
|
254
|
+
_log_grab_failure("could not restore a global grab; falling back to local")
|
|
255
|
+
holder.grab_set()
|
|
256
|
+
else:
|
|
257
|
+
holder.grab_set()
|
|
258
|
+
except (AttributeError, tkinter.TclError):
|
|
259
|
+
_log_grab_failure("could not restore the previous dialog's grab")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _log_grab_failure(message: str) -> None:
|
|
263
|
+
"""Report a grab failure without ever raising from a teardown path."""
|
|
264
|
+
from bootstack._runtime.utility import debug_log_exception
|
|
265
|
+
|
|
266
|
+
debug_log_exception(message)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# Bindtags whose widgets treat Enter as CONTENT rather than as a command.
|
|
270
|
+
# `Text` is Tk's multi-line text class, which `TextArea` and `CodeEditor` are
|
|
271
|
+
# both built on — naming the class covers them, and anything else Text-backed,
|
|
272
|
+
# without listing widgets.
|
|
273
|
+
_ENTER_IS_CONTENT = frozenset({"Text"})
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _key_was_consumed(widget: Any, keysym: str) -> bool:
|
|
277
|
+
"""Did `widget` already answer this Enter key by the time it reached us?
|
|
278
|
+
|
|
279
|
+
A dialog binds Return on its TOPLEVEL so the default button can be pressed
|
|
280
|
+
from an input field. The toplevel is last in the bindtag walk, so by the
|
|
281
|
+
time that binding runs a widget-level or class-level handler has already
|
|
282
|
+
had the key — and firing the default button on top of it either invokes the
|
|
283
|
+
same button twice or runs the default button's command over the one the
|
|
284
|
+
user actually pressed.
|
|
285
|
+
|
|
286
|
+
Two kinds of widget answer Enter, and the rule names the intent rather than
|
|
287
|
+
inferring it:
|
|
288
|
+
|
|
289
|
+
* a **button** invokes. bootstack installs `TButton <Key-Return>` ->
|
|
290
|
+
`button_default_binding` at app construction (`_runtime/app.py:151`).
|
|
291
|
+
* a **multi-line text widget** inserts a newline. That is issue #441: the
|
|
292
|
+
newline went in and the dialog then closed on top of it, because the
|
|
293
|
+
guard recognized only buttons.
|
|
294
|
+
|
|
295
|
+
In both cases a DISABLED widget is the exception: its class binding runs
|
|
296
|
+
but does nothing — `invoke` is a no-op on a disabled button, and
|
|
297
|
+
`tk::TextInsert` returns early on a disabled text (both measured,
|
|
298
|
+
`development/probe_441_key_already_handled.py`). Nothing answered the key,
|
|
299
|
+
so the default button should still get it. Standing down there would leave
|
|
300
|
+
the keyboard dead for the whole dialog.
|
|
301
|
+
|
|
302
|
+
⚠ THE KEY MATTERS, NOT ONLY THE WIDGET — which is why `keysym` is required
|
|
303
|
+
rather than defaulted. The two Enter keys are equivalent to a button and are
|
|
304
|
+
NOT equivalent to a text widget. Measured against the live binding table:
|
|
305
|
+
|
|
306
|
+
TButton <Key-Return> -> button_default_binding
|
|
307
|
+
TButton <Key-KP_Enter> -> button_default_binding (both, app.py:150)
|
|
308
|
+
Text <Key-Return> -> tk::TextInsert
|
|
309
|
+
Text <Key-KP_Enter> -> '# nothing'
|
|
310
|
+
|
|
311
|
+
Tk binds `Text <KP_Enter>` to the literal script `# nothing`
|
|
312
|
+
(`tk8.6/text.tcl:308`, unconditional — it is in the block that stops the
|
|
313
|
+
generic `<Key>` binding inserting `%A`, which for that key is `\r` rather
|
|
314
|
+
than `\n`). So wherever the keypad key arrives as its own keysym a text
|
|
315
|
+
widget inserts NOTHING, and reporting it consumed would leave that key dead
|
|
316
|
+
for the whole dialog. Windows never reaches this: it FOLDS the keypad key
|
|
317
|
+
into `Return` (see the binding site below), so `keysym` is `Return` there
|
|
318
|
+
and the text branch behaves as it always has. X11 reports `KP_Enter`
|
|
319
|
+
separately and is the case this scoping exists for. Aqua is unmeasured and
|
|
320
|
+
needs no measurement — both of its possible answers are handled.
|
|
321
|
+
|
|
322
|
+
⚠ The text branch tests `keysym != "KP_Enter"` rather than
|
|
323
|
+
`keysym == "Return"`, deliberately. An unrecognized keysym then reads as
|
|
324
|
+
CONSUMED, which is the conservative answer for a text widget: standing down
|
|
325
|
+
wrongly costs a dead key, while firing wrongly costs #441 itself — the
|
|
326
|
+
dialog closing on top of a newline the user just typed. The button branch
|
|
327
|
+
has no such asymmetry, both keys invoking there.
|
|
328
|
+
|
|
329
|
+
⚠ INTERROGATING THE BINDINGS INSTEAD WAS TRIED AND IS WRONG — do not
|
|
330
|
+
re-propose it as the "general" rule. Asking whether any bindtag carries a
|
|
331
|
+
real binding for the key looks principled and misclassifies the control:
|
|
332
|
+
bootstack's own `TextField` binds `<Return>` as an instance binding to emit
|
|
333
|
+
its `submit` event, so a plain entry reads as "already handled" and
|
|
334
|
+
`ask_string()` stops submitting. Tk also binds `TEntry <Return>` to the
|
|
335
|
+
literal script `# nothing`, so a non-empty script does not mean the key was
|
|
336
|
+
handled either. Binding inspection cannot separate binds-to-notify from
|
|
337
|
+
binds-to-consume; the class name is where that intent is recorded.
|
|
338
|
+
"""
|
|
339
|
+
try:
|
|
340
|
+
tags = {str(tag) for tag in widget.bindtags()}
|
|
341
|
+
except (AttributeError, tkinter.TclError):
|
|
342
|
+
# Nothing identifiable answered the key, so let the default button run
|
|
343
|
+
# rather than silently swallowing Enter for the whole dialog.
|
|
344
|
+
return False
|
|
345
|
+
|
|
346
|
+
if "TButton" in tags:
|
|
347
|
+
try:
|
|
348
|
+
return not widget.instate(["disabled"])
|
|
349
|
+
except (AttributeError, tkinter.TclError):
|
|
350
|
+
return True
|
|
351
|
+
|
|
352
|
+
if tags & _ENTER_IS_CONTENT:
|
|
353
|
+
if keysym == "KP_Enter":
|
|
354
|
+
# `Text <KP_Enter>` is the literal script `# nothing`, so nothing
|
|
355
|
+
# was inserted whatever the widget's state — the default button
|
|
356
|
+
# should still get the key rather than it dying here.
|
|
357
|
+
return False
|
|
358
|
+
try:
|
|
359
|
+
return str(widget.cget("state")) == "normal"
|
|
360
|
+
except (AttributeError, tkinter.TclError):
|
|
361
|
+
return True
|
|
362
|
+
|
|
363
|
+
return False
|
|
364
|
+
|
|
365
|
+
|
|
185
366
|
class Dialog:
|
|
186
367
|
"""A flexible dialog window using the builder pattern.
|
|
187
368
|
|
|
@@ -283,6 +464,13 @@ class Dialog:
|
|
|
283
464
|
self._footer: _Frame | None = None
|
|
284
465
|
self._border_frame: _Frame | None = None
|
|
285
466
|
|
|
467
|
+
# Who gets focus when the window comes up. `_focus_target` is the
|
|
468
|
+
# override a content builder can claim (QueryDialog's entry); the
|
|
469
|
+
# default button is the fallback. Resolved once in `show()`, after
|
|
470
|
+
# content is built, so the two cannot race — see `_focus_when_mapped`.
|
|
471
|
+
self._default_button: _Button | None = None
|
|
472
|
+
self._focus_target: Widget | None = None
|
|
473
|
+
|
|
286
474
|
self.result: Any = None
|
|
287
475
|
|
|
288
476
|
# --------------------------------------------------------------- API
|
|
@@ -333,6 +521,16 @@ class Dialog:
|
|
|
333
521
|
self._create_toplevel(modal=modal)
|
|
334
522
|
self._build_footer()
|
|
335
523
|
self._build_content()
|
|
524
|
+
|
|
525
|
+
# Resolve initial focus once, now that both halves exist. A content
|
|
526
|
+
# widget that claimed `_focus_target` wins; otherwise the default
|
|
527
|
+
# button takes it, which is what `DialogButton.default` promises.
|
|
528
|
+
# Deferred to <Map> because the window is still withdrawn here and
|
|
529
|
+
# `focus_set()` is a silent no-op until it is not (issue #439).
|
|
530
|
+
focus_target = self._focus_target or self._default_button
|
|
531
|
+
if focus_target is not None:
|
|
532
|
+
self._focus_when_mapped(focus_target)
|
|
533
|
+
|
|
336
534
|
self._position_dialog(
|
|
337
535
|
position=position,
|
|
338
536
|
anchor_to=_resolve_anchor_target(anchor_to),
|
|
@@ -353,9 +551,18 @@ class Dialog:
|
|
|
353
551
|
# sheet window class; calling grab_set on top of that is fine
|
|
354
552
|
# but unnecessary. Plain modal mode still uses grab to block
|
|
355
553
|
# interaction with the parent on platforms without a sheet.
|
|
554
|
+
previous_grab = None
|
|
356
555
|
if self._mode in ("modal", "sheet"):
|
|
556
|
+
# Remember who held the grab, and how, so it can be handed back
|
|
557
|
+
# — a nested modal must not leave its opener non-modal (#440),
|
|
558
|
+
# nor narrow an app-modal window to a local grab. Captured
|
|
559
|
+
# before grab_set(), which is the only order that works.
|
|
560
|
+
previous_grab = capture_grab(self._toplevel)
|
|
357
561
|
self._toplevel.grab_set()
|
|
358
|
-
|
|
562
|
+
try:
|
|
563
|
+
self._master.wait_window(self._toplevel)
|
|
564
|
+
finally:
|
|
565
|
+
restore_grab(previous_grab)
|
|
359
566
|
|
|
360
567
|
@property
|
|
361
568
|
def toplevel(self) -> Toplevel | None:
|
|
@@ -541,7 +748,10 @@ class Dialog:
|
|
|
541
748
|
return
|
|
542
749
|
|
|
543
750
|
if default_button is not None:
|
|
544
|
-
|
|
751
|
+
# Recorded, not focused here: `show()` resolves focus after the
|
|
752
|
+
# content is built, so a content widget asking for initial focus
|
|
753
|
+
# is not overruled by build order (issue #439).
|
|
754
|
+
self._default_button = default_button
|
|
545
755
|
top = self._toplevel
|
|
546
756
|
|
|
547
757
|
def press_default(event: Any, b: Any = default_button) -> None:
|
|
@@ -563,18 +773,46 @@ class Dialog:
|
|
|
563
773
|
leave the keyboard dead for the whole dialog — which is what a
|
|
564
774
|
content button that greys itself out on click would cause.
|
|
565
775
|
|
|
776
|
+
A multi-line text widget answers Enter too, by inserting a
|
|
777
|
+
newline, and standing down for it is issue #441 — the newline
|
|
778
|
+
went in and the dialog closed on top of it. `_key_was_consumed`
|
|
779
|
+
owns that decision for both kinds of widget.
|
|
780
|
+
|
|
781
|
+
It is handed the KEYSYM as well as the widget because a text
|
|
782
|
+
widget answers only the main Enter key: Tk binds its keypad
|
|
783
|
+
twin to a no-op script, so standing down for that one would
|
|
784
|
+
leave it dead. A button answers both.
|
|
785
|
+
|
|
566
786
|
The remaining case is the one this binding exists for — focus
|
|
567
787
|
in an entry, as in `QueryDialog`, where nothing else would
|
|
568
788
|
answer the key.
|
|
569
789
|
"""
|
|
570
|
-
|
|
571
|
-
if "TButton" in pressed.bindtags() and not pressed.instate(["disabled"]):
|
|
790
|
+
if _key_was_consumed(event.widget, event.keysym):
|
|
572
791
|
return
|
|
573
792
|
b.invoke()
|
|
574
793
|
|
|
575
|
-
# The keypad Enter key
|
|
576
|
-
#
|
|
577
|
-
#
|
|
794
|
+
# The keypad Enter key needs its own binding to reach the default
|
|
795
|
+
# button from an input field -- but only where the window system
|
|
796
|
+
# reports it separately, which is NOT everywhere. Measured with a
|
|
797
|
+
# real keypress, alternating against the main Enter key as a
|
|
798
|
+
# control (`development/probe_441_kp_enter_platform.py`):
|
|
799
|
+
#
|
|
800
|
+
# * Windows FOLDS it into `Return` -- keysym `Return`, keycode 13,
|
|
801
|
+
# char `\r`, byte-identical to the main Enter key -- so this
|
|
802
|
+
# binding never fires there. Synthesis is no help either: win32
|
|
803
|
+
# cannot map the keysym back to a keycode and `event_generate`
|
|
804
|
+
# delivers keysym `??` with keycode 0, matching no binding at
|
|
805
|
+
# all. Both routes to this path are closed on Windows, so no
|
|
806
|
+
# test there can cover it.
|
|
807
|
+
# * X11 reports `KP_Enter` as a distinct keysym. That is the case
|
|
808
|
+
# this binding exists for.
|
|
809
|
+
# * Aqua is unmeasured.
|
|
810
|
+
#
|
|
811
|
+
# Binding both is harmless where the two coincide. Where they do
|
|
812
|
+
# NOT, the keysym is threaded into `_key_was_consumed` so a text
|
|
813
|
+
# widget stands down only for the key it actually inserts for --
|
|
814
|
+
# Tk's `Text <KP_Enter>` is a no-op script, so treating the two
|
|
815
|
+
# alike would kill the keypad key inside a dialog TextArea.
|
|
578
816
|
for key in ("<Return>", "<KP_Enter>"):
|
|
579
817
|
self._toplevel.bind(key, press_default)
|
|
580
818
|
|
|
@@ -583,6 +821,54 @@ class Dialog:
|
|
|
583
821
|
else:
|
|
584
822
|
self._toplevel.bind("<Escape>", lambda e: self._toplevel.destroy())
|
|
585
823
|
|
|
824
|
+
@staticmethod
|
|
825
|
+
def _focus_when_mapped(widget: Widget) -> None:
|
|
826
|
+
"""Focus `widget` once it is actually mapped.
|
|
827
|
+
|
|
828
|
+
`focus_set()` is a SILENT no-op while the widget's toplevel is
|
|
829
|
+
withdrawn: `TkSetFocusWin` walks the ancestry and returns without
|
|
830
|
+
setting anything, reporting nothing. Footer buttons are built from
|
|
831
|
+
`_build_footer`, which runs while `_create_toplevel` still has the
|
|
832
|
+
window withdrawn, so focusing the default button there never took —
|
|
833
|
+
`focus_lastfor()` kept naming the toplevel, leaving keyboard users
|
|
834
|
+
with no focus ring and a Tab order starting from nowhere, in a dialog
|
|
835
|
+
documented as focusing its default button (issue #439).
|
|
836
|
+
|
|
837
|
+
Waiting for the widget's own `<Map>` states that precondition instead
|
|
838
|
+
of guessing a delay. Measured on Windows
|
|
839
|
+
(`development/probe_439_focus_timing.py`): the button is still
|
|
840
|
+
unmapped once `deiconify()` AND `update_idletasks()` have both
|
|
841
|
+
returned, so neither is a usable barrier. Asking after the toplevel is
|
|
842
|
+
deiconified happens to work — Tk defers a request made against a
|
|
843
|
+
mapped toplevel — but that deferral is a Tk implementation detail and
|
|
844
|
+
window managers map asynchronously, so the explicit wait is what
|
|
845
|
+
travels off this box.
|
|
846
|
+
|
|
847
|
+
Binding on the widget rather than scheduling on the root is
|
|
848
|
+
deliberate: a `<Map>` binding is torn down with the widget it is on,
|
|
849
|
+
so a dialog closed before it ever maps leaves nothing pending. An
|
|
850
|
+
`after` timer would outlive the widget and fire as an orphan.
|
|
851
|
+
"""
|
|
852
|
+
if widget.winfo_ismapped():
|
|
853
|
+
widget.focus_set()
|
|
854
|
+
return
|
|
855
|
+
|
|
856
|
+
def on_map(_event: Any = None) -> None:
|
|
857
|
+
# One-shot: the default button should not reclaim focus every time
|
|
858
|
+
# the dialog is unmapped and remapped (minimize/restore), which
|
|
859
|
+
# would yank focus off whatever the user had tabbed to.
|
|
860
|
+
try:
|
|
861
|
+
widget.unbind("<Map>", bind_id)
|
|
862
|
+
except tkinter.TclError:
|
|
863
|
+
pass
|
|
864
|
+
try:
|
|
865
|
+
if widget.winfo_exists():
|
|
866
|
+
widget.focus_set()
|
|
867
|
+
except tkinter.TclError:
|
|
868
|
+
pass
|
|
869
|
+
|
|
870
|
+
bind_id = widget.bind("<Map>", on_map, add="+")
|
|
871
|
+
|
|
586
872
|
def _position_dialog(
|
|
587
873
|
self,
|
|
588
874
|
position: Optional[Tuple[int, int]] = None,
|
bootstack/dialogs/_impl/query.py
CHANGED
|
@@ -161,16 +161,18 @@ class QueryDialog:
|
|
|
161
161
|
# anyway: focus goes to `entry.entry_widget`, and the composite frame is
|
|
162
162
|
# not in that widget's bindtags, so Tk would never reach it.
|
|
163
163
|
|
|
164
|
-
#
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
#
|
|
173
|
-
|
|
164
|
+
# Claim initial focus for the entry, so the user can type straight
|
|
165
|
+
# away. `Dialog` applies this once the window is mapped, in preference
|
|
166
|
+
# to the default button.
|
|
167
|
+
#
|
|
168
|
+
# This used to be a bare `focus_set()` plus an `after_idle` repeat to
|
|
169
|
+
# "ensure focus after dialog buttons set their own focus". Both ran
|
|
170
|
+
# while the toplevel was still withdrawn, where `focus_set()` is a
|
|
171
|
+
# silent no-op, so neither ever took and the entry was never actually
|
|
172
|
+
# focused — measured in `development/probe_439_default_button_focus.py`
|
|
173
|
+
# against pre-fix code, where focus sat on the toplevel. Handing the
|
|
174
|
+
# target to `Dialog` replaces the race with an ordering (issue #439).
|
|
175
|
+
self._dialog._focus_target = getattr(entry, "entry_widget", entry)
|
|
174
176
|
|
|
175
177
|
frame.pack(fill=X, expand=True)
|
|
176
178
|
self._entry_widget = entry
|
bootstack/widgets/_core/base.py
CHANGED
|
@@ -492,7 +492,7 @@ class PublicWidgetBase:
|
|
|
492
492
|
if placement.method == "flex":
|
|
493
493
|
index = kwargs.pop("index", placement.index)
|
|
494
494
|
if kwargs:
|
|
495
|
-
_reject_legacy_child_kwargs(kwargs, type(self).__name__)
|
|
495
|
+
_reject_legacy_child_kwargs(kwargs, type(self).__name__, "flex child")
|
|
496
496
|
_expand_margin(kwargs)
|
|
497
497
|
options.update(_flex_child_opts(self, kwargs))
|
|
498
498
|
master.add_child(self._internal, options, index=index)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import tkinter
|
|
4
|
-
from typing import Any
|
|
4
|
+
from typing import Any, Literal
|
|
5
5
|
|
|
6
6
|
PACK_KEYS = frozenset({
|
|
7
7
|
"side", "fill", "expand", "anchor",
|
|
@@ -218,16 +218,73 @@ class PublicContainer(PublicWidgetBase):
|
|
|
218
218
|
# --- Flex engine (Row / Column) --------------------------------------------
|
|
219
219
|
|
|
220
220
|
|
|
221
|
-
|
|
222
|
-
|
|
221
|
+
# Per-child keys a GRID CELL honors. `grow=` is deliberately ABSENT: Grid and
|
|
222
|
+
# the page/pane containers filter their child options to GRID_KEYS, so a `grow=`
|
|
223
|
+
# written on one of their children is silently dropped. `sticky`/`in_` are not
|
|
224
|
+
# user-facing — `sticky` is a legacy key the guard below rejects, and the cell
|
|
225
|
+
# derives it from `horizontal`/`vertical` instead.
|
|
226
|
+
GRID_CHILD_KEYS = (GRID_KEYS | {"horizontal", "vertical"}) - {"sticky", "in_"}
|
|
227
|
+
|
|
228
|
+
_ALIGN_ADVICE = (
|
|
229
|
+
"horizontal= takes left/center/right/stretch and vertical= takes "
|
|
230
|
+
"top/center/bottom/stretch (for example horizontal=\"stretch\")"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# The remedy depends on HOW the container places its children, and naming the
|
|
234
|
+
# wrong one is the defect this message exists to fix (issue #426). A flex
|
|
235
|
+
# container honors `grow=` on the child; a grid cell drops it, so recommending
|
|
236
|
+
# it there would send the user to a kwarg that does nothing — the same silent
|
|
237
|
+
# no-op #426 was filed about, just one step further along.
|
|
238
|
+
FLEX_CHILD_ADVICE = (
|
|
239
|
+
"Use grow= to claim leftover space along the stacking axis, or "
|
|
240
|
+
"horizontal=/vertical= to align or stretch across it — " + _ALIGN_ADVICE
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Container-level options are named WITHOUT a trailing `=` on purpose: the `X=`
|
|
244
|
+
# spelling is reserved for per-child remedies, which is what the regression test
|
|
245
|
+
# checks must be real keys.
|
|
246
|
+
GRID_CHILD_ADVICE = (
|
|
247
|
+
"Use horizontal=/vertical= to align or stretch the child inside its cell — "
|
|
248
|
+
+ _ALIGN_ADVICE
|
|
249
|
+
+ ". Leftover space is claimed by weighting the row or column on the "
|
|
250
|
+
"container (its columns/rows arguments), not by a kwarg on the child"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# The kind names the child's PLACEMENT, not its parent class: the dual-mode
|
|
254
|
+
# containers (Card/GroupBox/Expander/AccordionSection) are one or the other
|
|
255
|
+
# depending on their `layout=`, and the page/pane containers are grid cells
|
|
256
|
+
# without a `Grid` anywhere in sight.
|
|
257
|
+
ChildKind = Literal["flex child", "grid cell"]
|
|
258
|
+
|
|
259
|
+
_CHILD_ADVICE: dict[str, str] = {
|
|
260
|
+
"flex child": FLEX_CHILD_ADVICE,
|
|
261
|
+
"grid cell": GRID_CHILD_ADVICE,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _reject_legacy_child_kwargs(layout_kw: dict, where: str, kind: ChildKind) -> None:
|
|
266
|
+
"""Raise on legacy pack/grid placement kwargs passed to a laid-out child.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
layout_kw: the split-out layout kwargs to inspect.
|
|
270
|
+
where: the call site named in the message.
|
|
271
|
+
kind: how this container places the child — `'flex child'` or
|
|
272
|
+
`'grid cell'`. It selects the remedy, and the two differ: a grid
|
|
273
|
+
cell drops `grow=`, so recommending it there is the silent no-op
|
|
274
|
+
issue #426 exists to remove. Required rather than defaulted,
|
|
275
|
+
because a default hands the wrong advice to any caller who forgets
|
|
276
|
+
— which is how four call sites kept the defect through its own fix.
|
|
277
|
+
"""
|
|
223
278
|
bad = _LEGACY_CHILD_KEYS & layout_kw.keys()
|
|
224
279
|
if bad:
|
|
225
280
|
from bootstack.errors import BootstackError
|
|
226
281
|
|
|
282
|
+
# KeyError on an unknown kind is deliberate: a mistyped kind must fail
|
|
283
|
+
# loudly here rather than quietly pick a remedy for the other engine.
|
|
284
|
+
advice = _CHILD_ADVICE[kind]
|
|
227
285
|
raise BootstackError(
|
|
228
286
|
f"{where}: {', '.join(sorted(bad))} is not a valid layout option for "
|
|
229
|
-
f"a
|
|
230
|
-
f"in a Grid) instead — see the layout guide."
|
|
287
|
+
f"a {kind}. {advice} — see the layout guide."
|
|
231
288
|
)
|
|
232
289
|
|
|
233
290
|
|
|
@@ -376,7 +433,7 @@ def place_flex_child(
|
|
|
376
433
|
child._placement = Placement("place", frame, options)
|
|
377
434
|
return
|
|
378
435
|
|
|
379
|
-
_reject_legacy_child_kwargs(layout_kw, where)
|
|
436
|
+
_reject_legacy_child_kwargs(layout_kw, where, "flex child")
|
|
380
437
|
_expand_margin(layout_kw)
|
|
381
438
|
index = layout_kw.get("index")
|
|
382
439
|
opts = _flex_child_opts(child, layout_kw)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from tkinter import Toplevel
|
|
1
|
+
from tkinter import TclError, Toplevel
|
|
2
2
|
|
|
3
3
|
from typing_extensions import Unpack
|
|
4
4
|
|
|
@@ -44,6 +44,7 @@ class SelectBox(Field):
|
|
|
44
44
|
enable_search: bool = False,
|
|
45
45
|
group_by: str = None,
|
|
46
46
|
max_visible_items: int = None,
|
|
47
|
+
readonly: bool = False,
|
|
47
48
|
**kwargs: Unpack[FieldOptions]
|
|
48
49
|
):
|
|
49
50
|
"""Args:
|
|
@@ -60,6 +61,12 @@ class SelectBox(Field):
|
|
|
60
61
|
message: Optional helper/error message shown below the field.
|
|
61
62
|
allow_custom_values: If True, the entry is editable so users can type
|
|
62
63
|
arbitrary values in addition to choosing from the list.
|
|
64
|
+
readonly: If True, the value is visible but cannot be changed — no
|
|
65
|
+
typing and no popup, from the dropdown button or the entry. It
|
|
66
|
+
suppresses `allow_custom_values`/`enable_search` rather than
|
|
67
|
+
discarding them, so clearing it restores whichever was asked
|
|
68
|
+
for. Distinct from `state='disabled'`, which also dims the
|
|
69
|
+
whole field and drops it out of the tab order.
|
|
63
70
|
show_dropdown_button: If True (default), the dropdown button is shown. This option is
|
|
64
71
|
ignored if custom values are allowed.
|
|
65
72
|
dropdown_button_icon: The icon to display on the dropdown button.
|
|
@@ -95,6 +102,14 @@ class SelectBox(Field):
|
|
|
95
102
|
width: Width of the entry in characters.
|
|
96
103
|
required: If True, field cannot be empty.
|
|
97
104
|
"""
|
|
105
|
+
# On select, readonly means the popup does not open and no typing, but allow_custom_values by itself
|
|
106
|
+
# without readonly just means standard combobox behavior.
|
|
107
|
+
#
|
|
108
|
+
# Every input the interaction state is derived from is set BEFORE
|
|
109
|
+
# `super().__init__()`, because Field's constructor calls
|
|
110
|
+
# `_sync_addon_state()`, which this class overrides to read them.
|
|
111
|
+
self._readonly = readonly
|
|
112
|
+
self._search_enabled = enable_search
|
|
98
113
|
# Localization mode for option display (entry face + popup rows). None
|
|
99
114
|
# defers to the app's localize_mode; False keeps the raw labels.
|
|
100
115
|
self._localize = kwargs.pop('localize', None)
|
|
@@ -126,7 +141,6 @@ class SelectBox(Field):
|
|
|
126
141
|
# Re-translate the displayed text + option maps on a live locale switch.
|
|
127
142
|
self.winfo_toplevel().bind('<<LocaleChanged>>', self._on_locale_changed_refresh, add='+')
|
|
128
143
|
|
|
129
|
-
self._search_enabled = enable_search
|
|
130
144
|
self._group_by = group_by
|
|
131
145
|
self._max_visible_items = max_visible_items
|
|
132
146
|
self._popup_open = False
|
|
@@ -137,24 +151,98 @@ class SelectBox(Field):
|
|
|
137
151
|
self._item_labels = []
|
|
138
152
|
self._group_headers = []
|
|
139
153
|
|
|
140
|
-
#
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
#
|
|
152
|
-
|
|
153
|
-
|
|
154
|
+
# A typeable entry is the only way the popup can lose an entrance: a
|
|
155
|
+
# click there has to place a text cursor rather than open the list, so
|
|
156
|
+
# the button becomes the sole way in. `show_dropdown_button=False` is
|
|
157
|
+
# honored only while the entry is NOT typeable.
|
|
158
|
+
#
|
|
159
|
+
# This decision is made ONCE, here. A later `configure()` that turns
|
|
160
|
+
# typing on does NOT insert a button the caller asked not to build.
|
|
161
|
+
if self._entry_is_typeable() or show_dropdown_button:
|
|
162
|
+
self._insert_dropdown_button()
|
|
163
|
+
|
|
164
|
+
# Bind click-to-open unconditionally and gate it inside the handler.
|
|
165
|
+
# Binding on the transition instead would mean unbinding on the way
|
|
166
|
+
# back, and a released tkinter binding name is reused immediately, so a
|
|
167
|
+
# deferred unbind can delete a different, live binding (cost a critical
|
|
168
|
+
# defect in #392).
|
|
169
|
+
self.entry_widget.bind('<Button-1>', self._on_entry_click, add='+')
|
|
170
|
+
|
|
171
|
+
self._apply_interaction_state()
|
|
172
|
+
|
|
173
|
+
# ----- Interaction state: typing, popup, dropdown button -----
|
|
174
|
+
|
|
175
|
+
def _entry_is_typeable(self) -> bool:
|
|
176
|
+
"""Whether the user may type into the entry.
|
|
177
|
+
|
|
178
|
+
Read-only SUPPRESSES free typing rather than discarding it, so clearing
|
|
179
|
+
it restores whatever `allow_custom_values`/`enable_search` asked for
|
|
180
|
+
instead of falling back to a plain select.
|
|
181
|
+
"""
|
|
182
|
+
if self._readonly:
|
|
183
|
+
return False
|
|
184
|
+
return bool(self._allow_custom_values or self._search_enabled)
|
|
185
|
+
|
|
186
|
+
def _popup_allowed(self) -> bool:
|
|
187
|
+
"""Whether the option list may open, from either entrance."""
|
|
188
|
+
if self._readonly:
|
|
189
|
+
return False
|
|
190
|
+
return not self.entry_widget.instate(['disabled'])
|
|
191
|
+
|
|
192
|
+
def _insert_dropdown_button(self) -> None:
|
|
193
|
+
"""Add the dropdown addon, marked read-only safe.
|
|
194
|
+
|
|
195
|
+
`active_when_readonly=True` stops `Field` dimming it for the ttk
|
|
196
|
+
`readonly` state, which on a select only means "no free typing" and is
|
|
197
|
+
the default. Dimming for a genuine read-only select is applied in
|
|
198
|
+
`_sync_addon_state` instead, off `_readonly`.
|
|
199
|
+
"""
|
|
200
|
+
self.insert_addon(
|
|
201
|
+
Button,
|
|
202
|
+
position="after",
|
|
203
|
+
name="dropdown",
|
|
204
|
+
icon=self._dropdown_button_icon,
|
|
205
|
+
icon_only=True,
|
|
206
|
+
command=self._on_dropdown_click,
|
|
207
|
+
active_when_readonly=True,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _apply_interaction_state(self) -> None:
|
|
211
|
+
"""Recompute everything derived from readonly / custom values / search.
|
|
212
|
+
|
|
213
|
+
The ttk `readonly` state is an OUTPUT of this method, never storage.
|
|
214
|
+
It doubles as the widget's own "no free typing" flag, and writing it
|
|
215
|
+
from more than one place is exactly what let a caller's `read_only`
|
|
216
|
+
be silently overwritten (#453).
|
|
217
|
+
"""
|
|
218
|
+
typeable = self._entry_is_typeable()
|
|
219
|
+
self.entry_widget.state(['!readonly'] if typeable else ['readonly'])
|
|
220
|
+
|
|
221
|
+
# Only a click that actually opens the list should advertise itself.
|
|
222
|
+
if typeable or not self._popup_allowed():
|
|
223
|
+
self.entry_widget.configure(cursor='')
|
|
154
224
|
else:
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
225
|
+
self.entry_widget.configure(cursor='hand2')
|
|
226
|
+
|
|
227
|
+
self._sync_addon_state()
|
|
228
|
+
|
|
229
|
+
def _sync_addon_state(self, event: Any = None) -> None:
|
|
230
|
+
"""Match the addons to the field state, then apply the read-only rule.
|
|
231
|
+
|
|
232
|
+
`Field` dims every addon while the entry is `readonly`, assuming an
|
|
233
|
+
addon mutates the value. Here `readonly` is also how "no free typing"
|
|
234
|
+
is expressed and the dropdown is the primary interaction, so it opts
|
|
235
|
+
out via `active_when_readonly` and is dimmed from here on the real
|
|
236
|
+
read-only flag instead.
|
|
237
|
+
"""
|
|
238
|
+
super()._sync_addon_state(event)
|
|
239
|
+
button = self._addons.get('dropdown')
|
|
240
|
+
if button is None:
|
|
241
|
+
return
|
|
242
|
+
try:
|
|
243
|
+
button.configure(state='!disabled' if self._popup_allowed() else 'disabled')
|
|
244
|
+
except TclError:
|
|
245
|
+
pass
|
|
158
246
|
|
|
159
247
|
# ----- Option normalization + value<->text mapping -----
|
|
160
248
|
|
|
@@ -332,19 +420,29 @@ class SelectBox(Field):
|
|
|
332
420
|
|
|
333
421
|
def _on_dropdown_click(self):
|
|
334
422
|
"""Handle dropdown button click by focusing entry then showing popup."""
|
|
423
|
+
if not self._popup_allowed():
|
|
424
|
+
return
|
|
335
425
|
self.entry_widget.focus_set()
|
|
336
426
|
self._show_selection_options()
|
|
337
427
|
|
|
338
|
-
def
|
|
339
|
-
"""
|
|
340
|
-
|
|
341
|
-
|
|
428
|
+
def _on_entry_click(self, _event: Any = None) -> None:
|
|
429
|
+
"""Open the list when a click on the entry cannot mean anything else.
|
|
430
|
+
|
|
431
|
+
Bound for the widget's whole life and gated here, because a typeable
|
|
432
|
+
entry has to place a text cursor instead — and rebinding on every
|
|
433
|
+
transition risks deleting a recycled binding name (#392).
|
|
434
|
+
"""
|
|
435
|
+
if self._entry_is_typeable() or not self._popup_allowed():
|
|
436
|
+
return
|
|
437
|
+
self.after_idle(self._show_selection_options)
|
|
342
438
|
|
|
343
439
|
def _show_selection_options(self):
|
|
344
440
|
"""Create and display the popup list of selectable items."""
|
|
345
441
|
if not self._records or self._popup_open:
|
|
346
442
|
return
|
|
347
|
-
|
|
443
|
+
# Both entrances gate on this too; it is repeated here so no future
|
|
444
|
+
# caller can reach the popup around them.
|
|
445
|
+
if not self._popup_allowed():
|
|
348
446
|
return
|
|
349
447
|
|
|
350
448
|
self._popup_open = True
|
|
@@ -954,10 +1052,7 @@ class SelectBox(Field):
|
|
|
954
1052
|
return self._allow_custom_values
|
|
955
1053
|
else:
|
|
956
1054
|
self._allow_custom_values = value
|
|
957
|
-
|
|
958
|
-
self.entry_widget.state(['!readonly'])
|
|
959
|
-
else:
|
|
960
|
-
self.readonly(True)
|
|
1055
|
+
self._apply_interaction_state()
|
|
961
1056
|
return None
|
|
962
1057
|
|
|
963
1058
|
@configure_delegate('enable_search')
|
|
@@ -967,12 +1062,36 @@ class SelectBox(Field):
|
|
|
967
1062
|
return self._search_enabled
|
|
968
1063
|
else:
|
|
969
1064
|
self._search_enabled = value
|
|
970
|
-
|
|
971
|
-
self.entry_widget.state(['!readonly'])
|
|
972
|
-
else:
|
|
973
|
-
self.readonly(True)
|
|
1065
|
+
self._apply_interaction_state()
|
|
974
1066
|
return None
|
|
975
1067
|
|
|
1068
|
+
@configure_delegate('readonly')
|
|
1069
|
+
def _delegate_readonly(self, value: bool = None):
|
|
1070
|
+
"""Get or set read-only: the value is visible but cannot be changed.
|
|
1071
|
+
|
|
1072
|
+
Distinct from a plain select, which is already untypeable but whose
|
|
1073
|
+
list still opens, and from `disabled`, which additionally dims the
|
|
1074
|
+
field and drops it out of the tab order.
|
|
1075
|
+
"""
|
|
1076
|
+
if value is None:
|
|
1077
|
+
return self._readonly
|
|
1078
|
+
self._readonly = bool(value)
|
|
1079
|
+
self._apply_interaction_state()
|
|
1080
|
+
return None
|
|
1081
|
+
|
|
1082
|
+
@configure_delegate('state')
|
|
1083
|
+
def _delegate_state(self, value=None):
|
|
1084
|
+
"""Apply the field state, then re-derive what read-only owns.
|
|
1085
|
+
|
|
1086
|
+
`Field` writes `readonly`/`normal` straight onto the entry, so
|
|
1087
|
+
`state='normal'` — which is what clearing `disabled` sends — would
|
|
1088
|
+
otherwise silently clear a read-only select as well.
|
|
1089
|
+
"""
|
|
1090
|
+
result = super()._delegate_state(value)
|
|
1091
|
+
if value is not None:
|
|
1092
|
+
self._apply_interaction_state()
|
|
1093
|
+
return result
|
|
1094
|
+
|
|
976
1095
|
@configure_delegate('group_by')
|
|
977
1096
|
def _delegate_group_by(self, value=None):
|
|
978
1097
|
"""Get or set the option field the popup clusters rows under.
|
bootstack/widgets/appshell.py
CHANGED
|
@@ -106,7 +106,7 @@ class Page:
|
|
|
106
106
|
|
|
107
107
|
def guide_layout(self, child: Any, **layout_kw: Any) -> None:
|
|
108
108
|
if self._layout == "grid":
|
|
109
|
-
_reject_legacy_child_kwargs(layout_kw, "AppShell page")
|
|
109
|
+
_reject_legacy_child_kwargs(layout_kw, "AppShell page", "grid cell")
|
|
110
110
|
_expand_margin(layout_kw)
|
|
111
111
|
options = {k: v for k, v in layout_kw.items() if k in GRID_KEYS}
|
|
112
112
|
h = layout_kw.get("horizontal") or self._horizontal_items
|
bootstack/widgets/card.py
CHANGED
|
@@ -144,7 +144,7 @@ class Card(PublicContainer):
|
|
|
144
144
|
|
|
145
145
|
def _merge_layout_options(self, child: Any, layout_kw: dict) -> tuple[str, dict]:
|
|
146
146
|
# Only reached for grid layout (column/row use the flex path above).
|
|
147
|
-
_reject_legacy_child_kwargs(layout_kw, "Card")
|
|
147
|
+
_reject_legacy_child_kwargs(layout_kw, "Card", "grid cell")
|
|
148
148
|
options = {k: v for k, v in layout_kw.items() if k in GRID_KEYS}
|
|
149
149
|
h = layout_kw.get("horizontal") or self._horizontal_items
|
|
150
150
|
v = layout_kw.get("vertical") or self._vertical_items
|
bootstack/widgets/expander.py
CHANGED
|
@@ -166,7 +166,7 @@ class Expander(PublicContainer):
|
|
|
166
166
|
|
|
167
167
|
def guide_layout(self, child: PublicWidgetBase, **layout_kw: Any) -> None:
|
|
168
168
|
if self._layout == "grid":
|
|
169
|
-
_reject_legacy_child_kwargs(layout_kw, "Expander")
|
|
169
|
+
_reject_legacy_child_kwargs(layout_kw, "Expander", "grid cell")
|
|
170
170
|
_expand_margin(layout_kw)
|
|
171
171
|
options = {k: v for k, v in layout_kw.items() if k in GRID_KEYS}
|
|
172
172
|
h = layout_kw.get("horizontal") or self._horizontal_items
|
|
@@ -284,7 +284,7 @@ class AccordionSection:
|
|
|
284
284
|
|
|
285
285
|
def guide_layout(self, child: PublicWidgetBase, **layout_kw: Any) -> None:
|
|
286
286
|
if self._layout == "grid":
|
|
287
|
-
_reject_legacy_child_kwargs(layout_kw, "AccordionSection")
|
|
287
|
+
_reject_legacy_child_kwargs(layout_kw, "AccordionSection", "grid cell")
|
|
288
288
|
_expand_margin(layout_kw)
|
|
289
289
|
options = {k: v for k, v in layout_kw.items() if k in GRID_KEYS}
|
|
290
290
|
h = layout_kw.get("horizontal") or self._horizontal_items
|
bootstack/widgets/grid.py
CHANGED
|
@@ -111,7 +111,7 @@ class Grid(PublicContainer):
|
|
|
111
111
|
return "grid"
|
|
112
112
|
|
|
113
113
|
def _merge_layout_options(self, child: Any, layout_kw: dict) -> tuple[str, dict]:
|
|
114
|
-
_reject_legacy_child_kwargs(layout_kw, "Grid")
|
|
114
|
+
_reject_legacy_child_kwargs(layout_kw, "Grid", "grid cell")
|
|
115
115
|
options = {k: v for k, v in layout_kw.items() if k in GRID_KEYS}
|
|
116
116
|
# Derive the cell sticky from per-child horizontal/vertical, each falling
|
|
117
117
|
# back to the container default for its axis.
|
bootstack/widgets/groupbox.py
CHANGED
|
@@ -136,7 +136,7 @@ class GroupBox(PublicContainer):
|
|
|
136
136
|
|
|
137
137
|
def _merge_layout_options(self, child: Any, layout_kw: dict) -> tuple[str, dict]:
|
|
138
138
|
# Only reached for grid layout (column/row use the flex path above).
|
|
139
|
-
_reject_legacy_child_kwargs(layout_kw, "GroupBox")
|
|
139
|
+
_reject_legacy_child_kwargs(layout_kw, "GroupBox", "grid cell")
|
|
140
140
|
options = {k: v for k, v in layout_kw.items() if k in GRID_KEYS}
|
|
141
141
|
h = layout_kw.get("horizontal") or self._horizontal_items
|
|
142
142
|
v = layout_kw.get("vertical") or self._vertical_items
|
bootstack/widgets/pagestack.py
CHANGED
|
@@ -96,7 +96,7 @@ class StackPage:
|
|
|
96
96
|
|
|
97
97
|
def guide_layout(self, child: PublicWidgetBase, **layout_kw: Any) -> None:
|
|
98
98
|
if self._layout == "grid":
|
|
99
|
-
_reject_legacy_child_kwargs(layout_kw, "StackPage")
|
|
99
|
+
_reject_legacy_child_kwargs(layout_kw, "StackPage", "grid cell")
|
|
100
100
|
_expand_margin(layout_kw)
|
|
101
101
|
options = {k: v for k, v in layout_kw.items() if k in GRID_KEYS}
|
|
102
102
|
h = layout_kw.get("horizontal") or self._horizontal_items
|
bootstack/widgets/select.py
CHANGED
|
@@ -59,8 +59,12 @@ class Select(PublicWidgetBase):
|
|
|
59
59
|
before it scrolls (height is `max_visible_items * row_height`).
|
|
60
60
|
Group headers and separators consume some of that budget, so the
|
|
61
61
|
count is approximate. `None` (default) uses the built-in cap.
|
|
62
|
-
read_only: If `True`, value is visible but
|
|
63
|
-
|
|
62
|
+
read_only: If `True`, the value is visible but cannot be changed —
|
|
63
|
+
neither the dropdown arrow nor a click in the field opens the
|
|
64
|
+
option list, and the arrow is dimmed to show it. Takes precedence
|
|
65
|
+
over `searchable` and `allow_custom_values` while set, and restores
|
|
66
|
+
them when cleared. Unlike `disabled`, the field keeps its normal
|
|
67
|
+
appearance and stays in the tab order. Defaults to `False`.
|
|
64
68
|
disabled: If `True`, field is fully non-interactive and dimmed.
|
|
65
69
|
width: Width in character units.
|
|
66
70
|
accent: Accent token applied to the focus ring.
|
|
@@ -118,6 +122,7 @@ class Select(PublicWidgetBase):
|
|
|
118
122
|
"allow_custom_values": allow_custom_values,
|
|
119
123
|
"group_by": group_by,
|
|
120
124
|
"max_visible_items": max_visible_items,
|
|
125
|
+
"readonly": read_only
|
|
121
126
|
}
|
|
122
127
|
if value is not None:
|
|
123
128
|
internal_kwargs["value"] = value
|
|
@@ -131,8 +136,6 @@ class Select(PublicWidgetBase):
|
|
|
131
136
|
internal_kwargs["required"] = True
|
|
132
137
|
if disabled:
|
|
133
138
|
internal_kwargs["state"] = "disabled"
|
|
134
|
-
elif read_only:
|
|
135
|
-
internal_kwargs["state"] = "readonly"
|
|
136
139
|
if width is not None:
|
|
137
140
|
internal_kwargs["width"] = width
|
|
138
141
|
if accent is not None:
|
|
@@ -282,12 +285,22 @@ class Select(PublicWidgetBase):
|
|
|
282
285
|
|
|
283
286
|
@property
|
|
284
287
|
def read_only(self) -> bool:
|
|
285
|
-
"""Whether the field is
|
|
286
|
-
|
|
288
|
+
"""Whether the field is read-only — the value shows but cannot be changed.
|
|
289
|
+
|
|
290
|
+
An ordinary `Select` already refuses free typing, which is not the same
|
|
291
|
+
thing. A read-only one also keeps the option list shut, from both the
|
|
292
|
+
dropdown button and the field itself.
|
|
293
|
+
"""
|
|
294
|
+
# Report the setting, never the state derived from it — reporting the
|
|
295
|
+
# derivation is what made every select claim to be read-only (#453).
|
|
296
|
+
# Read it with `cget`, never `configure(name)`: the delegating form of
|
|
297
|
+
# `configure` answers a query with a 5-tuple, which is unconditionally
|
|
298
|
+
# truthy and would silently restore that behavior.
|
|
299
|
+
return bool(self._internal.cget("readonly"))
|
|
287
300
|
|
|
288
301
|
@read_only.setter
|
|
289
302
|
def read_only(self, v: bool) -> None:
|
|
290
|
-
self._internal.configure(
|
|
303
|
+
self._internal.configure(readonly=bool(v))
|
|
291
304
|
|
|
292
305
|
@property
|
|
293
306
|
def disabled(self) -> bool:
|
bootstack/widgets/splitview.py
CHANGED
|
@@ -92,7 +92,7 @@ class SplitPane:
|
|
|
92
92
|
|
|
93
93
|
def guide_layout(self, child: PublicWidgetBase, **layout_kw: Any) -> None:
|
|
94
94
|
if self._layout == "grid":
|
|
95
|
-
_reject_legacy_child_kwargs(layout_kw, "SplitPane")
|
|
95
|
+
_reject_legacy_child_kwargs(layout_kw, "SplitPane", "grid cell")
|
|
96
96
|
_expand_margin(layout_kw)
|
|
97
97
|
options = {k: v for k, v in layout_kw.items() if k in GRID_KEYS}
|
|
98
98
|
h = layout_kw.get("horizontal") or self._horizontal_items
|
bootstack/widgets/tabs.py
CHANGED
|
@@ -98,7 +98,7 @@ class TabPage:
|
|
|
98
98
|
|
|
99
99
|
def guide_layout(self, child: PublicWidgetBase, **layout_kw: Any) -> None:
|
|
100
100
|
if self._layout == "grid":
|
|
101
|
-
_reject_legacy_child_kwargs(layout_kw, "TabPage")
|
|
101
|
+
_reject_legacy_child_kwargs(layout_kw, "TabPage", "grid cell")
|
|
102
102
|
_expand_margin(layout_kw)
|
|
103
103
|
options = {k: v for k, v in layout_kw.items() if k in GRID_KEYS}
|
|
104
104
|
h = layout_kw.get("horizontal") or self._horizontal_items
|
bootstack/widgets/timefield.py
CHANGED
|
@@ -52,8 +52,10 @@ class TimeField(ValueSignalMixin, FieldAddonMixin, PublicWidgetBase):
|
|
|
52
52
|
way to bind a time field.
|
|
53
53
|
required: If `True`, field cannot be left empty.
|
|
54
54
|
disabled: If `True`, field is non-interactive.
|
|
55
|
-
read_only: If `True`,
|
|
56
|
-
|
|
55
|
+
read_only: If `True`, the time is visible but cannot be changed —
|
|
56
|
+
typing is blocked, and neither the clock button nor a click in the
|
|
57
|
+
field opens the time list. Unlike `disabled`, the field keeps its
|
|
58
|
+
normal appearance and stays in the tab order. Defaults to `False`.
|
|
57
59
|
width: Width in character cells.
|
|
58
60
|
accent: Accent color applied to the focus ring. Default `'primary'`.
|
|
59
61
|
density: Widget density.
|
|
@@ -111,10 +113,14 @@ class TimeField(ValueSignalMixin, FieldAddonMixin, PublicWidgetBase):
|
|
|
111
113
|
internal_kwargs["message"] = message
|
|
112
114
|
if required:
|
|
113
115
|
internal_kwargs["required"] = True
|
|
116
|
+
# Read-only rides its own option, not `state="readonly"` — the internal
|
|
117
|
+
# here is a select, where the ttk `readonly` state is an OUTPUT of the
|
|
118
|
+
# interaction state ("no free typing") and is re-derived at the end of
|
|
119
|
+
# construction, so a state write is overwritten before anyone sees it
|
|
120
|
+
# (#453). Same shape as the setter below and as `Select`.
|
|
121
|
+
internal_kwargs["readonly"] = read_only
|
|
114
122
|
if disabled:
|
|
115
123
|
internal_kwargs["state"] = "disabled"
|
|
116
|
-
elif read_only:
|
|
117
|
-
internal_kwargs["state"] = "readonly"
|
|
118
124
|
if width is not None:
|
|
119
125
|
internal_kwargs["width"] = width
|
|
120
126
|
if accent is not None:
|
|
@@ -162,12 +168,22 @@ class TimeField(ValueSignalMixin, FieldAddonMixin, PublicWidgetBase):
|
|
|
162
168
|
|
|
163
169
|
@property
|
|
164
170
|
def read_only(self) -> bool:
|
|
165
|
-
"""Whether the field is read-only
|
|
166
|
-
|
|
171
|
+
"""Whether the field is read-only — the time shows but cannot be changed."""
|
|
172
|
+
# Report the setting, not the state derived from it. The two agree at
|
|
173
|
+
# rest, but the derivation is cleared and restored around a programmatic
|
|
174
|
+
# value write, so reading it answers `False` for a locked field from
|
|
175
|
+
# inside that window (#453). Same reason `Select.read_only` reads the
|
|
176
|
+
# setting; `cget`, never `configure(name)`, which answers a query with a
|
|
177
|
+
# truthy 5-tuple.
|
|
178
|
+
return bool(self._internal.cget("readonly"))
|
|
167
179
|
|
|
168
180
|
@read_only.setter
|
|
169
181
|
def read_only(self, v: bool) -> None:
|
|
170
|
-
|
|
182
|
+
# Route through the `readonly` option, not `state="readonly"`. The
|
|
183
|
+
# internal here is a select, where the ttk `readonly` state is an
|
|
184
|
+
# OUTPUT of the interaction state ("no free typing") and is re-derived
|
|
185
|
+
# on every change — so a state write is overwritten immediately (#453).
|
|
186
|
+
self._internal.configure(readonly=bool(v))
|
|
171
187
|
|
|
172
188
|
# ----- Methods -----
|
|
173
189
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bootstack
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Build desktop GUI apps in pure Python and ship them as a standalone executable — built on Tk, no Tkinter boilerplate.
|
|
5
5
|
Author-email: Israel Dryer <israel.dryer@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -181,13 +181,13 @@ bootstack/dev/_watcher.py,sha256=MEWnb3feq1KBXwHbN8tpcOYX6y6BIqtJULZAiVvylpc,353
|
|
|
181
181
|
bootstack/dialogs/__init__.py,sha256=HKKmOPdXGdGyOw5lJiqxa1bhijDNwmiLl4jUWuyFEe4,33327
|
|
182
182
|
bootstack/dialogs/_impl/__init__.py,sha256=uphIED7kUbHS71MyAcZAlCsdrs9DrGtet5X9Ry56nLU,1427
|
|
183
183
|
bootstack/dialogs/_impl/colorchooser.py,sha256=umINO_pmbR6Q-vU3VTqGE1GqEypuL-d6Z7r1QO2R9dc,24412
|
|
184
|
-
bootstack/dialogs/_impl/datedialog.py,sha256=
|
|
185
|
-
bootstack/dialogs/_impl/dialog.py,sha256=
|
|
184
|
+
bootstack/dialogs/_impl/datedialog.py,sha256=akIxV_ss9gSdwH1go8pEZKvuwqcW0xpjX0fyIA-40cg,20275
|
|
185
|
+
bootstack/dialogs/_impl/dialog.py,sha256=0SClHDSM0MeRS8qFgJW6gh3Nft27j0YQqM9w6w_VcGI,44742
|
|
186
186
|
bootstack/dialogs/_impl/filterdialog.py,sha256=yEyO9Z1ywtRjDqtC9G_PJwn5mJlw4CnUmdrBM4e34B4,14850
|
|
187
187
|
bootstack/dialogs/_impl/fontdialog.py,sha256=oDckLjdnqhutMq0wob23s1K_2eGBbVG5gH0l-Yaa_U4,13733
|
|
188
188
|
bootstack/dialogs/_impl/formdialog.py,sha256=khQJnBNVxgBqzB_LVGWrFxNDMVSzo_ucjmxYSCP6BYM,31439
|
|
189
189
|
bootstack/dialogs/_impl/message.py,sha256=7cSyay9-VEBdqhoDadh76jbQGB1LzFTXzqJn-lz_xIs,17605
|
|
190
|
-
bootstack/dialogs/_impl/query.py,sha256=
|
|
190
|
+
bootstack/dialogs/_impl/query.py,sha256=TdKAaF45zJV8QrkQnsdGuXjaTwRccnsRGVj4XKP2gZs,22645
|
|
191
191
|
bootstack/events/__init__.py,sha256=unaLsCDpj_-u_2DbEjDBpHARdoa1rQD9mTmJxkoInpY,2865
|
|
192
192
|
bootstack/events/_event.py,sha256=RpcC5-U-VLOmsPninynIfzOOYvrCiahLNqg6yrdlwpg,4564
|
|
193
193
|
bootstack/events/_payloads.py,sha256=sg594kqOp7C98gFYyyexwsQo5uvDBVUetZZi2sk15mo,15816
|
|
@@ -265,13 +265,13 @@ bootstack/validation/validation_result.py,sha256=x-hkUx2hhkuAecBT6oR9wgguPT6WFut
|
|
|
265
265
|
bootstack/validation/validation_rules.py,sha256=1-Q1dKmIqqjcjbdhk9i8zTNiIqItixLiGY_0tnUN9-U,9324
|
|
266
266
|
bootstack/widgets/__init__.py,sha256=d_UXVCoYCW7VIQNtKdABX1SbUaEQSSEKNL4SUEQ9XAY,3360
|
|
267
267
|
bootstack/widgets/app.py,sha256=ohA-cpYIaUFggQulCdoN_x0gGvnWjAu04UhQj04BCAo,16579
|
|
268
|
-
bootstack/widgets/appshell.py,sha256=
|
|
268
|
+
bootstack/widgets/appshell.py,sha256=XbG9ZQZYoli0cUl5pfONqm1ScOqzt9OrqMK8VTWYUNw,50772
|
|
269
269
|
bootstack/widgets/avatar.py,sha256=4LTMm3S50PltRe-KF9M2sml0UEKo25KTdBjaqBC-Q0c,5383
|
|
270
270
|
bootstack/widgets/boolean_controls.py,sha256=_Sa7r0yTR2zzMPmDIIag4LXTlIGECYcSA44myJTaWmw,19649
|
|
271
271
|
bootstack/widgets/button.py,sha256=SAF-OrFPRpmT0KgFL8hy_jxQWpBvIGs6ICXreUbZTKY,10209
|
|
272
272
|
bootstack/widgets/buttongroup.py,sha256=SpEuVl_Yv83LtLYy2lzQlTthhaW8h4Papg0Skj7EZ6o,9828
|
|
273
273
|
bootstack/widgets/calendar.py,sha256=zkSCufG-s429qDr2ncb1qFe-MwYb2wLWgrlfvqdwaMQ,8096
|
|
274
|
-
bootstack/widgets/card.py,sha256=
|
|
274
|
+
bootstack/widgets/card.py,sha256=wRrF64HurkT6QRUoqcX6Ath7IUlYXBLVRn1zCcZScAg,7071
|
|
275
275
|
bootstack/widgets/carousel.py,sha256=Y_G1s-aavbV8bMgdEW_5ups4I5i4EF7WJg-7jvK9p8U,9453
|
|
276
276
|
bootstack/widgets/chart.py,sha256=iPwugwEEmQO1IQMcI0uPO7CMveTafmrsivkuqKz5OOo,14110
|
|
277
277
|
bootstack/widgets/codeeditor.py,sha256=rFbi-GEjRdupy8fSjtfTMGs1JHl_SDpLHgLF8H6hq5c,26361
|
|
@@ -279,17 +279,17 @@ bootstack/widgets/contextmenu.py,sha256=EnAGQQzEbOSbb9M-CK7lAzKFj81veOeR995an6kz
|
|
|
279
279
|
bootstack/widgets/datatable.py,sha256=7r_q6k-9dxiE3_EO0QMwPfDG8pDJ3Ptp-Mqwt4lpOnY,28774
|
|
280
280
|
bootstack/widgets/datefield.py,sha256=kgeqWw7AYrlJ3HpvzGotchD33QFU-9J5_kvXWxirpPM,15217
|
|
281
281
|
bootstack/widgets/divider.py,sha256=eP7umEmuagvAqh49nYEO9koo117jQD-9-mK4uVD3QL8,2387
|
|
282
|
-
bootstack/widgets/expander.py,sha256=
|
|
282
|
+
bootstack/widgets/expander.py,sha256=l5pQRVQHs-G91wOlx0bGiTTKMlYb_8D82lYcf6RVLHA,22345
|
|
283
283
|
bootstack/widgets/form.py,sha256=D8nN14nyAgSvpQDsqIGxZHBaXyVNzJhWSw83eINHj5w,7777
|
|
284
284
|
bootstack/widgets/gallery.py,sha256=JAboTI4kB9PeVdONhY9-HHM4_e1VnvHiKjolKGIUFhA,10205
|
|
285
285
|
bootstack/widgets/gauge.py,sha256=1ediRKxTSzid3doyzOX3ZN9xzjz4oq7nhyInKoqaZpI,6682
|
|
286
|
-
bootstack/widgets/grid.py,sha256=
|
|
287
|
-
bootstack/widgets/groupbox.py,sha256=
|
|
286
|
+
bootstack/widgets/grid.py,sha256=JiOdgL4YmCYI1MBs6UWN01EtTeVBFmTv7r-jHYX9Ld0,5418
|
|
287
|
+
bootstack/widgets/groupbox.py,sha256=2Gbx7hVezkrNkcEuTy_Nuj5w-O9hnuyeKyaHhzF_m1g,6928
|
|
288
288
|
bootstack/widgets/label.py,sha256=RnAz-jxCM3ib3QJrgafyPwvPzGGOsSFhlp8C2N9Fx54,9774
|
|
289
289
|
bootstack/widgets/listview.py,sha256=xZkEYIMtbDSsJCs46MFdFtObDSnPos7mQJIc1gPFeL8,14788
|
|
290
290
|
bootstack/widgets/menubutton.py,sha256=gmmZlUKpdyPatkkjS8kbS3XyYQoN9sLFFBOw5kEw_BA,16460
|
|
291
291
|
bootstack/widgets/numberfield.py,sha256=69Fu-4lWvAWaKxOcoMs5RHtDu318QnAndq0zOvQ__8U,16127
|
|
292
|
-
bootstack/widgets/pagestack.py,sha256=
|
|
292
|
+
bootstack/widgets/pagestack.py,sha256=g8jKo7SR8hyGKGVpE98xHsAuQkOuZrGR3XldNUWS54o,12538
|
|
293
293
|
bootstack/widgets/passwordfield.py,sha256=XZasSIc5e8TdK12tQGJ1CYRbrb2V7pHhhcWDpDCiyXQ,14034
|
|
294
294
|
bootstack/widgets/pathfield.py,sha256=UDSkIb_yUyO9Tz5Gop2uOF0s0jItN5hca--C-5pbeOU,17225
|
|
295
295
|
bootstack/widgets/picture.py,sha256=GIuJcMataK6OBqSQUSzR5wJEUurW3ekM4HXCMPHHEUQ,9381
|
|
@@ -298,7 +298,7 @@ bootstack/widgets/radio_variants.py,sha256=1se48gRKtVvs_Y21TfK18xetRwzF5WDWWY0zD
|
|
|
298
298
|
bootstack/widgets/radiogroup.py,sha256=WZC0BR6v6IA94cbbxinwTFeVex_gssCVZOybTROhpVM,10069
|
|
299
299
|
bootstack/widgets/scrollbar.py,sha256=ZZM7XdD0lRhlNBNr1TBz4Lun2IBHxVAPt00XdMJ3Rqc,2537
|
|
300
300
|
bootstack/widgets/scrollview.py,sha256=ce6DAvEW0F5cGe1oWcsVFHG7bVvoJhI63QiF85v0tdk,8413
|
|
301
|
-
bootstack/widgets/select.py,sha256=
|
|
301
|
+
bootstack/widgets/select.py,sha256=tUZjukhyRtEmzTUkrm7uu62CO8uc3FiroQsx8J3zgzM,14615
|
|
302
302
|
bootstack/widgets/selectbutton.py,sha256=COSgcQjrts2il18bmMck4VsD5ZLRCClCjck9asI8Zmk,7491
|
|
303
303
|
bootstack/widgets/sidebar_toggle.py,sha256=2yrAjdy3_U0pjkytmfZ1odE6AhNTGqroc3SWR0is0KY,5708
|
|
304
304
|
bootstack/widgets/sizegrip.py,sha256=-nuJOuBzduNrUDsNWfaq0Ixt6zlGny0RYg-8J0yu8G4,1473
|
|
@@ -306,14 +306,14 @@ bootstack/widgets/slider.py,sha256=Llna3ph-uzZELFX4V6Rkq4mHkdeKv0lPUe7_Tk-hyv8,1
|
|
|
306
306
|
bootstack/widgets/spinbox.py,sha256=pdkMF7LjQ5TGHvgCJv9I5_SmNf2_VP3MQ--UQFxf0Nw,5530
|
|
307
307
|
bootstack/widgets/spinnerfield.py,sha256=2FPLbCEHEqedU0reZrG_tjKM_FkmiFpZu9f15VkH1ao,15269
|
|
308
308
|
bootstack/widgets/splash.py,sha256=pdZgz6lEHzF-iZ9oGSlRcqzQOwJvUnSTDbn40Cx8ZNs,14352
|
|
309
|
-
bootstack/widgets/splitview.py,sha256=
|
|
309
|
+
bootstack/widgets/splitview.py,sha256=uycXrhDLVdfwbD2mti1gGluvtJIzNKtCP3oGpwiEodM,20087
|
|
310
310
|
bootstack/widgets/stacks.py,sha256=nCoGhLEaJ96yi9hrbze3q1StV_aGZuyxp_zW-hg6LM4,11778
|
|
311
311
|
bootstack/widgets/statusbar.py,sha256=bMVZeLKkV-lNC10zrXOh8MjL9TeojO-dVISlcIyICCw,7931
|
|
312
|
-
bootstack/widgets/tabs.py,sha256=
|
|
312
|
+
bootstack/widgets/tabs.py,sha256=al7F5vmCFpFXO-jj6DXE0hDreen9I6wef4Ns15ANux8,15564
|
|
313
313
|
bootstack/widgets/textarea.py,sha256=Zi6HeJ4CDmEUVnJpRUDbUfbZM_keLt9EG_l8NluJHa0,16852
|
|
314
314
|
bootstack/widgets/textfield.py,sha256=38rHx3xF6106Dpg72pCnghlgMuoS27KhJ8tAZa7sOTg,15113
|
|
315
315
|
bootstack/widgets/theme_toggle.py,sha256=0m1MeMfN9-7aeAVboRiJ2wTNkmiKJ-pQ2WJ5rrqv-U4,3473
|
|
316
|
-
bootstack/widgets/timefield.py,sha256=
|
|
316
|
+
bootstack/widgets/timefield.py,sha256=izIY6mrx9tHVexLSZOAfb14Y3O-8ETb58x-yylIPETU,13858
|
|
317
317
|
bootstack/widgets/toast.py,sha256=6tn6lW_y001LnUTCQD-hjyN7DhVEd1peTJHXIztLeeE,12301
|
|
318
318
|
bootstack/widgets/togglegroup.py,sha256=NVRDkKLLf8LLN35eUxY0taBtmfgjFZXZQwzG_mVry34,10631
|
|
319
319
|
bootstack/widgets/toolbar.py,sha256=o_drKIyp3D91qIBoTbpuWVnAlb6lL6h3rPTizEX3E2o,14578
|
|
@@ -323,9 +323,9 @@ bootstack/widgets/types.py,sha256=_86PwJ20gim0hGnCdPYSn1YVrrsTfZhyQ1zWDwHqQTY,13
|
|
|
323
323
|
bootstack/widgets/window.py,sha256=lwzYO6uAeXM_lSMXES911OsX8aVrJPKQHJu1u30bAIo,12921
|
|
324
324
|
bootstack/widgets/_core/__init__.py,sha256=rJPvvI3eOV9M5cICBD2XPZFokThf_tQyTeZhLPaqukU,1106
|
|
325
325
|
bootstack/widgets/_core/app_config.py,sha256=h_OaJ0jSif1eJeHWErpsXNyhtfCcmYQCcq0VriM48uk,9771
|
|
326
|
-
bootstack/widgets/_core/base.py,sha256=
|
|
326
|
+
bootstack/widgets/_core/base.py,sha256=G5xchiRbQI93zdpYYkMqSnfiSh_ZftCuQsr6aMdi0Tw,27038
|
|
327
327
|
bootstack/widgets/_core/choices.py,sha256=IGm4NspPJQgnGOxfuXWsqs4nSk7HSkdH0P60Cd1gnjo,2369
|
|
328
|
-
bootstack/widgets/_core/container.py,sha256=
|
|
328
|
+
bootstack/widgets/_core/container.py,sha256=h40FyWl_kY6WCioMKUMc9FYjI7t0dCq4u-VlvKdHrXA,18893
|
|
329
329
|
bootstack/widgets/_core/context.py,sha256=laL75HYr7kIx3KbZqtT_rO1wWRjV4pehNzWgcZUkw_I,764
|
|
330
330
|
bootstack/widgets/_core/events.py,sha256=-Hlg3KiITXoei4QxlAmjw65jVXSnr3s81s9PWcdaaKo,3791
|
|
331
331
|
bootstack/widgets/_core/field_mixin.py,sha256=pPdusObXGImu6hAkY6TxVCRoVoM0cbABy3FQIurhAqc,15164
|
|
@@ -371,7 +371,7 @@ bootstack/widgets/_impl/composites/radiogroup.py,sha256=CEYhCfBgH19Y7x-63nqKdycb
|
|
|
371
371
|
bootstack/widgets/_impl/composites/scrolledtext.py,sha256=Dkp2ATIe-UADhvVDl6W6xEEwe-3AWkv2mESC5wP9Xik,15000
|
|
372
372
|
bootstack/widgets/_impl/composites/scrolledtext.pyi,sha256=GceUarQJWlYKA2nly7mbIA3LJDxOJXO0QQyKEbbiQ3U,6475
|
|
373
373
|
bootstack/widgets/_impl/composites/scrollview.py,sha256=zGN-xG0lIgB2O5Yz7j-LF4UeGR6-Fx-KjoplqMzZp78,32762
|
|
374
|
-
bootstack/widgets/_impl/composites/selectbox.py,sha256=
|
|
374
|
+
bootstack/widgets/_impl/composites/selectbox.py,sha256=ncdXL1O4Bv_COuOQzytE6n8LDIT7Z31oR3FxZxIHuP8,55986
|
|
375
375
|
bootstack/widgets/_impl/composites/spinnerentry.py,sha256=6CSvsastkc9j6CsloFsaDACSDrfrkoFda9BlYEjJwzQ,7006
|
|
376
376
|
bootstack/widgets/_impl/composites/textentry.py,sha256=xH5NqG5bbMlv-OgaifIYkgUcNvXVSi4Q3VhFwqvCV50,2978
|
|
377
377
|
bootstack/widgets/_impl/composites/timeentry.py,sha256=T5X93ueTDvUEemD5y96jt6_ApQHeMjWON6p-YW1T9lY,7503
|
|
@@ -467,10 +467,10 @@ bootstack/widgets/_impl/primitives/sizegrip.py,sha256=uwQRzOEHjNPXtUURhT6k2TNYdy
|
|
|
467
467
|
bootstack/widgets/_impl/primitives/spinbox.py,sha256=5m0tZrqCw745nvIiBrJWnZ0q8fZ7bNEvopjROMbQolc,4526
|
|
468
468
|
bootstack/widgets/_impl/primitives/switch.py,sha256=7dt43uE2P_WdkxZZsGtCFRAQ20nI2jx5VUqGSrWwEmA,2543
|
|
469
469
|
bootstack/widgets/_impl/primitives/treeview.py,sha256=vnSowgzmtNEWst_i9ZgcacHjdvuheF9xmeTvTWE1bLg,2709
|
|
470
|
-
bootstack-0.3.
|
|
471
|
-
bootstack-0.3.
|
|
472
|
-
bootstack-0.3.
|
|
473
|
-
bootstack-0.3.
|
|
474
|
-
bootstack-0.3.
|
|
475
|
-
bootstack-0.3.
|
|
476
|
-
bootstack-0.3.
|
|
470
|
+
bootstack-0.3.2.dist-info/licenses/LICENSE,sha256=CUeTPUOM8Qc0yuqneJUwuUmDBooET5UfsHjMrkdBeCg,1097
|
|
471
|
+
bootstack-0.3.2.dist-info/licenses/NOTICE,sha256=c62TcygUKbXx1V3FEgziroDDry_NwmycqKEg_CRnYBU,1503
|
|
472
|
+
bootstack-0.3.2.dist-info/METADATA,sha256=2LYdOq369ONQlATAYL6CwvKpd9l_nLHylhC8U298q3Q,16107
|
|
473
|
+
bootstack-0.3.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
474
|
+
bootstack-0.3.2.dist-info/entry_points.txt,sha256=phOlfRy3Th86IYlNhQiUrTLLLzxie1IG-kMas_5iMdA,49
|
|
475
|
+
bootstack-0.3.2.dist-info/top_level.txt,sha256=IsKBAac2vRCegvRFSCzwcBr7TpHp4bLnLFgA75LOkXs,10
|
|
476
|
+
bootstack-0.3.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|