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,345 @@
|
|
|
1
|
+
"""Headless navigation state for the application shell.
|
|
2
|
+
|
|
3
|
+
`NavModel` is the single source of truth for shell navigation: the ordered set
|
|
4
|
+
of workspaces, which workspace is active, the active page *per* workspace
|
|
5
|
+
(persistent memory), the sidebar's `hidden -> compact -> expanded` axis, and the
|
|
6
|
+
detail-dock visibility. It is deliberately **Tk-free and testable headless** —
|
|
7
|
+
controls dispatch into it via the transition methods, and view regions subscribe
|
|
8
|
+
to it via `subscribe()`.
|
|
9
|
+
|
|
10
|
+
The model encodes two design invariants:
|
|
11
|
+
|
|
12
|
+
- The sidebar is a single linear axis `hidden -> compact -> expanded`, so an
|
|
13
|
+
incoherent state such as "hidden but expanded" is unrepresentable.
|
|
14
|
+
- The rail (workspace switcher) renders only when there is more than one
|
|
15
|
+
workspace, so a single-tier app is the degenerate one-workspace case with no
|
|
16
|
+
branching in the consumer.
|
|
17
|
+
|
|
18
|
+
See `development/appshell-navigation-spec.md` for the full design.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Callable, Literal
|
|
25
|
+
|
|
26
|
+
from bootstack._core import NavigationError
|
|
27
|
+
from bootstack.streams import Handle
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
SidebarMode = Literal["hidden", "compact", "expanded"]
|
|
31
|
+
"""The sidebar's position on its single linear visibility/compaction axis."""
|
|
32
|
+
|
|
33
|
+
ProviderKind = Literal["pages", "list", "tree", "custom"]
|
|
34
|
+
"""Which navigation provider fills a workspace's sidebar panel."""
|
|
35
|
+
|
|
36
|
+
NavFacet = Literal["workspace", "page", "sidebar", "dock", "structure"]
|
|
37
|
+
"""Which part of the navigation state a `NavChange` describes."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class WorkspaceState:
|
|
42
|
+
"""A workspace registered in the navigation model.
|
|
43
|
+
|
|
44
|
+
A workspace owns one sidebar panel (filled by a single provider) and
|
|
45
|
+
remembers its own active page. The rail renders one icon per workspace.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
key: str
|
|
49
|
+
"""Unique identifier for the workspace."""
|
|
50
|
+
text: str = ""
|
|
51
|
+
"""Rail label / tooltip text."""
|
|
52
|
+
icon: str | dict | None = None
|
|
53
|
+
"""Rail glyph — an icon name or icon-spec dict."""
|
|
54
|
+
is_footer: bool = False
|
|
55
|
+
"""Whether the rail icon is pinned to the bottom of the rail."""
|
|
56
|
+
provider: ProviderKind = "pages"
|
|
57
|
+
"""The provider that fills this workspace's sidebar panel."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class NavChange:
|
|
62
|
+
"""A notification emitted to subscribers when the model changes.
|
|
63
|
+
|
|
64
|
+
Only the field(s) relevant to `facet` are populated; subscribers filter on
|
|
65
|
+
`facet` and read the corresponding field (or the model itself).
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
facet: NavFacet
|
|
69
|
+
"""Which part of the state changed."""
|
|
70
|
+
workspace: str | None = None
|
|
71
|
+
"""The affected workspace key, for `'workspace'`/`'page'`/`'structure'`."""
|
|
72
|
+
page: str | None = None
|
|
73
|
+
"""The newly active page key, for `'page'`."""
|
|
74
|
+
sidebar_mode: SidebarMode | None = None
|
|
75
|
+
"""The new sidebar mode, for `'sidebar'`."""
|
|
76
|
+
dock_visible: bool | None = None
|
|
77
|
+
"""The new dock visibility, for `'dock'`."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class NavModel:
|
|
81
|
+
"""Observable, Tk-free navigation state for the shell.
|
|
82
|
+
|
|
83
|
+
Construct empty, register workspaces with `add_workspace()`, then drive
|
|
84
|
+
selection and visibility through the transition methods. Subscribe with
|
|
85
|
+
`subscribe()` to react to changes.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
sidebar_mode: Initial sidebar mode. Default `'expanded'`.
|
|
89
|
+
dock_visible: Initial detail-dock visibility. Default `False`.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
sidebar_mode: SidebarMode = "expanded",
|
|
96
|
+
dock_visible: bool = False,
|
|
97
|
+
) -> None:
|
|
98
|
+
self._workspaces: list[WorkspaceState] = []
|
|
99
|
+
self._index: dict[str, WorkspaceState] = {}
|
|
100
|
+
self._active_workspace: str | None = None
|
|
101
|
+
self._active_page: dict[str, str] = {}
|
|
102
|
+
self._sidebar_mode: SidebarMode = sidebar_mode
|
|
103
|
+
# The mode to restore when un-hiding; never 'hidden'.
|
|
104
|
+
self._restore_mode: SidebarMode = (
|
|
105
|
+
sidebar_mode if sidebar_mode != "hidden" else "expanded"
|
|
106
|
+
)
|
|
107
|
+
self._dock_visible: bool = dock_visible
|
|
108
|
+
self._listeners: list[Callable[[NavChange], None]] = []
|
|
109
|
+
|
|
110
|
+
# ----- Read state -----
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def workspaces(self) -> tuple[WorkspaceState, ...]:
|
|
114
|
+
"""All workspaces in insertion order (footers included)."""
|
|
115
|
+
return tuple(self._workspaces)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def active_workspace(self) -> str | None:
|
|
119
|
+
"""Key of the active workspace, or `None` if none is active."""
|
|
120
|
+
return self._active_workspace
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def sidebar_mode(self) -> SidebarMode:
|
|
124
|
+
"""The sidebar's current position on the `hidden/compact/expanded` axis."""
|
|
125
|
+
return self._sidebar_mode
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def sidebar_visible(self) -> bool:
|
|
129
|
+
"""Whether the sidebar is shown (i.e. not `'hidden'`)."""
|
|
130
|
+
return self._sidebar_mode != "hidden"
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def dock_visible(self) -> bool:
|
|
134
|
+
"""Whether the detail/inspector dock is shown."""
|
|
135
|
+
return self._dock_visible
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def rail_visible(self) -> bool:
|
|
139
|
+
"""Whether the workspace rail renders — true with more than one workspace.
|
|
140
|
+
|
|
141
|
+
A single-tier app has one workspace, so the rail is hidden; the switcher
|
|
142
|
+
is therefore optional with no branching in the consumer.
|
|
143
|
+
"""
|
|
144
|
+
return len(self._workspaces) > 1
|
|
145
|
+
|
|
146
|
+
def workspace(self, key: str) -> WorkspaceState:
|
|
147
|
+
"""Return the workspace registered under `key`.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
NavigationError: If no workspace with that key exists.
|
|
151
|
+
"""
|
|
152
|
+
if key not in self._index:
|
|
153
|
+
raise NavigationError(f"No workspace with key {key!r}")
|
|
154
|
+
return self._index[key]
|
|
155
|
+
|
|
156
|
+
def has_workspace(self, key: str) -> bool:
|
|
157
|
+
"""Return whether a workspace is registered under `key`."""
|
|
158
|
+
return key in self._index
|
|
159
|
+
|
|
160
|
+
def active_page(self, workspace: str | None = None) -> str | None:
|
|
161
|
+
"""Return the remembered active page for a workspace.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
workspace: Workspace key; defaults to the active workspace.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The remembered page key, or `None` if the workspace has none yet.
|
|
168
|
+
"""
|
|
169
|
+
ws_key = workspace if workspace is not None else self._active_workspace
|
|
170
|
+
if ws_key is None:
|
|
171
|
+
return None
|
|
172
|
+
return self._active_page.get(ws_key)
|
|
173
|
+
|
|
174
|
+
# ----- Transitions -----
|
|
175
|
+
|
|
176
|
+
def add_workspace(
|
|
177
|
+
self,
|
|
178
|
+
key: str,
|
|
179
|
+
*,
|
|
180
|
+
text: str = "",
|
|
181
|
+
icon: str | dict | None = None,
|
|
182
|
+
is_footer: bool = False,
|
|
183
|
+
provider: ProviderKind = "pages",
|
|
184
|
+
) -> WorkspaceState:
|
|
185
|
+
"""Register a workspace and return its state.
|
|
186
|
+
|
|
187
|
+
The first non-footer workspace added becomes active automatically, so a
|
|
188
|
+
single-tier app shows content without an explicit `select_workspace()`.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
key: Unique workspace identifier.
|
|
192
|
+
text: Rail label / tooltip.
|
|
193
|
+
icon: Rail glyph (icon name or icon-spec dict).
|
|
194
|
+
is_footer: Pin the rail icon to the rail bottom.
|
|
195
|
+
provider: Provider kind filling the workspace's sidebar.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
The created `WorkspaceState`.
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
ValueError: If `key` is empty.
|
|
202
|
+
NavigationError: If `key` is already registered.
|
|
203
|
+
"""
|
|
204
|
+
if not key:
|
|
205
|
+
raise ValueError("workspace key must be non-empty")
|
|
206
|
+
if key in self._index:
|
|
207
|
+
raise NavigationError(f"Workspace {key!r} already exists")
|
|
208
|
+
|
|
209
|
+
ws = WorkspaceState(
|
|
210
|
+
key=key, text=text, icon=icon, is_footer=is_footer, provider=provider
|
|
211
|
+
)
|
|
212
|
+
self._workspaces.append(ws)
|
|
213
|
+
self._index[key] = ws
|
|
214
|
+
|
|
215
|
+
if self._active_workspace is None and not is_footer:
|
|
216
|
+
self._active_workspace = key
|
|
217
|
+
|
|
218
|
+
self._notify(NavChange(facet="structure", workspace=key))
|
|
219
|
+
return ws
|
|
220
|
+
|
|
221
|
+
def select_workspace(self, key: str) -> None:
|
|
222
|
+
"""Make `key` the active workspace (no effect on sidebar visibility).
|
|
223
|
+
|
|
224
|
+
This is the lower-level primitive; `activate_rail()` layers the VS Code
|
|
225
|
+
rail gesture on top.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
NavigationError: If no workspace with that key exists.
|
|
229
|
+
"""
|
|
230
|
+
if key not in self._index:
|
|
231
|
+
raise NavigationError(f"No workspace with key {key!r}")
|
|
232
|
+
if key == self._active_workspace:
|
|
233
|
+
return
|
|
234
|
+
self._active_workspace = key
|
|
235
|
+
self._notify(NavChange(facet="workspace", workspace=key))
|
|
236
|
+
|
|
237
|
+
def select_page(self, key: str, *, workspace: str | None = None) -> None:
|
|
238
|
+
"""Set the active page for a workspace (its remembered page).
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
key: Page key.
|
|
242
|
+
workspace: Workspace to set it on; defaults to the active workspace.
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
NavigationError: If the target workspace does not exist.
|
|
246
|
+
ValueError: If there is no active workspace and none is given.
|
|
247
|
+
"""
|
|
248
|
+
ws_key = workspace if workspace is not None else self._active_workspace
|
|
249
|
+
if ws_key is None:
|
|
250
|
+
raise ValueError("no active workspace to select a page on")
|
|
251
|
+
if ws_key not in self._index:
|
|
252
|
+
raise NavigationError(f"No workspace with key {ws_key!r}")
|
|
253
|
+
if self._active_page.get(ws_key) == key:
|
|
254
|
+
return
|
|
255
|
+
self._active_page[ws_key] = key
|
|
256
|
+
self._notify(NavChange(facet="page", workspace=ws_key, page=key))
|
|
257
|
+
|
|
258
|
+
def activate_rail(self, key: str) -> None:
|
|
259
|
+
"""Handle a rail icon click — the VS Code workspace-switch gesture.
|
|
260
|
+
|
|
261
|
+
Clicking the **active** workspace's icon hides the sidebar (or shows it
|
|
262
|
+
again if already hidden); clicking a **different** workspace's icon
|
|
263
|
+
switches to it and shows the sidebar.
|
|
264
|
+
|
|
265
|
+
Raises:
|
|
266
|
+
NavigationError: If no workspace with that key exists.
|
|
267
|
+
"""
|
|
268
|
+
if key not in self._index:
|
|
269
|
+
raise NavigationError(f"No workspace with key {key!r}")
|
|
270
|
+
if key == self._active_workspace:
|
|
271
|
+
self.toggle_sidebar()
|
|
272
|
+
else:
|
|
273
|
+
self.select_workspace(key)
|
|
274
|
+
self.show_sidebar()
|
|
275
|
+
|
|
276
|
+
def set_sidebar_mode(self, mode: SidebarMode) -> None:
|
|
277
|
+
"""Set the sidebar mode directly.
|
|
278
|
+
|
|
279
|
+
Setting a non-hidden mode records it as the mode to restore when the
|
|
280
|
+
sidebar is next un-hidden.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
mode: `'hidden'`, `'compact'`, or `'expanded'`.
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
ValueError: If `mode` is not a valid sidebar mode.
|
|
287
|
+
"""
|
|
288
|
+
if mode not in ("hidden", "compact", "expanded"):
|
|
289
|
+
raise ValueError(f"invalid sidebar mode: {mode!r}")
|
|
290
|
+
if mode == self._sidebar_mode:
|
|
291
|
+
return
|
|
292
|
+
if mode == "hidden":
|
|
293
|
+
self._restore_mode = self._sidebar_mode # current is non-hidden here
|
|
294
|
+
else:
|
|
295
|
+
self._restore_mode = mode
|
|
296
|
+
self._sidebar_mode = mode
|
|
297
|
+
self._notify(NavChange(facet="sidebar", sidebar_mode=mode))
|
|
298
|
+
|
|
299
|
+
def show_sidebar(self) -> None:
|
|
300
|
+
"""Show the sidebar, restoring the last non-hidden mode."""
|
|
301
|
+
if self._sidebar_mode == "hidden":
|
|
302
|
+
self.set_sidebar_mode(self._restore_mode)
|
|
303
|
+
|
|
304
|
+
def hide_sidebar(self) -> None:
|
|
305
|
+
"""Hide the sidebar entirely (the visibility axis)."""
|
|
306
|
+
self.set_sidebar_mode("hidden")
|
|
307
|
+
|
|
308
|
+
def toggle_sidebar(self) -> None:
|
|
309
|
+
"""Toggle sidebar visibility — the hamburger / shortcut action."""
|
|
310
|
+
if self._sidebar_mode == "hidden":
|
|
311
|
+
self.show_sidebar()
|
|
312
|
+
else:
|
|
313
|
+
self.hide_sidebar()
|
|
314
|
+
|
|
315
|
+
def toggle_dock(self) -> None:
|
|
316
|
+
"""Toggle the detail/inspector dock visibility (its own axis)."""
|
|
317
|
+
self._dock_visible = not self._dock_visible
|
|
318
|
+
self._notify(NavChange(facet="dock", dock_visible=self._dock_visible))
|
|
319
|
+
|
|
320
|
+
# ----- Subscription -----
|
|
321
|
+
|
|
322
|
+
def subscribe(self, listener: Callable[[NavChange], None]) -> Handle:
|
|
323
|
+
"""Register `listener` for change notifications.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
listener: Called with a `NavChange` on every state transition.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
A cancellable `Handle` (call `.cancel()`, or use as a context
|
|
330
|
+
manager) that unsubscribes the listener.
|
|
331
|
+
"""
|
|
332
|
+
self._listeners.append(listener)
|
|
333
|
+
|
|
334
|
+
def unsubscribe() -> None:
|
|
335
|
+
try:
|
|
336
|
+
self._listeners.remove(listener)
|
|
337
|
+
except ValueError:
|
|
338
|
+
pass
|
|
339
|
+
|
|
340
|
+
return Handle(unsubscribe)
|
|
341
|
+
|
|
342
|
+
def _notify(self, change: NavChange) -> None:
|
|
343
|
+
# Iterate a copy so a listener may unsubscribe during dispatch.
|
|
344
|
+
for listener in list(self._listeners):
|
|
345
|
+
listener(change)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Shared normalization for the selection family's option shape.
|
|
2
|
+
|
|
3
|
+
`Select`, `SelectButton`, `RadioGroup`, and `ToggleGroup` all accept the same
|
|
4
|
+
`Option` shape (a plain string, a `(text, value)` tuple, or an `OptionDict`).
|
|
5
|
+
This module turns any of those into a canonical `OptionRecord` so every widget
|
|
6
|
+
shares one coercion + validation path.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Iterable, NamedTuple
|
|
11
|
+
|
|
12
|
+
from bootstack.widgets.types import Option
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OptionRecord(NamedTuple):
|
|
16
|
+
"""A normalized option — what every selection widget consumes internally."""
|
|
17
|
+
|
|
18
|
+
text: str
|
|
19
|
+
"""Display label."""
|
|
20
|
+
value: Any
|
|
21
|
+
"""Stored and emitted value."""
|
|
22
|
+
extras: dict
|
|
23
|
+
"""Reserved per-option extras (e.g. `icon`, `disabled`); empty for now."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def normalize_option(opt: Option) -> OptionRecord:
|
|
27
|
+
"""Coerce a single `Option` to an `OptionRecord`.
|
|
28
|
+
|
|
29
|
+
The dict form is a *data bag*: `text` is required (and `value` defaults to
|
|
30
|
+
it) UNLESS the dict carries an `icon` and a `value`, in which case `text` may
|
|
31
|
+
be omitted to request an icon-only option (`text` becomes `''`). `text`/
|
|
32
|
+
`value` and the recognized `icon`/`disabled` act on rendering; every other
|
|
33
|
+
key rides along as carried data, retrievable via the widget's `selection`.
|
|
34
|
+
Unrecognized keys are NOT rejected — the dict route is opt-in, so the caller
|
|
35
|
+
accepts the risk of a mistyped key.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
opt: A plain string, a `(text, value)` tuple, or an `OptionDict`.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The normalized `(text, value, extras)` record.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
TypeError: If `opt` is not a string, 2-tuple, or dict, or if a dict
|
|
45
|
+
`text` is not a string.
|
|
46
|
+
ValueError: If a tuple does not have exactly two items, or a dict has
|
|
47
|
+
neither a `text` key nor an `icon` + `value` pair.
|
|
48
|
+
"""
|
|
49
|
+
if isinstance(opt, str):
|
|
50
|
+
return OptionRecord(opt, opt, {})
|
|
51
|
+
|
|
52
|
+
if isinstance(opt, dict):
|
|
53
|
+
if "text" in opt:
|
|
54
|
+
text = opt["text"]
|
|
55
|
+
if not isinstance(text, str):
|
|
56
|
+
raise TypeError(f"option 'text' must be a string, got {type(text).__name__}: {opt!r}")
|
|
57
|
+
elif "icon" in opt and "value" in opt:
|
|
58
|
+
# Icon-only option: the label is omitted on purpose. The widget
|
|
59
|
+
# renders the glyph alone and infers icon-only from the empty text.
|
|
60
|
+
text = ""
|
|
61
|
+
else:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"option dict needs a 'text' key (or 'icon' + 'value' for an "
|
|
64
|
+
f"icon-only option): {opt!r}"
|
|
65
|
+
)
|
|
66
|
+
value = opt["value"] if "value" in opt else text
|
|
67
|
+
# The bag: every key except text/value, including the reserved
|
|
68
|
+
# icon/disabled (carried now, interpreted by the widget later).
|
|
69
|
+
extras = {k: opt[k] for k in opt if k not in ("text", "value")}
|
|
70
|
+
return OptionRecord(text, value, extras)
|
|
71
|
+
|
|
72
|
+
if isinstance(opt, tuple):
|
|
73
|
+
if len(opt) != 2:
|
|
74
|
+
raise ValueError(f"option tuple must be (text, value), got {len(opt)} items: {opt!r}")
|
|
75
|
+
text, value = opt
|
|
76
|
+
if not isinstance(text, str):
|
|
77
|
+
raise TypeError(f"option text must be a string, got {type(text).__name__}: {opt!r}")
|
|
78
|
+
return OptionRecord(text, value, {})
|
|
79
|
+
|
|
80
|
+
raise TypeError(
|
|
81
|
+
f"option must be a str, (text, value) tuple, or dict, got {type(opt).__name__}: {opt!r}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def normalize_options(opts: Iterable[Option] | None) -> list[OptionRecord]:
|
|
86
|
+
"""Coerce an iterable of `Option` values to a list of `OptionRecord`.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
opts: Options to normalize, or `None` for an empty list.
|
|
90
|
+
"""
|
|
91
|
+
if not opts:
|
|
92
|
+
return []
|
|
93
|
+
return [normalize_option(opt) for opt in opts]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def record_to_dict(record: OptionRecord) -> dict:
|
|
97
|
+
"""Render an `OptionRecord` as a public `{'text', 'value', ...extras}` dict."""
|
|
98
|
+
return {"text": record.text, "value": record.value, **record.extras}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def record_field(record: OptionRecord, field: str) -> Any:
|
|
102
|
+
"""Read one field from a record by name, spanning `text`/`value`/extras.
|
|
103
|
+
|
|
104
|
+
The record is treated as the flat `{'text', 'value', ...extras}` bag, so a
|
|
105
|
+
grouping or display selector can name any carried key (including `text` or
|
|
106
|
+
`value`).
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
record: The normalized option record.
|
|
110
|
+
field: The key to read.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The field's value, or `None` when the record does not carry it.
|
|
114
|
+
"""
|
|
115
|
+
if field == "text":
|
|
116
|
+
return record.text
|
|
117
|
+
if field == "value":
|
|
118
|
+
return record.value
|
|
119
|
+
return record.extras.get(field)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cluster_records(
|
|
123
|
+
records: list[OptionRecord], group_by: str | None
|
|
124
|
+
) -> list[tuple[str | None, list[OptionRecord]]]:
|
|
125
|
+
"""Cluster records into groups by a named bag field, for presentation.
|
|
126
|
+
|
|
127
|
+
Buckets appear in **first-appearance** order — the order each distinct group
|
|
128
|
+
value is first seen while walking `records`. Within a bucket the source
|
|
129
|
+
order is preserved. A record whose `group_by` field is absent, `None`, or
|
|
130
|
+
blank falls into the ungrouped bucket (keyed `None`), which renders without a
|
|
131
|
+
header; that bucket too keeps its first-appearance position.
|
|
132
|
+
|
|
133
|
+
Grouping is purely presentational: every record is returned exactly once and
|
|
134
|
+
unchanged, so `value`/`selection`/`options` are unaffected.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
records: The normalized option records, in source order.
|
|
138
|
+
group_by: The field name to cluster by, or `None` to skip grouping (the
|
|
139
|
+
whole list is returned as a single ungrouped bucket).
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
An ordered list of `(group_label, records)` pairs. `group_label` is the
|
|
143
|
+
group's value coerced to a string, or `None` for the ungrouped bucket.
|
|
144
|
+
"""
|
|
145
|
+
if not group_by:
|
|
146
|
+
return [(None, list(records))]
|
|
147
|
+
order: list[str | None] = []
|
|
148
|
+
buckets: dict[str | None, list[OptionRecord]] = {}
|
|
149
|
+
for rec in records:
|
|
150
|
+
raw = record_field(rec, group_by)
|
|
151
|
+
label = None if raw is None or str(raw) == "" else str(raw)
|
|
152
|
+
if label not in buckets:
|
|
153
|
+
buckets[label] = []
|
|
154
|
+
order.append(label)
|
|
155
|
+
buckets[label].append(rec)
|
|
156
|
+
return [(label, buckets[label]) for label in order]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def option_display(record: OptionRecord) -> tuple[Any, bool]:
|
|
160
|
+
"""Return the `(icon, disabled)` rendering hints from a record's extras.
|
|
161
|
+
|
|
162
|
+
These are the two recognized extra keys that the selection widgets act on:
|
|
163
|
+
`icon` (an icon spec rendered beside the label, or `None`) and `disabled`
|
|
164
|
+
(when truthy the option is shown dimmed and is not user-selectable). Every
|
|
165
|
+
other extra is inert carried data. Each widget maps the pair onto its own
|
|
166
|
+
primitive (`icon=`/`state='disabled'` for the button-backed widgets).
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
record: The normalized option record.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
A `(icon, disabled)` tuple — `icon` is the spec or `None`; `disabled`
|
|
173
|
+
is a plain `bool`.
|
|
174
|
+
"""
|
|
175
|
+
return record.extras.get("icon"), bool(record.extras.get("disabled"))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def option_localize(record: OptionRecord) -> Any:
|
|
179
|
+
"""Return a record's per-option `localize` override, or `None` if absent.
|
|
180
|
+
|
|
181
|
+
The `localize` key rides in the option's extras (the data bag), like `icon`
|
|
182
|
+
and `disabled`. When present it overrides the widget's group-level
|
|
183
|
+
`localize=` for just this option; `None` means defer to the group setting.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
record: The normalized option record.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
The `localize` override (`True`/`False`/`'auto'`), or `None` when the
|
|
190
|
+
option does not carry one.
|
|
191
|
+
"""
|
|
192
|
+
return record.extras.get("localize")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def option_is_icon_only(record: OptionRecord) -> bool:
|
|
196
|
+
"""Whether an option should render as icon-only (a glyph with no label).
|
|
197
|
+
|
|
198
|
+
Inferred — there is no explicit flag: an option is icon-only when it carries
|
|
199
|
+
an `icon` and its `text` is blank. This lets a compact toggle/radio group be
|
|
200
|
+
expressed as `[{'icon': 'list', 'value': 'list'}, ...]` without the caller
|
|
201
|
+
setting `icon_only` per button.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
record: The normalized option record.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
`True` when the record has an icon and no (non-whitespace) label.
|
|
208
|
+
"""
|
|
209
|
+
icon = record.extras.get("icon")
|
|
210
|
+
return icon is not None and not (record.text or "").strip()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from bootstack.widgets._core.options import OptionRecord, record_to_dict, option_localize
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SelectionGroupMixin:
|
|
9
|
+
"""Shared item-management surface for the option-group widgets.
|
|
10
|
+
|
|
11
|
+
Mixed into `RadioGroup` and `ToggleGroup`, which both wrap an internal
|
|
12
|
+
composite exposing `keys()`, `configure_item()`, and `remove()`. Each class
|
|
13
|
+
keeps its own `add()` (the option semantics differ) and its own value /
|
|
14
|
+
change surface; only the value-keyed item operations live here.
|
|
15
|
+
|
|
16
|
+
The internal composites store only each option's text and value, so the
|
|
17
|
+
mixin keeps the normalized records here (the data bag) — keyed by the
|
|
18
|
+
stringified value, matching the selection variable's coercion — so
|
|
19
|
+
`selection` can hand back each option's full `{text, value, ...extras}`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
_internal: Any
|
|
23
|
+
|
|
24
|
+
def _set_option_records(self, records: list[OptionRecord]) -> None:
|
|
25
|
+
"""Seed the option records from a normalized construction list."""
|
|
26
|
+
self._option_records: dict[str, OptionRecord] = {
|
|
27
|
+
str(rec.value): rec for rec in records
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def _add_option_record(self, text: str, value: Any, extras: dict | None = None) -> None:
|
|
31
|
+
"""Register a record for an option added at runtime."""
|
|
32
|
+
if not hasattr(self, "_option_records"):
|
|
33
|
+
self._option_records = {}
|
|
34
|
+
self._option_records[str(value)] = OptionRecord(text, value, extras or {})
|
|
35
|
+
|
|
36
|
+
def _record_dict_for(self, value: Any) -> dict | None:
|
|
37
|
+
"""Return the `{text, value, ...extras}` dict for a value, or None."""
|
|
38
|
+
if value is None or value == "":
|
|
39
|
+
return None
|
|
40
|
+
rec = getattr(self, "_option_records", {}).get(str(value))
|
|
41
|
+
return record_to_dict(rec) if rec is not None else None
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def _option_render(icon: Any, disabled: bool, text: str | None = None) -> tuple[dict, dict]:
|
|
45
|
+
"""Map the `icon`/`disabled` hints to (internal-add kwargs, extras).
|
|
46
|
+
|
|
47
|
+
The internal group forwards `icon`/`state` to each button primitive; the
|
|
48
|
+
extras dict is what `selection` hands back. An icon with a blank `text`
|
|
49
|
+
is inferred as icon-only (the button shows the glyph alone). Returns a
|
|
50
|
+
pair so a single call seeds both the rendered button and the carried
|
|
51
|
+
record.
|
|
52
|
+
"""
|
|
53
|
+
add_kwargs: dict[str, Any] = {}
|
|
54
|
+
extras: dict[str, Any] = {}
|
|
55
|
+
if icon is not None:
|
|
56
|
+
add_kwargs["icon"] = icon
|
|
57
|
+
extras["icon"] = icon
|
|
58
|
+
if not (text or "").strip():
|
|
59
|
+
add_kwargs["icon_only"] = True
|
|
60
|
+
if disabled:
|
|
61
|
+
add_kwargs["state"] = "disabled"
|
|
62
|
+
extras["disabled"] = True
|
|
63
|
+
return add_kwargs, extras
|
|
64
|
+
|
|
65
|
+
def _resolve_localize(self, record: OptionRecord | None = None, override: Any = None) -> Any:
|
|
66
|
+
"""Resolve the effective `localize` for an option.
|
|
67
|
+
|
|
68
|
+
Precedence: an explicit per-item `override` (from `add(localize=)`), then
|
|
69
|
+
the option's own `localize` key (carried in its data bag), then the
|
|
70
|
+
widget's group-level `localize=`. `None` at every level means defer to
|
|
71
|
+
the app's `localize_mode`, so it is not forwarded to the item.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
record: The option's normalized record, when adding from options.
|
|
75
|
+
override: A per-call `localize` from `add()`.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The resolved `localize` mode, or `None` to inherit the app default.
|
|
79
|
+
"""
|
|
80
|
+
if override is not None:
|
|
81
|
+
return override
|
|
82
|
+
if record is not None:
|
|
83
|
+
item = option_localize(record)
|
|
84
|
+
if item is not None:
|
|
85
|
+
return item
|
|
86
|
+
return getattr(self, "_localize", None)
|
|
87
|
+
|
|
88
|
+
def remove(self, value: Any) -> None:
|
|
89
|
+
"""Remove the option identified by `value`.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
value: The value the option was added with.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
KeyError: If no option with that value exists.
|
|
96
|
+
"""
|
|
97
|
+
self._internal.remove(value)
|
|
98
|
+
getattr(self, "_option_records", {}).pop(str(value), None)
|
|
99
|
+
|
|
100
|
+
def configure_item(
|
|
101
|
+
self,
|
|
102
|
+
value: Any,
|
|
103
|
+
*,
|
|
104
|
+
label: str | None = None,
|
|
105
|
+
disabled: bool | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Update a single option in place, without rebuilding the group.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
value: The option to update (the value it was added with).
|
|
111
|
+
label: New display text, when given.
|
|
112
|
+
disabled: When given, disable (`True`) or re-enable (`False`) just
|
|
113
|
+
this option. A later group-level `disabled` change resets every
|
|
114
|
+
option's state, so apply per-option states after it.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
KeyError: If no option with that value exists.
|
|
118
|
+
"""
|
|
119
|
+
if label is not None:
|
|
120
|
+
self._internal.configure_item(value, text=label)
|
|
121
|
+
if disabled is not None:
|
|
122
|
+
self._internal.configure_item(value, state="disabled" if disabled else "normal")
|
|
123
|
+
|
|
124
|
+
def __len__(self) -> int:
|
|
125
|
+
"""The number of options in the group."""
|
|
126
|
+
return len(self._internal.keys())
|
|
127
|
+
|
|
128
|
+
def __contains__(self, value: Any) -> bool:
|
|
129
|
+
"""Whether an option with the given `value` is in the group."""
|
|
130
|
+
return value in self._internal.keys()
|