bootstack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bootstack/__init__.py +157 -0
- bootstack/__main__.py +5 -0
- bootstack/_core/__init__.py +21 -0
- bootstack/_core/capabilities/__init__.py +45 -0
- bootstack/_core/capabilities/after.py +103 -0
- bootstack/_core/capabilities/bind.py +192 -0
- bootstack/_core/capabilities/bindtags.py +112 -0
- bootstack/_core/capabilities/busy.py +72 -0
- bootstack/_core/capabilities/clipboard.py +89 -0
- bootstack/_core/capabilities/focus.py +118 -0
- bootstack/_core/capabilities/grab.py +65 -0
- bootstack/_core/capabilities/grid.py +211 -0
- bootstack/_core/capabilities/localization.py +231 -0
- bootstack/_core/capabilities/pack.py +140 -0
- bootstack/_core/capabilities/place.py +113 -0
- bootstack/_core/capabilities/selection.py +136 -0
- bootstack/_core/capabilities/signals.py +244 -0
- bootstack/_core/capabilities/winfo.py +315 -0
- bootstack/_core/colorutils.py +234 -0
- bootstack/_core/exceptions.py +25 -0
- bootstack/_core/images.py +463 -0
- bootstack/_core/mixins/__init__.py +1 -0
- bootstack/_core/mixins/ttk_state.py +35 -0
- bootstack/_core/mixins/widget.py +132 -0
- bootstack/_core/paths.py +49 -0
- bootstack/_core/publisher.py +149 -0
- bootstack/_core/variables.py +62 -0
- bootstack/_runtime/__init__.py +3 -0
- bootstack/_runtime/app.py +930 -0
- bootstack/_runtime/base_window.py +945 -0
- bootstack/_runtime/events.py +399 -0
- bootstack/_runtime/shortcuts.py +496 -0
- bootstack/_runtime/tk_patch.py +43 -0
- bootstack/_runtime/toplevel.py +276 -0
- bootstack/_runtime/utility.py +457 -0
- bootstack/_runtime/visual_focus.py +240 -0
- bootstack/_runtime/window_utilities.py +1123 -0
- bootstack/assets/__init__.py +21 -0
- bootstack/assets/bootstack.ico +0 -0
- bootstack/assets/bootstack.png +0 -0
- bootstack/assets/elements/__init__.py +0 -0
- bootstack/assets/elements/badge-pill.png +0 -0
- bootstack/assets/elements/badge-square.png +0 -0
- bootstack/assets/elements/button-compact.png +0 -0
- bootstack/assets/elements/button-default.png +0 -0
- bootstack/assets/elements/buttongroup-after-h-compact.png +0 -0
- bootstack/assets/elements/buttongroup-after-h-default.png +0 -0
- bootstack/assets/elements/buttongroup-after-v-compact.png +0 -0
- bootstack/assets/elements/buttongroup-after-v-default.png +0 -0
- bootstack/assets/elements/buttongroup-before-h-compact.png +0 -0
- bootstack/assets/elements/buttongroup-before-h-default.png +0 -0
- bootstack/assets/elements/buttongroup-before-v-compact.png +0 -0
- bootstack/assets/elements/buttongroup-before-v-default.png +0 -0
- bootstack/assets/elements/buttongroup-center-h-compact.png +0 -0
- bootstack/assets/elements/buttongroup-center-h-default.png +0 -0
- bootstack/assets/elements/buttongroup-center-v-compact.png +0 -0
- bootstack/assets/elements/buttongroup-center-v-default.png +0 -0
- bootstack/assets/elements/card.png +0 -0
- bootstack/assets/elements/checkbox-checked.png +0 -0
- bootstack/assets/elements/checkbox-indeterminate.png +0 -0
- bootstack/assets/elements/checkbox-unchecked.png +0 -0
- bootstack/assets/elements/field.png +0 -0
- bootstack/assets/elements/input-addon-compact.png +0 -0
- bootstack/assets/elements/input-addon-default.png +0 -0
- bootstack/assets/elements/input-compact.png +0 -0
- bootstack/assets/elements/input-default.png +0 -0
- bootstack/assets/elements/list-item-separated.png +0 -0
- bootstack/assets/elements/list-item.png +0 -0
- bootstack/assets/elements/listrow-compact.png +0 -0
- bootstack/assets/elements/listrow-default.png +0 -0
- bootstack/assets/elements/manifest.toml +361 -0
- bootstack/assets/elements/menu-item.png +0 -0
- bootstack/assets/elements/navitem-compact.png +0 -0
- bootstack/assets/elements/navitem-default.png +0 -0
- bootstack/assets/elements/progressbar-h-compact.png +0 -0
- bootstack/assets/elements/progressbar-h-default.png +0 -0
- bootstack/assets/elements/progressbar-v-compact.png +0 -0
- bootstack/assets/elements/progressbar-v-default.png +0 -0
- bootstack/assets/elements/radiobutton.png +0 -0
- bootstack/assets/elements/scrollbar-horizontal.png +0 -0
- bootstack/assets/elements/scrollbar-vertical.png +0 -0
- bootstack/assets/elements/slider-handle.png +0 -0
- bootstack/assets/elements/slider-track-h.png +0 -0
- bootstack/assets/elements/slider-track-v.png +0 -0
- bootstack/assets/elements/switch-off.png +0 -0
- bootstack/assets/elements/switch-on.png +0 -0
- bootstack/assets/elements/tab-h.png +0 -0
- bootstack/assets/elements/tab-v.png +0 -0
- bootstack/assets/icons/bootstrap.ttf +0 -0
- bootstack/assets/icons/glyphmap.json +2080 -0
- bootstack/assets/icons/icon_metrics.json +12470 -0
- bootstack/assets/locales/ar/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/ar/LC_MESSAGES/bootstack.po +856 -0
- bootstack/assets/locales/bg/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/bg/LC_MESSAGES/bootstack.po +878 -0
- bootstack/assets/locales/cs/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/cs/LC_MESSAGES/bootstack.po +856 -0
- bootstack/assets/locales/da/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/da/LC_MESSAGES/bootstack.po +856 -0
- bootstack/assets/locales/de/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/de/LC_MESSAGES/bootstack.po +856 -0
- bootstack/assets/locales/en/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/en/LC_MESSAGES/bootstack.po +878 -0
- bootstack/assets/locales/es/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/es/LC_MESSAGES/bootstack.po +856 -0
- bootstack/assets/locales/fr/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/fr/LC_MESSAGES/bootstack.po +856 -0
- bootstack/assets/locales/he/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/he/LC_MESSAGES/bootstack.po +854 -0
- bootstack/assets/locales/hi/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/hi/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/it/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/it/LC_MESSAGES/bootstack.po +844 -0
- bootstack/assets/locales/ja/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/ja/LC_MESSAGES/bootstack.po +917 -0
- bootstack/assets/locales/ko/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/ko/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/nb/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/nb/LC_MESSAGES/bootstack.po +844 -0
- bootstack/assets/locales/nl/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/nl/LC_MESSAGES/bootstack.po +844 -0
- bootstack/assets/locales/pl/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/pl/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/pt/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/pt/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/sl/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/sl/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/sv/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/sv/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/tr/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/tr/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.po +845 -0
- bootstack/cli/__init__.py +133 -0
- bootstack/cli/__main__.py +6 -0
- bootstack/cli/add.py +395 -0
- bootstack/cli/appicon.py +285 -0
- bootstack/cli/build.py +115 -0
- bootstack/cli/config.py +313 -0
- bootstack/cli/demo.py +564 -0
- bootstack/cli/dev.py +153 -0
- bootstack/cli/doctor.py +195 -0
- bootstack/cli/icons.py +98 -0
- bootstack/cli/promote.py +120 -0
- bootstack/cli/pyinstaller.py +268 -0
- bootstack/cli/run.py +95 -0
- bootstack/cli/start.py +117 -0
- bootstack/cli/templates/__init__.py +931 -0
- bootstack/clipboard.py +48 -0
- bootstack/constants.py +318 -0
- bootstack/data/README.md +615 -0
- bootstack/data/__init__.py +78 -0
- bootstack/data/_observable.py +276 -0
- bootstack/data/base.py +780 -0
- bootstack/data/file_source.py +367 -0
- bootstack/data/memory_source.py +450 -0
- bootstack/data/query.py +367 -0
- bootstack/data/readers.py +289 -0
- bootstack/data/sqlite_source.py +869 -0
- bootstack/data/types.py +354 -0
- bootstack/data/writers.py +232 -0
- bootstack/dev/__init__.py +36 -0
- bootstack/dev/_capture.py +141 -0
- bootstack/dev/_env.py +43 -0
- bootstack/dev/_registry.py +140 -0
- bootstack/dev/_reloader.py +351 -0
- bootstack/dev/_reset.py +50 -0
- bootstack/dev/_watcher.py +91 -0
- bootstack/dialogs/__init__.py +948 -0
- bootstack/dialogs/_impl/__init__.py +47 -0
- bootstack/dialogs/_impl/colorchooser.py +588 -0
- bootstack/dialogs/_impl/datedialog.py +450 -0
- bootstack/dialogs/_impl/dialog.py +594 -0
- bootstack/dialogs/_impl/filterdialog.py +358 -0
- bootstack/dialogs/_impl/fontdialog.py +364 -0
- bootstack/dialogs/_impl/formdialog.py +564 -0
- bootstack/dialogs/_impl/message.py +486 -0
- bootstack/dialogs/_impl/query.py +570 -0
- bootstack/errors.py +67 -0
- bootstack/events/__init__.py +111 -0
- bootstack/events/_event.py +135 -0
- bootstack/events/_payloads.py +539 -0
- bootstack/events/_subscription.py +38 -0
- bootstack/i18n/README.md +77 -0
- bootstack/i18n/__init__.py +23 -0
- bootstack/i18n/catalog.py +121 -0
- bootstack/i18n/intl_format.py +584 -0
- bootstack/i18n/msgcat.py +425 -0
- bootstack/i18n/specs.py +156 -0
- bootstack/images.py +563 -0
- bootstack/py.typed +1 -0
- bootstack/scheduling/__init__.py +11 -0
- bootstack/scheduling/_schedule.py +218 -0
- bootstack/shortcuts.py +21 -0
- bootstack/signals/README.md +98 -0
- bootstack/signals/__init__.py +10 -0
- bootstack/signals/integration.py +100 -0
- bootstack/signals/signal.py +353 -0
- bootstack/signals/types.py +6 -0
- bootstack/store.py +286 -0
- bootstack/streams/__init__.py +12 -0
- bootstack/streams/_stream.py +321 -0
- bootstack/style/__init__.py +38 -0
- bootstack/style/builders/__init__.py +51 -0
- bootstack/style/builders/badge.py +46 -0
- bootstack/style/builders/button.py +339 -0
- bootstack/style/builders/buttongroup.py +311 -0
- bootstack/style/builders/calendar.py +271 -0
- bootstack/style/builders/checkbutton.py +110 -0
- bootstack/style/builders/combobox.py +113 -0
- bootstack/style/builders/contextmenu.py +268 -0
- bootstack/style/builders/entry.py +82 -0
- bootstack/style/builders/expander.py +148 -0
- bootstack/style/builders/field.py +335 -0
- bootstack/style/builders/frame.py +50 -0
- bootstack/style/builders/label.py +28 -0
- bootstack/style/builders/labelframe.py +34 -0
- bootstack/style/builders/listview.py +369 -0
- bootstack/style/builders/menubar.py +91 -0
- bootstack/style/builders/menubutton.py +359 -0
- bootstack/style/builders/panedwindow.py +25 -0
- bootstack/style/builders/progressbar.py +67 -0
- bootstack/style/builders/radiobutton.py +99 -0
- bootstack/style/builders/scale.py +64 -0
- bootstack/style/builders/scrollbar.py +225 -0
- bootstack/style/builders/separator.py +49 -0
- bootstack/style/builders/sidenav.py +643 -0
- bootstack/style/builders/sizegrip.py +15 -0
- bootstack/style/builders/spinbox.py +119 -0
- bootstack/style/builders/switch.py +70 -0
- bootstack/style/builders/tabitem.py +204 -0
- bootstack/style/builders/togglegroup.py +294 -0
- bootstack/style/builders/toolbutton.py +275 -0
- bootstack/style/builders/tooltip.py +26 -0
- bootstack/style/builders/treeview.py +193 -0
- bootstack/style/builders/utils.py +455 -0
- bootstack/style/builders_tk/__init__.py +16 -0
- bootstack/style/builders_tk/defaults.py +229 -0
- bootstack/style/element.py +173 -0
- bootstack/style/fonts.py +123 -0
- bootstack/style/style.py +609 -0
- bootstack/style/style_builder_base.py +716 -0
- bootstack/style/style_builder_mixed.py +93 -0
- bootstack/style/style_builder_tk.py +109 -0
- bootstack/style/style_builder_ttk.py +353 -0
- bootstack/style/style_resolver.py +447 -0
- bootstack/style/theme.py +245 -0
- bootstack/style/theme_provider.py +471 -0
- bootstack/style/themes/__init__.py +128 -0
- bootstack/style/tk_patch.py +5 -0
- bootstack/style/token_maps.py +41 -0
- bootstack/style/types.py +32 -0
- bootstack/style/typography.py +523 -0
- bootstack/style/utility.py +746 -0
- bootstack/types.py +39 -0
- bootstack/validation/__init__.py +6 -0
- bootstack/validation/types.py +15 -0
- bootstack/validation/validation_result.py +17 -0
- bootstack/validation/validation_rules.py +205 -0
- bootstack/widgets/__init__.py +74 -0
- bootstack/widgets/_core/__init__.py +31 -0
- bootstack/widgets/_core/app_config.py +266 -0
- bootstack/widgets/_core/base.py +461 -0
- bootstack/widgets/_core/container.py +334 -0
- bootstack/widgets/_core/context.py +35 -0
- bootstack/widgets/_core/events.py +130 -0
- bootstack/widgets/_core/field_mixin.py +353 -0
- bootstack/widgets/_core/icon_image_props.py +72 -0
- bootstack/widgets/_core/image_binding.py +93 -0
- bootstack/widgets/_core/navmodel.py +345 -0
- bootstack/widgets/_core/options.py +210 -0
- bootstack/widgets/_core/selection_group.py +130 -0
- bootstack/widgets/_core/window_controls.py +96 -0
- bootstack/widgets/_core/window_menu.py +246 -0
- bootstack/widgets/_impl/__init__.py +1 -0
- bootstack/widgets/_impl/_internal/__init__.py +0 -0
- bootstack/widgets/_impl/_internal/wrapper_base.py +307 -0
- bootstack/widgets/_impl/_parts/__init__.py +11 -0
- bootstack/widgets/_impl/_parts/numberentry_part.py +385 -0
- bootstack/widgets/_impl/_parts/spinnerentry_part.py +434 -0
- bootstack/widgets/_impl/_parts/textentry_part.py +406 -0
- bootstack/widgets/_impl/composites/__init__.py +33 -0
- bootstack/widgets/_impl/composites/_dateutils.py +33 -0
- bootstack/widgets/_impl/composites/_image_fit.py +105 -0
- bootstack/widgets/_impl/composites/accordion.py +381 -0
- bootstack/widgets/_impl/composites/avatar.py +191 -0
- bootstack/widgets/_impl/composites/buttongroup.py +371 -0
- bootstack/widgets/_impl/composites/calendar.py +972 -0
- bootstack/widgets/_impl/composites/carousel.py +567 -0
- bootstack/widgets/_impl/composites/chart.py +882 -0
- bootstack/widgets/_impl/composites/compositeframe.py +298 -0
- bootstack/widgets/_impl/composites/contextmenu.py +1951 -0
- bootstack/widgets/_impl/composites/dateentry.py +404 -0
- bootstack/widgets/_impl/composites/dropdownbutton.py +325 -0
- bootstack/widgets/_impl/composites/expander.py +515 -0
- bootstack/widgets/_impl/composites/field.py +670 -0
- bootstack/widgets/_impl/composites/form.py +1066 -0
- bootstack/widgets/_impl/composites/gallery.py +551 -0
- bootstack/widgets/_impl/composites/list/__init__.py +15 -0
- bootstack/widgets/_impl/composites/list/listitem.py +802 -0
- bootstack/widgets/_impl/composites/list/listview.py +1433 -0
- bootstack/widgets/_impl/composites/menu/__init__.py +18 -0
- bootstack/widgets/_impl/composites/menu/model.py +358 -0
- bootstack/widgets/_impl/composites/menu/render_native.py +134 -0
- bootstack/widgets/_impl/composites/menu/render_themed.py +134 -0
- bootstack/widgets/_impl/composites/meter.py +860 -0
- bootstack/widgets/_impl/composites/numericentry.py +201 -0
- bootstack/widgets/_impl/composites/pagestack.py +395 -0
- bootstack/widgets/_impl/composites/passwordentry.py +142 -0
- bootstack/widgets/_impl/composites/pathentry.py +168 -0
- bootstack/widgets/_impl/composites/picture.py +289 -0
- bootstack/widgets/_impl/composites/radiogroup.py +511 -0
- bootstack/widgets/_impl/composites/scrolledtext.py +375 -0
- bootstack/widgets/_impl/composites/scrolledtext.pyi +186 -0
- bootstack/widgets/_impl/composites/scrollview.py +764 -0
- bootstack/widgets/_impl/composites/selectbox.py +1026 -0
- bootstack/widgets/_impl/composites/shell/__init__.py +40 -0
- bootstack/widgets/_impl/composites/shell/content_host.py +52 -0
- bootstack/widgets/_impl/composites/shell/layout.py +335 -0
- bootstack/widgets/_impl/composites/shell/nav_panel.py +345 -0
- bootstack/widgets/_impl/composites/shell/providers.py +558 -0
- bootstack/widgets/_impl/composites/shell/rail.py +117 -0
- bootstack/widgets/_impl/composites/shell/shell.py +581 -0
- bootstack/widgets/_impl/composites/shell/workspace.py +273 -0
- bootstack/widgets/_impl/composites/sidenav/__init__.py +16 -0
- bootstack/widgets/_impl/composites/sidenav/header.py +81 -0
- bootstack/widgets/_impl/composites/sidenav/separator.py +44 -0
- bootstack/widgets/_impl/composites/slider/__init__.py +7 -0
- bootstack/widgets/_impl/composites/slider/_shared.py +195 -0
- bootstack/widgets/_impl/composites/slider/rangeslider.py +982 -0
- bootstack/widgets/_impl/composites/slider/slider.py +851 -0
- bootstack/widgets/_impl/composites/spinnerentry.py +185 -0
- bootstack/widgets/_impl/composites/tableview/__init__.py +5 -0
- bootstack/widgets/_impl/composites/tableview/tableview.py +3388 -0
- bootstack/widgets/_impl/composites/tableview/types.py +169 -0
- bootstack/widgets/_impl/composites/tabs/__init__.py +23 -0
- bootstack/widgets/_impl/composites/tabs/tabitem.py +389 -0
- bootstack/widgets/_impl/composites/tabs/tabs.py +974 -0
- bootstack/widgets/_impl/composites/tabs/tabview.py +650 -0
- bootstack/widgets/_impl/composites/textarea/__init__.py +26 -0
- bootstack/widgets/_impl/composites/textarea/change.py +46 -0
- bootstack/widgets/_impl/composites/textarea/codeeditor.py +526 -0
- bootstack/widgets/_impl/composites/textarea/core.py +495 -0
- bootstack/widgets/_impl/composites/textarea/decoration.py +42 -0
- bootstack/widgets/_impl/composites/textarea/diff.py +127 -0
- bootstack/widgets/_impl/composites/textarea/extensions/__init__.py +1 -0
- bootstack/widgets/_impl/composites/textarea/extensions/bracket_matcher.py +138 -0
- bootstack/widgets/_impl/composites/textarea/extensions/indent_guides.py +158 -0
- bootstack/widgets/_impl/composites/textarea/extensions/line_numbers.py +138 -0
- bootstack/widgets/_impl/composites/textarea/extensions/pygments_highlighter.py +312 -0
- bootstack/widgets/_impl/composites/textarea/extensions/smart_indent.py +177 -0
- bootstack/widgets/_impl/composites/textarea/filter.py +171 -0
- bootstack/widgets/_impl/composites/textarea/search_overlay.py +459 -0
- bootstack/widgets/_impl/composites/textarea/sidebar.py +88 -0
- bootstack/widgets/_impl/composites/textarea/style_registry.py +178 -0
- bootstack/widgets/_impl/composites/textarea/textarea.py +606 -0
- bootstack/widgets/_impl/composites/textarea/undo.py +217 -0
- bootstack/widgets/_impl/composites/textentry.py +57 -0
- bootstack/widgets/_impl/composites/timeentry.py +176 -0
- bootstack/widgets/_impl/composites/toast.py +390 -0
- bootstack/widgets/_impl/composites/toast_stack.py +156 -0
- bootstack/widgets/_impl/composites/togglegroup.py +418 -0
- bootstack/widgets/_impl/composites/toolbar.py +608 -0
- bootstack/widgets/_impl/composites/tooltip.py +491 -0
- bootstack/widgets/_impl/composites/tree/__init__.py +7 -0
- bootstack/widgets/_impl/composites/tree/source_binding.py +136 -0
- bootstack/widgets/_impl/composites/tree/treeitem.py +393 -0
- bootstack/widgets/_impl/composites/tree/treenode.py +174 -0
- bootstack/widgets/_impl/composites/tree/treeview.py +841 -0
- bootstack/widgets/_impl/mixins/__init__.py +24 -0
- bootstack/widgets/_impl/mixins/configure_mixin.py +216 -0
- bootstack/widgets/_impl/mixins/entry_mixin.py +134 -0
- bootstack/widgets/_impl/mixins/font_mixin.py +368 -0
- bootstack/widgets/_impl/mixins/icon_mixin.py +61 -0
- bootstack/widgets/_impl/mixins/localization_mixin.py +253 -0
- bootstack/widgets/_impl/mixins/signal_mixin.py +268 -0
- bootstack/widgets/_impl/mixins/validation_mixin.py +226 -0
- bootstack/widgets/_impl/primitives/__init__.py +49 -0
- bootstack/widgets/_impl/primitives/_menubutton.py +107 -0
- bootstack/widgets/_impl/primitives/badge.py +45 -0
- bootstack/widgets/_impl/primitives/button.py +76 -0
- bootstack/widgets/_impl/primitives/card.py +45 -0
- bootstack/widgets/_impl/primitives/checkbutton.py +124 -0
- bootstack/widgets/_impl/primitives/checktoggle.py +62 -0
- bootstack/widgets/_impl/primitives/combobox.py +156 -0
- bootstack/widgets/_impl/primitives/entry.py +87 -0
- bootstack/widgets/_impl/primitives/flexframe.py +448 -0
- bootstack/widgets/_impl/primitives/frame.py +185 -0
- bootstack/widgets/_impl/primitives/gridframe.py +546 -0
- bootstack/widgets/_impl/primitives/label.py +84 -0
- bootstack/widgets/_impl/primitives/labelframe.py +54 -0
- bootstack/widgets/_impl/primitives/optionmenu.py +387 -0
- bootstack/widgets/_impl/primitives/packframe.py +227 -0
- bootstack/widgets/_impl/primitives/panedwindow.py +45 -0
- bootstack/widgets/_impl/primitives/progressbar.py +83 -0
- bootstack/widgets/_impl/primitives/radiobutton.py +115 -0
- bootstack/widgets/_impl/primitives/radiotoggle.py +54 -0
- bootstack/widgets/_impl/primitives/scrollbar.py +42 -0
- bootstack/widgets/_impl/primitives/separator.py +43 -0
- bootstack/widgets/_impl/primitives/sizegrip.py +33 -0
- bootstack/widgets/_impl/primitives/spinbox.py +95 -0
- bootstack/widgets/_impl/primitives/switch.py +44 -0
- bootstack/widgets/_impl/primitives/treeview.py +69 -0
- bootstack/widgets/app.py +371 -0
- bootstack/widgets/appshell.py +1179 -0
- bootstack/widgets/avatar.py +140 -0
- bootstack/widgets/boolean_controls.py +455 -0
- bootstack/widgets/button.py +224 -0
- bootstack/widgets/buttongroup.py +239 -0
- bootstack/widgets/calendar.py +195 -0
- bootstack/widgets/card.py +159 -0
- bootstack/widgets/carousel.py +241 -0
- bootstack/widgets/chart.py +302 -0
- bootstack/widgets/codeeditor.py +675 -0
- bootstack/widgets/contextmenu.py +371 -0
- bootstack/widgets/datatable.py +688 -0
- bootstack/widgets/datefield.py +395 -0
- bootstack/widgets/divider.py +60 -0
- bootstack/widgets/expander.py +579 -0
- bootstack/widgets/form.py +200 -0
- bootstack/widgets/gallery.py +250 -0
- bootstack/widgets/gauge.py +168 -0
- bootstack/widgets/grid.py +121 -0
- bootstack/widgets/groupbox.py +162 -0
- bootstack/widgets/label.py +229 -0
- bootstack/widgets/listview.py +354 -0
- bootstack/widgets/menubutton.py +419 -0
- bootstack/widgets/numberfield.py +431 -0
- bootstack/widgets/pagestack.py +345 -0
- bootstack/widgets/passwordfield.py +383 -0
- bootstack/widgets/pathfield.py +454 -0
- bootstack/widgets/picture.py +251 -0
- bootstack/widgets/progressbar.py +106 -0
- bootstack/widgets/radio_variants.py +271 -0
- bootstack/widgets/radiogroup.py +228 -0
- bootstack/widgets/scrollbar.py +64 -0
- bootstack/widgets/scrollview.py +186 -0
- bootstack/widgets/select.py +302 -0
- bootstack/widgets/selectbutton.py +182 -0
- bootstack/widgets/sidebar_toggle.py +130 -0
- bootstack/widgets/sizegrip.py +42 -0
- bootstack/widgets/slider.py +408 -0
- bootstack/widgets/spinbox.py +147 -0
- bootstack/widgets/spinnerfield.py +413 -0
- bootstack/widgets/splash.py +367 -0
- bootstack/widgets/splitview.py +482 -0
- bootstack/widgets/stacks.py +249 -0
- bootstack/widgets/statusbar.py +189 -0
- bootstack/widgets/tabs.py +425 -0
- bootstack/widgets/textarea.py +459 -0
- bootstack/widgets/textfield.py +410 -0
- bootstack/widgets/theme_toggle.py +92 -0
- bootstack/widgets/timefield.py +340 -0
- bootstack/widgets/toast.py +314 -0
- bootstack/widgets/togglegroup.py +231 -0
- bootstack/widgets/toolbar.py +347 -0
- bootstack/widgets/tooltip.py +94 -0
- bootstack/widgets/tree.py +648 -0
- bootstack/widgets/types.py +305 -0
- bootstack/widgets/window.py +307 -0
- bootstack-0.1.0.dist-info/METADATA +301 -0
- bootstack-0.1.0.dist-info/RECORD +471 -0
- bootstack-0.1.0.dist-info/WHEEL +5 -0
- bootstack-0.1.0.dist-info/entry_points.txt +2 -0
- bootstack-0.1.0.dist-info/licenses/LICENSE +21 -0
- bootstack-0.1.0.dist-info/licenses/NOTICE +10 -0
- bootstack-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Capture a ``with bs.App()`` block's body from source for hot reload.
|
|
2
|
+
|
|
3
|
+
PROVISIONAL — part of the carved-out ``bootstack.dev`` surface.
|
|
4
|
+
|
|
5
|
+
The body of a ``with`` block runs in the caller's frame and is never handed to
|
|
6
|
+
the context manager, so a flag alone cannot re-run it. In dev mode the App
|
|
7
|
+
captures *where* its body lives (file + the ``as`` target name) and, on each
|
|
8
|
+
reload, re-reads the file and re-extracts the matching block's statements — so a
|
|
9
|
+
save is picked up. Matching is by the ``as`` target name, which is stable across
|
|
10
|
+
edits that shift line numbers.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from types import FrameType
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class BodyCapture:
|
|
22
|
+
"""Where a reloadable ``with`` block lives, plus its compiled body cache."""
|
|
23
|
+
|
|
24
|
+
filename: str
|
|
25
|
+
"""Absolute path of the source file holding the ``with`` block."""
|
|
26
|
+
target_names: tuple[str, ...]
|
|
27
|
+
"""The ``as <name>`` targets of the block (e.g. ``("app",)``)."""
|
|
28
|
+
module_globals: dict
|
|
29
|
+
"""The module namespace the body executes in (frame globals)."""
|
|
30
|
+
with_lineno: int
|
|
31
|
+
"""Line of the ``with`` statement at capture time (a matching hint only)."""
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def is_capturable(self) -> bool:
|
|
35
|
+
"""Whether this capture can drive an in-process reload."""
|
|
36
|
+
return bool(self.filename) and bool(self.target_names)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def capture_from_frame(frame: FrameType) -> BodyCapture | None:
|
|
40
|
+
"""Capture the enclosing ``with`` block from a caller frame.
|
|
41
|
+
|
|
42
|
+
Returns None when the block is not reloadable in-process — notably when it
|
|
43
|
+
is authored inside a function (frame locals differ from globals), so the
|
|
44
|
+
body's names cannot be safely re-bound by re-exec.
|
|
45
|
+
"""
|
|
46
|
+
# A module-level `with` runs with locals IS globals. Inside a function the
|
|
47
|
+
# two differ; re-execing the body into globals would mis-scope its names.
|
|
48
|
+
if frame.f_locals is not frame.f_globals:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
filename = frame.f_code.co_filename
|
|
52
|
+
lineno = frame.f_lineno
|
|
53
|
+
try:
|
|
54
|
+
source = _read(filename)
|
|
55
|
+
except OSError:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
node = _find_with_node(source, lineno)
|
|
59
|
+
if node is None:
|
|
60
|
+
return None
|
|
61
|
+
targets = _with_target_names(node)
|
|
62
|
+
if not targets:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
return BodyCapture(
|
|
66
|
+
filename=filename,
|
|
67
|
+
target_names=targets,
|
|
68
|
+
module_globals=frame.f_globals,
|
|
69
|
+
with_lineno=node.lineno,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def find_current_body(capture: BodyCapture) -> list[ast.stmt]:
|
|
74
|
+
"""Re-read the source and return the matching block's current statements.
|
|
75
|
+
|
|
76
|
+
Raises ``SyntaxError`` if the file no longer parses (the caller renders a
|
|
77
|
+
banner and keeps the process alive), or ``RuntimeError`` if the block can no
|
|
78
|
+
longer be located.
|
|
79
|
+
"""
|
|
80
|
+
source = _read(capture.filename)
|
|
81
|
+
tree = ast.parse(source, capture.filename) # SyntaxError propagates
|
|
82
|
+
node = _match_with_node(tree, capture)
|
|
83
|
+
if node is None:
|
|
84
|
+
raise RuntimeError(
|
|
85
|
+
"hot reload: could not locate the `with` block to reload "
|
|
86
|
+
f"(looking for `as {', '.join(capture.target_names)}` in "
|
|
87
|
+
f"{capture.filename})"
|
|
88
|
+
)
|
|
89
|
+
return list(node.body)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def compile_body(filename: str, body: list[ast.stmt]) -> Any:
|
|
93
|
+
"""Compile a block body (list of statements) into an executable code object."""
|
|
94
|
+
module = ast.Module(body=list(body), type_ignores=[])
|
|
95
|
+
ast.fix_missing_locations(module)
|
|
96
|
+
return compile(module, filename, "exec")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# --- internals ---------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _read(filename: str) -> str:
|
|
103
|
+
with open(filename, "r", encoding="utf-8") as fh:
|
|
104
|
+
return fh.read()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _find_with_node(source: str, lineno: int) -> ast.With | None:
|
|
108
|
+
"""Find the top-level ``with`` node at (or nearest to) a line."""
|
|
109
|
+
try:
|
|
110
|
+
tree = ast.parse(source)
|
|
111
|
+
except SyntaxError:
|
|
112
|
+
return None
|
|
113
|
+
candidates = [n for n in tree.body if isinstance(n, ast.With)]
|
|
114
|
+
if not candidates:
|
|
115
|
+
return None
|
|
116
|
+
for node in candidates:
|
|
117
|
+
if node.lineno == lineno:
|
|
118
|
+
return node
|
|
119
|
+
return min(candidates, key=lambda n: abs(n.lineno - lineno))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _match_with_node(tree: ast.Module, capture: BodyCapture) -> ast.With | None:
|
|
123
|
+
"""Locate the block to reload, preferring a target-name match."""
|
|
124
|
+
candidates = [n for n in tree.body if isinstance(n, ast.With)]
|
|
125
|
+
if not candidates:
|
|
126
|
+
return None
|
|
127
|
+
want = set(capture.target_names)
|
|
128
|
+
by_name = [n for n in candidates if want & set(_with_target_names(n))]
|
|
129
|
+
if by_name:
|
|
130
|
+
# Prefer the one nearest the original line if several share the name.
|
|
131
|
+
return min(by_name, key=lambda n: abs(n.lineno - capture.with_lineno))
|
|
132
|
+
return min(candidates, key=lambda n: abs(n.lineno - capture.with_lineno))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _with_target_names(node: ast.With) -> tuple[str, ...]:
|
|
136
|
+
names = []
|
|
137
|
+
for item in node.items:
|
|
138
|
+
var = item.optional_vars
|
|
139
|
+
if isinstance(var, ast.Name):
|
|
140
|
+
names.append(var.id)
|
|
141
|
+
return tuple(names)
|
bootstack/dev/_env.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Dev-mode environment detection for bootstack hot reload.
|
|
2
|
+
|
|
3
|
+
PROVISIONAL — the `bootstack.dev` surface ships in 0.1.0 but is carved out of
|
|
4
|
+
the API freeze: it may change in a minor release while it settles.
|
|
5
|
+
|
|
6
|
+
`bootstack dev <file>` launches an app with these variables set so that
|
|
7
|
+
`App.run()` installs the hot-reload loop instead of a plain mainloop.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
#: Set to ``"1"`` by ``bootstack dev`` to enable hot reload in the child process.
|
|
14
|
+
DEV_ENV_VAR = "BOOTSTACK_DEV"
|
|
15
|
+
|
|
16
|
+
#: Selects the reload strategy: ``"inprocess"`` (default — re-exec the with-body
|
|
17
|
+
#: in place, preserving state) or ``"restart"`` (re-exec the whole process on
|
|
18
|
+
#: save). ``bootstack dev --restart`` sets this to ``"restart"``.
|
|
19
|
+
DEV_MODE_VAR = "BOOTSTACK_DEV_MODE"
|
|
20
|
+
|
|
21
|
+
#: Reasonable minimum window size applied under ``bootstack dev`` when the app
|
|
22
|
+
#: did not set its own ``min_size`` — so the reload window starts usable and a
|
|
23
|
+
#: reload that shrinks content never collapses it below this floor. Dev-only;
|
|
24
|
+
#: production launches are unaffected.
|
|
25
|
+
DEV_MIN_SIZE = (640, 480)
|
|
26
|
+
|
|
27
|
+
#: Exit code the dev child uses to ask the ``bootstack dev`` supervisor to
|
|
28
|
+
#: relaunch it — the cross-platform restart signal. The app exits with this code
|
|
29
|
+
#: instead of calling ``os.execv``, which on Windows spawns a new process (new
|
|
30
|
+
#: PID) and terminates the caller rather than replacing it in place. The CLI
|
|
31
|
+
#: supervisor re-launches on this code; any other exit code ends the session.
|
|
32
|
+
DEV_RESTART_EXIT_CODE = 42
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_dev_mode() -> bool:
|
|
36
|
+
"""Return True when running under ``bootstack dev`` (hot reload enabled)."""
|
|
37
|
+
return os.environ.get(DEV_ENV_VAR) == "1"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def dev_mode() -> str:
|
|
41
|
+
"""Return the configured reload strategy: ``"inprocess"`` or ``"restart"``."""
|
|
42
|
+
mode = os.environ.get(DEV_MODE_VAR, "inprocess")
|
|
43
|
+
return mode if mode in ("inprocess", "restart") else "inprocess"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Registry behind ``@reloadable`` for deterministic multi-file hot reload.
|
|
2
|
+
|
|
3
|
+
PROVISIONAL — part of the carved-out ``bootstack.dev`` surface.
|
|
4
|
+
|
|
5
|
+
A page builder in another file calls bootstack widgets into whatever container
|
|
6
|
+
is active when it runs (the thread-local container stack). ``@reloadable`` marks
|
|
7
|
+
such a builder so the reloader can, when only that file changes, re-invoke the
|
|
8
|
+
*current* version of the builder into the exact region it last painted —
|
|
9
|
+
bypassing the entry module's possibly-stale ``from x import f`` binding and
|
|
10
|
+
giving per-page reload granularity.
|
|
11
|
+
|
|
12
|
+
In production (no dev mode) the decorator is a transparent no-op.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
from bootstack.dev._env import is_dev_mode
|
|
19
|
+
|
|
20
|
+
# qualified-name -> the most recently defined builder function. Reloading a page
|
|
21
|
+
# module re-runs its top level, which re-registers the builder under the same
|
|
22
|
+
# qualname, so this always points at the live version.
|
|
23
|
+
_builders: dict[str, Callable] = {}
|
|
24
|
+
|
|
25
|
+
# qualified-name -> list of mount records describing where the builder painted,
|
|
26
|
+
# so a per-page reload can clear and rebuild just those regions.
|
|
27
|
+
_mounts: dict[str, list["MountRecord"]] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MountRecord:
|
|
31
|
+
"""Where a ``@reloadable`` builder painted, for targeted re-invocation."""
|
|
32
|
+
|
|
33
|
+
__slots__ = ("container", "args", "kwargs")
|
|
34
|
+
|
|
35
|
+
def __init__(self, container: Any, args: tuple, kwargs: dict) -> None:
|
|
36
|
+
self.container = container
|
|
37
|
+
self.args = args
|
|
38
|
+
self.kwargs = kwargs
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def reloadable(func: Callable) -> Callable:
|
|
42
|
+
"""Mark a builder function as a hot-reload unit (no-op in production).
|
|
43
|
+
|
|
44
|
+
Apply to a function that *builds* part of the UI by calling widgets into the
|
|
45
|
+
active container::
|
|
46
|
+
|
|
47
|
+
@reloadable
|
|
48
|
+
def build_dashboard(db):
|
|
49
|
+
bs.Label("Dashboard", font="heading-lg")
|
|
50
|
+
bs.DataTable(data_source=db)
|
|
51
|
+
|
|
52
|
+
In dev mode the framework records where the builder mounted; when its file is
|
|
53
|
+
saved, only that region is torn down and rebuilt with the new code. Outside
|
|
54
|
+
dev mode the function is returned unchanged.
|
|
55
|
+
"""
|
|
56
|
+
if not is_dev_mode():
|
|
57
|
+
return func
|
|
58
|
+
|
|
59
|
+
qualname = f"{func.__module__}.{func.__qualname__}"
|
|
60
|
+
# Register at DECORATION time so importlib.reload (which re-runs the module
|
|
61
|
+
# top level, re-decorating) refreshes the builder to the new version. The
|
|
62
|
+
# wrapper then always invokes the current registration.
|
|
63
|
+
_builders[qualname] = func
|
|
64
|
+
|
|
65
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
66
|
+
from bootstack.widgets._core.context import current_container
|
|
67
|
+
|
|
68
|
+
container = current_container()
|
|
69
|
+
if container is not None:
|
|
70
|
+
_mounts.setdefault(qualname, []).append(
|
|
71
|
+
MountRecord(container, args, kwargs)
|
|
72
|
+
)
|
|
73
|
+
return _builders.get(qualname, func)(*args, **kwargs)
|
|
74
|
+
|
|
75
|
+
wrapper.__wrapped__ = func # type: ignore[attr-defined]
|
|
76
|
+
wrapper.__name__ = getattr(func, "__name__", "reloadable")
|
|
77
|
+
wrapper.__qualname__ = getattr(func, "__qualname__", "reloadable")
|
|
78
|
+
wrapper.__doc__ = func.__doc__
|
|
79
|
+
wrapper._bs_reloadable_qualname = qualname # type: ignore[attr-defined]
|
|
80
|
+
return wrapper
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def builders_in_module(module_name: str) -> list[str]:
|
|
84
|
+
"""Return the qualnames of registered builders defined in a module."""
|
|
85
|
+
prefix = module_name + "."
|
|
86
|
+
return [q for q in _builders if q.startswith(prefix)]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def reinvoke(qualname: str) -> bool:
|
|
90
|
+
"""Clear and rebuild every region a builder painted. Returns True if any ran.
|
|
91
|
+
|
|
92
|
+
Used for per-page reload: when only a page file changed, its builder(s) are
|
|
93
|
+
re-invoked into their recorded regions instead of re-running the entry body.
|
|
94
|
+
"""
|
|
95
|
+
from bootstack.dev._reset import clear_container
|
|
96
|
+
from bootstack.widgets._core.context import push_container, pop_container
|
|
97
|
+
|
|
98
|
+
func = _builders.get(qualname)
|
|
99
|
+
records = _mounts.get(qualname)
|
|
100
|
+
if func is None or not records:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
ran = False
|
|
104
|
+
for record in list(records):
|
|
105
|
+
container = record.container
|
|
106
|
+
if not _container_alive(container):
|
|
107
|
+
records.remove(record)
|
|
108
|
+
continue
|
|
109
|
+
clear_container(container)
|
|
110
|
+
push_container(container)
|
|
111
|
+
try:
|
|
112
|
+
func(*record.args, **record.kwargs)
|
|
113
|
+
ran = True
|
|
114
|
+
finally:
|
|
115
|
+
pop_container(container)
|
|
116
|
+
return ran
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def reset_mounts() -> None:
|
|
120
|
+
"""Forget mount records (used before a full rebuild re-records them).
|
|
121
|
+
|
|
122
|
+
Builders are kept — they are keyed by qualname and overwritten when their
|
|
123
|
+
module reloads, so there is nothing stale to clear, and dropping them would
|
|
124
|
+
discard registrations from modules that did not reload this pass.
|
|
125
|
+
"""
|
|
126
|
+
_mounts.clear()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def reset() -> None:
|
|
130
|
+
"""Forget all registered builders and mount records (used on teardown)."""
|
|
131
|
+
_builders.clear()
|
|
132
|
+
_mounts.clear()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _container_alive(container: Any) -> bool:
|
|
136
|
+
internal = getattr(container, "_internal", container)
|
|
137
|
+
try:
|
|
138
|
+
return bool(internal.winfo_exists())
|
|
139
|
+
except Exception:
|
|
140
|
+
return False
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""The hot-reload engine: watch, re-exec, recover.
|
|
2
|
+
|
|
3
|
+
PROVISIONAL — part of the carved-out ``bootstack.dev`` surface.
|
|
4
|
+
|
|
5
|
+
Two strategies, chosen in :func:`install_reloader`:
|
|
6
|
+
|
|
7
|
+
* **in-process** (default) — on save, re-read the entry file, reset the region
|
|
8
|
+
the ``with`` body built, and re-exec the body in place. Module-level state
|
|
9
|
+
(signals, datasources, stores) lives outside the body and survives. Multi-file
|
|
10
|
+
edits reload the changed page modules first; ``@reloadable`` builders rebuild
|
|
11
|
+
just their own region.
|
|
12
|
+
* **restart** — on save, exit with a sentinel code so the ``bootstack dev``
|
|
13
|
+
supervisor relaunches the process. The floor for apps that aren't a
|
|
14
|
+
module-level ``with bs.App()``; window geometry is persisted so it re-opens
|
|
15
|
+
where it was. (We avoid ``os.execv``: on Windows it does not replace the
|
|
16
|
+
process in place.)
|
|
17
|
+
|
|
18
|
+
The watcher only enqueues; all Tk work happens on a main-thread ``after`` pump,
|
|
19
|
+
so the one tkinter threading rule is never broken.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import queue
|
|
25
|
+
import sys
|
|
26
|
+
import traceback
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from bootstack.dev._capture import BodyCapture, compile_body, find_current_body
|
|
30
|
+
from bootstack.dev._env import dev_mode, is_dev_mode
|
|
31
|
+
from bootstack.dev._watcher import PollWatcher
|
|
32
|
+
|
|
33
|
+
_PREFIX = "[bootstack dev]"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _project_watch_root(start: str) -> str:
|
|
37
|
+
"""Widen the watch root from a starting file to the project tree.
|
|
38
|
+
|
|
39
|
+
Walks up from `start` looking for a `bootstack.toml`; when found, watches its
|
|
40
|
+
`src/` subtree if present (the conventional layout) else the project dir — so
|
|
41
|
+
edits to any module in the project, not just the entry file's directory, are
|
|
42
|
+
picked up. Falls back to the starting file's directory when no project marker
|
|
43
|
+
is found.
|
|
44
|
+
"""
|
|
45
|
+
start = os.path.abspath(start)
|
|
46
|
+
cur = start if os.path.isdir(start) else os.path.dirname(start)
|
|
47
|
+
base = cur
|
|
48
|
+
for _ in range(40): # bounded walk-up
|
|
49
|
+
if os.path.isfile(os.path.join(cur, "bootstack.toml")):
|
|
50
|
+
src = os.path.join(cur, "src")
|
|
51
|
+
return src if os.path.isdir(src) else cur
|
|
52
|
+
parent = os.path.dirname(cur)
|
|
53
|
+
if parent == cur:
|
|
54
|
+
break
|
|
55
|
+
cur = parent
|
|
56
|
+
return base
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def install_reloader(app: Any) -> None:
|
|
60
|
+
"""Install the appropriate reloader for the running app, if in dev mode."""
|
|
61
|
+
if not is_dev_mode():
|
|
62
|
+
return
|
|
63
|
+
mode = dev_mode()
|
|
64
|
+
supports = getattr(app, "_dev_supports_inprocess", False)
|
|
65
|
+
capture: BodyCapture | None = getattr(app, "_dev_body", None)
|
|
66
|
+
in_process_ok = supports and capture is not None and capture.is_capturable
|
|
67
|
+
|
|
68
|
+
if mode == "restart" or not in_process_ok:
|
|
69
|
+
if mode != "restart":
|
|
70
|
+
if not supports:
|
|
71
|
+
_log(
|
|
72
|
+
"in-process reload isn't supported for this app type yet - "
|
|
73
|
+
"using process restart on save"
|
|
74
|
+
)
|
|
75
|
+
elif capture is None or not capture.is_capturable:
|
|
76
|
+
_log(
|
|
77
|
+
"in-process reload needs a module-level `with bs.App()` - "
|
|
78
|
+
"using process restart on save"
|
|
79
|
+
)
|
|
80
|
+
_RestartReloader(app).install()
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
_InProcessReloader(app, capture).install()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _log(message: str) -> None:
|
|
87
|
+
print(f"{_PREFIX} {message}", flush=True)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _BaseReloader:
|
|
91
|
+
"""Shared watcher + main-thread pump plumbing."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, app: Any) -> None:
|
|
94
|
+
self.app = app
|
|
95
|
+
self.root = app._internal
|
|
96
|
+
self._queue: "queue.Queue[set[str]]" = queue.Queue()
|
|
97
|
+
self._watcher: PollWatcher | None = None
|
|
98
|
+
self._pump_id: Any = None
|
|
99
|
+
|
|
100
|
+
def _watch_root(self) -> str:
|
|
101
|
+
raise NotImplementedError
|
|
102
|
+
|
|
103
|
+
def install(self) -> None:
|
|
104
|
+
self._watcher = PollWatcher(self._watch_root(), self._queue.put)
|
|
105
|
+
self._watcher.start()
|
|
106
|
+
self._schedule_pump()
|
|
107
|
+
|
|
108
|
+
def _schedule_pump(self) -> None:
|
|
109
|
+
try:
|
|
110
|
+
self._pump_id = self.root.after(120, self._pump)
|
|
111
|
+
except Exception:
|
|
112
|
+
self._pump_id = None
|
|
113
|
+
|
|
114
|
+
def _pump(self) -> None:
|
|
115
|
+
changed: set[str] = set()
|
|
116
|
+
try:
|
|
117
|
+
while True:
|
|
118
|
+
changed |= self._queue.get_nowait()
|
|
119
|
+
except queue.Empty:
|
|
120
|
+
pass
|
|
121
|
+
if changed:
|
|
122
|
+
try:
|
|
123
|
+
self._handle(changed)
|
|
124
|
+
except Exception:
|
|
125
|
+
traceback.print_exc()
|
|
126
|
+
try:
|
|
127
|
+
if self.root.winfo_exists():
|
|
128
|
+
self._schedule_pump()
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
def _handle(self, changed: set[str]) -> None:
|
|
133
|
+
raise NotImplementedError
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class _RestartReloader(_BaseReloader):
|
|
137
|
+
"""Re-exec the whole process on save (the robust floor)."""
|
|
138
|
+
|
|
139
|
+
def install(self) -> None:
|
|
140
|
+
super().install()
|
|
141
|
+
_log(f"watching for changes - process restart on save (mode={dev_mode()})")
|
|
142
|
+
|
|
143
|
+
def _watch_root(self) -> str:
|
|
144
|
+
entry = sys.argv[0] if sys.argv and sys.argv[0] else os.getcwd()
|
|
145
|
+
return _project_watch_root(entry) or os.getcwd()
|
|
146
|
+
|
|
147
|
+
def _handle(self, changed: set[str]) -> None:
|
|
148
|
+
_log("change detected - restarting")
|
|
149
|
+
if self._watcher is not None:
|
|
150
|
+
self._watcher.stop()
|
|
151
|
+
# Persist geometry (and, for a shell, the selected page) so the
|
|
152
|
+
# relaunched process re-opens in place. We exit immediately, skipping
|
|
153
|
+
# destroy, so save now.
|
|
154
|
+
for attr in ("_save_window_state", "_save_nav_state"):
|
|
155
|
+
save = getattr(self.app._internal, attr, None)
|
|
156
|
+
if callable(save):
|
|
157
|
+
try:
|
|
158
|
+
save()
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
sys.stdout.flush()
|
|
162
|
+
sys.stderr.flush()
|
|
163
|
+
# Ask the `bootstack dev` supervisor to relaunch us by exiting with the
|
|
164
|
+
# sentinel code. We do NOT os.execv: on Windows it spawns a new process
|
|
165
|
+
# and terminates this one, orphaning the app and dropping the supervisor
|
|
166
|
+
# after the first reload. os._exit skips interpreter cleanup/atexit — the
|
|
167
|
+
# supervisor owns the new process's lifecycle.
|
|
168
|
+
from bootstack.dev._env import DEV_RESTART_EXIT_CODE
|
|
169
|
+
|
|
170
|
+
os._exit(DEV_RESTART_EXIT_CODE)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class _InProcessReloader(_BaseReloader):
|
|
174
|
+
"""Reset and re-exec the ``with`` body in place, preserving module state."""
|
|
175
|
+
|
|
176
|
+
def __init__(self, app: Any, capture: BodyCapture) -> None:
|
|
177
|
+
super().__init__(app)
|
|
178
|
+
self.capture = capture
|
|
179
|
+
self._error_banner: Any = None
|
|
180
|
+
|
|
181
|
+
def install(self) -> None:
|
|
182
|
+
super().install()
|
|
183
|
+
rel = os.path.relpath(self.capture.filename)
|
|
184
|
+
_log(f"hot reload active - editing {rel} updates the running app on save")
|
|
185
|
+
|
|
186
|
+
def _watch_root(self) -> str:
|
|
187
|
+
return _project_watch_root(self.capture.filename)
|
|
188
|
+
|
|
189
|
+
def _handle(self, changed: set[str]) -> None:
|
|
190
|
+
entry = os.path.abspath(self.capture.filename)
|
|
191
|
+
changed_abs = {os.path.abspath(p) for p in changed}
|
|
192
|
+
entry_changed = entry in changed_abs
|
|
193
|
+
page_files = changed_abs - {entry}
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
# Per-page fast path: the entry is unchanged AND every changed file is a
|
|
197
|
+
# module that holds registered @reloadable builders — rebuild just those
|
|
198
|
+
# regions, skip the entry body. A changed module WITHOUT a builder
|
|
199
|
+
# (constants/models with module-level state) must take the full path:
|
|
200
|
+
# importlib.reload would otherwise split object identity (fresh objects
|
|
201
|
+
# while live widgets still hold the old ones) without rebinding. (#326)
|
|
202
|
+
builder_files = set(self._builder_module_files())
|
|
203
|
+
if not entry_changed and page_files and page_files <= builder_files:
|
|
204
|
+
reloaded = self._reload_modules(page_files)
|
|
205
|
+
if reloaded and self._reinvoke_pages(reloaded):
|
|
206
|
+
self._clear_error()
|
|
207
|
+
self.app._dev_after_rebuild()
|
|
208
|
+
_log("reloaded " + ", ".join(sorted(reloaded)))
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
# Full reload: reload any changed project modules first (so the re-exec'd
|
|
212
|
+
# entry body rebinds to the fresh objects), then re-run the body.
|
|
213
|
+
self._reload_modules(page_files)
|
|
214
|
+
self._full_reload()
|
|
215
|
+
self._clear_error()
|
|
216
|
+
_log("reloaded")
|
|
217
|
+
except SyntaxError as exc:
|
|
218
|
+
self._show_error(_format_syntax_error(exc))
|
|
219
|
+
_log("syntax error - fix and save again")
|
|
220
|
+
except Exception:
|
|
221
|
+
self._show_error(traceback.format_exc())
|
|
222
|
+
_log("reload failed - see the banner; fix and save again")
|
|
223
|
+
|
|
224
|
+
def _full_reload(self) -> None:
|
|
225
|
+
# Compile BEFORE tearing down, so a syntax error never blanks the UI.
|
|
226
|
+
body = find_current_body(self.capture)
|
|
227
|
+
code = compile_body(self.capture.filename, body)
|
|
228
|
+
|
|
229
|
+
route = _safe(self.app._dev_capture_route)
|
|
230
|
+
self.app._dev_reset_region()
|
|
231
|
+
|
|
232
|
+
from bootstack.widgets._core.context import push_container, pop_container
|
|
233
|
+
|
|
234
|
+
push_container(self.app)
|
|
235
|
+
try:
|
|
236
|
+
exec(code, self.capture.module_globals)
|
|
237
|
+
finally:
|
|
238
|
+
pop_container(self.app)
|
|
239
|
+
|
|
240
|
+
self.app._dev_after_rebuild()
|
|
241
|
+
if route is not None:
|
|
242
|
+
try:
|
|
243
|
+
self.app._dev_restore_route(route)
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
def _reinvoke_pages(self, modules: set[str]) -> bool:
|
|
248
|
+
from bootstack.dev import _registry
|
|
249
|
+
|
|
250
|
+
ran = False
|
|
251
|
+
for module in modules:
|
|
252
|
+
for qualname in _registry.builders_in_module(module):
|
|
253
|
+
if _registry.reinvoke(qualname):
|
|
254
|
+
ran = True
|
|
255
|
+
return ran
|
|
256
|
+
|
|
257
|
+
def _builder_module_files(self) -> dict[str, str]:
|
|
258
|
+
"""Map abspath -> module name for loaded modules holding registered builders."""
|
|
259
|
+
from bootstack.dev import _registry
|
|
260
|
+
|
|
261
|
+
out: dict[str, str] = {}
|
|
262
|
+
for name, module in list(sys.modules.items()):
|
|
263
|
+
path = getattr(module, "__file__", None)
|
|
264
|
+
if path and _registry.builders_in_module(name):
|
|
265
|
+
out[os.path.abspath(path)] = name
|
|
266
|
+
return out
|
|
267
|
+
|
|
268
|
+
def _reload_modules(self, files: set[str]) -> set[str]:
|
|
269
|
+
"""importlib.reload any loaded project module whose file changed."""
|
|
270
|
+
if not files:
|
|
271
|
+
return set()
|
|
272
|
+
import importlib
|
|
273
|
+
|
|
274
|
+
reloaded: set[str] = set()
|
|
275
|
+
for name, module in list(sys.modules.items()):
|
|
276
|
+
path = getattr(module, "__file__", None)
|
|
277
|
+
if not path:
|
|
278
|
+
continue
|
|
279
|
+
if os.path.abspath(path) in files:
|
|
280
|
+
importlib.reload(module) # exceptions surface as a banner
|
|
281
|
+
reloaded.add(name)
|
|
282
|
+
return reloaded
|
|
283
|
+
|
|
284
|
+
# --- error banner --------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
def _show_error(self, text: str) -> None:
|
|
287
|
+
import tkinter as tk
|
|
288
|
+
|
|
289
|
+
self._clear_error()
|
|
290
|
+
# `_content_frame` is an attribute on App but a method on AppShell/
|
|
291
|
+
# Workbench — resolve both to the content widget that hosts the banner.
|
|
292
|
+
frame = getattr(self.app, "_content_frame", None)
|
|
293
|
+
if callable(frame):
|
|
294
|
+
frame = frame()
|
|
295
|
+
if frame is None:
|
|
296
|
+
sys.stderr.write(text + "\n")
|
|
297
|
+
return
|
|
298
|
+
try:
|
|
299
|
+
banner = tk.Frame(frame, background="#2b0f10")
|
|
300
|
+
banner.place(relx=0, rely=0, relwidth=1, relheight=1)
|
|
301
|
+
tk.Label(
|
|
302
|
+
banner,
|
|
303
|
+
text="Hot reload failed - fix the error and save again",
|
|
304
|
+
background="#2b0f10",
|
|
305
|
+
foreground="#ff7b72",
|
|
306
|
+
font=("TkDefaultFont", 11, "bold"),
|
|
307
|
+
anchor="w",
|
|
308
|
+
).pack(fill="x", padx=14, pady=(12, 4))
|
|
309
|
+
body = tk.Text(
|
|
310
|
+
banner,
|
|
311
|
+
background="#2b0f10",
|
|
312
|
+
foreground="#ffd7d5",
|
|
313
|
+
relief="flat",
|
|
314
|
+
borderwidth=0,
|
|
315
|
+
wrap="word",
|
|
316
|
+
padx=14,
|
|
317
|
+
pady=4,
|
|
318
|
+
highlightthickness=0,
|
|
319
|
+
)
|
|
320
|
+
body.insert("1.0", text)
|
|
321
|
+
body.configure(state="disabled")
|
|
322
|
+
body.pack(fill="both", expand=True)
|
|
323
|
+
self._error_banner = banner
|
|
324
|
+
except Exception:
|
|
325
|
+
sys.stderr.write(text + "\n")
|
|
326
|
+
|
|
327
|
+
def _clear_error(self) -> None:
|
|
328
|
+
banner = self._error_banner
|
|
329
|
+
self._error_banner = None
|
|
330
|
+
if banner is not None:
|
|
331
|
+
try:
|
|
332
|
+
banner.destroy()
|
|
333
|
+
except Exception:
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _safe(fn: Any) -> Any:
|
|
338
|
+
try:
|
|
339
|
+
return fn()
|
|
340
|
+
except Exception:
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _format_syntax_error(exc: SyntaxError) -> str:
|
|
345
|
+
where = f"{exc.filename}:{exc.lineno}"
|
|
346
|
+
parts = [f"SyntaxError: {exc.msg}", f" at {where}"]
|
|
347
|
+
if exc.text:
|
|
348
|
+
parts.append(" " + exc.text.rstrip())
|
|
349
|
+
if exc.offset:
|
|
350
|
+
parts.append(" " + " " * (exc.offset - 1) + "^")
|
|
351
|
+
return "\n".join(parts)
|