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.
@@ -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, result_target,
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
- self._master.wait_window(self._toplevel)
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
- self._master.wait_window(self._toplevel)
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
- default_button.focus_set()
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
- pressed = event.widget
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 reports `KP_Enter`, a separate keysym from
576
- # `Return` on Windows, X11 and Aqua alike, so it needs its own
577
- # binding to reach the default button from an input field.
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,
@@ -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
- # Focus the entry field
165
- def _focus():
166
- if hasattr(entry, 'entry_widget'):
167
- entry.entry_widget.focus_set()
168
- else:
169
- entry.focus_set()
170
-
171
- _focus()
172
- # Ensure focus after dialog buttons set their own focus
173
- entry.after_idle(_focus)
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
@@ -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
- def _reject_legacy_child_kwargs(layout_kw: dict, where: str) -> None:
222
- """Raise on legacy pack/grid placement kwargs passed to a flex child."""
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 Row/Column/Grid child. Use grow= / align_self= (and justify_self= "
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
- # Add dropdown button if needed
141
- if allow_custom_values or show_dropdown_button:
142
- self.insert_addon(
143
- Button,
144
- position="after",
145
- name="dropdown",
146
- icon=self._dropdown_button_icon,
147
- icon_only=True,
148
- command=self._on_dropdown_click
149
- )
150
-
151
- # Configure entry state based on search and custom value settings
152
- if allow_custom_values or enable_search:
153
- self.entry_widget.state(['!readonly'])
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
- # Set entry to readonly but keep dropdown button enabled
156
- self.entry_widget.state(['readonly'])
157
- self.after_idle(self._bind_readonly_selection_on_click)
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 _bind_readonly_selection_on_click(self):
339
- """Bind entry click to show popup when in readonly mode."""
340
- self.entry_widget.configure(cursor="hand2")
341
- self.entry_widget.bind('<Button-1>', lambda _: self.after_idle(self._show_selection_options), add='+')
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
- if self.entry_widget.instate(['disabled']):
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
- if value or self._search_enabled:
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
- if value or self._allow_custom_values:
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.
@@ -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
@@ -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.
@@ -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
@@ -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
@@ -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 the popup cannot be
63
- opened.
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 visible but the popup cannot be opened."""
286
- return str(self._entry_widget().cget("state")) == "readonly"
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(state="readonly" if v else "normal")
303
+ self._internal.configure(readonly=bool(v))
291
304
 
292
305
  @property
293
306
  def disabled(self) -> bool:
@@ -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
@@ -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`, free-text entry is blocked; user must pick
56
- from the dropdown.
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 (selectable but not editable)."""
166
- return self._internal._entry.instate(("readonly",))
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
- self._internal.configure(state="readonly" if v else "normal")
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.0
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=L9aGW7bqqEwrhWgU8zxl4Gv_ZWXNZ9x1dO_KpuejHSk,18860
185
- bootstack/dialogs/_impl/dialog.py,sha256=ZoASFZag1FdYSrPFvrSwo6HHVScJLrWGTiCxJblhBKA,29914
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=S2zW8ZYIxHAGtVRSKLEV7JNmJC119IeMQjsuCEP5tCg,22142
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=TwTzDIlIos3Cri6XUhPCiYb8h6VyTlylBn9nZK1Fiwk,50759
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=8tfmuPKksZzxxq1LpSeaokz492lgXlFbWhxJ2NE8AFg,7058
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=UbwvkLDZNCFuzlFuAemWcEDmTN99uRTxMaGwrtuo0jc,22319
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=Fa777RMCBWt7nhz_vkNz5vuWWTQNsF8cGLjqbY4Qs4M,5405
287
- bootstack/widgets/groupbox.py,sha256=XKrYqKoh4b0aeeKQ8qoxqWwdyzQCaZegJmtQfaZO56c,6915
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=mK98IuExK-9xEcRP9OANn5_YLJgnR9RZndxuHLsXj2s,12525
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=YET5D2-LQJtJw9gFNM2-ef-ZjKbYntANhS_MYFIiQAQ,13709
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=OPzXaG7ZMEIwXtE4mr-iGpMkS6h-8x7lUl516Zcrau0,20074
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=mRzCb0NLLjMnNRET4_R0v7a3_ccL6IGRPIpYcRWpc8o,15551
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=KoWaAugjK_wX2I_GdFlsJtpvWiXPY096mdCKJOREAio,12560
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=5u57-fG0gdVNSqMwda6hfQoTay-72PbJoGGamapfpqs,27024
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=zrMadhoswXERiY4jjzeot2dnqpus-PKmG_vHESiuoqM,16051
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=p33lKNiu-L7PbgYnjvs3nLOaxDgQrqnjfuvm_HkXO8g,50447
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.0.dist-info/licenses/LICENSE,sha256=CUeTPUOM8Qc0yuqneJUwuUmDBooET5UfsHjMrkdBeCg,1097
471
- bootstack-0.3.0.dist-info/licenses/NOTICE,sha256=c62TcygUKbXx1V3FEgziroDDry_NwmycqKEg_CRnYBU,1503
472
- bootstack-0.3.0.dist-info/METADATA,sha256=OWxwKlDIgf7qzuU1Ni7BCkzWflhlfHdhcxJe1Rxj2Gc,16107
473
- bootstack-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
474
- bootstack-0.3.0.dist-info/entry_points.txt,sha256=phOlfRy3Th86IYlNhQiUrTLLLzxie1IG-kMas_5iMdA,49
475
- bootstack-0.3.0.dist-info/top_level.txt,sha256=IsKBAac2vRCegvRFSCzwcBr7TpHp4bLnLFgA75LOkXs,10
476
- bootstack-0.3.0.dist-info/RECORD,,
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,,