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,1026 @@
|
|
|
1
|
+
from tkinter import Toplevel
|
|
2
|
+
|
|
3
|
+
from typing_extensions import Unpack
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from bootstack.events import ChangeEvent
|
|
8
|
+
from bootstack.widgets._core.options import (
|
|
9
|
+
cluster_records,
|
|
10
|
+
normalize_options,
|
|
11
|
+
option_display,
|
|
12
|
+
option_is_icon_only,
|
|
13
|
+
record_to_dict,
|
|
14
|
+
)
|
|
15
|
+
from bootstack.widgets._impl.primitives.button import Button
|
|
16
|
+
from bootstack.widgets._impl.primitives.frame import Frame
|
|
17
|
+
from bootstack.widgets._impl.primitives.label import Label
|
|
18
|
+
from bootstack.widgets._impl.primitives.separator import Separator
|
|
19
|
+
from bootstack.widgets._impl.composites.scrollview import ScrollView
|
|
20
|
+
from bootstack.widgets._impl.composites.field import Field, FieldOptions
|
|
21
|
+
from bootstack.widgets._impl.mixins import configure_delegate
|
|
22
|
+
from bootstack.widgets.types import Master, Option
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SelectBox(Field):
|
|
26
|
+
"""Dropdown-like field widget built on top of Field.
|
|
27
|
+
|
|
28
|
+
Renders a field with a suffix button that opens a popup list of available
|
|
29
|
+
items. Selecting an item updates the field value and emits `<<Change>>`.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
_POPUP_MAX_HEIGHT = 200 # px — popup never exceeds this, but shrinks to fit fewer items
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
master: Master = None,
|
|
37
|
+
value: Any = None,
|
|
38
|
+
items: list[Option] = None,
|
|
39
|
+
label: str = None,
|
|
40
|
+
message: str = None,
|
|
41
|
+
allow_custom_values: bool = False,
|
|
42
|
+
strict_value: bool = False,
|
|
43
|
+
show_dropdown_button: bool = True,
|
|
44
|
+
dropdown_button_icon: str = None,
|
|
45
|
+
enable_search: bool = False,
|
|
46
|
+
group_by: str = None,
|
|
47
|
+
max_visible_items: int = None,
|
|
48
|
+
**kwargs: Unpack[FieldOptions]
|
|
49
|
+
):
|
|
50
|
+
"""Args:
|
|
51
|
+
master: Parent widget. If None, uses the default root window.
|
|
52
|
+
value: Initial selected value (value-space). Should match one of the
|
|
53
|
+
options unless `allow_custom_values` is set.
|
|
54
|
+
items: Options to present in the popup — each a string,
|
|
55
|
+
`(text, value)` tuple, or `{'text', 'value'}` dict. The popup
|
|
56
|
+
and field display each option's text; the selected value is
|
|
57
|
+
emitted in value-space. A dict option may also carry `'icon'`
|
|
58
|
+
(rendered beside the row label) and `'disabled'` (when `True` the
|
|
59
|
+
row is dimmed and cannot be chosen).
|
|
60
|
+
label: Optional label text shown above the field.
|
|
61
|
+
message: Optional helper/error message shown below the field.
|
|
62
|
+
allow_custom_values: If True, the entry is editable so users can type
|
|
63
|
+
arbitrary values in addition to choosing from the list.
|
|
64
|
+
show_dropdown_button: If True (default), the dropdown button is shown. This option is
|
|
65
|
+
ignored if custom values are allowed.
|
|
66
|
+
dropdown_button_icon: The icon to display on the dropdown button.
|
|
67
|
+
enable_search: If True, allows typing in the entry to filter the popup list.
|
|
68
|
+
When combined with allow_custom_values=False, the first filtered item is selected
|
|
69
|
+
when the popup closes. With allow_custom_values=True, any typed value is kept.
|
|
70
|
+
group_by: Name of an option field to cluster the popup rows under
|
|
71
|
+
non-selectable group headers (e.g. `'category'`). The field is
|
|
72
|
+
read from each option's flat record, so it may be any carried bag
|
|
73
|
+
key (or `'text'`/`'value'`). Groups appear in first-appearance
|
|
74
|
+
order; options missing the field render headerless. Grouping is
|
|
75
|
+
presentational only — `value`/`selection` are unaffected. None
|
|
76
|
+
(default) renders a flat list.
|
|
77
|
+
max_visible_items: Approximate number of option rows the popup shows
|
|
78
|
+
before it scrolls (height is `max_visible_items * row_height`).
|
|
79
|
+
Group headers and separators consume some of that budget, so the
|
|
80
|
+
count is approximate. None (default) uses the built-in cap.
|
|
81
|
+
|
|
82
|
+
Other Parameters:
|
|
83
|
+
allow_blank: If True, empty input is allowed.
|
|
84
|
+
accent: Accent token for styling the focus ring and active border.
|
|
85
|
+
value_format: ICU format pattern for parsing/formatting.
|
|
86
|
+
font: Font for text display.
|
|
87
|
+
foreground: Text color.
|
|
88
|
+
initial_focus: If True, widget receives focus when created.
|
|
89
|
+
justify: Text justification ('left', 'center', 'right').
|
|
90
|
+
show_message: If True, displays message text below the field.
|
|
91
|
+
padding: Padding around the entry widget.
|
|
92
|
+
state: The widget state ('normal', 'disabled', 'readonly').
|
|
93
|
+
textvariable: Tkinter Variable to link with the entry text.
|
|
94
|
+
See [tkinter Variables](https://docs.python.org/3/library/tkinter.html#tkinter-variables).
|
|
95
|
+
textsignal: Signal object for reactive text updates.
|
|
96
|
+
width: Width of the entry in characters.
|
|
97
|
+
required: If True, field cannot be empty.
|
|
98
|
+
"""
|
|
99
|
+
# Localization mode for option display (entry face + popup rows). None
|
|
100
|
+
# defers to the app's localize_mode; False keeps the raw labels.
|
|
101
|
+
self._localize = kwargs.pop('localize', None)
|
|
102
|
+
# Normalize options once. The maps only decouple an option's *display
|
|
103
|
+
# text* from its *value*; the entry keeps owning the raw value (so
|
|
104
|
+
# `value_format` parsing, e.g. TimeField's time objects, is preserved).
|
|
105
|
+
# When localization is active the display text is the translated label,
|
|
106
|
+
# so a plain option is treated like a decoupled one (display != value).
|
|
107
|
+
self._records = normalize_options(items)
|
|
108
|
+
self._allow_custom_values = allow_custom_values
|
|
109
|
+
self._strict_value = strict_value
|
|
110
|
+
self._rebuild_option_maps()
|
|
111
|
+
|
|
112
|
+
# Forward an explicit localize mode to Field so the label/message follow
|
|
113
|
+
# it too; when unset, Field keeps its own 'auto' default.
|
|
114
|
+
if self._localize is not None:
|
|
115
|
+
kwargs['localize'] = self._localize
|
|
116
|
+
|
|
117
|
+
# Seed the entry with the display form of the initial value.
|
|
118
|
+
super().__init__(master, value=self._resolve_display(value), label=label, message=message, **kwargs)
|
|
119
|
+
|
|
120
|
+
# Re-translate the displayed text + option maps on a live locale switch.
|
|
121
|
+
self.winfo_toplevel().bind('<<LocaleChanged>>', self._on_locale_changed_refresh, add='+')
|
|
122
|
+
|
|
123
|
+
self._search_enabled = enable_search
|
|
124
|
+
self._group_by = group_by
|
|
125
|
+
self._max_visible_items = max_visible_items
|
|
126
|
+
self._popup_open = False
|
|
127
|
+
self._popup_state = None
|
|
128
|
+
self._dropdown_button_icon = dropdown_button_icon or 'chevron-down'
|
|
129
|
+
self._popup_frame = None
|
|
130
|
+
self._popup_inner = None
|
|
131
|
+
self._item_labels = []
|
|
132
|
+
self._group_headers = []
|
|
133
|
+
|
|
134
|
+
# Add dropdown button if needed
|
|
135
|
+
if allow_custom_values or show_dropdown_button:
|
|
136
|
+
self.insert_addon(
|
|
137
|
+
Button,
|
|
138
|
+
position="after",
|
|
139
|
+
name="dropdown",
|
|
140
|
+
icon=self._dropdown_button_icon,
|
|
141
|
+
icon_only=True,
|
|
142
|
+
command=self._on_dropdown_click
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Configure entry state based on search and custom value settings
|
|
146
|
+
if allow_custom_values or enable_search:
|
|
147
|
+
self.entry_widget.state(['!readonly'])
|
|
148
|
+
else:
|
|
149
|
+
# Set entry to readonly but keep dropdown button enabled
|
|
150
|
+
self.entry_widget.state(['readonly'])
|
|
151
|
+
self.after_idle(self._bind_readonly_selection_on_click)
|
|
152
|
+
|
|
153
|
+
# ----- Option normalization + value<->text mapping -----
|
|
154
|
+
|
|
155
|
+
def _effective_localize(self, item_mode: Any = None) -> Any:
|
|
156
|
+
"""Resolve the active localize mode, deferring to the app when unset.
|
|
157
|
+
|
|
158
|
+
A per-option `item_mode` (its `localize` bag key) wins; then the
|
|
159
|
+
widget-level mode; then the app's `localize_mode`.
|
|
160
|
+
"""
|
|
161
|
+
if item_mode is not None:
|
|
162
|
+
return item_mode
|
|
163
|
+
if self._localize is not None:
|
|
164
|
+
return self._localize
|
|
165
|
+
try:
|
|
166
|
+
from bootstack._runtime.app import get_app_settings
|
|
167
|
+
return get_app_settings().localize_mode
|
|
168
|
+
except Exception:
|
|
169
|
+
return 'auto'
|
|
170
|
+
|
|
171
|
+
def _display_text(self, raw: str, item_mode: Any = None) -> str:
|
|
172
|
+
"""Map a raw option label to its display text under the active mode.
|
|
173
|
+
|
|
174
|
+
Returns the label unchanged when localization is off or no translation
|
|
175
|
+
is registered; otherwise the catalog translation.
|
|
176
|
+
"""
|
|
177
|
+
if not raw:
|
|
178
|
+
return raw
|
|
179
|
+
if self._effective_localize(item_mode) is False:
|
|
180
|
+
return raw
|
|
181
|
+
try:
|
|
182
|
+
from bootstack.i18n import MessageCatalog
|
|
183
|
+
return MessageCatalog.translate(raw) or raw
|
|
184
|
+
except Exception:
|
|
185
|
+
return raw
|
|
186
|
+
|
|
187
|
+
def _record_display(self, rec) -> str:
|
|
188
|
+
"""The display text for one option record (honoring its localize key)."""
|
|
189
|
+
return self._display_text(rec.text, rec.extras.get('localize'))
|
|
190
|
+
|
|
191
|
+
def _rebuild_option_maps(self) -> None:
|
|
192
|
+
"""Rebuild displaytext<->value lookups from the current records.
|
|
193
|
+
|
|
194
|
+
Maps are keyed on each option's *display* text (the translated label
|
|
195
|
+
when localization is active), so the entry's shown text round-trips to
|
|
196
|
+
the option value. Duplicate display texts keep the first value;
|
|
197
|
+
duplicate values keep the first text. Popup selection resolves by the
|
|
198
|
+
chosen row's record, so duplicate texts with distinct values still
|
|
199
|
+
select correctly from the list — only the `value` setter (which keys on
|
|
200
|
+
value) is affected by duplicates.
|
|
201
|
+
"""
|
|
202
|
+
self._value_by_text: dict[str, Any] = {}
|
|
203
|
+
self._text_by_value: dict[Any, str] = {}
|
|
204
|
+
for rec in self._records:
|
|
205
|
+
display = self._record_display(rec)
|
|
206
|
+
self._value_by_text.setdefault(display, rec.value)
|
|
207
|
+
try:
|
|
208
|
+
self._text_by_value.setdefault(rec.value, display)
|
|
209
|
+
except TypeError:
|
|
210
|
+
pass # unhashable value — no reverse map; popup selection still works
|
|
211
|
+
|
|
212
|
+
def _on_locale_changed_refresh(self, _event=None) -> None:
|
|
213
|
+
"""Re-translate the displayed label on a live locale change.
|
|
214
|
+
|
|
215
|
+
Maps the currently-shown text back to its option value under the OLD
|
|
216
|
+
maps, rebuilds the maps with the new translations, then re-displays that
|
|
217
|
+
option's new label — only when it actually changed. A custom/typed value
|
|
218
|
+
(one not backed by an option, e.g. a `TimeEntry` interval) is left
|
|
219
|
+
untouched, so nothing is reformatted or re-emitted for it.
|
|
220
|
+
"""
|
|
221
|
+
current_text = self.entry_widget.get()
|
|
222
|
+
value = self._value_by_text.get(current_text) if current_text else None
|
|
223
|
+
self._rebuild_option_maps()
|
|
224
|
+
if value is None:
|
|
225
|
+
return
|
|
226
|
+
new_display = self._text_by_value.get(value, current_text)
|
|
227
|
+
if new_display != current_text:
|
|
228
|
+
self._set_display_text_silently(new_display)
|
|
229
|
+
|
|
230
|
+
def _set_display_text_silently(self, text: str) -> None:
|
|
231
|
+
"""Replace the entry's displayed text without emitting `<<Change>>`."""
|
|
232
|
+
is_readonly = self.entry_widget.instate(['readonly'])
|
|
233
|
+
if is_readonly:
|
|
234
|
+
self.entry_widget.state(['!readonly'])
|
|
235
|
+
# Field's value setter is programmatic (no emit); display-only here.
|
|
236
|
+
Field.value.fset(self, text)
|
|
237
|
+
if is_readonly:
|
|
238
|
+
self.entry_widget.state(['readonly'])
|
|
239
|
+
|
|
240
|
+
def _resolve_display(self, value: Any) -> Any:
|
|
241
|
+
"""Map a value-space value to what the entry should hold.
|
|
242
|
+
|
|
243
|
+
The entry owns the raw value <-> display text mapping (via
|
|
244
|
+
`value_format`); the option map only kicks in to decouple an option's
|
|
245
|
+
display text from its value:
|
|
246
|
+
|
|
247
|
+
- None/empty -> the value itself (the entry clears).
|
|
248
|
+
- A decoupled option (its text differs from its value) -> the option's
|
|
249
|
+
display text, which the entry shows verbatim.
|
|
250
|
+
- A known plain option, a custom value, or a typed value (e.g. a
|
|
251
|
+
`datetime.time`) -> the value itself, so the entry's `value_format`
|
|
252
|
+
parses/formats it.
|
|
253
|
+
- An unknown value, when `strict_value` is set and custom values are
|
|
254
|
+
off -> raises `ValueError`.
|
|
255
|
+
"""
|
|
256
|
+
if value is None or value == "":
|
|
257
|
+
return value
|
|
258
|
+
try:
|
|
259
|
+
if value in self._text_by_value:
|
|
260
|
+
text = self._text_by_value[value]
|
|
261
|
+
return text if text != value else value
|
|
262
|
+
except TypeError:
|
|
263
|
+
pass
|
|
264
|
+
if self._strict_value and not self._allow_custom_values:
|
|
265
|
+
raise ValueError(f"{value!r} is not one of the options")
|
|
266
|
+
return value
|
|
267
|
+
|
|
268
|
+
def _on_dropdown_click(self):
|
|
269
|
+
"""Handle dropdown button click by focusing entry then showing popup."""
|
|
270
|
+
self.entry_widget.focus_set()
|
|
271
|
+
self._show_selection_options()
|
|
272
|
+
|
|
273
|
+
def _bind_readonly_selection_on_click(self):
|
|
274
|
+
"""Bind entry click to show popup when in readonly mode."""
|
|
275
|
+
self.entry_widget.configure(cursor="hand2")
|
|
276
|
+
self.entry_widget.bind('<Button-1>', lambda _: self.after_idle(self._show_selection_options), add='+')
|
|
277
|
+
|
|
278
|
+
def _show_selection_options(self):
|
|
279
|
+
"""Create and display the popup list of selectable items."""
|
|
280
|
+
if not self._records or self._popup_open:
|
|
281
|
+
return
|
|
282
|
+
if self.entry_widget.instate(['disabled']):
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
self._popup_open = True
|
|
286
|
+
self.update_idletasks()
|
|
287
|
+
|
|
288
|
+
# Create popup toplevel
|
|
289
|
+
toplevel = self._create_popup_toplevel()
|
|
290
|
+
|
|
291
|
+
# Create tracking variables for popup state
|
|
292
|
+
popup_state = {
|
|
293
|
+
'item_was_selected': False,
|
|
294
|
+
'popup_closed': False,
|
|
295
|
+
'first_filtered_item': None,
|
|
296
|
+
'entry_focus_handler': None,
|
|
297
|
+
'key_bindings': [],
|
|
298
|
+
# Set once the rows exist — grouping can reorder them, so the
|
|
299
|
+
# initial highlight is computed against render order (see below).
|
|
300
|
+
'highlighted_index': 0,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
# Setup close handler
|
|
304
|
+
def close_popup(event=None):
|
|
305
|
+
self._close_popup(toplevel, popup_state)
|
|
306
|
+
|
|
307
|
+
self._popup_state = popup_state
|
|
308
|
+
|
|
309
|
+
# Create popup content frame with items
|
|
310
|
+
self._popup_frame = self._create_popup_frame(toplevel, popup_state)
|
|
311
|
+
popup_state['highlighted_index'] = self._initial_highlight_index()
|
|
312
|
+
|
|
313
|
+
# Shrink popup to fit content (still withdrawn — resize is invisible)
|
|
314
|
+
self._fit_popup_height(toplevel)
|
|
315
|
+
|
|
316
|
+
# Setup event bindings based on mode
|
|
317
|
+
self._setup_popup_bindings(toplevel, popup_state, close_popup)
|
|
318
|
+
|
|
319
|
+
# Show popup and set focus
|
|
320
|
+
toplevel.deiconify()
|
|
321
|
+
toplevel.lift()
|
|
322
|
+
|
|
323
|
+
# Scroll to the selected item. Two after_idle calls are needed: the first
|
|
324
|
+
# lets Tkinter process the deiconify/pack events; the second runs once the
|
|
325
|
+
# canvas has finished computing button positions so winfo_y() is accurate.
|
|
326
|
+
idx = popup_state['highlighted_index']
|
|
327
|
+
self.after_idle(lambda: self.after_idle(
|
|
328
|
+
lambda: self._update_highlight(popup_state, idx)
|
|
329
|
+
))
|
|
330
|
+
|
|
331
|
+
if self._search_enabled:
|
|
332
|
+
self.entry_widget.focus_force()
|
|
333
|
+
self.entry_widget.icursor('end')
|
|
334
|
+
else:
|
|
335
|
+
toplevel.focus_force()
|
|
336
|
+
|
|
337
|
+
def _compute_popup_position(self, height: int) -> tuple[int, int, int]:
|
|
338
|
+
"""Return (x, y, width) for a popup of the given height.
|
|
339
|
+
|
|
340
|
+
Flips above the entry when there isn't enough room below — matches
|
|
341
|
+
Tk's combobox PlacePopdown behavior.
|
|
342
|
+
"""
|
|
343
|
+
x = self.winfo_rootx() + (1 if self._search_enabled else 3)
|
|
344
|
+
gap_below = 8 if self._search_enabled else 5
|
|
345
|
+
gap_above = 4 if self._search_enabled else 1
|
|
346
|
+
entry_top = self.entry_widget.winfo_rooty()
|
|
347
|
+
entry_bottom = entry_top + self.entry_widget.winfo_height()
|
|
348
|
+
# Clamp to a sane minimum so opening the popup before the field has been
|
|
349
|
+
# laid out (winfo_width() == 1) can't produce a negative geometry.
|
|
350
|
+
width = max(self.winfo_width() - (2 if self._search_enabled else 6), 1)
|
|
351
|
+
screen_h = self.winfo_screenheight()
|
|
352
|
+
if entry_bottom + gap_below + height > screen_h and entry_top - gap_above - height >= 0:
|
|
353
|
+
y = entry_top - gap_above - height
|
|
354
|
+
else:
|
|
355
|
+
y = entry_bottom + gap_below
|
|
356
|
+
return x, y, width
|
|
357
|
+
|
|
358
|
+
def _popup_max_height(self, item_h: int = None) -> int:
|
|
359
|
+
"""Maximum popup height in px before it scrolls.
|
|
360
|
+
|
|
361
|
+
`max_visible_items` (when set) caps the popup at roughly that many option
|
|
362
|
+
rows — `n * row_height` plus a little chrome overhead. Before any row
|
|
363
|
+
exists a nominal row height is assumed; `_fit_popup_height` recomputes
|
|
364
|
+
with the measured height. Falls back to the built-in `_POPUP_MAX_HEIGHT`.
|
|
365
|
+
"""
|
|
366
|
+
if self._max_visible_items:
|
|
367
|
+
return self._max_visible_items * (item_h or 30) + 8
|
|
368
|
+
return self._POPUP_MAX_HEIGHT
|
|
369
|
+
|
|
370
|
+
def _create_popup_toplevel(self):
|
|
371
|
+
"""Create the popup toplevel window (withdrawn, provisional max-height size)."""
|
|
372
|
+
max_h = self._popup_max_height()
|
|
373
|
+
x, y, width = self._compute_popup_position(max_h)
|
|
374
|
+
|
|
375
|
+
# The base_window.py warning about overrideredirect on Aqua applies
|
|
376
|
+
# to full app windows that combine it with grab/transient semantics;
|
|
377
|
+
# this popup uses neither (it dismisses via a root <Button-1>
|
|
378
|
+
# binding), so overrideredirect is safe here and matches Tk's own
|
|
379
|
+
# ttk::combobox::PlacePopdown approach for Mac popdowns.
|
|
380
|
+
toplevel = Toplevel(self)
|
|
381
|
+
toplevel.withdraw()
|
|
382
|
+
toplevel.overrideredirect(True)
|
|
383
|
+
toplevel.attributes('-topmost', True)
|
|
384
|
+
toplevel.minsize(width, 0)
|
|
385
|
+
toplevel.maxsize(width * 2, max_h)
|
|
386
|
+
toplevel.geometry(f"{width}x{max_h}+{x}+{y}")
|
|
387
|
+
return toplevel
|
|
388
|
+
|
|
389
|
+
def _fit_popup_height(self, toplevel):
|
|
390
|
+
"""Shrink the popup to fit its content, up to the max popup height.
|
|
391
|
+
|
|
392
|
+
Called after the popup frame is built but before deiconify, so the
|
|
393
|
+
window is still withdrawn and the resize is invisible.
|
|
394
|
+
"""
|
|
395
|
+
toplevel.update_idletasks()
|
|
396
|
+
|
|
397
|
+
item_h = self._item_labels[0].winfo_reqheight() if self._item_labels else 32
|
|
398
|
+
# Measure the packed content directly so group headers (which are not in
|
|
399
|
+
# _item_labels) are included; fall back to a per-row estimate.
|
|
400
|
+
if self._popup_inner is not None and self._popup_inner.winfo_exists():
|
|
401
|
+
self._popup_inner.update_idletasks()
|
|
402
|
+
content_h = self._popup_inner.winfo_reqheight() + 8
|
|
403
|
+
else:
|
|
404
|
+
content_h = len(self._item_labels) * item_h + 8
|
|
405
|
+
# 8px overhead: outer_frame border (2px each side) + padding (3px each side)
|
|
406
|
+
cap = self._popup_max_height(item_h)
|
|
407
|
+
actual_h = max(min(content_h, cap), item_h)
|
|
408
|
+
|
|
409
|
+
x, y, width = self._compute_popup_position(actual_h)
|
|
410
|
+
toplevel.maxsize(width * 2, max(cap, actual_h))
|
|
411
|
+
toplevel.geometry(f"{width}x{actual_h}+{x}+{y}")
|
|
412
|
+
|
|
413
|
+
def _create_popup_frame(self, toplevel, popup_state):
|
|
414
|
+
"""Create popup frame with scrollable item list."""
|
|
415
|
+
# Outer frame with border and padding — match the entry's input
|
|
416
|
+
# surface so the popup fill blends with the field background.
|
|
417
|
+
outer_frame = Frame(toplevel, padding=3, show_border=True, surface='content')
|
|
418
|
+
outer_frame.pack(fill='both', expand=True)
|
|
419
|
+
|
|
420
|
+
# Create scrollview inside the outer frame
|
|
421
|
+
scrollview = ScrollView(
|
|
422
|
+
outer_frame,
|
|
423
|
+
scroll_direction='vertical',
|
|
424
|
+
scrollbar_visibility='always',
|
|
425
|
+
scrollbar_variant='thin',
|
|
426
|
+
surface='content',
|
|
427
|
+
)
|
|
428
|
+
scrollview.pack(fill='both', expand=True)
|
|
429
|
+
|
|
430
|
+
# Create inner frame for items
|
|
431
|
+
inner_frame = Frame(scrollview)
|
|
432
|
+
scrollview.add(inner_frame)
|
|
433
|
+
self._popup_inner = inner_frame
|
|
434
|
+
|
|
435
|
+
# Make inner frame fill the canvas width
|
|
436
|
+
def on_canvas_configure(event):
|
|
437
|
+
scrollview.canvas.itemconfig(scrollview._window_id, width=event.width - 2)
|
|
438
|
+
scrollview.canvas.bind('<Configure>', on_canvas_configure, add='+')
|
|
439
|
+
|
|
440
|
+
# Expand scrollregion to include vertical padding so content doesn't clip borders
|
|
441
|
+
def on_inner_frame_configure(event):
|
|
442
|
+
bbox = scrollview.canvas.bbox('all')
|
|
443
|
+
if bbox:
|
|
444
|
+
x0, y0, x1, y1 = bbox
|
|
445
|
+
padding_y = 1
|
|
446
|
+
scrollview.canvas.configure(scrollregion=(x0, y0 - padding_y, x1, y1 + padding_y))
|
|
447
|
+
inner_frame.bind('<Configure>', on_inner_frame_configure, add='+')
|
|
448
|
+
|
|
449
|
+
self._item_labels = []
|
|
450
|
+
self._group_headers = []
|
|
451
|
+
self._popup_rows = []
|
|
452
|
+
current_text = self.entry_widget.get()
|
|
453
|
+
|
|
454
|
+
# Get accent from Field's _accent attribute, fallback to primary if None
|
|
455
|
+
accent = getattr(self, '_accent', None) or 'primary'
|
|
456
|
+
|
|
457
|
+
def pack_row(row, **kw):
|
|
458
|
+
# Pack a popup row and remember its pack options, so search re-packs
|
|
459
|
+
# (which forget then re-pack) preserve per-row spacing.
|
|
460
|
+
kw.setdefault('fill', 'x')
|
|
461
|
+
row._pack_kw = kw
|
|
462
|
+
row.pack(**kw)
|
|
463
|
+
self._popup_rows.append(row)
|
|
464
|
+
|
|
465
|
+
# Cluster rows by the grouping field (a no-op single bucket when
|
|
466
|
+
# group_by is None). A named group gets a bold header, preceded by a
|
|
467
|
+
# separator except at the very top. Option buttons keep their
|
|
468
|
+
# render-order slot in _item_labels so the existing nav/search/highlight
|
|
469
|
+
# indexing is unchanged. Headers and separators are non-selectable.
|
|
470
|
+
i = 0
|
|
471
|
+
for group_label, recs in cluster_records(self._records, self._group_by):
|
|
472
|
+
header_widgets = []
|
|
473
|
+
if group_label is not None:
|
|
474
|
+
if self._popup_rows: # not the first row — divide from above
|
|
475
|
+
sep = Separator(inner_frame, orient='horizontal')
|
|
476
|
+
sep._is_separator = True
|
|
477
|
+
pack_row(sep, pady=(5, 2))
|
|
478
|
+
header_widgets.append(sep)
|
|
479
|
+
header = self._create_group_header(inner_frame, group_label)
|
|
480
|
+
pack_row(header)
|
|
481
|
+
header_widgets.append(header)
|
|
482
|
+
group_buttons = []
|
|
483
|
+
|
|
484
|
+
for rec in recs:
|
|
485
|
+
icon, disabled = option_display(rec)
|
|
486
|
+
# Translate the row label once (honoring a per-option localize
|
|
487
|
+
# key); pass localize=False so the Button shows it verbatim and
|
|
488
|
+
# the entry/search/value all agree on the same display text.
|
|
489
|
+
display = self._record_display(rec)
|
|
490
|
+
btn_kwargs = {}
|
|
491
|
+
if icon is not None:
|
|
492
|
+
btn_kwargs['icon'] = icon
|
|
493
|
+
if option_is_icon_only(rec):
|
|
494
|
+
btn_kwargs['icon_only'] = True
|
|
495
|
+
btn = Button(
|
|
496
|
+
inner_frame,
|
|
497
|
+
text=display,
|
|
498
|
+
localize=False,
|
|
499
|
+
accent=accent,
|
|
500
|
+
variant='selectbox_item',
|
|
501
|
+
takefocus=False,
|
|
502
|
+
command=lambda v=rec.value: self._on_item_click(v, toplevel, popup_state),
|
|
503
|
+
**btn_kwargs,
|
|
504
|
+
)
|
|
505
|
+
pack_row(btn)
|
|
506
|
+
|
|
507
|
+
# Store the option's value (for selection) and display text (for
|
|
508
|
+
# filtering — search matches what the user sees).
|
|
509
|
+
btn._item_value = rec.value
|
|
510
|
+
btn._item_text = display
|
|
511
|
+
btn._item_index = i
|
|
512
|
+
btn._item_disabled = disabled
|
|
513
|
+
# When highlighted, scroll to reveal the group's header/separator
|
|
514
|
+
# above the button (not just the button) so the section heading
|
|
515
|
+
# stays on screen.
|
|
516
|
+
btn._reveal_top = header_widgets[0] if header_widgets else btn
|
|
517
|
+
i += 1
|
|
518
|
+
|
|
519
|
+
# A disabled option is dimmed and cannot be chosen — clicking it
|
|
520
|
+
# does nothing (ttk blocks the command) and it is skipped by the
|
|
521
|
+
# keyboard navigation and search auto-select below.
|
|
522
|
+
if disabled:
|
|
523
|
+
btn.state(['disabled'])
|
|
524
|
+
# Apply selected state if this row matches the displayed text
|
|
525
|
+
if display == current_text:
|
|
526
|
+
btn.state(['selected'])
|
|
527
|
+
|
|
528
|
+
self._item_labels.append(btn)
|
|
529
|
+
group_buttons.append(btn)
|
|
530
|
+
|
|
531
|
+
if header_widgets:
|
|
532
|
+
self._group_headers.append((header_widgets, group_buttons))
|
|
533
|
+
|
|
534
|
+
return scrollview
|
|
535
|
+
|
|
536
|
+
def _repack_popup_rows(self, is_visible):
|
|
537
|
+
"""Re-pack popup rows in canonical render order, hiding filtered ones.
|
|
538
|
+
|
|
539
|
+
Forgets every row then re-packs the visible ones in their original
|
|
540
|
+
order, so a row that reappears after being filtered out lands back in
|
|
541
|
+
its correct slot (rather than at the end, which a bare `pack()` of a
|
|
542
|
+
previously-forgotten widget would do). The forget/pack pass is coalesced
|
|
543
|
+
at idle, so there is no visible flicker.
|
|
544
|
+
|
|
545
|
+
Args:
|
|
546
|
+
is_visible: Predicate called with each row widget; truthy keeps it.
|
|
547
|
+
"""
|
|
548
|
+
for row in self._popup_rows:
|
|
549
|
+
row.pack_forget()
|
|
550
|
+
seen_content = False
|
|
551
|
+
for row in self._popup_rows:
|
|
552
|
+
if not is_visible(row):
|
|
553
|
+
continue
|
|
554
|
+
is_sep = getattr(row, '_is_separator', False)
|
|
555
|
+
if is_sep and not seen_content:
|
|
556
|
+
continue # a divider with nothing visible above it — drop it
|
|
557
|
+
row.pack(**getattr(row, '_pack_kw', {'fill': 'x'}))
|
|
558
|
+
seen_content = seen_content or not is_sep
|
|
559
|
+
|
|
560
|
+
def _create_group_header(self, parent, text):
|
|
561
|
+
"""Create a non-selectable group-header label for the popup.
|
|
562
|
+
|
|
563
|
+
Rendered in the option rows' font, bold, and verbatim — the group value
|
|
564
|
+
is never transformed, so `selection`/`group_by` keep the original text.
|
|
565
|
+
"""
|
|
566
|
+
return Label(parent, text=str(text), font='body[bold]', anchor='w', padding=(8, 4, 4, 3))
|
|
567
|
+
|
|
568
|
+
def _initial_highlight_index(self) -> int:
|
|
569
|
+
"""Render-order index of the row to highlight when the popup opens.
|
|
570
|
+
|
|
571
|
+
The currently-selected option when it is present and enabled, otherwise
|
|
572
|
+
the first enabled option. Computed against `_item_labels` (render order)
|
|
573
|
+
so it stays correct when grouping reorders rows.
|
|
574
|
+
"""
|
|
575
|
+
selected = self.value
|
|
576
|
+
first_enabled = None
|
|
577
|
+
for idx, btn in enumerate(self._item_labels):
|
|
578
|
+
if getattr(btn, '_item_disabled', False):
|
|
579
|
+
continue
|
|
580
|
+
if first_enabled is None:
|
|
581
|
+
first_enabled = idx
|
|
582
|
+
if btn._item_value == selected:
|
|
583
|
+
return idx
|
|
584
|
+
return first_enabled if first_enabled is not None else 0
|
|
585
|
+
|
|
586
|
+
def _on_item_click(self, value, toplevel, popup_state):
|
|
587
|
+
"""Handle click on item."""
|
|
588
|
+
popup_state['item_was_selected'] = True
|
|
589
|
+
self._set_selected_value(value, toplevel, popup_state)
|
|
590
|
+
|
|
591
|
+
def _first_enabled_index(self) -> int:
|
|
592
|
+
"""Index of the first non-disabled option, or 0 if all are disabled."""
|
|
593
|
+
return next(
|
|
594
|
+
(i for i, rec in enumerate(self._records) if not option_display(rec)[1]),
|
|
595
|
+
0,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
def _step_highlight(self, popup_state, step):
|
|
599
|
+
"""Move the highlight by `step`, skipping disabled rows.
|
|
600
|
+
|
|
601
|
+
Stops at the first non-disabled visible row in the given direction;
|
|
602
|
+
keeps the current highlight when the edge is reached.
|
|
603
|
+
"""
|
|
604
|
+
visible_buttons = [btn for btn in self._item_labels if btn.winfo_manager()]
|
|
605
|
+
if not visible_buttons:
|
|
606
|
+
return
|
|
607
|
+
idx = popup_state['highlighted_index']
|
|
608
|
+
n = len(visible_buttons)
|
|
609
|
+
while True:
|
|
610
|
+
idx += step
|
|
611
|
+
if idx < 0 or idx >= n:
|
|
612
|
+
return # reached the edge — keep the current highlight
|
|
613
|
+
if not getattr(visible_buttons[idx], '_item_disabled', False):
|
|
614
|
+
self._update_highlight(popup_state, idx)
|
|
615
|
+
return
|
|
616
|
+
|
|
617
|
+
def _update_highlight(self, popup_state, new_index):
|
|
618
|
+
"""Update the highlighted item in the popup."""
|
|
619
|
+
visible_buttons = [btn for btn in self._item_labels if btn.winfo_manager()]
|
|
620
|
+
if not visible_buttons:
|
|
621
|
+
return
|
|
622
|
+
|
|
623
|
+
# Clamp index to valid range
|
|
624
|
+
new_index = max(0, min(new_index, len(visible_buttons) - 1))
|
|
625
|
+
old_index = popup_state['highlighted_index']
|
|
626
|
+
|
|
627
|
+
# Remove highlight from old button
|
|
628
|
+
if 0 <= old_index < len(visible_buttons):
|
|
629
|
+
visible_buttons[old_index].state(['!selected'])
|
|
630
|
+
|
|
631
|
+
# Add highlight to new button
|
|
632
|
+
visible_buttons[new_index].state(['selected'])
|
|
633
|
+
popup_state['highlighted_index'] = new_index
|
|
634
|
+
|
|
635
|
+
# Scroll to make highlighted item visible
|
|
636
|
+
btn = visible_buttons[new_index]
|
|
637
|
+
if self._popup_frame and self._popup_frame.winfo_exists():
|
|
638
|
+
self._popup_frame.canvas.update_idletasks()
|
|
639
|
+
# Get button position relative to canvas
|
|
640
|
+
btn_y = btn.winfo_y()
|
|
641
|
+
btn_height = btn.winfo_height()
|
|
642
|
+
canvas_height = self._popup_frame.canvas.winfo_height()
|
|
643
|
+
|
|
644
|
+
# Scroll if needed
|
|
645
|
+
scroll_top = self._popup_frame.canvas.canvasy(0)
|
|
646
|
+
scroll_bottom = scroll_top + canvas_height
|
|
647
|
+
|
|
648
|
+
# When scrolling up, reveal the group's header/separator above the
|
|
649
|
+
# button (not just the button) so the section heading stays visible.
|
|
650
|
+
reveal = getattr(btn, '_reveal_top', None) or btn
|
|
651
|
+
reveal_y = reveal.winfo_y()
|
|
652
|
+
|
|
653
|
+
if reveal_y < scroll_top:
|
|
654
|
+
self._popup_frame.canvas.yview_moveto(reveal_y / self._popup_frame.canvas.bbox('all')[3])
|
|
655
|
+
elif btn_y + btn_height > scroll_bottom:
|
|
656
|
+
target = (btn_y + btn_height - canvas_height) / self._popup_frame.canvas.bbox('all')[3]
|
|
657
|
+
self._popup_frame.canvas.yview_moveto(target)
|
|
658
|
+
|
|
659
|
+
def _setup_popup_bindings(self, toplevel, popup_state, close_popup):
|
|
660
|
+
"""Setup all event bindings for the popup."""
|
|
661
|
+
# Escape always closes
|
|
662
|
+
toplevel.bind("<Escape>", close_popup)
|
|
663
|
+
|
|
664
|
+
# Arrow key navigation (skips disabled rows)
|
|
665
|
+
def on_arrow_down(event):
|
|
666
|
+
self._step_highlight(popup_state, 1)
|
|
667
|
+
return 'break'
|
|
668
|
+
|
|
669
|
+
def on_arrow_up(event):
|
|
670
|
+
self._step_highlight(popup_state, -1)
|
|
671
|
+
return 'break'
|
|
672
|
+
|
|
673
|
+
def on_enter(event):
|
|
674
|
+
visible_buttons = [btn for btn in self._item_labels if btn.winfo_manager()]
|
|
675
|
+
idx = popup_state['highlighted_index']
|
|
676
|
+
if 0 <= idx < len(visible_buttons):
|
|
677
|
+
btn = visible_buttons[idx]
|
|
678
|
+
if getattr(btn, '_item_disabled', False):
|
|
679
|
+
return 'break' # a disabled row can't be chosen
|
|
680
|
+
popup_state['item_was_selected'] = True
|
|
681
|
+
self._set_selected_value(btn._item_value, toplevel, popup_state)
|
|
682
|
+
return 'break'
|
|
683
|
+
|
|
684
|
+
toplevel.bind("<Down>", on_arrow_down)
|
|
685
|
+
toplevel.bind("<Up>", on_arrow_up)
|
|
686
|
+
toplevel.bind("<Return>", on_enter)
|
|
687
|
+
|
|
688
|
+
# Also bind to entry widget for search mode
|
|
689
|
+
if self._search_enabled:
|
|
690
|
+
self._setup_search_bindings(toplevel, popup_state, close_popup)
|
|
691
|
+
entry_down = self.entry_widget.bind('<Down>', on_arrow_down, add='+')
|
|
692
|
+
entry_up = self.entry_widget.bind('<Up>', on_arrow_up, add='+')
|
|
693
|
+
entry_enter = self.entry_widget.bind('<Return>', on_enter, add='+')
|
|
694
|
+
popup_state['key_bindings'].append(('<Down>', entry_down))
|
|
695
|
+
popup_state['key_bindings'].append(('<Up>', entry_up))
|
|
696
|
+
popup_state['key_bindings'].append(('<Return>', entry_enter))
|
|
697
|
+
else:
|
|
698
|
+
toplevel.bind("<FocusOut>", close_popup)
|
|
699
|
+
|
|
700
|
+
# Initial highlight state is applied after deiconify (see _show_selection_options)
|
|
701
|
+
|
|
702
|
+
def _apply_search_filter(self, popup_state):
|
|
703
|
+
"""Filter the popup rows to those matching the entry text.
|
|
704
|
+
|
|
705
|
+
Shows option buttons whose display text contains the (case-insensitive)
|
|
706
|
+
search text; a group's header and separator show only while the group
|
|
707
|
+
has a visible option. The first enabled match drives the auto-select
|
|
708
|
+
target and the reset highlight. A no-op when the text is unchanged or the
|
|
709
|
+
popup is gone.
|
|
710
|
+
"""
|
|
711
|
+
if popup_state['popup_closed'] or not self._popup_frame or not self._popup_frame.winfo_exists():
|
|
712
|
+
return
|
|
713
|
+
|
|
714
|
+
search_text = self.entry_widget.get().lower()
|
|
715
|
+
|
|
716
|
+
# Skip if search text hasn't changed (e.g., arrow key release)
|
|
717
|
+
if search_text == popup_state['last_search_text']:
|
|
718
|
+
return
|
|
719
|
+
popup_state['last_search_text'] = search_text
|
|
720
|
+
|
|
721
|
+
# Decide which option buttons match — on the visible TEXT. The first
|
|
722
|
+
# ENABLED match drives auto-select + the reset highlight.
|
|
723
|
+
match = {}
|
|
724
|
+
first_visible = None
|
|
725
|
+
for btn in self._item_labels:
|
|
726
|
+
ok = search_text in btn._item_text.lower()
|
|
727
|
+
match[btn] = ok
|
|
728
|
+
if ok and first_visible is None and not getattr(btn, '_item_disabled', False):
|
|
729
|
+
first_visible = btn
|
|
730
|
+
|
|
731
|
+
# A group's header + separator show only while it has a visible option.
|
|
732
|
+
header_visible = {}
|
|
733
|
+
for header_widgets, group_buttons in self._group_headers:
|
|
734
|
+
vis = any(match.get(b, False) for b in group_buttons)
|
|
735
|
+
for w in header_widgets:
|
|
736
|
+
header_visible[w] = vis
|
|
737
|
+
self._repack_popup_rows(
|
|
738
|
+
lambda row: match.get(row, header_visible.get(row, True))
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
# Track first filtered item for auto-select (value-space)
|
|
742
|
+
popup_state['first_filtered_item'] = first_visible._item_value if first_visible else None
|
|
743
|
+
|
|
744
|
+
# Reset highlight to the first enabled visible item
|
|
745
|
+
visible_buttons = [b for b in self._item_labels if b.winfo_manager()]
|
|
746
|
+
target = next(
|
|
747
|
+
(i for i, b in enumerate(visible_buttons)
|
|
748
|
+
if not getattr(b, '_item_disabled', False)),
|
|
749
|
+
0,
|
|
750
|
+
)
|
|
751
|
+
self._update_highlight(popup_state, target)
|
|
752
|
+
|
|
753
|
+
def _setup_search_bindings(self, toplevel, popup_state, close_popup):
|
|
754
|
+
"""Setup search-specific event bindings."""
|
|
755
|
+
# Initialize first filtered item (stored in value-space); skip disabled
|
|
756
|
+
# so an auto-select on close never lands on a non-selectable option.
|
|
757
|
+
enabled = [rec for rec in self._records if not option_display(rec)[1]]
|
|
758
|
+
if enabled:
|
|
759
|
+
popup_state['first_filtered_item'] = enabled[0].value
|
|
760
|
+
popup_state['last_search_text'] = self.entry_widget.get().lower()
|
|
761
|
+
|
|
762
|
+
# Bind KeyRelease for filtering (delegates to the testable method)
|
|
763
|
+
keyrelease_binding = self.entry_widget.bind(
|
|
764
|
+
'<KeyRelease>', lambda e: self._apply_search_filter(popup_state), add='+'
|
|
765
|
+
)
|
|
766
|
+
popup_state['key_bindings'].append(('<KeyRelease>', keyrelease_binding))
|
|
767
|
+
|
|
768
|
+
# Bind Tab to select highlighted item
|
|
769
|
+
def on_tab(event):
|
|
770
|
+
if popup_state['popup_closed']:
|
|
771
|
+
return
|
|
772
|
+
visible_buttons = [btn for btn in self._item_labels if btn.winfo_manager()]
|
|
773
|
+
idx = popup_state['highlighted_index']
|
|
774
|
+
if 0 <= idx < len(visible_buttons):
|
|
775
|
+
btn = visible_buttons[idx]
|
|
776
|
+
if getattr(btn, '_item_disabled', False):
|
|
777
|
+
return 'break' # a disabled row can't be chosen
|
|
778
|
+
popup_state['item_was_selected'] = True
|
|
779
|
+
self._set_selected_value(btn._item_value, toplevel, popup_state)
|
|
780
|
+
return 'break'
|
|
781
|
+
|
|
782
|
+
tab_binding = self.entry_widget.bind('<Tab>', on_tab)
|
|
783
|
+
popup_state['key_bindings'].append(('<Tab>', tab_binding))
|
|
784
|
+
|
|
785
|
+
# Setup click-outside detection
|
|
786
|
+
def on_root_click(event):
|
|
787
|
+
x, y = event.x_root, event.y_root
|
|
788
|
+
|
|
789
|
+
# Check if click is inside entry widget
|
|
790
|
+
ex, ey = self.entry_widget.winfo_rootx(), self.entry_widget.winfo_rooty()
|
|
791
|
+
ew, eh = self.entry_widget.winfo_width(), self.entry_widget.winfo_height()
|
|
792
|
+
if ex <= x <= ex + ew and ey <= y <= ey + eh:
|
|
793
|
+
return
|
|
794
|
+
|
|
795
|
+
# Check if click is inside toplevel
|
|
796
|
+
if toplevel.winfo_exists():
|
|
797
|
+
tx, ty = toplevel.winfo_rootx(), toplevel.winfo_rooty()
|
|
798
|
+
tw, th = toplevel.winfo_width(), toplevel.winfo_height()
|
|
799
|
+
if tx <= x <= tx + tw and ty <= y <= ty + th:
|
|
800
|
+
return
|
|
801
|
+
|
|
802
|
+
close_popup()
|
|
803
|
+
|
|
804
|
+
def bind_click():
|
|
805
|
+
if popup_state['popup_closed']:
|
|
806
|
+
return
|
|
807
|
+
root = self.winfo_toplevel()
|
|
808
|
+
bind_id = root.bind('<Button-1>', on_root_click, add='+')
|
|
809
|
+
popup_state['entry_focus_handler'] = bind_id
|
|
810
|
+
|
|
811
|
+
self.after(100, bind_click)
|
|
812
|
+
|
|
813
|
+
def _close_popup(self, toplevel, popup_state):
|
|
814
|
+
"""Close the popup and cleanup bindings."""
|
|
815
|
+
if popup_state['popup_closed']:
|
|
816
|
+
return
|
|
817
|
+
|
|
818
|
+
popup_state['popup_closed'] = True
|
|
819
|
+
self._popup_open = False
|
|
820
|
+
|
|
821
|
+
# Unbind handlers
|
|
822
|
+
if popup_state['entry_focus_handler'] is not None and self._search_enabled:
|
|
823
|
+
root = self.winfo_toplevel()
|
|
824
|
+
root.unbind('<Button-1>', popup_state['entry_focus_handler'])
|
|
825
|
+
|
|
826
|
+
# Unbind key bindings
|
|
827
|
+
for sequence, funcid in popup_state['key_bindings']:
|
|
828
|
+
self.entry_widget.unbind(sequence, funcid)
|
|
829
|
+
|
|
830
|
+
# Destroy toplevel
|
|
831
|
+
if toplevel.winfo_exists():
|
|
832
|
+
toplevel.destroy()
|
|
833
|
+
|
|
834
|
+
# Clean up popup references
|
|
835
|
+
self._popup_frame = None
|
|
836
|
+
self._popup_inner = None
|
|
837
|
+
self._item_labels = []
|
|
838
|
+
self._group_headers = []
|
|
839
|
+
self._popup_rows = []
|
|
840
|
+
self._popup_state = None
|
|
841
|
+
|
|
842
|
+
# Handle value selection for search mode without custom values
|
|
843
|
+
if self._search_enabled and not self._allow_custom_values:
|
|
844
|
+
if not popup_state['item_was_selected']:
|
|
845
|
+
if popup_state['first_filtered_item'] is not None:
|
|
846
|
+
self._last_selected_value = popup_state['first_filtered_item']
|
|
847
|
+
self.value = popup_state['first_filtered_item']
|
|
848
|
+
|
|
849
|
+
def _set_selected_value(self, selected_value, toplevel, popup_state):
|
|
850
|
+
"""Set the selected value and close the popup."""
|
|
851
|
+
if selected_value is None:
|
|
852
|
+
return
|
|
853
|
+
|
|
854
|
+
self._last_selected_value = selected_value
|
|
855
|
+
self.value = selected_value
|
|
856
|
+
self._close_popup(toplevel, popup_state)
|
|
857
|
+
|
|
858
|
+
@configure_delegate('items')
|
|
859
|
+
def _delegate_items(self, value: list[Option] = None):
|
|
860
|
+
"""Get the normalized option records, or set new options."""
|
|
861
|
+
if value is None:
|
|
862
|
+
return list(self._records)
|
|
863
|
+
else:
|
|
864
|
+
self._records = normalize_options(value)
|
|
865
|
+
self._rebuild_option_maps()
|
|
866
|
+
# Reconcile the displayed selection: if its text is no longer an
|
|
867
|
+
# option (and custom values aren't allowed), clear it.
|
|
868
|
+
if self.entry_widget.get() not in self._value_by_text and not self._allow_custom_values:
|
|
869
|
+
self.value = None
|
|
870
|
+
return None
|
|
871
|
+
|
|
872
|
+
@configure_delegate('allow_custom_values')
|
|
873
|
+
def _delegate_allow_custom_values(self, value: bool = None):
|
|
874
|
+
"""Get or set whether free-form text entry is allowed."""
|
|
875
|
+
if value is None:
|
|
876
|
+
return self._allow_custom_values
|
|
877
|
+
else:
|
|
878
|
+
self._allow_custom_values = value
|
|
879
|
+
if value or self._search_enabled:
|
|
880
|
+
self.entry_widget.state(['!readonly'])
|
|
881
|
+
else:
|
|
882
|
+
self.readonly(True)
|
|
883
|
+
return None
|
|
884
|
+
|
|
885
|
+
@configure_delegate('enable_search')
|
|
886
|
+
def _delegate_enable_search(self, value: bool = None):
|
|
887
|
+
"""Get or set whether search filtering is enabled."""
|
|
888
|
+
if value is None:
|
|
889
|
+
return self._search_enabled
|
|
890
|
+
else:
|
|
891
|
+
self._search_enabled = value
|
|
892
|
+
if value or self._allow_custom_values:
|
|
893
|
+
self.entry_widget.state(['!readonly'])
|
|
894
|
+
else:
|
|
895
|
+
self.readonly(True)
|
|
896
|
+
return None
|
|
897
|
+
|
|
898
|
+
@configure_delegate('group_by')
|
|
899
|
+
def _delegate_group_by(self, value=None):
|
|
900
|
+
"""Get or set the option field the popup clusters rows under.
|
|
901
|
+
|
|
902
|
+
The change takes effect the next time the popup opens (it is rebuilt on
|
|
903
|
+
each open). Following the configure-delegate convention, `None` reads the
|
|
904
|
+
current field; disable grouping at runtime by setting it to `''`.
|
|
905
|
+
"""
|
|
906
|
+
if value is None:
|
|
907
|
+
return self._group_by
|
|
908
|
+
self._group_by = value or None
|
|
909
|
+
return None
|
|
910
|
+
|
|
911
|
+
@configure_delegate('max_visible_items')
|
|
912
|
+
def _delegate_max_visible_items(self, value=None):
|
|
913
|
+
"""Get or set the approximate visible-row cap before the popup scrolls.
|
|
914
|
+
|
|
915
|
+
The change takes effect the next time the popup opens. Following the
|
|
916
|
+
configure-delegate convention, `None` reads the current value; set it to
|
|
917
|
+
`0` to restore the built-in default cap.
|
|
918
|
+
"""
|
|
919
|
+
if value is None:
|
|
920
|
+
return self._max_visible_items
|
|
921
|
+
self._max_visible_items = value or None
|
|
922
|
+
return None
|
|
923
|
+
|
|
924
|
+
@configure_delegate('value')
|
|
925
|
+
def _delegate_value(self, value=None):
|
|
926
|
+
if value is None:
|
|
927
|
+
return self.value
|
|
928
|
+
self.value = value
|
|
929
|
+
return None
|
|
930
|
+
|
|
931
|
+
@property
|
|
932
|
+
def selected_index(self) -> int:
|
|
933
|
+
"""Get or set the selected index.
|
|
934
|
+
|
|
935
|
+
Returns -1 if nothing is selected (the displayed text matches no
|
|
936
|
+
option). Setting to -1 or None clears the selection.
|
|
937
|
+
"""
|
|
938
|
+
text = self.entry_widget.get()
|
|
939
|
+
for i, rec in enumerate(self._records):
|
|
940
|
+
if self._record_display(rec) == text:
|
|
941
|
+
return i
|
|
942
|
+
return -1
|
|
943
|
+
|
|
944
|
+
@selected_index.setter
|
|
945
|
+
def selected_index(self, index):
|
|
946
|
+
if index is None or index == -1:
|
|
947
|
+
self.value = None
|
|
948
|
+
elif 0 <= index < len(self._records):
|
|
949
|
+
self.value = self._records[index].value
|
|
950
|
+
else:
|
|
951
|
+
raise IndexError(f"index {index} out of range for {len(self._records)} options")
|
|
952
|
+
|
|
953
|
+
@property
|
|
954
|
+
def text(self) -> str:
|
|
955
|
+
"""The current display text shown in the field (the formatted string)."""
|
|
956
|
+
return self.entry_widget.get()
|
|
957
|
+
|
|
958
|
+
@property
|
|
959
|
+
def selection(self) -> dict | None:
|
|
960
|
+
"""The selected option as a full record dict (the data bag), or None.
|
|
961
|
+
|
|
962
|
+
Returns the matching option's `{text, value, ...extras}`; None when
|
|
963
|
+
nothing is selected or the current value is a custom/off-list one (not
|
|
964
|
+
one of the options).
|
|
965
|
+
"""
|
|
966
|
+
value = self.value
|
|
967
|
+
if value is None:
|
|
968
|
+
return None
|
|
969
|
+
for rec in self._records:
|
|
970
|
+
if rec.value == value:
|
|
971
|
+
return record_to_dict(rec)
|
|
972
|
+
return None
|
|
973
|
+
|
|
974
|
+
@property
|
|
975
|
+
def value(self):
|
|
976
|
+
"""The selected value, or None when the field is empty.
|
|
977
|
+
|
|
978
|
+
For a decoupled option (its display text differs from its value) this
|
|
979
|
+
returns the option's value. Otherwise it returns the entry's own raw
|
|
980
|
+
value, so `value_format` parsing — e.g. TimeField's `datetime.time` —
|
|
981
|
+
is preserved.
|
|
982
|
+
"""
|
|
983
|
+
text = self.entry_widget.get()
|
|
984
|
+
if text == "":
|
|
985
|
+
return None
|
|
986
|
+
if text in self._value_by_text and self._value_by_text[text] != text:
|
|
987
|
+
return self._value_by_text[text]
|
|
988
|
+
return Field.value.fget(self)
|
|
989
|
+
|
|
990
|
+
@value.setter
|
|
991
|
+
def value(self, value):
|
|
992
|
+
"""Select `value`, updating the displayed text and emitting `<<Change>>`.
|
|
993
|
+
|
|
994
|
+
A decoupled option shows its text; a plain/typed/custom value is handed
|
|
995
|
+
to the entry, which owns the raw value <-> text formatting. An unknown
|
|
996
|
+
value raises `ValueError` when `strict_value` is set and custom values
|
|
997
|
+
are off.
|
|
998
|
+
"""
|
|
999
|
+
prev_value = self.value
|
|
1000
|
+
display = self._resolve_display(value)
|
|
1001
|
+
is_readonly = self.entry_widget.instate(['readonly'])
|
|
1002
|
+
if is_readonly:
|
|
1003
|
+
self.entry_widget.state(['!readonly'])
|
|
1004
|
+
if display is None or display == "":
|
|
1005
|
+
# The entry part's value() setter doesn't reliably clear the field;
|
|
1006
|
+
# empty it directly.
|
|
1007
|
+
self.entry_widget.delete(0, 'end')
|
|
1008
|
+
else:
|
|
1009
|
+
# The entry owns raw<->text; setting its value is programmatic (no emit).
|
|
1010
|
+
Field.value.fset(self, display)
|
|
1011
|
+
if is_readonly:
|
|
1012
|
+
self.entry_widget.state(['readonly'])
|
|
1013
|
+
new_value = self.value
|
|
1014
|
+
if new_value != prev_value:
|
|
1015
|
+
self.entry_widget._prev_changed_value = new_value
|
|
1016
|
+
if not getattr(self, "_suppress_changed_event", False):
|
|
1017
|
+
self.entry_widget.event_generate(
|
|
1018
|
+
'<<Change>>',
|
|
1019
|
+
data=ChangeEvent(
|
|
1020
|
+
value=new_value,
|
|
1021
|
+
prev_value=prev_value,
|
|
1022
|
+
text=self.entry_widget.get(),
|
|
1023
|
+
),
|
|
1024
|
+
when="tail"
|
|
1025
|
+
)
|
|
1026
|
+
|