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,1066 @@
|
|
|
1
|
+
"""Dynamic form widget for building data entry layouts quickly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from tkinter import BooleanVar, DoubleVar, IntVar, StringVar, Text, Variable
|
|
8
|
+
from typing import Any, Callable, Iterable, Literal, Mapping, Sequence, TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from bootstack.constants import DEFAULT_MIN_COL_WIDTH
|
|
11
|
+
from bootstack.signals import Signal
|
|
12
|
+
from bootstack.widgets._impl.primitives.button import Button
|
|
13
|
+
from bootstack.widgets._impl.primitives.checkbutton import CheckButton
|
|
14
|
+
from bootstack.widgets._impl.primitives.switch import Switch
|
|
15
|
+
from bootstack.widgets._impl.composites.dateentry import DateEntry
|
|
16
|
+
from bootstack.widgets._impl.composites.field import Field
|
|
17
|
+
from bootstack.widgets._impl.primitives.frame import Frame
|
|
18
|
+
from bootstack.widgets._impl.primitives.label import Label
|
|
19
|
+
from bootstack.widgets._impl.primitives.labelframe import LabelFrame
|
|
20
|
+
from bootstack.widgets._impl.mixins import configure_delegate
|
|
21
|
+
from bootstack.widgets._impl.composites.numericentry import NumericEntry
|
|
22
|
+
from bootstack.widgets._impl.composites.tabs.tabview import TabView
|
|
23
|
+
from bootstack.widgets._impl.composites.passwordentry import PasswordEntry
|
|
24
|
+
from bootstack.widgets._impl.composites.slider.slider import Slider
|
|
25
|
+
from bootstack.widgets._impl.composites.selectbox import SelectBox
|
|
26
|
+
from bootstack.widgets._impl.primitives.spinbox import Spinbox
|
|
27
|
+
from bootstack.widgets._impl.composites.textentry import TextEntry
|
|
28
|
+
from bootstack.widgets.types import EditorType, Master
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from bootstack.dialogs._impl.dialog import DialogButton
|
|
32
|
+
ButtonInput = str | Mapping[str, Any] | DialogButton
|
|
33
|
+
|
|
34
|
+
DType = Literal['int', 'float', 'bool', 'date', 'datetime', 'password', 'str'] | type | None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class FieldItem:
|
|
39
|
+
"""A single field in a `Form`, addressed by its `key`."""
|
|
40
|
+
|
|
41
|
+
key: str
|
|
42
|
+
"""Unique field identifier — used to read and write the field value."""
|
|
43
|
+
label: str | None = None
|
|
44
|
+
"""Display label shown above the field. Defaults to the capitalized `key`."""
|
|
45
|
+
dtype: DType = None
|
|
46
|
+
"""Type hint controlling the default editor — `'str'`, `'int'`, `'float'`,
|
|
47
|
+
`'bool'`, `'date'`, `'datetime'`, or `'password'`. `None` infers the type from
|
|
48
|
+
the initial value."""
|
|
49
|
+
readonly: bool = False
|
|
50
|
+
"""Render the field read-only. Default `False`."""
|
|
51
|
+
required: bool = False
|
|
52
|
+
"""Mark the field as required — adds a `'required'` validation rule and appends
|
|
53
|
+
an asterisk to the label. Honored by the text, number, password, date, and
|
|
54
|
+
select editors; has no effect on boolean or slider editors. Default `False`."""
|
|
55
|
+
visible: bool = True
|
|
56
|
+
"""Show the field. Set `False` to hide it. Default `True`."""
|
|
57
|
+
column: int | None = None
|
|
58
|
+
"""Zero-based grid column. Auto-placed when `None`."""
|
|
59
|
+
row: int | None = None
|
|
60
|
+
"""Zero-based grid row. Auto-placed when `None`."""
|
|
61
|
+
columnspan: int = 1
|
|
62
|
+
"""Number of grid columns to span. Default `1`."""
|
|
63
|
+
rowspan: int = 1
|
|
64
|
+
"""Number of grid rows to span. Default `1`."""
|
|
65
|
+
editor: EditorType | None = None
|
|
66
|
+
"""Force a specific editor widget (see `EditorType`). `None` infers it from
|
|
67
|
+
`dtype`."""
|
|
68
|
+
editor_options: dict[str, Any] = field(default_factory=dict)
|
|
69
|
+
"""Extra keyword arguments forwarded to the editor widget. For `'select'`,
|
|
70
|
+
pass `{"values": ["A", "B", "C"]}`; add `{"allow_custom_values": True}` for an
|
|
71
|
+
editable combobox."""
|
|
72
|
+
type: Literal['field'] = "field"
|
|
73
|
+
"""Item-type discriminator. Set automatically; only needed when building items
|
|
74
|
+
as plain dicts."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class GroupItem:
|
|
79
|
+
"""A labeled group of fields with its own column layout, placed in a `Form`."""
|
|
80
|
+
|
|
81
|
+
items: list[FieldItem | Mapping[str, Any] | GroupItem | TabsItem] = field(default_factory=list)
|
|
82
|
+
"""Child `FieldItem`, `GroupItem`, or `TabsItem` instances (or equivalent dicts)."""
|
|
83
|
+
label: str | None = None
|
|
84
|
+
"""Section heading shown above the group border. No border is drawn when `None`."""
|
|
85
|
+
col_count: int = 1
|
|
86
|
+
"""Number of columns within the group. Default `1`."""
|
|
87
|
+
min_col_width: int = DEFAULT_MIN_COL_WIDTH
|
|
88
|
+
"""Minimum column width in pixels."""
|
|
89
|
+
width: int | None = None
|
|
90
|
+
"""Fixed width for the group container."""
|
|
91
|
+
height: int | None = None
|
|
92
|
+
"""Fixed height for the group container."""
|
|
93
|
+
column: int | None = None
|
|
94
|
+
"""Zero-based grid column in the parent form. Auto-placed when `None`."""
|
|
95
|
+
row: int | None = None
|
|
96
|
+
"""Zero-based grid row in the parent form. Auto-placed when `None`."""
|
|
97
|
+
columnspan: int = 1
|
|
98
|
+
"""Columns to span in the parent grid. Default `1`."""
|
|
99
|
+
rowspan: int = 1
|
|
100
|
+
"""Rows to span in the parent grid. Default `1`."""
|
|
101
|
+
padding: int | str | tuple[int, int] | tuple[int, int, int, int] | None = 8
|
|
102
|
+
"""Internal padding inside the group border. Default `8`."""
|
|
103
|
+
type: Literal['group'] = "group"
|
|
104
|
+
"""Item-type discriminator. Set automatically; only needed when building items
|
|
105
|
+
as plain dicts."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class TabItem:
|
|
110
|
+
"""A single tab within a `TabsItem`."""
|
|
111
|
+
|
|
112
|
+
label: str
|
|
113
|
+
"""Tab button label."""
|
|
114
|
+
items: list[FieldItem | Mapping[str, Any] | GroupItem | TabsItem] = field(default_factory=list)
|
|
115
|
+
"""`FieldItem`, `GroupItem`, or `TabsItem` instances for this tab's content."""
|
|
116
|
+
padding: int | str | tuple[int, int] | tuple[int, int, int, int] | None = 8
|
|
117
|
+
"""Internal padding inside the tab body. Default `8`."""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class TabsItem:
|
|
122
|
+
"""A tab container holding one or more `TabItem` entries, placed in a `Form`."""
|
|
123
|
+
|
|
124
|
+
tabs: list[TabItem | Mapping[str, Any]] = field(default_factory=list)
|
|
125
|
+
"""`TabItem` instances (or equivalent dicts) defining each tab."""
|
|
126
|
+
label: str | None = None
|
|
127
|
+
"""Optional heading shown above the tab bar."""
|
|
128
|
+
width: int | None = None
|
|
129
|
+
"""Fixed width for the tab container."""
|
|
130
|
+
height: int | None = None
|
|
131
|
+
"""Fixed height for the tab container."""
|
|
132
|
+
column: int | None = None
|
|
133
|
+
"""Zero-based grid column in the parent form. Auto-placed when `None`."""
|
|
134
|
+
row: int | None = None
|
|
135
|
+
"""Zero-based grid row in the parent form. Auto-placed when `None`."""
|
|
136
|
+
columnspan: int = 1
|
|
137
|
+
"""Columns to span in the parent grid. Default `1`."""
|
|
138
|
+
rowspan: int = 1
|
|
139
|
+
"""Rows to span in the parent grid. Default `1`."""
|
|
140
|
+
type: Literal['tabs'] = "tabs"
|
|
141
|
+
"""Item-type discriminator. Set automatically; only needed when building items
|
|
142
|
+
as plain dicts."""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
FormItem = FieldItem | GroupItem | TabsItem
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Form(Frame):
|
|
149
|
+
"""A configurable form that can be generated from data or explicit items.
|
|
150
|
+
|
|
151
|
+
Form is a *field manager* that provides a domain-specific API for accessing
|
|
152
|
+
and manipulating form fields and their values.
|
|
153
|
+
|
|
154
|
+
Field Access:
|
|
155
|
+
- `field(key)` — returns the Field widget for a key
|
|
156
|
+
- `fields()` — returns all Field widgets in order
|
|
157
|
+
- `keys()` — returns all field keys in order
|
|
158
|
+
|
|
159
|
+
Value Operations:
|
|
160
|
+
- `get_field_value(key)` — get a single field's value
|
|
161
|
+
- `set_field_value(key, value)` — set a single field's value
|
|
162
|
+
- `get()` / `set(values)` — get/set all values as a dict
|
|
163
|
+
- `value` property — get/set all values as a dict
|
|
164
|
+
|
|
165
|
+
Variable & Signal Access:
|
|
166
|
+
- `field_variable(key)` — get Tk Variable for a field
|
|
167
|
+
- `field_signal(key)` — get Signal for a field's value
|
|
168
|
+
- `field_textsignal(key)` — get Signal for a field's text
|
|
169
|
+
|
|
170
|
+
Attributes:
|
|
171
|
+
result: Result value set by button commands. None until a button with a
|
|
172
|
+
`result` or `command` is pressed.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
master: Parent widget.
|
|
176
|
+
data: Initial data backing the form. If items are not provided,
|
|
177
|
+
field items are inferred from the keys and value types.
|
|
178
|
+
items: Optional explicit form definition. Accepts dictionaries that
|
|
179
|
+
match the FieldItem/GroupItem/TabsItem shapes or the dataclass
|
|
180
|
+
instances directly.
|
|
181
|
+
col_count: Number of columns at the top level.
|
|
182
|
+
min_col_width: Minimum width for each column in pixels.
|
|
183
|
+
on_data_change: Optional callback invoked with the updated data dict
|
|
184
|
+
whenever a field value changes.
|
|
185
|
+
width: Requested width for the form container.
|
|
186
|
+
height: Requested height for the form container.
|
|
187
|
+
accent: Accent token for the form container (e.g., 'primary', 'secondary').
|
|
188
|
+
buttons: Optional footer buttons. Accepts plain strings, DialogButton
|
|
189
|
+
instances, or dictionaries that map to DialogButton kwargs.
|
|
190
|
+
**kwargs: Additional Frame configuration options.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def __init__(
|
|
194
|
+
self,
|
|
195
|
+
master: Master = None,
|
|
196
|
+
*,
|
|
197
|
+
data: dict[str, Any] | None = None,
|
|
198
|
+
items: Sequence[FormItem | Mapping[str, Any]] | None = None,
|
|
199
|
+
col_count: int = 1,
|
|
200
|
+
min_col_width: int = DEFAULT_MIN_COL_WIDTH,
|
|
201
|
+
on_data_change: Callable[[dict[str, Any]], Any] | None = None,
|
|
202
|
+
width: int | None = None,
|
|
203
|
+
height: int | None = None,
|
|
204
|
+
accent: str | None = None,
|
|
205
|
+
buttons: Sequence[ButtonInput] | None = None,
|
|
206
|
+
**kwargs: Any,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Build a configurable form from data or explicit items.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
master: Parent widget.
|
|
212
|
+
data: Initial data backing the form; keys become field names.
|
|
213
|
+
items: Explicit form layout (FieldItem/GroupItem/TabsItem or mappings).
|
|
214
|
+
col_count: Number of columns at the top level.
|
|
215
|
+
min_col_width: Minimum width per column in pixels.
|
|
216
|
+
on_data_change: Callback invoked with updated data when a field changes.
|
|
217
|
+
width: Requested form width; if None, size naturally.
|
|
218
|
+
height: Requested form height; if None, size naturally.
|
|
219
|
+
accent: Accent token for the form container.
|
|
220
|
+
buttons: Optional footer buttons (DialogButton, mapping, or string).
|
|
221
|
+
**kwargs: Additional Frame configuration options.
|
|
222
|
+
"""
|
|
223
|
+
super().__init__(master=master, width=width, height=height, accent=accent, **kwargs)
|
|
224
|
+
|
|
225
|
+
self._data: dict[str, Any] = dict(data) if data else {}
|
|
226
|
+
self.result: Any = None
|
|
227
|
+
self._on_data_change = on_data_change
|
|
228
|
+
self._col_count = col_count
|
|
229
|
+
self._min_col_width = min_col_width
|
|
230
|
+
self._widgets: dict[str, Any] = {}
|
|
231
|
+
self._variables: dict[str, Variable] = {}
|
|
232
|
+
self._signals: dict[str, Any] = {}
|
|
233
|
+
self._textsignals: dict[str, Any] = {}
|
|
234
|
+
self._items_by_key: dict[str, FieldItem] = {}
|
|
235
|
+
self._suspend_sync = False
|
|
236
|
+
|
|
237
|
+
normalized_items = self._normalize_items(items or self._infer_items_from_data(self._data))
|
|
238
|
+
|
|
239
|
+
self.columnconfigure(0, weight=1)
|
|
240
|
+
self.rowconfigure(0, weight=1)
|
|
241
|
+
|
|
242
|
+
container = Frame(self)
|
|
243
|
+
container.grid(row=0, column=0, sticky='nsew')
|
|
244
|
+
self._content_frame = Frame(container)
|
|
245
|
+
self._content_frame.pack(fill='both', expand=True)
|
|
246
|
+
|
|
247
|
+
# Respect explicit width/height by preventing geometry propagation.
|
|
248
|
+
if width or height:
|
|
249
|
+
if width:
|
|
250
|
+
self.configure(width=width)
|
|
251
|
+
container.configure(width=width)
|
|
252
|
+
if height:
|
|
253
|
+
self.configure(height=height)
|
|
254
|
+
container.configure(height=height)
|
|
255
|
+
self.grid_propagate(False)
|
|
256
|
+
self.pack_propagate(False)
|
|
257
|
+
container.grid_propagate(False)
|
|
258
|
+
|
|
259
|
+
self._build_items(
|
|
260
|
+
self._content_frame, normalized_items, col_count=self._col_count, min_col_width=self._min_col_width)
|
|
261
|
+
|
|
262
|
+
if buttons:
|
|
263
|
+
footer = Frame(self)
|
|
264
|
+
footer.grid(row=1, column=0, sticky='ew', pady=(8, 0))
|
|
265
|
+
footer.columnconfigure(0, weight=1)
|
|
266
|
+
self._build_buttons(footer, buttons)
|
|
267
|
+
|
|
268
|
+
# Reactive form-level validity aggregate. AND of the member fields'
|
|
269
|
+
# `valid` signals; recomputed whenever any field's validity changes.
|
|
270
|
+
self._valid_signal: Signal = Signal(True)
|
|
271
|
+
self._validity_entries: list[Any] = []
|
|
272
|
+
self._register_validity_aggregate()
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def data(self) -> dict[str, Any]:
|
|
276
|
+
"""Current data backing the form."""
|
|
277
|
+
return dict(self._collect_data())
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def valid(self) -> Signal:
|
|
281
|
+
"""Reactive `Signal[bool]` — True when every field passes validation.
|
|
282
|
+
|
|
283
|
+
AND-ed over the member fields' reactive `valid` signals and recomputed
|
|
284
|
+
whenever any field's validity changes (a blur, a key for key-triggered
|
|
285
|
+
rules, or a manual validate). A field is valid until proven otherwise,
|
|
286
|
+
so this reads `True` before any rule has run — call `validate()` to force
|
|
287
|
+
a full pass (for example, to gate a submit button's initial state).
|
|
288
|
+
"""
|
|
289
|
+
return self._valid_signal
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def errors(self) -> dict[str, str]:
|
|
293
|
+
"""Current validation errors keyed by field key; empty when all valid.
|
|
294
|
+
|
|
295
|
+
Read live from the fields' reactive `error` signals, so it always
|
|
296
|
+
reflects the latest validation run.
|
|
297
|
+
"""
|
|
298
|
+
out: dict[str, str] = {}
|
|
299
|
+
for key, widget in self._widgets.items():
|
|
300
|
+
entry = self._validity_entry(widget)
|
|
301
|
+
if entry is not None and not entry._valid_signal():
|
|
302
|
+
out[key] = entry._error_signal()
|
|
303
|
+
return out
|
|
304
|
+
|
|
305
|
+
@staticmethod
|
|
306
|
+
def _validity_entry(widget: Any) -> Any | None:
|
|
307
|
+
"""Return a field's validation-bearing entry, or None if it has none."""
|
|
308
|
+
if not isinstance(widget, Field):
|
|
309
|
+
return None
|
|
310
|
+
entry = getattr(widget, "_entry", None)
|
|
311
|
+
if entry is not None and hasattr(entry, "_valid_signal"):
|
|
312
|
+
return entry
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
def _register_validity_aggregate(self) -> None:
|
|
316
|
+
"""Subscribe each field's validity signal into the form `valid` aggregate."""
|
|
317
|
+
for widget in self._widgets.values():
|
|
318
|
+
entry = self._validity_entry(widget)
|
|
319
|
+
if entry is not None:
|
|
320
|
+
self._validity_entries.append(entry)
|
|
321
|
+
entry._valid_signal.subscribe(lambda *_: self._recompute_validity())
|
|
322
|
+
self._recompute_validity()
|
|
323
|
+
|
|
324
|
+
def _recompute_validity(self) -> None:
|
|
325
|
+
"""Recompute the form `valid` aggregate from its fields' signals."""
|
|
326
|
+
# Signal.set dedupes unchanged values, so this won't re-notify needlessly.
|
|
327
|
+
self._valid_signal.set(all(e._valid_signal() for e in self._validity_entries))
|
|
328
|
+
|
|
329
|
+
def validate(self) -> bool:
|
|
330
|
+
"""Run validation rules on all field widgets; returns True if all pass."""
|
|
331
|
+
all_valid = True
|
|
332
|
+
first_invalid_widget = None
|
|
333
|
+
|
|
334
|
+
def _validate_field(widget: Field) -> bool:
|
|
335
|
+
entry = getattr(widget, "_entry", widget)
|
|
336
|
+
if not getattr(entry, "_rules", None):
|
|
337
|
+
return True
|
|
338
|
+
# Delegate to the entry's own validator. An explicit form.validate()
|
|
339
|
+
# is a manual action, so the "manual" trigger runs every rule; the
|
|
340
|
+
# entry validates its typed value, updates the field's reactive
|
|
341
|
+
# validity signals, and emits the validation events.
|
|
342
|
+
return entry.validate(entry._get_validation_value(), trigger="manual")
|
|
343
|
+
|
|
344
|
+
for widget in self._widgets.values():
|
|
345
|
+
if isinstance(widget, Field):
|
|
346
|
+
ok = _validate_field(widget)
|
|
347
|
+
if not ok and first_invalid_widget is None:
|
|
348
|
+
first_invalid_widget = widget
|
|
349
|
+
all_valid = all_valid and ok
|
|
350
|
+
if first_invalid_widget:
|
|
351
|
+
try:
|
|
352
|
+
first_invalid_widget.focus_set()
|
|
353
|
+
except Exception:
|
|
354
|
+
pass
|
|
355
|
+
return all_valid
|
|
356
|
+
|
|
357
|
+
# --- Field access API (v2) -------------------------------------------
|
|
358
|
+
|
|
359
|
+
def field(self, key: str) -> Field:
|
|
360
|
+
"""Return the Field widget for the given key.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
key: The field key.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
The Field widget instance.
|
|
367
|
+
|
|
368
|
+
Raises:
|
|
369
|
+
KeyError: If no field with the given key exists.
|
|
370
|
+
"""
|
|
371
|
+
if key not in self._widgets:
|
|
372
|
+
raise KeyError(f"No field with key '{key}'")
|
|
373
|
+
return self._widgets[key]
|
|
374
|
+
|
|
375
|
+
def fields(self) -> tuple[Field, ...]:
|
|
376
|
+
"""Return all field widgets in insertion order.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Tuple of Field widget instances.
|
|
380
|
+
"""
|
|
381
|
+
return tuple(self._widgets[k] for k in self._items_by_key.keys() if k in self._widgets)
|
|
382
|
+
|
|
383
|
+
def keys(self) -> tuple[str, ...]:
|
|
384
|
+
"""Return all field keys in insertion order.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
Tuple of field key strings.
|
|
388
|
+
"""
|
|
389
|
+
return tuple(self._items_by_key.keys())
|
|
390
|
+
|
|
391
|
+
# --- Value helpers (explicit, no overloading) -------------------------
|
|
392
|
+
|
|
393
|
+
def get_field_value(self, key: str) -> Any:
|
|
394
|
+
"""Return the current value of the field.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
key: The field key.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
The current field value.
|
|
401
|
+
|
|
402
|
+
Raises:
|
|
403
|
+
KeyError: If no field with the given key exists.
|
|
404
|
+
"""
|
|
405
|
+
if key not in self._widgets:
|
|
406
|
+
raise KeyError(f"No field with key '{key}'")
|
|
407
|
+
return self._read_value_from_widget(key)
|
|
408
|
+
|
|
409
|
+
def set_field_value(self, key: str, value: Any) -> None:
|
|
410
|
+
"""Set the value of the field.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
key: The field key.
|
|
414
|
+
value: The new value to set.
|
|
415
|
+
|
|
416
|
+
Raises:
|
|
417
|
+
KeyError: If no field with the given key exists.
|
|
418
|
+
"""
|
|
419
|
+
if key not in self._items_by_key:
|
|
420
|
+
raise KeyError(f"No field with key '{key}'")
|
|
421
|
+
item = self._items_by_key[key]
|
|
422
|
+
self._apply_value_to_widget(key, item, value)
|
|
423
|
+
self._data[key] = value
|
|
424
|
+
|
|
425
|
+
# --- Variable & signal accessors (v2 short names) ---------------------
|
|
426
|
+
|
|
427
|
+
def field_variable(self, key: str) -> Variable | None:
|
|
428
|
+
"""Return the Tk Variable for the field.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
key: The field key.
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
The Tk Variable, or None if not available.
|
|
435
|
+
"""
|
|
436
|
+
return self._variables.get(key)
|
|
437
|
+
|
|
438
|
+
def field_signal(self, key: str):
|
|
439
|
+
"""Return the Signal for the field value.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
key: The field key.
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
The Signal, or None if not available.
|
|
446
|
+
"""
|
|
447
|
+
return self._signals.get(key)
|
|
448
|
+
|
|
449
|
+
def field_textsignal(self, key: str):
|
|
450
|
+
"""Return the Signal for the field text.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
key: The field key.
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
The text Signal, or None if not available.
|
|
457
|
+
"""
|
|
458
|
+
return self._textsignals.get(key)
|
|
459
|
+
|
|
460
|
+
# --- Legacy variable/signal accessors (backward compatibility) --------
|
|
461
|
+
|
|
462
|
+
def get_field_variable(self, key: str) -> Variable | None:
|
|
463
|
+
"""Return the Tk variable associated with a field key, if any.
|
|
464
|
+
|
|
465
|
+
Deprecated:
|
|
466
|
+
Use `field_variable(key)` instead.
|
|
467
|
+
"""
|
|
468
|
+
return self.field_variable(key)
|
|
469
|
+
|
|
470
|
+
def get_field_signal(self, key: str):
|
|
471
|
+
"""Return the Signal associated with a field key, if any.
|
|
472
|
+
|
|
473
|
+
Deprecated:
|
|
474
|
+
Use `field_signal(key)` instead.
|
|
475
|
+
"""
|
|
476
|
+
return self.field_signal(key)
|
|
477
|
+
|
|
478
|
+
def get_field_textsignal(self, key: str):
|
|
479
|
+
"""Return the TextSignal associated with a field key, if any.
|
|
480
|
+
|
|
481
|
+
Deprecated:
|
|
482
|
+
Use `field_textsignal(key)` instead.
|
|
483
|
+
"""
|
|
484
|
+
return self.field_textsignal(key)
|
|
485
|
+
|
|
486
|
+
# --- Form-level value API (v2 standardization) ------------------------
|
|
487
|
+
|
|
488
|
+
def get(self) -> dict[str, Any]:
|
|
489
|
+
"""Return all field values as a dictionary.
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
Dictionary mapping field keys to their current values.
|
|
493
|
+
"""
|
|
494
|
+
return self.data
|
|
495
|
+
|
|
496
|
+
def set(self, values: Mapping[str, Any]) -> None:
|
|
497
|
+
"""Set multiple field values from a dictionary.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
values: Dictionary mapping field keys to values.
|
|
501
|
+
"""
|
|
502
|
+
self._data = dict(values)
|
|
503
|
+
self._suspend_sync = True
|
|
504
|
+
try:
|
|
505
|
+
for key, item in self._items_by_key.items():
|
|
506
|
+
value = self._data.get(key)
|
|
507
|
+
self._apply_value_to_widget(key, item, value)
|
|
508
|
+
finally:
|
|
509
|
+
self._suspend_sync = False
|
|
510
|
+
|
|
511
|
+
@property
|
|
512
|
+
def value(self) -> dict[str, Any]:
|
|
513
|
+
"""Get or set all form field values as a dictionary."""
|
|
514
|
+
return self.get()
|
|
515
|
+
|
|
516
|
+
@value.setter
|
|
517
|
+
def value(self, values: Mapping[str, Any]) -> None:
|
|
518
|
+
self.set(values)
|
|
519
|
+
|
|
520
|
+
@configure_delegate('data')
|
|
521
|
+
def _delegate_data(self, value: Mapping[str, Any] = None):
|
|
522
|
+
if value is None:
|
|
523
|
+
return dict(self._collect_data())
|
|
524
|
+
else:
|
|
525
|
+
self._data = dict(value)
|
|
526
|
+
self._suspend_sync = True
|
|
527
|
+
try:
|
|
528
|
+
for key, item in self._items_by_key.items():
|
|
529
|
+
value = self._data.get(key)
|
|
530
|
+
self._apply_value_to_widget(key, item, value)
|
|
531
|
+
finally:
|
|
532
|
+
self._suspend_sync = False
|
|
533
|
+
return None
|
|
534
|
+
|
|
535
|
+
def _build_items(self, parent: Frame, items: Sequence[FormItem], *, col_count: int, min_col_width: int) -> None:
|
|
536
|
+
for col in range(col_count):
|
|
537
|
+
parent.columnconfigure(col, weight=1, minsize=min_col_width)
|
|
538
|
+
|
|
539
|
+
auto_row = 0
|
|
540
|
+
auto_col = 0
|
|
541
|
+
|
|
542
|
+
for item in items:
|
|
543
|
+
widget = None
|
|
544
|
+
columnspan = 1
|
|
545
|
+
rowspan = 1
|
|
546
|
+
|
|
547
|
+
if isinstance(item, FieldItem):
|
|
548
|
+
widget = self._build_field(parent, item)
|
|
549
|
+
columnspan = item.columnspan
|
|
550
|
+
rowspan = item.rowspan
|
|
551
|
+
elif isinstance(item, GroupItem):
|
|
552
|
+
container = LabelFrame(parent, text=item.label, padding=item.padding) if item.label else Frame(
|
|
553
|
+
parent, padding=item.padding)
|
|
554
|
+
if item.width:
|
|
555
|
+
container.configure(width=item.width)
|
|
556
|
+
if item.height:
|
|
557
|
+
container.configure(height=item.height)
|
|
558
|
+
nested_items = self._normalize_items(item.items)
|
|
559
|
+
self._build_items(
|
|
560
|
+
container,
|
|
561
|
+
nested_items,
|
|
562
|
+
col_count=item.col_count or col_count,
|
|
563
|
+
min_col_width=item.min_col_width or min_col_width,
|
|
564
|
+
)
|
|
565
|
+
widget = container
|
|
566
|
+
columnspan = item.columnspan
|
|
567
|
+
rowspan = item.rowspan
|
|
568
|
+
elif isinstance(item, TabsItem):
|
|
569
|
+
tv_kwargs = {}
|
|
570
|
+
if item.width is not None:
|
|
571
|
+
tv_kwargs['width'] = item.width
|
|
572
|
+
if item.height is not None:
|
|
573
|
+
tv_kwargs['height'] = item.height
|
|
574
|
+
tabview = TabView(parent, **tv_kwargs)
|
|
575
|
+
for i, tab in enumerate(self._normalize_tabs(item.tabs)):
|
|
576
|
+
page = tabview.add(f'tab_{i}', text=tab.label)
|
|
577
|
+
if tab.padding is not None:
|
|
578
|
+
page.configure(padding=tab.padding)
|
|
579
|
+
self._build_items(
|
|
580
|
+
page, self._normalize_items(tab.items), col_count=col_count, min_col_width=min_col_width)
|
|
581
|
+
widget = tabview
|
|
582
|
+
columnspan = item.columnspan
|
|
583
|
+
rowspan = item.rowspan
|
|
584
|
+
|
|
585
|
+
if widget is None:
|
|
586
|
+
continue
|
|
587
|
+
|
|
588
|
+
row = item.row if isinstance(item, (FieldItem, GroupItem, TabsItem)) and item.row is not None else auto_row
|
|
589
|
+
column = item.column if isinstance(
|
|
590
|
+
item, (FieldItem, GroupItem, TabsItem)) and item.column is not None else auto_col
|
|
591
|
+
|
|
592
|
+
widget.grid(row=row, column=column, columnspan=columnspan, rowspan=rowspan, sticky='nsew', padx=6, pady=4)
|
|
593
|
+
|
|
594
|
+
if isinstance(item, (FieldItem, GroupItem, TabsItem)) and item.row is None and item.column is None:
|
|
595
|
+
auto_col += columnspan
|
|
596
|
+
if auto_col >= col_count:
|
|
597
|
+
auto_col = 0
|
|
598
|
+
auto_row += 1
|
|
599
|
+
|
|
600
|
+
def _build_field(self, parent: Frame, item: FieldItem):
|
|
601
|
+
if not item.visible:
|
|
602
|
+
return None
|
|
603
|
+
|
|
604
|
+
editor = item.editor or self._default_editor_for_dtype(item.dtype, self._data.get(item.key))
|
|
605
|
+
options = dict(item.editor_options or {})
|
|
606
|
+
if item.required:
|
|
607
|
+
options["required"] = True
|
|
608
|
+
initial_value = self._data.get(item.key)
|
|
609
|
+
variable = self._variable_for_item(item, initial_value, editor)
|
|
610
|
+
label_text = item.label if item.label is not None else item.key.replace("_", " ").title()
|
|
611
|
+
|
|
612
|
+
container = Frame(parent)
|
|
613
|
+
container.columnconfigure(0, weight=1) # Allow field widgets to expand horizontally
|
|
614
|
+
|
|
615
|
+
# Validation options that should only be passed to widgets supporting ValidationMixin
|
|
616
|
+
validation_options = {'show_message', 'required', 'validator'}
|
|
617
|
+
|
|
618
|
+
field_widget: Any
|
|
619
|
+
if editor == 'textfield':
|
|
620
|
+
field_widget = TextEntry(
|
|
621
|
+
container, value=initial_value or "", label=label_text, textvariable=variable, **options)
|
|
622
|
+
elif editor == 'numberfield':
|
|
623
|
+
numeric_value = initial_value if initial_value is not None else 0
|
|
624
|
+
field_widget = NumericEntry(
|
|
625
|
+
container, value=numeric_value, label=label_text, **options)
|
|
626
|
+
self._bind_numeric_variable(item.key, field_widget, variable)
|
|
627
|
+
elif editor == 'passwordfield':
|
|
628
|
+
field_widget = PasswordEntry(
|
|
629
|
+
container, value=initial_value or "", label=label_text, textvariable=variable, **options)
|
|
630
|
+
elif editor == 'datefield':
|
|
631
|
+
field_widget = DateEntry(container, value=initial_value, label=label_text, textvariable=variable, **options)
|
|
632
|
+
else:
|
|
633
|
+
# Filter out validation options for widgets that don't support ValidationMixin
|
|
634
|
+
filtered_options = {k: v for k, v in options.items() if k not in validation_options}
|
|
635
|
+
|
|
636
|
+
# Checkbox and switch use an inline label; select handles its own label.
|
|
637
|
+
if editor in ('checkbox', 'switch'):
|
|
638
|
+
if not filtered_options.get("text"):
|
|
639
|
+
filtered_options["text"] = label_text
|
|
640
|
+
elif label_text != "" and editor != 'select':
|
|
641
|
+
Label(container, text=label_text).pack(anchor='w', pady=(0, 2))
|
|
642
|
+
|
|
643
|
+
if editor == 'select':
|
|
644
|
+
items = options.pop('items', options.pop('values', None)) or []
|
|
645
|
+
items = [str(i) for i in items]
|
|
646
|
+
field_widget = SelectBox(
|
|
647
|
+
container,
|
|
648
|
+
label=label_text,
|
|
649
|
+
value=initial_value or "",
|
|
650
|
+
items=items,
|
|
651
|
+
textvariable=variable,
|
|
652
|
+
**options
|
|
653
|
+
)
|
|
654
|
+
if initial_value is not None and variable is not None:
|
|
655
|
+
variable.set(initial_value)
|
|
656
|
+
elif editor == 'spinnerfield':
|
|
657
|
+
field_widget = Spinbox(container, textvariable=variable, **filtered_options)
|
|
658
|
+
if initial_value is not None:
|
|
659
|
+
variable.set(initial_value)
|
|
660
|
+
elif editor == 'textarea':
|
|
661
|
+
field_widget = Text(container, **filtered_options)
|
|
662
|
+
if initial_value:
|
|
663
|
+
field_widget.insert('1.0', str(initial_value))
|
|
664
|
+
elif editor == 'switch':
|
|
665
|
+
field_widget = Switch(container, variable=variable, **filtered_options)
|
|
666
|
+
elif editor == 'checkbox':
|
|
667
|
+
field_widget = CheckButton(container, variable=variable, **filtered_options)
|
|
668
|
+
elif editor == 'slider':
|
|
669
|
+
slider_opts = dict(filtered_options)
|
|
670
|
+
if 'from_' in slider_opts:
|
|
671
|
+
slider_opts['minvalue'] = slider_opts.pop('from_')
|
|
672
|
+
if 'to' in slider_opts:
|
|
673
|
+
slider_opts['maxvalue'] = slider_opts.pop('to')
|
|
674
|
+
field_widget = Slider(container, variable=variable, **slider_opts)
|
|
675
|
+
else:
|
|
676
|
+
field_widget = TextEntry(
|
|
677
|
+
container, value=initial_value or "", label=label_text, textvariable=variable, **options)
|
|
678
|
+
|
|
679
|
+
if editor != 'textarea':
|
|
680
|
+
field_widget.pack(fill='x', expand=True)
|
|
681
|
+
else:
|
|
682
|
+
field_widget.pack(fill='both', expand=True)
|
|
683
|
+
|
|
684
|
+
if isinstance(field_widget, Field):
|
|
685
|
+
field_widget.bind("<<Change>>", lambda _e, k=item.key: self._sync_value_from_widget(k))
|
|
686
|
+
field_widget.pack(fill='both', expand=True)
|
|
687
|
+
if not isinstance(field_widget, NumericEntry):
|
|
688
|
+
traced_var = getattr(field_widget, "variable", None)
|
|
689
|
+
if traced_var is not None:
|
|
690
|
+
self._register_variable(item.key, traced_var)
|
|
691
|
+
elif isinstance(field_widget, Text):
|
|
692
|
+
text_var = variable or StringVar(value=str(initial_value) if initial_value is not None else "")
|
|
693
|
+
self._register_variable(item.key, text_var)
|
|
694
|
+
self._bind_text_change(field_widget, item.key, text_var)
|
|
695
|
+
elif variable is not None:
|
|
696
|
+
self._register_variable(item.key, variable)
|
|
697
|
+
|
|
698
|
+
# record signals if the widget exposes them
|
|
699
|
+
signal_obj = getattr(field_widget, "_signal", None)
|
|
700
|
+
if signal_obj is not None:
|
|
701
|
+
self._signals[item.key] = signal_obj
|
|
702
|
+
text_signal = getattr(field_widget, "_textsignal", None)
|
|
703
|
+
if text_signal is not None:
|
|
704
|
+
self._textsignals[item.key] = text_signal
|
|
705
|
+
|
|
706
|
+
if item.readonly:
|
|
707
|
+
self._set_readonly(field_widget)
|
|
708
|
+
|
|
709
|
+
self._widgets[item.key] = field_widget
|
|
710
|
+
return container
|
|
711
|
+
|
|
712
|
+
def _build_buttons(self, parent: Frame, buttons: Sequence[ButtonInput]) -> None:
|
|
713
|
+
parsed = self._normalize_buttons(buttons)
|
|
714
|
+
for spec in reversed(parsed):
|
|
715
|
+
btn_color = getattr(spec, 'accent', None) or getattr(spec, 'color', None)
|
|
716
|
+
btn_variant = getattr(spec, 'variant', None)
|
|
717
|
+
|
|
718
|
+
if not btn_color:
|
|
719
|
+
# Get color and variant from role
|
|
720
|
+
btn_color, btn_variant = self._style_for_role(spec.role)
|
|
721
|
+
|
|
722
|
+
btn = Button(parent, text=spec.text, accent=btn_color, variant=btn_variant)
|
|
723
|
+
btn.configure(command=self._make_button_command(spec))
|
|
724
|
+
btn.pack(side='right', padx=(4, 0))
|
|
725
|
+
|
|
726
|
+
# --- normalization --------------------------------------------------
|
|
727
|
+
def _normalize_items(self, items: Iterable[FormItem | Mapping[str, Any]]) -> list[FormItem]:
|
|
728
|
+
normalized: list[FormItem] = []
|
|
729
|
+
for raw in items:
|
|
730
|
+
item: FormItem | None = None
|
|
731
|
+
if isinstance(raw, (FieldItem, GroupItem, TabsItem)):
|
|
732
|
+
item = raw
|
|
733
|
+
elif isinstance(raw, Mapping):
|
|
734
|
+
type_hint = raw.get('type', 'field')
|
|
735
|
+
if type_hint == 'group':
|
|
736
|
+
item = GroupItem(
|
|
737
|
+
items=list(raw.get('items', [])),
|
|
738
|
+
label=raw.get('label'),
|
|
739
|
+
col_count=raw.get('col_count', 1),
|
|
740
|
+
min_col_width=raw.get('min_col_width', DEFAULT_MIN_COL_WIDTH),
|
|
741
|
+
width=raw.get('width'),
|
|
742
|
+
height=raw.get('height'),
|
|
743
|
+
column=raw.get('column'),
|
|
744
|
+
row=raw.get('row'),
|
|
745
|
+
columnspan=raw.get('columnspan', 1),
|
|
746
|
+
rowspan=raw.get('rowspan', 1),
|
|
747
|
+
)
|
|
748
|
+
elif type_hint == 'tabs':
|
|
749
|
+
item = TabsItem(
|
|
750
|
+
tabs=list(raw.get('tabs', [])),
|
|
751
|
+
label=raw.get('label'),
|
|
752
|
+
width=raw.get('width'),
|
|
753
|
+
height=raw.get('height'),
|
|
754
|
+
column=raw.get('column'),
|
|
755
|
+
row=raw.get('row'),
|
|
756
|
+
columnspan=raw.get('columnspan', 1),
|
|
757
|
+
rowspan=raw.get('rowspan', 1),
|
|
758
|
+
)
|
|
759
|
+
else:
|
|
760
|
+
key_value = raw.get('key')
|
|
761
|
+
if key_value is None:
|
|
762
|
+
continue
|
|
763
|
+
item = FieldItem(
|
|
764
|
+
key=str(key_value),
|
|
765
|
+
label=raw.get('label'),
|
|
766
|
+
dtype=raw.get('dtype'),
|
|
767
|
+
readonly=raw.get('readonly', False),
|
|
768
|
+
required=raw.get('required', False),
|
|
769
|
+
visible=raw.get('visible', True),
|
|
770
|
+
column=raw.get('column'),
|
|
771
|
+
row=raw.get('row'),
|
|
772
|
+
columnspan=raw.get('columnspan', 1),
|
|
773
|
+
rowspan=raw.get('rowspan', 1),
|
|
774
|
+
editor=raw.get('editor'),
|
|
775
|
+
editor_options=dict(raw.get('editor_options', {}) or {}),
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
if isinstance(item, GroupItem):
|
|
779
|
+
item.items = self._normalize_items(item.items)
|
|
780
|
+
if isinstance(item, TabsItem):
|
|
781
|
+
item.tabs = self._normalize_tabs(item.tabs)
|
|
782
|
+
if isinstance(item, FieldItem):
|
|
783
|
+
self._items_by_key[item.key] = item
|
|
784
|
+
|
|
785
|
+
if item:
|
|
786
|
+
normalized.append(item)
|
|
787
|
+
return normalized
|
|
788
|
+
|
|
789
|
+
def _normalize_tabs(self, tabs: Iterable[TabItem | Mapping[str, Any]]) -> list[TabItem]:
|
|
790
|
+
normalized: list[TabItem] = []
|
|
791
|
+
for raw in tabs:
|
|
792
|
+
if isinstance(raw, TabItem):
|
|
793
|
+
normalized.append(raw)
|
|
794
|
+
elif isinstance(raw, Mapping):
|
|
795
|
+
normalized.append(TabItem(label=str(raw.get('label', 'Tab')), items=list(raw.get('items', []))))
|
|
796
|
+
return normalized
|
|
797
|
+
|
|
798
|
+
def _normalize_buttons(self, buttons: Sequence[ButtonInput]) -> list["DialogButton"]:
|
|
799
|
+
from bootstack.dialogs._impl.dialog import DialogButton # local import to avoid circular init
|
|
800
|
+
|
|
801
|
+
normalized: list[DialogButton] = []
|
|
802
|
+
for raw in buttons:
|
|
803
|
+
if isinstance(raw, DialogButton):
|
|
804
|
+
normalized.append(raw)
|
|
805
|
+
elif isinstance(raw, Mapping):
|
|
806
|
+
normalized.append(DialogButton(**raw)) # type: ignore[arg-type]
|
|
807
|
+
elif isinstance(raw, str):
|
|
808
|
+
normalized.append(DialogButton(text=raw))
|
|
809
|
+
return normalized
|
|
810
|
+
|
|
811
|
+
# --- data helpers ---------------------------------------------------
|
|
812
|
+
def _collect_data(self) -> dict[str, Any]:
|
|
813
|
+
current: dict[str, Any] = dict(self._data)
|
|
814
|
+
for key in self._widgets.keys():
|
|
815
|
+
current[key] = self._read_value_from_widget(key)
|
|
816
|
+
return current
|
|
817
|
+
|
|
818
|
+
def _read_value_from_widget(self, key: str) -> Any:
|
|
819
|
+
widget = self._widgets.get(key)
|
|
820
|
+
if widget is None:
|
|
821
|
+
return self._data.get(key)
|
|
822
|
+
|
|
823
|
+
if hasattr(widget, "value"):
|
|
824
|
+
val_attr = getattr(widget, "value")
|
|
825
|
+
value = val_attr() if callable(val_attr) else val_attr
|
|
826
|
+
elif key in self._variables:
|
|
827
|
+
value = self._variables[key].get()
|
|
828
|
+
elif isinstance(widget, Text):
|
|
829
|
+
value = widget.get("1.0", "end-1c")
|
|
830
|
+
else:
|
|
831
|
+
try:
|
|
832
|
+
value = widget.get()
|
|
833
|
+
except Exception:
|
|
834
|
+
value = self._data.get(key)
|
|
835
|
+
|
|
836
|
+
item = self._items_by_key.get(key)
|
|
837
|
+
if item:
|
|
838
|
+
value = self._coerce_value(item.dtype, value)
|
|
839
|
+
return value
|
|
840
|
+
|
|
841
|
+
def _apply_value_to_widget(self, key: str, item: FieldItem, value: Any) -> None:
|
|
842
|
+
widget = self._widgets.get(key)
|
|
843
|
+
if widget is None:
|
|
844
|
+
return
|
|
845
|
+
|
|
846
|
+
if isinstance(widget, Field):
|
|
847
|
+
if hasattr(widget, "_suppress_changed_event"):
|
|
848
|
+
widget._suppress_changed_event = True # type: ignore[attr-defined]
|
|
849
|
+
try:
|
|
850
|
+
widget.value = value
|
|
851
|
+
finally:
|
|
852
|
+
widget._suppress_changed_event = False # type: ignore[attr-defined]
|
|
853
|
+
else:
|
|
854
|
+
widget.value = value
|
|
855
|
+
return
|
|
856
|
+
|
|
857
|
+
if isinstance(widget, Text):
|
|
858
|
+
widget.delete("1.0", "end")
|
|
859
|
+
if value is not None:
|
|
860
|
+
widget.insert("1.0", str(value))
|
|
861
|
+
return
|
|
862
|
+
|
|
863
|
+
if key in self._variables:
|
|
864
|
+
self._variables[key].set("" if value is None else value)
|
|
865
|
+
return
|
|
866
|
+
|
|
867
|
+
try:
|
|
868
|
+
widget.configure(value=value)
|
|
869
|
+
except Exception:
|
|
870
|
+
pass
|
|
871
|
+
|
|
872
|
+
def _sync_value_from_widget(self, key: str) -> None:
|
|
873
|
+
if self._suspend_sync:
|
|
874
|
+
return
|
|
875
|
+
if key not in self._items_by_key:
|
|
876
|
+
return
|
|
877
|
+
new_value = self._read_value_from_widget(key)
|
|
878
|
+
self._data[key] = new_value
|
|
879
|
+
if self._on_data_change:
|
|
880
|
+
self._on_data_change(dict(self._data))
|
|
881
|
+
self.event_generate("<<BsDataChange>>")
|
|
882
|
+
|
|
883
|
+
def _variable_for_item(self, item: FieldItem, initial: Any, editor: EditorType | None) -> Variable | None:
|
|
884
|
+
dtype = item.dtype
|
|
885
|
+
if editor in ('checkbox', 'switch'):
|
|
886
|
+
return BooleanVar(value=bool(initial) if initial is not None else False)
|
|
887
|
+
if editor in ('numberfield', 'spinnerfield', 'slider') or dtype in ('int', int, 'float', float):
|
|
888
|
+
if dtype in ('float', float):
|
|
889
|
+
return DoubleVar(value=float(initial) if initial is not None else 0.0)
|
|
890
|
+
return IntVar(value=int(initial) if initial is not None else 0)
|
|
891
|
+
return StringVar(value="" if initial is None else str(initial))
|
|
892
|
+
|
|
893
|
+
def _default_editor_for_dtype(self, dtype: Any, value: Any) -> EditorType:
|
|
894
|
+
if dtype in ('int', int, 'float', float):
|
|
895
|
+
return 'numberfield'
|
|
896
|
+
if dtype in ('bool', bool):
|
|
897
|
+
return 'checkbox'
|
|
898
|
+
if dtype in ('date', 'datetime', date, datetime):
|
|
899
|
+
return 'datefield'
|
|
900
|
+
if dtype in ('password',):
|
|
901
|
+
return 'passwordfield'
|
|
902
|
+
if value is not None:
|
|
903
|
+
if isinstance(value, (int, float)):
|
|
904
|
+
return 'numberfield'
|
|
905
|
+
if isinstance(value, (bool,)):
|
|
906
|
+
return 'checkbox'
|
|
907
|
+
if isinstance(value, (date, datetime)):
|
|
908
|
+
return 'datefield'
|
|
909
|
+
return 'textfield'
|
|
910
|
+
|
|
911
|
+
def _coerce_value(self, dtype: Any, value: Any) -> Any:
|
|
912
|
+
if dtype in ('int', int):
|
|
913
|
+
try:
|
|
914
|
+
return int(value)
|
|
915
|
+
except Exception:
|
|
916
|
+
return value
|
|
917
|
+
if dtype in ('float', float):
|
|
918
|
+
try:
|
|
919
|
+
return float(value)
|
|
920
|
+
except Exception:
|
|
921
|
+
return value
|
|
922
|
+
if dtype in ('bool', bool):
|
|
923
|
+
return bool(value)
|
|
924
|
+
if dtype in ('date', 'datetime', date, datetime):
|
|
925
|
+
return value
|
|
926
|
+
return value
|
|
927
|
+
|
|
928
|
+
def _register_variable(self, key: str, var: Variable) -> None:
|
|
929
|
+
self._variables[key] = var
|
|
930
|
+
var.trace_add("write", lambda *_a, k=key: self._sync_value_from_widget(k))
|
|
931
|
+
|
|
932
|
+
def _bind_text_change(self, widget: Text, key: str, var: StringVar | None = None) -> None:
|
|
933
|
+
_updating = {"text": False}
|
|
934
|
+
|
|
935
|
+
def _on_change(_event=None):
|
|
936
|
+
if _updating["text"]:
|
|
937
|
+
return
|
|
938
|
+
_updating["text"] = True
|
|
939
|
+
try:
|
|
940
|
+
widget.edit_modified(False)
|
|
941
|
+
text = widget.get("1.0", "end-1c")
|
|
942
|
+
if var is not None:
|
|
943
|
+
var.set(text)
|
|
944
|
+
self._sync_value_from_widget(key)
|
|
945
|
+
finally:
|
|
946
|
+
_updating["text"] = False
|
|
947
|
+
|
|
948
|
+
def _on_var_change(*_args):
|
|
949
|
+
if _updating["text"] or var is None:
|
|
950
|
+
return
|
|
951
|
+
_updating["text"] = True
|
|
952
|
+
try:
|
|
953
|
+
widget.delete("1.0", "end")
|
|
954
|
+
widget.insert("1.0", var.get())
|
|
955
|
+
finally:
|
|
956
|
+
_updating["text"] = False
|
|
957
|
+
|
|
958
|
+
widget.bind("<<Modified>>", _on_change)
|
|
959
|
+
widget.edit_modified(False)
|
|
960
|
+
|
|
961
|
+
if var is not None:
|
|
962
|
+
var.trace_add("write", _on_var_change)
|
|
963
|
+
|
|
964
|
+
def _bind_numeric_variable(self, key: str, widget: NumericEntry, var: Variable | None) -> None:
|
|
965
|
+
if var is None:
|
|
966
|
+
return
|
|
967
|
+
|
|
968
|
+
self._register_variable(key, var)
|
|
969
|
+
|
|
970
|
+
def _sync_numeric_var(*_args):
|
|
971
|
+
new_value = widget.value
|
|
972
|
+
if new_value is None:
|
|
973
|
+
return
|
|
974
|
+
try:
|
|
975
|
+
current_value = var.get()
|
|
976
|
+
except Exception:
|
|
977
|
+
current_value = None
|
|
978
|
+
if current_value == new_value:
|
|
979
|
+
return
|
|
980
|
+
previous_suspend = self._suspend_sync
|
|
981
|
+
self._suspend_sync = True
|
|
982
|
+
try:
|
|
983
|
+
var.set(new_value)
|
|
984
|
+
finally:
|
|
985
|
+
self._suspend_sync = previous_suspend
|
|
986
|
+
|
|
987
|
+
text_signal = getattr(widget, "signal", None)
|
|
988
|
+
if text_signal is not None:
|
|
989
|
+
text_signal.subscribe(lambda *_: _sync_numeric_var())
|
|
990
|
+
_sync_numeric_var()
|
|
991
|
+
|
|
992
|
+
def _set_readonly(self, widget: Any) -> None:
|
|
993
|
+
if isinstance(widget, Field):
|
|
994
|
+
widget.readonly(True)
|
|
995
|
+
else:
|
|
996
|
+
try:
|
|
997
|
+
widget.state(['disabled'])
|
|
998
|
+
except Exception:
|
|
999
|
+
try:
|
|
1000
|
+
widget.configure(state='disabled')
|
|
1001
|
+
except Exception:
|
|
1002
|
+
pass
|
|
1003
|
+
|
|
1004
|
+
# --- button helpers -------------------------------------------------
|
|
1005
|
+
def _make_button_command(self, spec: DialogButton):
|
|
1006
|
+
def command():
|
|
1007
|
+
if spec.command:
|
|
1008
|
+
spec.command(self) # type: ignore[arg-type]
|
|
1009
|
+
self.result = spec.result if spec.result is not None else self.data
|
|
1010
|
+
|
|
1011
|
+
return command
|
|
1012
|
+
|
|
1013
|
+
def _style_for_role(self, role: str) -> tuple[str, str | None]:
|
|
1014
|
+
"""Get color and variant for a button role.
|
|
1015
|
+
|
|
1016
|
+
Returns:
|
|
1017
|
+
Tuple of (color, variant) for the role.
|
|
1018
|
+
"""
|
|
1019
|
+
if role == "primary":
|
|
1020
|
+
return ("primary", None)
|
|
1021
|
+
if role == "secondary":
|
|
1022
|
+
return ("default", None)
|
|
1023
|
+
if role == "danger":
|
|
1024
|
+
return ("danger", None)
|
|
1025
|
+
if role == "cancel":
|
|
1026
|
+
return ("default", "outline")
|
|
1027
|
+
if role == "help":
|
|
1028
|
+
return ("primary", "link")
|
|
1029
|
+
return ("default", None)
|
|
1030
|
+
|
|
1031
|
+
# --- inference ------------------------------------------------------
|
|
1032
|
+
def _infer_items_from_data(self, data: Mapping[str, Any]) -> list[FieldItem]:
|
|
1033
|
+
inferred: list[FieldItem] = []
|
|
1034
|
+
for key, value in data.items():
|
|
1035
|
+
inferred.append(
|
|
1036
|
+
FieldItem(
|
|
1037
|
+
key=str(key),
|
|
1038
|
+
label=str(key).replace('_', ' ').title(),
|
|
1039
|
+
dtype=self._infer_dtype_from_value(value),
|
|
1040
|
+
editor=self._default_editor_for_dtype(self._infer_dtype_from_value(value), value),
|
|
1041
|
+
editor_options={"show_message": True},
|
|
1042
|
+
)
|
|
1043
|
+
)
|
|
1044
|
+
return inferred
|
|
1045
|
+
|
|
1046
|
+
@staticmethod
|
|
1047
|
+
def _infer_dtype_from_value(value: Any) -> DType:
|
|
1048
|
+
if isinstance(value, bool):
|
|
1049
|
+
return 'bool'
|
|
1050
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
1051
|
+
return 'int'
|
|
1052
|
+
if isinstance(value, float):
|
|
1053
|
+
return 'float'
|
|
1054
|
+
if isinstance(value, (date, datetime)):
|
|
1055
|
+
return 'date'
|
|
1056
|
+
return 'str'
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
__all__ = [
|
|
1060
|
+
"Form",
|
|
1061
|
+
"FormItem",
|
|
1062
|
+
"FieldItem",
|
|
1063
|
+
"GroupItem",
|
|
1064
|
+
"TabsItem",
|
|
1065
|
+
"TabItem",
|
|
1066
|
+
]
|