bootstack 0.4.7__py3-none-any.whl → 0.4.8__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.
@@ -10,7 +10,10 @@ a thin glue layer.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
- from typing import Any, TYPE_CHECKING
13
+ import itertools
14
+ import weakref
15
+ from tkinter import TclError
16
+ from typing import Any, Callable, TYPE_CHECKING
14
17
 
15
18
  from bootstack.i18n.msgcat import MessageCatalog
16
19
  from bootstack.i18n.specs import (
@@ -177,6 +180,91 @@ def get_current_locale() -> str:
177
180
  return MessageCatalog.locale()
178
181
 
179
182
 
183
+ # =============================================================================
184
+ # Locale Change Propagation
185
+ # =============================================================================
186
+
187
+ _LOCALE_EVENT = "<<LocaleChanged>>"
188
+
189
+
190
+ # Don't bind a widget's refresh directly on the window: nothing removes it
191
+ # when the widget is destroyed.
192
+ class _LocaleRefreshers:
193
+ """The refresh methods one toplevel calls on a locale change."""
194
+
195
+ def __init__(self, toplevel: Any) -> None:
196
+ self._toplevel = toplevel
197
+ self._refreshers: dict[int, weakref.WeakMethod] = {}
198
+ self._keys = itertools.count()
199
+ self._funcid: str | None = None
200
+
201
+ def add(self, refresh: Callable[[], Any]) -> None:
202
+ """Register `refresh`, a bound method of a widget on this toplevel."""
203
+ self._ensure_bound()
204
+ key = next(self._keys)
205
+ refreshers = self._refreshers
206
+ # Drop the entry when the widget is collected, so a window whose
207
+ # locale never changes does not accumulate dead references either.
208
+ refreshers[key] = weakref.WeakMethod(
209
+ refresh, lambda _ref: refreshers.pop(key, None)
210
+ )
211
+
212
+ def _ensure_bound(self) -> None:
213
+ """Bind the dispatcher, or bind it again if something replaced it.
214
+
215
+ A `bind` of the event without `add` on this toplevel replaces every
216
+ binding for it, the dispatcher included, which would silently stop
217
+ every widget on the window from refreshing.
218
+ """
219
+ toplevel = self._toplevel
220
+ if self._funcid is not None:
221
+ script = str(toplevel.tk.call("bind", toplevel._w, _LOCALE_EVENT))
222
+ # The line prefix `_patched_bind` emits, as `_patched_unbind` matches it.
223
+ if ('if {"[%s ' % self._funcid) in script:
224
+ return
225
+ self._funcid = toplevel.bind(_LOCALE_EVENT, self._dispatch, add="+")
226
+
227
+ def _dispatch(self, _event: Any = None) -> None:
228
+ """Call each live widget's refresh, in the order they registered."""
229
+ for key, ref in list(self._refreshers.items()):
230
+ refresh = ref()
231
+ if refresh is None:
232
+ continue
233
+ widget = refresh.__self__
234
+ try:
235
+ alive = widget.winfo_exists()
236
+ except TclError:
237
+ alive = False
238
+ if not alive:
239
+ # Destroyed, but still referenced from somewhere else.
240
+ self._refreshers.pop(key, None)
241
+ continue
242
+ try:
243
+ refresh()
244
+ except Exception:
245
+ # Report and carry on, as a handler bound on its own would:
246
+ # one failing widget must not stop the rest from refreshing.
247
+ widget._report_exception()
248
+
249
+
250
+ def bind_locale_refresh(refresh: Callable[[], Any]) -> None:
251
+ """Call `refresh` on every locale change for as long as its widget exists.
252
+
253
+ The locale-change event is generated on the root; `refresh` is called,
254
+ with no arguments, when the event reaches the toplevel of the widget it is
255
+ bound to, after the widgets registered before it. A destroyed widget is
256
+ never called, and registering holds no strong reference to it.
257
+
258
+ Args:
259
+ refresh: A bound method of the widget to refresh.
260
+ """
261
+ toplevel = refresh.__self__.winfo_toplevel()
262
+ refreshers = toplevel.__dict__.get("_bs_locale_refreshers")
263
+ if refreshers is None:
264
+ refreshers = toplevel._bs_locale_refreshers = _LocaleRefreshers(toplevel)
265
+ refreshers.add(refresh)
266
+
267
+
180
268
  # =============================================================================
181
269
  # Signal Formatting
182
270
  # =============================================================================
@@ -227,5 +315,6 @@ __all__ = [
227
315
  "resolve_variable_text",
228
316
  "apply_spec",
229
317
  "get_current_locale",
318
+ "bind_locale_refresh",
230
319
  "create_formatted_signal",
231
320
  ]
@@ -9,6 +9,7 @@ from bootstack._core.exceptions import StyleBuilderError
9
9
  from bootstack.style.style_builder_base import StyleBuilderBase
10
10
  from bootstack.style.element import Element, ElementImage
11
11
  from bootstack.style.theme_provider import ThemeProvider
12
+ from bootstack.style.utility import pretile_element_image
12
13
 
13
14
 
14
15
  class BuilderCallable(Protocol):
@@ -344,8 +345,37 @@ class StyleBuilderTtk(StyleBuilderBase):
344
345
  if name in existing:
345
346
  # Element already exists; avoid duplicate creation error on theme rebuilds
346
347
  return
348
+ try:
349
+ args, kwargs = self._pretile_element_images(args, kwargs)
350
+ except Exception:
351
+ # Pre-tiling only speeds painting up; the element draws from its own images.
352
+ from bootstack._runtime.utility import debug_log_exception
353
+ debug_log_exception(f"could not pre-tile the images of element {name!r}")
347
354
  self.style.element_create(name, "image", *args, **kwargs)
348
355
 
356
+ def _pretile_element_images(self, args: list, kwargs: dict) -> tuple[list, dict]:
357
+ """Swap an element's images for copies that paint a wide parcel in few repeats.
358
+
359
+ Only an element that stretches horizontally is pre-tiled; one that does
360
+ not is drawn at its image's own width, so a wider image would draw wider.
361
+ No `sticky` means `nsew`.
362
+ """
363
+ sticky = kwargs.get("sticky")
364
+ if sticky is not None and not ("e" in sticky and "w" in sticky):
365
+ return args, kwargs
366
+
367
+ master = self.style.master
368
+ border = kwargs.get("border", 0)
369
+ default, *specs = args
370
+ tiled = [pretile_element_image(default, border, master)]
371
+ tiled += [(state, pretile_element_image(image, border, master)) for state, image in specs]
372
+
373
+ if tiled[0] != default and "width" not in kwargs:
374
+ # The element's minimum width defaults to its image's width, so pin
375
+ # it to the original image's.
376
+ kwargs = {**kwargs, "width": int(self.style.tk.call("image", "width", default))}
377
+ return tiled, kwargs
378
+
349
379
  def create_style_layout(self, ttk_style: str, element: Element | list[Element]):
350
380
  if isinstance(element, list):
351
381
  self.style.layout(ttk_style, [e.spec() for e in element])
@@ -1,3 +1,4 @@
1
+ import tkinter
1
2
  from colorsys import hls_to_rgb, rgb_to_hls
2
3
  from dataclasses import dataclass
3
4
  from pathlib import Path
@@ -553,6 +554,73 @@ def recolor_element_image(
553
554
  return ElementImageResult(image=photo_image, meta=meta)
554
555
 
555
556
 
557
+ PRETILE_SPAN = 256
558
+ """The width, in pixels, an element image's middle column is repeated to."""
559
+
560
+
561
+ def pretile_element_image(
562
+ image: str,
563
+ border: int | tuple[int, ...] | list[int],
564
+ master: tkinter.Misc,
565
+ ) -> str:
566
+ """Return a copy of an element image with its middle column repeated.
567
+
568
+ A stretched image element is drawn by repeating its middle column across the
569
+ parcel, one draw per repeat, so an element image only a few pixels wide makes
570
+ a wide widget slow to paint. The copy repeats the column to at least
571
+ `PRETILE_SPAN` pixels, in whole repeats starting at the column's left edge as
572
+ the element's own drawing does, so it draws the same pixels in fewer repeats.
573
+ The left and right columns are copied unchanged.
574
+
575
+ Copies are cached by source image and border, so elements that share an image
576
+ share its copy.
577
+
578
+ Args:
579
+ image: Name of the source photo image.
580
+ border: The element's border: one value, or up to four in the order left,
581
+ top, right, bottom, where right defaults to left.
582
+ master: Widget whose interpreter owns `image`.
583
+
584
+ Returns:
585
+ The name of the copy, or `image` itself when its middle column is already
586
+ at least `PRETILE_SPAN` pixels wide or has no width.
587
+ """
588
+ values = [border] if isinstance(border, int) else list(border)
589
+ left = int(values[0]) if values else 0
590
+ right = int(values[2]) if len(values) > 2 else left
591
+
592
+ cache_key = ("element-pretile", str(image), left, right)
593
+ cached = ImageService.get_cached(cache_key)
594
+ if cached is not None:
595
+ return str(cached)
596
+
597
+ tk = master.tk
598
+ width = int(tk.call("image", "width", image))
599
+ height = int(tk.call("image", "height", image))
600
+ middle = width - left - right
601
+ if middle <= 0 or middle >= PRETILE_SPAN:
602
+ return str(image)
603
+
604
+ span = middle * -(-PRETILE_SPAN // middle)
605
+ tiled = tkinter.PhotoImage(master=master, width=left + span + right, height=height)
606
+
607
+ def copy(x0: int, x1: int, *to: int) -> None:
608
+ tk.call(
609
+ tiled, "copy", image, "-from", x0, 0, x1, height,
610
+ "-to", *to, "-compositingrule", "set",
611
+ )
612
+
613
+ if left:
614
+ copy(0, left, 0, 0)
615
+ # A -to rectangle larger than the -from region repeats the region across it.
616
+ copy(left, left + middle, left, 0, left + span, height)
617
+ if right:
618
+ copy(width - right, width, left + span, 0)
619
+
620
+ ImageService.set_cached(cache_key, tiled)
621
+ return str(tiled)
622
+
623
+
556
624
  def should_darken(bg_hex: str) -> bool:
557
625
  """Determine whether to darken or lighten based on luminance and saturation."""
558
626
  r, g, b = [v / 255.0 for v in ImageColor.getrgb(bg_hex)]
@@ -446,8 +446,14 @@ class PublicWidgetBase:
446
446
  if placement.method == "flex":
447
447
  # The FlexFrame's managed list IS the attached-sibling order, so the
448
448
  # current managed index is the slot a plain attach() should restore.
449
+ from bootstack.widgets._core.container import snapshot_flex_slot
450
+
449
451
  idx = placement.master.index_of(self._internal)
450
- placement.index = idx if idx >= 0 else None
452
+ if idx >= 0:
453
+ snapshot_flex_slot(placement, placement.master, idx)
454
+ else:
455
+ placement.index = None
456
+ placement.preceding = ()
451
457
  placement.master.remove_child(self._internal)
452
458
  elif placement.method == "pack":
453
459
  slaves = list(placement.master.pack_slaves())
@@ -485,7 +491,7 @@ class PublicWidgetBase:
485
491
  """
486
492
  from bootstack.widgets._core.container import (
487
493
  PACK_KEYS, GRID_KEYS, PLACE_KEYS, normalize_fill, _expand_margin, _flex_child_opts,
488
- _reject_legacy_child_kwargs, resolve_pack_order
494
+ _reject_legacy_child_kwargs, resolve_pack_order, restored_flex_index
489
495
  )
490
496
 
491
497
  placement = getattr(self, "_placement", None)
@@ -503,7 +509,10 @@ class PublicWidgetBase:
503
509
  options = dict(placement.options)
504
510
 
505
511
  if placement.method == "flex":
506
- index = kwargs.pop("index", placement.index)
512
+ if "index" in kwargs:
513
+ index = kwargs.pop("index")
514
+ else:
515
+ index = restored_flex_index(placement)
507
516
  if kwargs:
508
517
  _reject_legacy_child_kwargs(kwargs, type(self).__name__, "flex child")
509
518
  _expand_margin(kwargs)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import tkinter
4
+ import weakref
4
5
  from typing import Any, Literal
5
6
 
6
7
  PACK_KEYS = frozenset({
@@ -101,11 +102,12 @@ class Placement:
101
102
  Recorded on the child at layout time by `guide_layout`; consumed by
102
103
  `PublicWidgetBase.detach`/`attach` to remove and re-insert the widget
103
104
  without re-running its constructor. `options` holds the resolved Tk
104
- geometry-manager options (no ordering refs); `index` is the pack position
105
- among siblings, snapshotted afresh on each `detach`.
105
+ geometry-manager options (no ordering refs); `index` is the position among
106
+ siblings, snapshotted afresh on each `detach`. For a flex child, set it
107
+ with `snapshot_flex_slot`, which keeps `preceding` in step with it.
106
108
  """
107
109
 
108
- __slots__ = ("method", "master", "options", "index")
110
+ __slots__ = ("method", "master", "options", "index", "preceding")
109
111
 
110
112
  def __init__(
111
113
  self,
@@ -114,10 +116,31 @@ class Placement:
114
116
  options: dict,
115
117
  index: int | None = None,
116
118
  ) -> None:
117
- self.method = method # "pack" | "grid" | "place"
119
+ self.method = method # "pack" | "grid" | "place" | "flex"
118
120
  self.master = master # the Tk master the child is placed in
119
121
  self.options = options # resolved Tk options (no before/after/index)
120
122
  self.index = index # pack order among siblings (snapshot at detach)
123
+ self.preceding: tuple = () # weakrefs to the flex siblings before `index`
124
+
125
+
126
+ def snapshot_flex_slot(placement: Placement, frame: Any, index: int) -> None:
127
+ """Record `index` in `frame` as the slot a plain `attach()` restores.
128
+
129
+ The siblings before the slot are kept by weak reference so that one
130
+ destroyed before the attach no longer counts toward the index.
131
+ """
132
+ placement.index = index
133
+ placement.preceding = tuple(weakref.ref(w) for w, _ in frame._managed[:index])
134
+
135
+
136
+ def restored_flex_index(placement: Placement) -> int | None:
137
+ """The snapshotted slot, less the preceding siblings destroyed since."""
138
+ from bootstack.widgets._impl.primitives.flexframe import _alive
139
+
140
+ if placement.index is None:
141
+ return None
142
+ gone = sum(1 for ref in placement.preceding if not ((w := ref()) and _alive(w)))
143
+ return max(placement.index - gone, 0)
121
144
 
122
145
 
123
146
  def resolve_pack_order(order: dict, master: tkinter.Misc) -> dict:
@@ -441,8 +464,11 @@ def place_flex_child(
441
464
  frame.add_child(child._internal, opts, index=index)
442
465
  child._placement = Placement("flex", frame, opts)
443
466
  else:
444
- # Record the slot a later attach() restores: an explicit index, else the
445
- # natural append position (current child count).
446
- if index is None:
447
- index = len(frame._managed)
467
+ # Record the slot a later attach() restores: an explicit index as given,
468
+ # else the natural append position (current child count).
448
469
  child._placement = Placement("flex", frame, opts, index=index)
470
+ if index is None:
471
+ # Prune first: a dead sibling left in `_managed` would be counted
472
+ # twice, once in the count and again when attach() discounts it.
473
+ frame._prune_destroyed()
474
+ snapshot_flex_slot(child._placement, frame, len(frame._managed))
@@ -8,6 +8,7 @@ both text and numeric values.
8
8
  from tkinter import Event, TclError
9
9
  from typing import Any, Callable
10
10
 
11
+ from bootstack._core.localization import bind_locale_refresh
11
12
  from bootstack.events import ChangeEvent, InputEvent
12
13
  from bootstack.i18n import MessageCatalog, IntlFormatter
13
14
  from bootstack.widgets._impl.primitives.spinbox import Spinbox
@@ -131,7 +132,7 @@ class SpinnerEntryPart(ValidationMixin, Spinbox):
131
132
  self.bind('<FocusIn>', self._store_prev_value, add=True)
132
133
  self.bind('<FocusOut>', self._handle_focus_out, add=True)
133
134
  self.bind('<Return>', self._handle_return, add=True)
134
- self.winfo_toplevel().bind('<<LocaleChanged>>', self._on_locale_changed, add='+')
135
+ bind_locale_refresh(self._on_locale_changed)
135
136
 
136
137
  # Set initial focus
137
138
  if initial_focus:
@@ -1,6 +1,7 @@
1
1
  from tkinter import Event, TclError
2
2
  from typing import Any, Callable
3
3
 
4
+ from bootstack._core.localization import bind_locale_refresh
4
5
  from bootstack.events import ChangeEvent, InputEvent
5
6
  from bootstack.i18n import MessageCatalog, IntlFormatter
6
7
  from bootstack.widgets._impl.primitives.entry import Entry
@@ -102,7 +103,7 @@ class TextEntryPart(ValidationMixin, Entry):
102
103
  self.bind('<FocusIn>', self._store_prev_value, add=True)
103
104
  self.bind('<FocusOut>', self._handle_focus_out, add=True)
104
105
  self.bind('<Return>', self._handle_return, add=True)
105
- self.winfo_toplevel().bind('<<LocaleChanged>>', self._on_locale_changed, add='+')
106
+ bind_locale_refresh(self._on_locale_changed)
106
107
 
107
108
  # set initial focus
108
109
  if initial_focus:
@@ -4,6 +4,7 @@ from typing_extensions import Unpack
4
4
 
5
5
  from typing import Any
6
6
 
7
+ from bootstack._core.localization import bind_locale_refresh
7
8
  from bootstack.events import ChangeEvent
8
9
  from bootstack.widgets._core.options import (
9
10
  cluster_records,
@@ -139,7 +140,7 @@ class SelectBox(Field):
139
140
  self._entry._get_validation_value = self._validation_value
140
141
 
141
142
  # Re-translate the displayed text + option maps on a live locale switch.
142
- self.winfo_toplevel().bind('<<LocaleChanged>>', self._on_locale_changed_refresh, add='+')
143
+ bind_locale_refresh(self._on_locale_changed_refresh)
143
144
 
144
145
  self._group_by = group_by
145
146
  self._max_visible_items = max_visible_items
@@ -17,6 +17,7 @@ from bootstack.events import (
17
17
  from bootstack.widgets._impl.composites.pagestack import PageStack
18
18
  from bootstack.widgets.types import Master
19
19
  from bootstack._core import NavigationError
20
+ from bootstack._core.localization import bind_locale_refresh
20
21
 
21
22
 
22
23
  class TabView(Frame):
@@ -121,9 +122,7 @@ class TabView(Frame):
121
122
  self._trace_id = self._tab_variable.trace_add('write', self._on_tab_selected)
122
123
 
123
124
  # Locale retranslation
124
- self.winfo_toplevel().bind(
125
- "<<LocaleChanged>>", self._on_locale_changed, add='+'
126
- )
125
+ bind_locale_refresh(self._on_locale_changed)
127
126
 
128
127
  # Clean up trace on destroy
129
128
  self.bind('<Destroy>', self._on_destroy, add='+')
@@ -18,6 +18,7 @@ from bootstack._core.localization import (
18
18
  resolve_text,
19
19
  resolve_variable_text,
20
20
  apply_spec,
21
+ bind_locale_refresh,
21
22
  create_formatted_signal,
22
23
  )
23
24
  from bootstack.i18n.specs import (
@@ -67,8 +68,7 @@ class LocalizationMixin(Misc):
67
68
  self._localize_mode = localize
68
69
  self._default_value_format = value_format
69
70
 
70
- root = self.winfo_toplevel() # event is generated on root
71
- root.bind("<<LocaleChanged>>", self._on_locale_changed, add="+")
71
+ bind_locale_refresh(self._on_locale_changed)
72
72
 
73
73
  # Check if widget has a textsignal or textvariable with value_format
74
74
  if value_format and self._has_signal_or_variable():
@@ -107,7 +107,8 @@ class FlexFrame(Frame):
107
107
  self._gap = gap
108
108
  # Ordered (widget, opts) entries. opts may carry: grow, horizontal,
109
109
  # vertical, padx, pady (resolved margins), and spacer metadata
110
- # (_spacer, _spacer_size, _spacer_weight).
110
+ # (_spacer, _spacer_size, _spacer_weight). add_child adds the id of the
111
+ # child's <Destroy> binding (_destroy_bind).
111
112
  self._managed: list[tuple[tk.Widget, dict[str, Any]]] = []
112
113
  # Highest main-axis track index configured on the previous relayout,
113
114
  # so we can zero stale tracks when the plan shrinks.
@@ -116,6 +117,12 @@ class FlexFrame(Frame):
116
117
  # append fast-path decide in O(1) whether the stack is "pure" (see
117
118
  # `_can_append_in_place`) without rescanning every add.
118
119
  self._flex_child_count = 0
120
+ # Set when a managed child is destroyed out-of-band (Tk destroy()
121
+ # bypasses remove_child). Until it is dropped, the dead child keeps its
122
+ # track, so the next fast-path append would land after it with a
123
+ # leading gap. `_prune_destroyed` re-plans once, before the next add or
124
+ # index lookup, or at idle if neither comes.
125
+ self._needs_prune = False
119
126
  if propagate is not None:
120
127
  self.grid_propagate(propagate)
121
128
 
@@ -197,6 +204,7 @@ class FlexFrame(Frame):
197
204
 
198
205
  def index_of(self, widget: tk.Widget) -> int:
199
206
  """Return the managed index of `widget`, or -1 if not managed."""
207
+ self._prune_destroyed()
200
208
  for i, (w, _) in enumerate(self._managed):
201
209
  if w is widget:
202
210
  return i
@@ -215,7 +223,18 @@ class FlexFrame(Frame):
215
223
  sibling's track — a mid-list insert, a distribution mode, or a
216
224
  grow/spacer child — takes the full `_relayout`.
217
225
  """
218
- entry = (widget, dict(opts))
226
+ # Close up behind any child destroyed since the last change, so `index`
227
+ # counts live siblings and the fast path's next track follows them.
228
+ self._prune_destroyed()
229
+ opts = dict(opts)
230
+ # Kept in opts so remove_child can drop it and a detach/attach cycle
231
+ # does not stack up handlers.
232
+ opts["_destroy_bind"] = widget.bind(
233
+ "<Destroy>",
234
+ lambda e, w=widget: self._on_child_destroy(e, w),
235
+ add="+",
236
+ )
237
+ entry = (widget, opts)
219
238
  appending = index is None or index >= len(self._managed)
220
239
  new_is_flex = bool(opts.get("grow")) or bool(opts.get("_spacer"))
221
240
  # Decide the fast path BEFORE the count reflects this child (the check
@@ -240,6 +259,7 @@ class FlexFrame(Frame):
240
259
  _, removed_opts = self._managed[idx]
241
260
  if removed_opts.get("grow") or removed_opts.get("_spacer"):
242
261
  self._flex_child_count -= 1
262
+ widget.unbind("<Destroy>", removed_opts["_destroy_bind"])
243
263
  try:
244
264
  tk.Grid.forget(widget)
245
265
  except tk.TclError:
@@ -247,6 +267,27 @@ class FlexFrame(Frame):
247
267
  self._managed.pop(idx)
248
268
  self._relayout()
249
269
 
270
+ def _on_child_destroy(self, event: tk.Event, widget: tk.Widget) -> None:
271
+ """Flag a prune when a managed child is destroyed, and schedule one."""
272
+ if event.widget is not widget or self._needs_prune:
273
+ return
274
+ self._needs_prune = True
275
+ # On the root: an idle job owned by this frame would be orphaned when
276
+ # the frame itself is destroyed along with its children.
277
+ try:
278
+ self._root().after_idle(self._prune_destroyed)
279
+ except (tk.TclError, AttributeError):
280
+ pass
281
+
282
+ def _prune_destroyed(self) -> None:
283
+ """Drop children destroyed out-of-band and close up the survivors.
284
+
285
+ `_relayout` does the dropping and re-grids survivors in place, so they
286
+ see no `<Unmap>`/`<Map>`. A no-op unless a destroy was flagged.
287
+ """
288
+ if self._needs_prune and _alive(self):
289
+ self._relayout()
290
+
250
291
  def _can_append_in_place(self) -> bool:
251
292
  """True when appending a plain child cannot move any existing sibling.
252
293
 
@@ -385,6 +426,7 @@ class FlexFrame(Frame):
385
426
  # Drop children destroyed out from under us: Tk's destroy() does not route
386
427
  # through remove_child, so a dead child lingers in _managed and re-gridding
387
428
  # it below would raise "bad window path name". (GridFrame guards the same.)
429
+ self._needs_prune = False
388
430
  if any(not _alive(w) for w, _ in self._managed):
389
431
  self._managed = [e for e in self._managed if _alive(e[0])]
390
432
  self._flex_child_count = sum(
@@ -492,9 +534,9 @@ class FlexFrame(Frame):
492
534
  trail = margin_main[1]
493
535
  elif margin_main is not None:
494
536
  lead += margin_main
495
- pad: dict[str, Any] = {}
496
- if lead or trail:
497
- pad[main_axis] = (lead, trail)
537
+ # Always written: re-gridding in place keeps any option not passed,
538
+ # so a survivor that becomes the first child would keep its gap.
539
+ pad: dict[str, Any] = {main_axis: (lead, trail)}
498
540
  margin_cross = o.get(cross_axis)
499
541
  if margin_cross is not None:
500
542
  pad[cross_axis] = margin_cross
@@ -5,6 +5,7 @@ from typing import Any, Callable, TypedDict, TYPE_CHECKING
5
5
 
6
6
  from typing_extensions import Unpack
7
7
 
8
+ from bootstack._core.localization import bind_locale_refresh
8
9
  from bootstack.events import ChangeEvent
9
10
  from bootstack.widgets._core.options import normalize_options, option_display, record_to_dict
10
11
  from bootstack.widgets._impl.composites.contextmenu import ContextMenu, ContextMenuItem
@@ -125,9 +126,8 @@ class OptionMenu(MenuButton):
125
126
  # Bind signal to change event (also keeps the face display in sync)
126
127
  self._bind_change_event()
127
128
  self._sync_display()
128
- # The locale-change event is generated on the root; bind there (not on
129
- # self) so the face re-translates live, matching the mixin's pattern.
130
- self.winfo_toplevel().bind('<<LocaleChanged>>', lambda _e: self._sync_display(), add="+")
129
+ # Re-translate the face live on a locale change, as the mixin does.
130
+ bind_locale_refresh(self._sync_display)
131
131
 
132
132
  # Create menu items that update the shared variable
133
133
  self._context_menu = self._build_context_menu()
@@ -108,24 +108,20 @@ class Label(IconProperty, ImageProperty, PublicWidgetBase):
108
108
 
109
109
  if isinstance(image, _ImageHandle):
110
110
  deferred_image = image
111
- has_text = bool(text) or textsignal is not None
112
- if not has_text or icon_only:
113
- internal_kwargs["icon_only"] = True
114
- internal_kwargs["compound"] = "image"
115
- else:
116
- internal_kwargs["compound"] = icon_position
117
111
  else:
118
112
  internal_kwargs["image"] = image
119
113
  if icon is not None:
120
114
  internal_kwargs["icon"] = icon
121
- has_text = bool(text) or textsignal is not None
122
- if not has_text or icon_only:
123
- internal_kwargs["icon_only"] = True
124
- internal_kwargs["compound"] = "image"
125
- else:
126
- internal_kwargs["compound"] = icon_position
127
- elif icon_only:
115
+ has_text = bool(text) or textsignal is not None
116
+ has_graphic = icon is not None or image is not None
117
+ if icon_only or (has_graphic and not has_text):
128
118
  internal_kwargs["icon_only"] = True
119
+ if has_graphic:
120
+ internal_kwargs["compound"] = "image"
121
+ elif has_text:
122
+ # Set even without a graphic, so an icon or image assigned later
123
+ # sits beside the text instead of replacing it.
124
+ internal_kwargs["compound"] = icon_position
129
125
  if anchor is not None:
130
126
  internal_kwargs["anchor"] = anchor
131
127
  if justify is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bootstack
3
- Version: 0.4.7
3
+ Version: 0.4.8
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
@@ -12,7 +12,7 @@ bootstack/_core/__init__.py,sha256=Ib1dWGMP7RvvZ95h9qGCKP0RTseX2Ki6gFf_roKpprw,3
12
12
  bootstack/_core/capture.py,sha256=wOH2DilCRh1oHIb_ymK-2X9EYUb2k_H7sjoLuyXSftA,19366
13
13
  bootstack/_core/exceptions.py,sha256=9HazDsy88JjdoT6YTg5yVVk9b1aofp8gG_N4uFDPo9U,656
14
14
  bootstack/_core/images.py,sha256=Gn5WnBRzq_qmhc8LjnTrD7BSXz-l22gDwzuAAZsExGo,18206
15
- bootstack/_core/localization.py,sha256=BOsNkH_1BAqKrRkbhOiQNuNfzyICYsoF6-MsXx__vWQ,7256
15
+ bootstack/_core/localization.py,sha256=4AXhYCslqQPGThGyeGcYNcUnpD3Ah_4QjBoSOwSUxHM,10950
16
16
  bootstack/_core/paths.py,sha256=rBhbFg4Z6__8K_y1Z15b5Xe_alOHvAlCtTbip2Qih-E,1714
17
17
  bootstack/_core/signal_binding.py,sha256=j3MuiuvUL7YrKTczwo96xy2KQRk5zXFgr29lwd41GPo,7480
18
18
  bootstack/_core/variables.py,sha256=OHvDBEBnuSGYwtYsjaYGVBirfkZ9DkPChkKEOZQQuwo,2020
@@ -200,7 +200,7 @@ bootstack/style/style.py,sha256=3UzQaFFa97Oj7D_qiBeIvifd7UDhkjSQ_-n3k0CBlAs,2156
200
200
  bootstack/style/style_builder_base.py,sha256=O5UhaoKUCdufw9WL5EWj-N4QSUFI0jxb3VceQpEc54w,30185
201
201
  bootstack/style/style_builder_mixed.py,sha256=SRzXYkq2etFrk3wS5hsAbjDRWm2VtUPHSk6s9ki3JLc,3737
202
202
  bootstack/style/style_builder_tk.py,sha256=dFXoAIcSsvSOWNhQ8YWag2vpjUZKo-0NIG6mdveBC5E,3798
203
- bootstack/style/style_builder_ttk.py,sha256=gyM7HarQ5L9TxkjKRD3P0eHndpC8sEJfJeBhY9O2TLs,13106
203
+ bootstack/style/style_builder_ttk.py,sha256=MewVKYP-QDZ8RsUiOrMdqNHyOMTIg44pJpued1nBu-s,14651
204
204
  bootstack/style/style_resolver.py,sha256=rkyKZvKbh0qDBIDfyS-Puwl_H2a5YuQlGWXhbgtk98w,16519
205
205
  bootstack/style/theme.py,sha256=Oa8pBuOLUvHPKa5eCQdhXPPJli_AplR_WwP-C7Cl80g,10402
206
206
  bootstack/style/theme_provider.py,sha256=dGlzEVdlGHPwGTvCa9evGYqSo9donsL6qvaC1kbsKQ4,17452
@@ -208,7 +208,7 @@ bootstack/style/tk_patch.py,sha256=0rRJEUQYqt3YVakl-c-Irfp8uC35GTHX0pq-655lFAc,1
208
208
  bootstack/style/token_maps.py,sha256=xlaCJHqPT-3B1wWmpweRYl2TMjTGLinBLoJQPMp-xVQ,1290
209
209
  bootstack/style/types.py,sha256=_Tnxs_TI_FyR_pMnHqMKR5BE-OwE8mlPJ0VjgRgA6Mg,1196
210
210
  bootstack/style/typography.py,sha256=pTAQlKcOyqMpmjFngf8tCYq85pbV7zPv8mHw5raE_9I,17026
211
- bootstack/style/utility.py,sha256=OM8Du3yvAErIMPSOikTirbZPLbyo1x0U3RreVD689K4,25576
211
+ bootstack/style/utility.py,sha256=UpzRSgwfHU5frv8Q2ULvAWwNvhStn5_27sLrpOEoX-8,28140
212
212
  bootstack/style/builders/__init__.py,sha256=UiLt9WJWnFHAa8zR2sWydeW2756flMQZarw2tDTlnOg,1272
213
213
  bootstack/style/builders/badge.py,sha256=3OQiA4n93c74Df64sfsuC-v3nM8rJ_KnwBx-OOE_r38,2018
214
214
  bootstack/style/builders/button.py,sha256=u-96U4nwFvALNKWwRTYmX9mWUb0u76SgAyKwwL2OMuk,11822
@@ -271,7 +271,7 @@ bootstack/widgets/gallery.py,sha256=JAboTI4kB9PeVdONhY9-HHM4_e1VnvHiKjolKGIUFhA,
271
271
  bootstack/widgets/gauge.py,sha256=1ediRKxTSzid3doyzOX3ZN9xzjz4oq7nhyInKoqaZpI,6682
272
272
  bootstack/widgets/grid.py,sha256=JiOdgL4YmCYI1MBs6UWN01EtTeVBFmTv7r-jHYX9Ld0,5418
273
273
  bootstack/widgets/groupbox.py,sha256=2Gbx7hVezkrNkcEuTy_Nuj5w-O9hnuyeKyaHhzF_m1g,6928
274
- bootstack/widgets/label.py,sha256=RnAz-jxCM3ib3QJrgafyPwvPzGGOsSFhlp8C2N9Fx54,9774
274
+ bootstack/widgets/label.py,sha256=MEQNG9wURtiEB32rQZCEwMh4v3JmtJFlKl0FyJfHBLU,9620
275
275
  bootstack/widgets/listview.py,sha256=xZkEYIMtbDSsJCs46MFdFtObDSnPos7mQJIc1gPFeL8,14788
276
276
  bootstack/widgets/menubutton.py,sha256=wbWBFE82fJ7b1HeBA1rm-wjhXPw3Q69XArPZlAqyOJw,16545
277
277
  bootstack/widgets/numberfield.py,sha256=FtoadKMP4b4yVbKJclyicSeP3JeoE77axtrtj94ISaw,16313
@@ -309,9 +309,9 @@ bootstack/widgets/types.py,sha256=_86PwJ20gim0hGnCdPYSn1YVrrsTfZhyQ1zWDwHqQTY,13
309
309
  bootstack/widgets/window.py,sha256=lwzYO6uAeXM_lSMXES911OsX8aVrJPKQHJu1u30bAIo,12921
310
310
  bootstack/widgets/_core/__init__.py,sha256=rJPvvI3eOV9M5cICBD2XPZFokThf_tQyTeZhLPaqukU,1106
311
311
  bootstack/widgets/_core/app_config.py,sha256=h_OaJ0jSif1eJeHWErpsXNyhtfCcmYQCcq0VriM48uk,9771
312
- bootstack/widgets/_core/base.py,sha256=ca6UltQDDjdeOwr6iu1tuUdsh5oqcXFuJxEQzRZaxA4,27605
312
+ bootstack/widgets/_core/base.py,sha256=u8ZamP8uahu3w8iwSxMnycvUdRkALR8J_ozvLDLazHo,27943
313
313
  bootstack/widgets/_core/choices.py,sha256=IGm4NspPJQgnGOxfuXWsqs4nSk7HSkdH0P60Cd1gnjo,2369
314
- bootstack/widgets/_core/container.py,sha256=h40FyWl_kY6WCioMKUMc9FYjI7t0dCq4u-VlvKdHrXA,18893
314
+ bootstack/widgets/_core/container.py,sha256=bpwWOTtyzzDUQ354ZBtw2NT-XfESKKrCDQo3iRaVRx4,20170
315
315
  bootstack/widgets/_core/context.py,sha256=laL75HYr7kIx3KbZqtT_rO1wWRjV4pehNzWgcZUkw_I,764
316
316
  bootstack/widgets/_core/events.py,sha256=-Hlg3KiITXoei4QxlAmjw65jVXSnr3s81s9PWcdaaKo,3791
317
317
  bootstack/widgets/_core/field_mixin.py,sha256=kFHOcSs16x20PEDuakVmukkzEMqA1cKfqtGZicxD-zU,16557
@@ -328,8 +328,8 @@ bootstack/widgets/_impl/_internal/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeR
328
328
  bootstack/widgets/_impl/_internal/wrapper_base.py,sha256=ztjz9tbC5w1HLlgOC5galPhk2mZfmWJk7Mcq9D4YC58,12731
329
329
  bootstack/widgets/_impl/_parts/__init__.py,sha256=DdRqDmFQevi7KLWhuyffsfvSSCEBACi6tZJYPPAptoA,371
330
330
  bootstack/widgets/_impl/_parts/numberentry_part.py,sha256=wCHfO0ZB9n2vSXpiJ0c4k0Cf5pn9FdzGZzVcaKdU49M,15463
331
- bootstack/widgets/_impl/_parts/spinnerentry_part.py,sha256=X5Lkqy62cjyKj12IdjhL_OYjBbYOVn-PDahOlqWIzxA,17353
332
- bootstack/widgets/_impl/_parts/textentry_part.py,sha256=A7n-YXMJNc-UIQL3q_T_gIIRFPXx3l6qBPxwOSlT3K0,17152
331
+ bootstack/widgets/_impl/_parts/spinnerentry_part.py,sha256=e73MGW-wTzjf0e4ZChZSN8O3498Zycm-0oRgEykd_Ls,17378
332
+ bootstack/widgets/_impl/_parts/textentry_part.py,sha256=wDEVaY4jhWW2iyyZ-s2N4KxV51OsUgYGWgiLXLocVYE,17177
333
333
  bootstack/widgets/_impl/composites/__init__.py,sha256=uNb9c9-i-vfq9vhZgwRxsBw0KL2BYNdZOWy7pb_QZDk,1077
334
334
  bootstack/widgets/_impl/composites/_dateutils.py,sha256=DQhiy_PsR6MAJT8zYBwwJTpsgrkQ2C9SDzm2Z5qLcyc,1075
335
335
  bootstack/widgets/_impl/composites/_image_fit.py,sha256=MwEfaXibW1htqUu5fvinenuN7LWiEX7Ydz3a-IxJjAk,3638
@@ -357,7 +357,7 @@ bootstack/widgets/_impl/composites/radiogroup.py,sha256=CEYhCfBgH19Y7x-63nqKdycb
357
357
  bootstack/widgets/_impl/composites/scrolledtext.py,sha256=Dkp2ATIe-UADhvVDl6W6xEEwe-3AWkv2mESC5wP9Xik,15000
358
358
  bootstack/widgets/_impl/composites/scrolledtext.pyi,sha256=GceUarQJWlYKA2nly7mbIA3LJDxOJXO0QQyKEbbiQ3U,6475
359
359
  bootstack/widgets/_impl/composites/scrollview.py,sha256=zGN-xG0lIgB2O5Yz7j-LF4UeGR6-Fx-KjoplqMzZp78,32762
360
- bootstack/widgets/_impl/composites/selectbox.py,sha256=3oXYUYhjlaP4zUaY_D1pvyez3VZEvX42oceK7FEX8Iw,58642
360
+ bootstack/widgets/_impl/composites/selectbox.py,sha256=YozC4_yjxdGzqogPixNFtOigg_Vhl5YkzjsJYzvUAyQ,58667
361
361
  bootstack/widgets/_impl/composites/spinnerentry.py,sha256=6CSvsastkc9j6CsloFsaDACSDrfrkoFda9BlYEjJwzQ,7006
362
362
  bootstack/widgets/_impl/composites/textentry.py,sha256=xH5NqG5bbMlv-OgaifIYkgUcNvXVSi4Q3VhFwqvCV50,2978
363
363
  bootstack/widgets/_impl/composites/timeentry.py,sha256=T5X93ueTDvUEemD5y96jt6_ApQHeMjWON6p-YW1T9lY,7503
@@ -394,7 +394,7 @@ bootstack/widgets/_impl/composites/tableview/types.py,sha256=oDmJco19M42Zv_lUEps
394
394
  bootstack/widgets/_impl/composites/tabs/__init__.py,sha256=yRRJCO9egzFCDNN7xDyQcH8KqCQx6AgGE-eVtpn4KSc,427
395
395
  bootstack/widgets/_impl/composites/tabs/tabitem.py,sha256=MOdJuIOEHp80WSBO2Jg8Nh45OXzxdmUDc1FnYiYR-SQ,15160
396
396
  bootstack/widgets/_impl/composites/tabs/tabs.py,sha256=1SL8YCkVPSkIfOobummodbY4zR07s9ZSsj3m0vLz22I,38264
397
- bootstack/widgets/_impl/composites/tabs/tabview.py,sha256=uzQYe1ImZP9GKY-3XfxoqZx9oZVbmTz7d9hkJIo-a5A,22917
397
+ bootstack/widgets/_impl/composites/tabs/tabview.py,sha256=SzuvOXAkzkkIrs7rCP63QKA5W4oaJZoGV1x8zJMWjns,22918
398
398
  bootstack/widgets/_impl/composites/textarea/__init__.py,sha256=tqNmDfbAsnwIRtz6kfstkfH3jD_mZd5I5SUMLmsWU74,721
399
399
  bootstack/widgets/_impl/composites/textarea/change.py,sha256=VO4mmBkCjukm5Qmddl-7C6teRf0fri24L5w31vVCXl4,1556
400
400
  bootstack/widgets/_impl/composites/textarea/codeeditor.py,sha256=SPLw3OoPxfhoKTrxM-RfVe1UFIxZiGI1dLhJofHmb5s,21711
@@ -424,7 +424,7 @@ bootstack/widgets/_impl/mixins/configure_mixin.py,sha256=iHSE-kUVE4r1ew-AbRd4Xqy
424
424
  bootstack/widgets/_impl/mixins/entry_mixin.py,sha256=hBp2h34KDWqYr_H2P1-63_JwVARoDvvL04hX6nD_0Ho,4405
425
425
  bootstack/widgets/_impl/mixins/font_mixin.py,sha256=XyoPC10ByhM5IoTTztyzwRxNut0_BwewRTaBPZYSGjg,12295
426
426
  bootstack/widgets/_impl/mixins/icon_mixin.py,sha256=ScSQuSwGf8uo5ykXxdXTeNteRm2xIgUxiDd_LxIeQUs,1993
427
- bootstack/widgets/_impl/mixins/localization_mixin.py,sha256=ZceeudYQumJobVc8ixy9WefzCeRLLYxVgrzql_U1yjI,9800
427
+ bootstack/widgets/_impl/mixins/localization_mixin.py,sha256=md7L1sfgNRNitQyOgpyNRml-nSaVC1fBfdDZvZyKvn0,9738
428
428
  bootstack/widgets/_impl/mixins/signal_mixin.py,sha256=k1fnEe__S6N81W8xh-WEwyWJeFjWrJKDNyFIZnJheRw,9853
429
429
  bootstack/widgets/_impl/mixins/validation_mixin.py,sha256=nJSDbYeMHoEsJuVPzg5L3rB_GHLnp0CtBEvvb89ayNc,8847
430
430
  bootstack/widgets/_impl/primitives/__init__.py,sha256=TK1pfKns8H1a5nVCBvGGwXkkv9qeXSYGG8cESO5Etw4,1143
@@ -436,12 +436,12 @@ bootstack/widgets/_impl/primitives/checkbutton.py,sha256=avIG6zhW-TATa9cYFrw-tF-
436
436
  bootstack/widgets/_impl/primitives/checktoggle.py,sha256=x_A6sFIeUJwc8MHz_1uBAMCQV0YQFUZx6LKXM0qdnvE,3565
437
437
  bootstack/widgets/_impl/primitives/combobox.py,sha256=GPBzFgYYq0RW8FBwtaPpYzc7CeUSuGGyMEIx_EGtQsg,6297
438
438
  bootstack/widgets/_impl/primitives/entry.py,sha256=surq9Slix7hRPXjblN-EhVaMsKCJnQyxFeCoPwVvu14,3499
439
- bootstack/widgets/_impl/primitives/flexframe.py,sha256=Kpj0E0cybMGDLNdClJ_56khkNedA7sj4EwvYlFWA93I,22686
439
+ bootstack/widgets/_impl/primitives/flexframe.py,sha256=z_XiVjqZep8o_CPd8zeGVmFoDtUg-TUM8n21iGBy5Zo,24830
440
440
  bootstack/widgets/_impl/primitives/frame.py,sha256=Q2FWhgGkBU74YgYnKr9TFlkbrf_qXlJES4SZlAmJUNk,7226
441
441
  bootstack/widgets/_impl/primitives/gridframe.py,sha256=hr74jDwX9pdw0LquQNANHFkpyubw7vmyX8vUhXI7VfY,21629
442
442
  bootstack/widgets/_impl/primitives/label.py,sha256=2XxnObg6xmbLIkefCMYkb9hwyZIuU8FJclvcMpnszW0,3495
443
443
  bootstack/widgets/_impl/primitives/labelframe.py,sha256=IB5fqGgwG44vDrszFRBLEqrJGW5IXX1veK5AhetPGOE,2013
444
- bootstack/widgets/_impl/primitives/optionmenu.py,sha256=cH15ls1msZx74FzWIj3mZdBwBCJ3uYf0iwVbA3mesfQ,16671
444
+ bootstack/widgets/_impl/primitives/optionmenu.py,sha256=xSFuPEEuGeOnv7cCuQIK1R2sP4NrwQdLDxKlFuPJ7lI,16601
445
445
  bootstack/widgets/_impl/primitives/packframe.py,sha256=maUgSNlnJFoLr6ErpRMoO4922uJMPrxkfzTkc2jc-tg,8754
446
446
  bootstack/widgets/_impl/primitives/panedwindow.py,sha256=kSuXphwDDwITTenmHVMLwevBg5czbeJjJzRHUO504KM,1697
447
447
  bootstack/widgets/_impl/primitives/progressbar.py,sha256=Mmj-AUmfQEB671OzDvWxezowcBliZdQkuwWaCHBedmU,3129
@@ -453,10 +453,10 @@ bootstack/widgets/_impl/primitives/sizegrip.py,sha256=uwQRzOEHjNPXtUURhT6k2TNYdy
453
453
  bootstack/widgets/_impl/primitives/spinbox.py,sha256=McGzm6WyQWmTtU4Yzx7wdtiPhsZZu4hkJh_IpKR6a1s,4415
454
454
  bootstack/widgets/_impl/primitives/switch.py,sha256=7dt43uE2P_WdkxZZsGtCFRAQ20nI2jx5VUqGSrWwEmA,2543
455
455
  bootstack/widgets/_impl/primitives/treeview.py,sha256=vnSowgzmtNEWst_i9ZgcacHjdvuheF9xmeTvTWE1bLg,2709
456
- bootstack-0.4.7.dist-info/licenses/LICENSE,sha256=CUeTPUOM8Qc0yuqneJUwuUmDBooET5UfsHjMrkdBeCg,1097
457
- bootstack-0.4.7.dist-info/licenses/NOTICE,sha256=c62TcygUKbXx1V3FEgziroDDry_NwmycqKEg_CRnYBU,1503
458
- bootstack-0.4.7.dist-info/METADATA,sha256=aSm-wNO_I9iVcy6RPaxdPoop-gC7LWpPbsTQArW8Z_A,16107
459
- bootstack-0.4.7.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
460
- bootstack-0.4.7.dist-info/entry_points.txt,sha256=phOlfRy3Th86IYlNhQiUrTLLLzxie1IG-kMas_5iMdA,49
461
- bootstack-0.4.7.dist-info/top_level.txt,sha256=IsKBAac2vRCegvRFSCzwcBr7TpHp4bLnLFgA75LOkXs,10
462
- bootstack-0.4.7.dist-info/RECORD,,
456
+ bootstack-0.4.8.dist-info/licenses/LICENSE,sha256=CUeTPUOM8Qc0yuqneJUwuUmDBooET5UfsHjMrkdBeCg,1097
457
+ bootstack-0.4.8.dist-info/licenses/NOTICE,sha256=c62TcygUKbXx1V3FEgziroDDry_NwmycqKEg_CRnYBU,1503
458
+ bootstack-0.4.8.dist-info/METADATA,sha256=oSwwUata-lhwZVUVdwbrpw6SZRiptwmEyqVieGiTq4k,16107
459
+ bootstack-0.4.8.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
460
+ bootstack-0.4.8.dist-info/entry_points.txt,sha256=phOlfRy3Th86IYlNhQiUrTLLLzxie1IG-kMas_5iMdA,49
461
+ bootstack-0.4.8.dist-info/top_level.txt,sha256=IsKBAac2vRCegvRFSCzwcBr7TpHp4bLnLFgA75LOkXs,10
462
+ bootstack-0.4.8.dist-info/RECORD,,