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,841 @@
|
|
|
1
|
+
"""Internal TreeView — a virtualized recycle view over a tree of TreeNodes.
|
|
2
|
+
|
|
3
|
+
This is the engine behind the public `Tree`. It mirrors the internal ListView
|
|
4
|
+
recycle view (row-widget pool, window recycling, custom scrollbar, keyboard
|
|
5
|
+
nav) but its data provider is a *flattened visible-node list* recomputed from
|
|
6
|
+
the node hierarchy whenever a branch is expanded, collapsed, or mutated.
|
|
7
|
+
|
|
8
|
+
Note: this `TreeView` is unrelated to the ttk `TreeView` primitive in
|
|
9
|
+
`_impl/primitives/treeview.py` (still used by DataTable). They live in separate
|
|
10
|
+
modules and are never imported together.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from tkinter import TclError
|
|
16
|
+
from typing import Any, Callable, Iterator, Literal, Optional
|
|
17
|
+
|
|
18
|
+
from bootstack.widgets._impl.composites.tree.treeitem import TreeItem, EMPTY
|
|
19
|
+
from bootstack.widgets._impl.composites.tree.treenode import TreeNode
|
|
20
|
+
from bootstack.widgets._impl.primitives.frame import Frame, FrameKwargs
|
|
21
|
+
from bootstack.widgets._impl.primitives.scrollbar import Scrollbar
|
|
22
|
+
from bootstack.widgets.types import Master, ScrollbarVariant
|
|
23
|
+
from typing_extensions import Unpack
|
|
24
|
+
|
|
25
|
+
VISIBLE_ROWS = 20
|
|
26
|
+
ROW_HEIGHT = 32
|
|
27
|
+
OVERSCAN_ROWS = 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TreeView(Frame):
|
|
31
|
+
"""A virtual-scrolling hierarchical view backed by `TreeNode` handles."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
master: Master = None,
|
|
36
|
+
*,
|
|
37
|
+
selection_mode: Literal["none", "single", "multi"] = "single",
|
|
38
|
+
show_selection_controls: bool = False,
|
|
39
|
+
select_on_click: bool = True,
|
|
40
|
+
indent: int = 16,
|
|
41
|
+
striped: bool = False,
|
|
42
|
+
striped_background: str = "background[+0.85]",
|
|
43
|
+
show_separator: bool = False,
|
|
44
|
+
scrollbar_visibility: Literal["always", "never"] = "always",
|
|
45
|
+
enable_hover: bool = True,
|
|
46
|
+
density: Literal["default", "compact"] = "default",
|
|
47
|
+
accent_selection: bool = False,
|
|
48
|
+
scrollbar_variant: ScrollbarVariant = "thin",
|
|
49
|
+
**kwargs: Unpack[FrameKwargs],
|
|
50
|
+
) -> None:
|
|
51
|
+
_user_accent = kwargs.get("accent")
|
|
52
|
+
super().__init__(master, variant="container", ttk_class="ListView.TFrame", **kwargs)
|
|
53
|
+
self._accent = _user_accent or "primary"
|
|
54
|
+
self._accent_selection = accent_selection
|
|
55
|
+
|
|
56
|
+
self.winsys = self.tk.call("tk", "windowingsystem")
|
|
57
|
+
|
|
58
|
+
self._selection_mode = selection_mode
|
|
59
|
+
self._show_selection_controls = show_selection_controls
|
|
60
|
+
self._select_on_click = select_on_click
|
|
61
|
+
self._indent = indent
|
|
62
|
+
self._striped = striped
|
|
63
|
+
self._striped_background = striped_background
|
|
64
|
+
self._show_separator = show_separator
|
|
65
|
+
self._scrollbar_visibility = scrollbar_visibility
|
|
66
|
+
self._scrollbar_variant = scrollbar_variant
|
|
67
|
+
self._enable_hover = enable_hover
|
|
68
|
+
self._density = density
|
|
69
|
+
|
|
70
|
+
# Hierarchy + derived flat view.
|
|
71
|
+
self._roots: list[TreeNode] = []
|
|
72
|
+
self._visible: list[dict] = []
|
|
73
|
+
|
|
74
|
+
# Selection / focus state (identity based; survives collapse).
|
|
75
|
+
self._selected: set[TreeNode] = set()
|
|
76
|
+
# Partially-selected parents (tri-state cascade populates this in the
|
|
77
|
+
# multi-select checkpoint); shown as a 'mixed' marker on the control.
|
|
78
|
+
self._mixed: set[TreeNode] = set()
|
|
79
|
+
self._focused_node: Optional[TreeNode] = None
|
|
80
|
+
|
|
81
|
+
# Type-ahead.
|
|
82
|
+
self._typeahead = ""
|
|
83
|
+
self._typeahead_after: Optional[str] = None
|
|
84
|
+
|
|
85
|
+
# Pending relayout `after` id (coalesced; cancelled on destroy so a
|
|
86
|
+
# queued callback never fires on a torn-down widget).
|
|
87
|
+
self._relayout_after: Optional[str] = None
|
|
88
|
+
|
|
89
|
+
# Virtual scrolling state.
|
|
90
|
+
self._start_index = 0
|
|
91
|
+
self._prev_start_index = 0
|
|
92
|
+
self._visible_rows = VISIBLE_ROWS
|
|
93
|
+
self._row_height = ROW_HEIGHT
|
|
94
|
+
self._page_size = VISIBLE_ROWS + OVERSCAN_ROWS
|
|
95
|
+
self._rows: list[TreeItem] = []
|
|
96
|
+
self._mousewheel_bound: set = set()
|
|
97
|
+
|
|
98
|
+
from bootstack.style.style import get_style
|
|
99
|
+
self._scrollbar_gutter = get_style().style_builder.scale(6)
|
|
100
|
+
sb_surface = getattr(self, '_surface', None)
|
|
101
|
+
sb_kw = {'surface': sb_surface} if sb_surface else {}
|
|
102
|
+
self._scrollbar = Scrollbar(
|
|
103
|
+
self, orient="vertical", command=self._on_scroll,
|
|
104
|
+
variant=self._scrollbar_variant, **sb_kw)
|
|
105
|
+
if self._scrollbar_visibility == "always":
|
|
106
|
+
self._scrollbar.pack(side="right", fill="y", padx=(self._scrollbar_gutter, 0))
|
|
107
|
+
|
|
108
|
+
self._container = Frame(self, variant="container", ttk_class="ListView.TFrame")
|
|
109
|
+
self._container.pack(side="left", fill="both", expand=True)
|
|
110
|
+
|
|
111
|
+
self._ensure_row_pool(self._page_size)
|
|
112
|
+
|
|
113
|
+
self.bind("<Configure>", self._on_resize, add="+")
|
|
114
|
+
self._bind_scroll_events(self)
|
|
115
|
+
self._bind_scroll_events(self._container)
|
|
116
|
+
|
|
117
|
+
# Row -> container events.
|
|
118
|
+
self._container.bind("<<TreeItemSelect>>", self._on_item_select, add="+")
|
|
119
|
+
self._container.bind("<<TreeItemActivate>>", self._on_item_activate, add="+")
|
|
120
|
+
self._container.bind("<<TreeItemToggle>>", self._on_item_toggle, add="+")
|
|
121
|
+
self._container.bind("<<TreeItemRightClick>>", self._on_item_right_click, add="+")
|
|
122
|
+
self._container.bind("<<TreeItemFocus>>", self._on_item_focus, add="+")
|
|
123
|
+
|
|
124
|
+
self._bind_keys(self)
|
|
125
|
+
self._bind_keys(self._container)
|
|
126
|
+
|
|
127
|
+
self._relayout_after = self.after(10, self._remeasure_and_relayout)
|
|
128
|
+
|
|
129
|
+
# ================= hierarchy / flatten =================
|
|
130
|
+
|
|
131
|
+
def _base_record(self, node: TreeNode, depth: int) -> dict:
|
|
132
|
+
expanded = node.expanded
|
|
133
|
+
if expanded and node.open_icon:
|
|
134
|
+
icon = node.open_icon
|
|
135
|
+
elif not expanded and node.closed_icon:
|
|
136
|
+
icon = node.closed_icon
|
|
137
|
+
else:
|
|
138
|
+
icon = node.icon
|
|
139
|
+
return dict(
|
|
140
|
+
node=node, depth=depth, label=node.label, icon=icon,
|
|
141
|
+
expandable=node.expandable, expanded=expanded,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def _flatten(self) -> None:
|
|
145
|
+
out: list[dict] = []
|
|
146
|
+
|
|
147
|
+
def walk(nodes: list[TreeNode], depth: int) -> None:
|
|
148
|
+
for n in nodes:
|
|
149
|
+
out.append(self._base_record(n, depth))
|
|
150
|
+
if n.expanded and n.children:
|
|
151
|
+
walk(n.children, depth + 1)
|
|
152
|
+
|
|
153
|
+
walk(self._roots, 0)
|
|
154
|
+
self._visible = out
|
|
155
|
+
|
|
156
|
+
def _iter_all_nodes(self) -> Iterator[TreeNode]:
|
|
157
|
+
"""Depth-first over every node (including collapsed), in tree order."""
|
|
158
|
+
def walk(nodes: list[TreeNode]) -> Iterator[TreeNode]:
|
|
159
|
+
for n in nodes:
|
|
160
|
+
yield n
|
|
161
|
+
yield from walk(n.children)
|
|
162
|
+
yield from walk(self._roots)
|
|
163
|
+
|
|
164
|
+
def _index_of(self, node: TreeNode) -> int:
|
|
165
|
+
for i, rec in enumerate(self._visible):
|
|
166
|
+
if rec["node"] is node:
|
|
167
|
+
return i
|
|
168
|
+
return -1
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def count(self) -> int:
|
|
172
|
+
return len(self._visible)
|
|
173
|
+
|
|
174
|
+
def _on_structure_changed(self) -> None:
|
|
175
|
+
"""Re-flatten and relayout after expand/collapse/mutation."""
|
|
176
|
+
self._flatten()
|
|
177
|
+
# Drop selection/focus for nodes no longer in the tree.
|
|
178
|
+
live = set(self._iter_all_nodes())
|
|
179
|
+
self._selected &= live
|
|
180
|
+
self._mixed &= live
|
|
181
|
+
if self._focused_node is not None and self._focused_node not in live:
|
|
182
|
+
self._focused_node = None
|
|
183
|
+
self._remeasure_and_relayout()
|
|
184
|
+
|
|
185
|
+
# ================= row pool / recycling =================
|
|
186
|
+
|
|
187
|
+
def _ensure_row_pool(self, needed: int) -> None:
|
|
188
|
+
while len(self._rows) < needed:
|
|
189
|
+
row = TreeItem(
|
|
190
|
+
self._container, indent=self._indent, focusable=True,
|
|
191
|
+
hoverable=self._enable_hover, selection_mode=self._selection_mode,
|
|
192
|
+
show_selection_controls=self._show_selection_controls,
|
|
193
|
+
select_on_click=self._select_on_click,
|
|
194
|
+
show_separator=self._show_separator, density=self._density,
|
|
195
|
+
accent=self._accent, accent_selection=self._accent_selection,
|
|
196
|
+
)
|
|
197
|
+
row.pack(fill="x")
|
|
198
|
+
self._rows.append(row)
|
|
199
|
+
self._bind_keys(row)
|
|
200
|
+
self._apply_widget_surface(row, len(self._rows) - 1)
|
|
201
|
+
while len(self._rows) > needed:
|
|
202
|
+
row = self._rows.pop()
|
|
203
|
+
# Drop this row's (and its descendants') mousewheel-bound ids so the
|
|
204
|
+
# set doesn't grow without bound and a reused Tk path can't be
|
|
205
|
+
# mistaken for already-bound.
|
|
206
|
+
prefix = str(row)
|
|
207
|
+
self._mousewheel_bound = {
|
|
208
|
+
w for w in self._mousewheel_bound if not w.startswith(prefix)
|
|
209
|
+
}
|
|
210
|
+
row.pack_forget()
|
|
211
|
+
try:
|
|
212
|
+
row.destroy()
|
|
213
|
+
except TclError:
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
def _clamp_indices(self) -> None:
|
|
217
|
+
max_start = max(0, self.count - self._visible_rows)
|
|
218
|
+
self._start_index = max(0, min(self._start_index, max_start))
|
|
219
|
+
|
|
220
|
+
def _update_rows(self) -> None:
|
|
221
|
+
self._clamp_indices()
|
|
222
|
+
scroll_distance = self._start_index - self._prev_start_index
|
|
223
|
+
if abs(scroll_distance) <= 3 and scroll_distance != 0:
|
|
224
|
+
self._recycle_rows(scroll_distance)
|
|
225
|
+
else:
|
|
226
|
+
self._full_update_rows()
|
|
227
|
+
self._prev_start_index = self._start_index
|
|
228
|
+
total = max(1, self.count)
|
|
229
|
+
first = self._start_index / total
|
|
230
|
+
last = min(1.0, (self._start_index + self._visible_rows) / total)
|
|
231
|
+
self._scrollbar.set(first, last)
|
|
232
|
+
|
|
233
|
+
def _recycle_rows(self, scroll_distance: int) -> None:
|
|
234
|
+
if scroll_distance > 0:
|
|
235
|
+
for _ in range(scroll_distance):
|
|
236
|
+
if not self._rows:
|
|
237
|
+
break
|
|
238
|
+
top = self._rows.pop(0)
|
|
239
|
+
self._update_single_row(top, self._start_index + len(self._rows))
|
|
240
|
+
top.pack_forget()
|
|
241
|
+
top.pack(side="top", fill="x")
|
|
242
|
+
self._rows.append(top)
|
|
243
|
+
elif scroll_distance < 0:
|
|
244
|
+
for _ in range(abs(scroll_distance)):
|
|
245
|
+
if not self._rows:
|
|
246
|
+
break
|
|
247
|
+
bottom = self._rows.pop()
|
|
248
|
+
self._update_single_row(bottom, self._start_index)
|
|
249
|
+
bottom.pack_forget()
|
|
250
|
+
if self._rows:
|
|
251
|
+
bottom.pack(side="top", fill="x", before=self._rows[0])
|
|
252
|
+
else:
|
|
253
|
+
bottom.pack(side="top", fill="x")
|
|
254
|
+
self._rows.insert(0, bottom)
|
|
255
|
+
|
|
256
|
+
def _full_update_rows(self) -> None:
|
|
257
|
+
for i, row in enumerate(self._rows):
|
|
258
|
+
self._update_single_row(row, self._start_index + i)
|
|
259
|
+
|
|
260
|
+
def _render_record(self, data_index: int) -> dict:
|
|
261
|
+
if data_index < 0 or data_index >= self.count:
|
|
262
|
+
return EMPTY
|
|
263
|
+
base = self._visible[data_index]
|
|
264
|
+
node = base["node"]
|
|
265
|
+
rec = dict(base)
|
|
266
|
+
rec["selected"] = node in self._selected
|
|
267
|
+
rec["focused"] = node is self._focused_node
|
|
268
|
+
rec["mixed"] = node in self._mixed
|
|
269
|
+
rec["item_index"] = data_index
|
|
270
|
+
return rec
|
|
271
|
+
|
|
272
|
+
def _update_single_row(self, row: TreeItem, data_index: int) -> None:
|
|
273
|
+
row.update_data(self._render_record(data_index))
|
|
274
|
+
# Re-apply the stripe surface by DATA index (idempotent): a recycled row
|
|
275
|
+
# showing a different index must take that index's color, else striping
|
|
276
|
+
# parity drifts under scrolling.
|
|
277
|
+
self._apply_widget_surface(row, data_index)
|
|
278
|
+
self._bind_mousewheel_recursive(row)
|
|
279
|
+
|
|
280
|
+
def _apply_widget_surface(self, row: TreeItem, widget_index: int) -> None:
|
|
281
|
+
base_surface = getattr(self, "_surface", "background")
|
|
282
|
+
if not self._striped:
|
|
283
|
+
surface = base_surface
|
|
284
|
+
else:
|
|
285
|
+
surface = self._striped_background if widget_index % 2 == 1 else base_surface
|
|
286
|
+
if hasattr(row, "set_surface"):
|
|
287
|
+
row.set_surface(surface)
|
|
288
|
+
|
|
289
|
+
# ================= scrolling =================
|
|
290
|
+
|
|
291
|
+
def _on_scroll(self, *args) -> None:
|
|
292
|
+
if args[0] == "moveto":
|
|
293
|
+
fraction = float(args[1])
|
|
294
|
+
max_start = max(0, self.count - self._visible_rows)
|
|
295
|
+
self._start_index = int(round(fraction * max_start))
|
|
296
|
+
elif args[0] == "scroll":
|
|
297
|
+
amount = int(args[1])
|
|
298
|
+
step = max(1, self._visible_rows // 2) if args[2] == "pages" else 1
|
|
299
|
+
self._start_index += amount * step
|
|
300
|
+
self._clamp_indices()
|
|
301
|
+
self._update_rows()
|
|
302
|
+
|
|
303
|
+
def _bind_scroll_events(self, widget) -> None:
|
|
304
|
+
if self.winsys.lower() == "x11":
|
|
305
|
+
widget.bind("<Button-4>", self._on_mousewheel, add="+")
|
|
306
|
+
widget.bind("<Button-5>", self._on_mousewheel, add="+")
|
|
307
|
+
else:
|
|
308
|
+
widget.bind("<MouseWheel>", self._on_mousewheel, add="+")
|
|
309
|
+
|
|
310
|
+
def _bind_mousewheel_recursive(self, widget) -> None:
|
|
311
|
+
wid = str(widget)
|
|
312
|
+
if wid not in self._mousewheel_bound:
|
|
313
|
+
self._bind_scroll_events(widget)
|
|
314
|
+
self._mousewheel_bound.add(wid)
|
|
315
|
+
try:
|
|
316
|
+
for child in widget.winfo_children():
|
|
317
|
+
self._bind_mousewheel_recursive(child)
|
|
318
|
+
except Exception:
|
|
319
|
+
pass
|
|
320
|
+
|
|
321
|
+
def _on_mousewheel(self, event) -> None:
|
|
322
|
+
under = self.winfo_containing(event.x_root, event.y_root)
|
|
323
|
+
if under is None:
|
|
324
|
+
return
|
|
325
|
+
cur, is_child = under, False
|
|
326
|
+
while cur is not None:
|
|
327
|
+
if cur == self:
|
|
328
|
+
is_child = True
|
|
329
|
+
break
|
|
330
|
+
try:
|
|
331
|
+
cur = cur.master
|
|
332
|
+
except AttributeError:
|
|
333
|
+
break
|
|
334
|
+
if not is_child:
|
|
335
|
+
return
|
|
336
|
+
if self.winsys.lower() == "x11":
|
|
337
|
+
delta = -1 if getattr(event, "num", 0) == 4 else 1
|
|
338
|
+
else:
|
|
339
|
+
delta = -1 if event.delta > 0 else 1
|
|
340
|
+
self._start_index += delta
|
|
341
|
+
self._clamp_indices()
|
|
342
|
+
self._update_rows()
|
|
343
|
+
|
|
344
|
+
def _on_resize(self, event) -> None:
|
|
345
|
+
if event.widget == self:
|
|
346
|
+
if self._relayout_after is not None:
|
|
347
|
+
try:
|
|
348
|
+
self.after_cancel(self._relayout_after)
|
|
349
|
+
except Exception:
|
|
350
|
+
pass
|
|
351
|
+
self._relayout_after = self.after_idle(self._remeasure_and_relayout)
|
|
352
|
+
|
|
353
|
+
def _remeasure_and_relayout(self) -> None:
|
|
354
|
+
self._relayout_after = None
|
|
355
|
+
if not self._rows or not self.winfo_exists():
|
|
356
|
+
return
|
|
357
|
+
rh = self._rows[0].winfo_height()
|
|
358
|
+
if rh <= 1:
|
|
359
|
+
rh = self._rows[0].winfo_reqheight()
|
|
360
|
+
if rh and rh != self._row_height:
|
|
361
|
+
self._row_height = rh
|
|
362
|
+
container_height = self._container.winfo_height()
|
|
363
|
+
if container_height > 0:
|
|
364
|
+
visible = max(1, container_height // max(1, self._row_height))
|
|
365
|
+
page_size = visible + OVERSCAN_ROWS
|
|
366
|
+
if visible != self._visible_rows or page_size != self._page_size:
|
|
367
|
+
self._visible_rows = visible
|
|
368
|
+
self._page_size = page_size
|
|
369
|
+
self._ensure_row_pool(self._page_size)
|
|
370
|
+
self._clamp_indices()
|
|
371
|
+
self._prev_start_index = -999 # force a full update
|
|
372
|
+
self._update_rows()
|
|
373
|
+
|
|
374
|
+
# ================= row event handlers =================
|
|
375
|
+
|
|
376
|
+
def _on_item_select(self, event) -> None:
|
|
377
|
+
node = event.data
|
|
378
|
+
if not isinstance(node, TreeNode):
|
|
379
|
+
return
|
|
380
|
+
if self._selection_mode == "none":
|
|
381
|
+
self._focused_node = node
|
|
382
|
+
self._update_rows()
|
|
383
|
+
else:
|
|
384
|
+
# single replaces, multi toggles (so a row click with visible
|
|
385
|
+
# checkboxes reads as a checklist) — handled in select().
|
|
386
|
+
self.select(node)
|
|
387
|
+
|
|
388
|
+
def _on_item_activate(self, event) -> None:
|
|
389
|
+
node = event.data
|
|
390
|
+
if isinstance(node, TreeNode):
|
|
391
|
+
self._focused_node = node
|
|
392
|
+
# Double-clicking an expandable row toggles it (Explorer convention),
|
|
393
|
+
# in addition to firing the activate event.
|
|
394
|
+
if node.expandable:
|
|
395
|
+
self.toggle(node)
|
|
396
|
+
self._emit("<<TreeActivate>>", node)
|
|
397
|
+
|
|
398
|
+
def _on_item_toggle(self, event) -> None:
|
|
399
|
+
node = event.data
|
|
400
|
+
if isinstance(node, TreeNode):
|
|
401
|
+
self.toggle(node)
|
|
402
|
+
|
|
403
|
+
def _on_item_right_click(self, event) -> None:
|
|
404
|
+
data = event.data
|
|
405
|
+
if isinstance(data, dict) and isinstance(data.get("node"), TreeNode):
|
|
406
|
+
self._focused_node = data["node"]
|
|
407
|
+
self._update_rows()
|
|
408
|
+
self._emit("<<TreeRightClick>>", data)
|
|
409
|
+
|
|
410
|
+
def _on_item_focus(self, event) -> None:
|
|
411
|
+
node = event.data
|
|
412
|
+
if isinstance(node, TreeNode):
|
|
413
|
+
self._focused_node = node
|
|
414
|
+
|
|
415
|
+
def _emit(self, sequence: str, data: Any) -> None:
|
|
416
|
+
try:
|
|
417
|
+
self.event_generate(sequence, data=data)
|
|
418
|
+
except TclError:
|
|
419
|
+
pass
|
|
420
|
+
|
|
421
|
+
# ================= public-ish API (called by the wrapper) =================
|
|
422
|
+
|
|
423
|
+
def add(self, label: str = "", *, parent: Optional[TreeNode] = None,
|
|
424
|
+
children: Optional[list] = None, index: Optional[int] = None,
|
|
425
|
+
**kwargs: Any) -> TreeNode:
|
|
426
|
+
node = TreeNode(label, **kwargs)
|
|
427
|
+
node._tree = self
|
|
428
|
+
node.parent = parent
|
|
429
|
+
siblings = parent.children if parent is not None else self._roots
|
|
430
|
+
if index is None:
|
|
431
|
+
siblings.append(node)
|
|
432
|
+
else:
|
|
433
|
+
siblings.insert(index, node)
|
|
434
|
+
if children:
|
|
435
|
+
for spec in children:
|
|
436
|
+
self._add_from_spec(spec, parent=node)
|
|
437
|
+
self._on_structure_changed()
|
|
438
|
+
return node
|
|
439
|
+
|
|
440
|
+
def _attach_subtree(self, node: TreeNode) -> None:
|
|
441
|
+
"""Point `node` and every descendant at this tree (so node convenience
|
|
442
|
+
methods work for a pre-built subtree passed as a spec)."""
|
|
443
|
+
node._tree = self
|
|
444
|
+
for d in node.descendants():
|
|
445
|
+
d._tree = self
|
|
446
|
+
|
|
447
|
+
def _add_from_spec(self, spec: Any, parent: TreeNode) -> TreeNode:
|
|
448
|
+
"""Add a node from a dict spec (declarative `nodes=`) or a TreeNode."""
|
|
449
|
+
if isinstance(spec, TreeNode):
|
|
450
|
+
spec.parent = parent
|
|
451
|
+
parent.children.append(spec)
|
|
452
|
+
self._attach_subtree(spec)
|
|
453
|
+
return spec
|
|
454
|
+
if isinstance(spec, str):
|
|
455
|
+
spec = {"label": spec}
|
|
456
|
+
spec = dict(spec)
|
|
457
|
+
children = spec.pop("children", None)
|
|
458
|
+
node = TreeNode(**spec)
|
|
459
|
+
node._tree = self
|
|
460
|
+
node.parent = parent
|
|
461
|
+
parent.children.append(node)
|
|
462
|
+
if children:
|
|
463
|
+
for child_spec in children:
|
|
464
|
+
self._add_from_spec(child_spec, parent=node)
|
|
465
|
+
return node
|
|
466
|
+
|
|
467
|
+
def load(self, specs: list) -> None:
|
|
468
|
+
"""Replace the whole tree from a declarative list of specs."""
|
|
469
|
+
self._roots = []
|
|
470
|
+
self._selected.clear()
|
|
471
|
+
self._mixed.clear()
|
|
472
|
+
self._focused_node = None
|
|
473
|
+
for spec in specs or []:
|
|
474
|
+
self._add_root_from_spec(spec)
|
|
475
|
+
self._on_structure_changed()
|
|
476
|
+
|
|
477
|
+
def _add_root_from_spec(self, spec: Any) -> TreeNode:
|
|
478
|
+
if isinstance(spec, TreeNode):
|
|
479
|
+
spec.parent = None
|
|
480
|
+
self._roots.append(spec)
|
|
481
|
+
self._attach_subtree(spec)
|
|
482
|
+
return spec
|
|
483
|
+
if isinstance(spec, str):
|
|
484
|
+
spec = {"label": spec}
|
|
485
|
+
spec = dict(spec)
|
|
486
|
+
children = spec.pop("children", None)
|
|
487
|
+
node = TreeNode(**spec)
|
|
488
|
+
node._tree = self
|
|
489
|
+
node.parent = None
|
|
490
|
+
self._roots.append(node)
|
|
491
|
+
if children:
|
|
492
|
+
for child_spec in children:
|
|
493
|
+
self._add_from_spec(child_spec, parent=node)
|
|
494
|
+
return node
|
|
495
|
+
|
|
496
|
+
def remove(self, node: TreeNode) -> None:
|
|
497
|
+
siblings = node.parent.children if node.parent is not None else self._roots
|
|
498
|
+
try:
|
|
499
|
+
siblings.remove(node)
|
|
500
|
+
except ValueError:
|
|
501
|
+
return
|
|
502
|
+
self._on_structure_changed()
|
|
503
|
+
|
|
504
|
+
def move(self, node: TreeNode, new_parent: Optional[TreeNode], index: Any = "end") -> None:
|
|
505
|
+
# Guard against cycles: a node cannot be moved into itself or one of its
|
|
506
|
+
# own descendants (that would make _flatten recurse forever).
|
|
507
|
+
if new_parent is node or (new_parent is not None and node in new_parent.ancestors()):
|
|
508
|
+
raise ValueError("cannot move a node into itself or one of its descendants")
|
|
509
|
+
siblings = node.parent.children if node.parent is not None else self._roots
|
|
510
|
+
try:
|
|
511
|
+
siblings.remove(node)
|
|
512
|
+
except ValueError:
|
|
513
|
+
return
|
|
514
|
+
node.parent = new_parent
|
|
515
|
+
target = new_parent.children if new_parent is not None else self._roots
|
|
516
|
+
if index == "end" or index is None or index >= len(target):
|
|
517
|
+
target.append(node)
|
|
518
|
+
else:
|
|
519
|
+
target.insert(int(index), node)
|
|
520
|
+
self._on_structure_changed()
|
|
521
|
+
|
|
522
|
+
def clear(self) -> None:
|
|
523
|
+
self._roots = []
|
|
524
|
+
self._selected.clear()
|
|
525
|
+
self._mixed.clear()
|
|
526
|
+
self._focused_node = None
|
|
527
|
+
self._on_structure_changed()
|
|
528
|
+
|
|
529
|
+
# ---- expansion ----
|
|
530
|
+
|
|
531
|
+
def expand(self, node: TreeNode) -> None:
|
|
532
|
+
if not node.expandable:
|
|
533
|
+
return
|
|
534
|
+
if node.loader is not None and not node._loaded:
|
|
535
|
+
self._load_children(node)
|
|
536
|
+
if not node.expanded:
|
|
537
|
+
node.expanded = True
|
|
538
|
+
self._on_structure_changed()
|
|
539
|
+
self._emit("<<TreeExpand>>", node)
|
|
540
|
+
|
|
541
|
+
def collapse(self, node: TreeNode) -> None:
|
|
542
|
+
if node.expanded:
|
|
543
|
+
node.expanded = False
|
|
544
|
+
self._on_structure_changed()
|
|
545
|
+
self._emit("<<TreeCollapse>>", node)
|
|
546
|
+
|
|
547
|
+
def toggle(self, node: TreeNode) -> None:
|
|
548
|
+
if node.expanded:
|
|
549
|
+
self.collapse(node)
|
|
550
|
+
else:
|
|
551
|
+
self.expand(node)
|
|
552
|
+
|
|
553
|
+
def _load_children(self, node: TreeNode) -> None:
|
|
554
|
+
# Mark loaded up front so a re-entrant expand during the loader call
|
|
555
|
+
# can't trigger a second load. Loaders should handle their own errors;
|
|
556
|
+
# a raising loader yields an empty (leaf) node.
|
|
557
|
+
node._loaded = True
|
|
558
|
+
try:
|
|
559
|
+
result = node.loader(node) if node.loader else None
|
|
560
|
+
except Exception:
|
|
561
|
+
result = None
|
|
562
|
+
for spec in (result or []):
|
|
563
|
+
self._add_from_spec(spec, parent=node)
|
|
564
|
+
# Deferred cascade: if this parent was selected while its lazy children
|
|
565
|
+
# were still unloaded, the freshly loaded children inherit that state.
|
|
566
|
+
if node in self._selected:
|
|
567
|
+
for child in node.children:
|
|
568
|
+
self._set_subtree_selected(child, True)
|
|
569
|
+
|
|
570
|
+
def reload_children(self, node: TreeNode) -> None:
|
|
571
|
+
"""Refresh a lazy node: drop its loaded children and fetch them again.
|
|
572
|
+
|
|
573
|
+
Only meaningful for nodes created with a `loader`. If the node is
|
|
574
|
+
expanded the children reload immediately; otherwise they reload on the
|
|
575
|
+
next expand. A no-op for non-lazy nodes.
|
|
576
|
+
"""
|
|
577
|
+
if node.loader is None:
|
|
578
|
+
return
|
|
579
|
+
node.children = []
|
|
580
|
+
node._loaded = False
|
|
581
|
+
if node.expanded:
|
|
582
|
+
self._load_children(node)
|
|
583
|
+
self._on_structure_changed()
|
|
584
|
+
|
|
585
|
+
def expand_all(self) -> None:
|
|
586
|
+
# Repeat passes until nothing new appears: lazily-loaded children only
|
|
587
|
+
# exist after their parent's loader runs, so a single pass would miss
|
|
588
|
+
# deeper lazy levels. Each lazy node loads at most once (`_loaded`), so
|
|
589
|
+
# this terminates for any finite tree.
|
|
590
|
+
while True:
|
|
591
|
+
changed = False
|
|
592
|
+
for node in list(self._iter_all_nodes()):
|
|
593
|
+
if node.loader is not None and not node._loaded:
|
|
594
|
+
self._load_children(node)
|
|
595
|
+
changed = True
|
|
596
|
+
if node.expandable and not node.expanded:
|
|
597
|
+
node.expanded = True
|
|
598
|
+
changed = True
|
|
599
|
+
if not changed:
|
|
600
|
+
break
|
|
601
|
+
self._on_structure_changed()
|
|
602
|
+
|
|
603
|
+
def collapse_all(self) -> None:
|
|
604
|
+
for node in self._iter_all_nodes():
|
|
605
|
+
node.expanded = False
|
|
606
|
+
self._on_structure_changed()
|
|
607
|
+
|
|
608
|
+
def reveal(self, node: TreeNode) -> None:
|
|
609
|
+
for anc in list(node.ancestors()):
|
|
610
|
+
if anc.loader is not None and not anc._loaded:
|
|
611
|
+
self._load_children(anc)
|
|
612
|
+
anc.expanded = True
|
|
613
|
+
self._flatten()
|
|
614
|
+
self._focused_node = node
|
|
615
|
+
idx = self._index_of(node)
|
|
616
|
+
if idx >= 0:
|
|
617
|
+
self._scroll_into_view(idx)
|
|
618
|
+
self._update_rows()
|
|
619
|
+
|
|
620
|
+
# ---- selection ----
|
|
621
|
+
|
|
622
|
+
def _emit_selection_changed(self) -> None:
|
|
623
|
+
from bootstack.events import TreeSelectionEvent
|
|
624
|
+
self._emit("<<TreeSelectionChange>>", TreeSelectionEvent(nodes=self.get_selected()))
|
|
625
|
+
|
|
626
|
+
def _set_subtree_selected(self, node: TreeNode, selected: bool) -> None:
|
|
627
|
+
"""Select/deselect a node and cascade to its LOADED descendants.
|
|
628
|
+
|
|
629
|
+
A node with unloaded lazy children is set on its own (deferred cascade);
|
|
630
|
+
its children inherit the state when they load (see `_load_children`).
|
|
631
|
+
"""
|
|
632
|
+
if node.loader is not None and not node._loaded:
|
|
633
|
+
targets = [node] # defer cascade until children load
|
|
634
|
+
else:
|
|
635
|
+
targets = [node, *node.descendants()]
|
|
636
|
+
for n in targets:
|
|
637
|
+
if selected:
|
|
638
|
+
self._selected.add(n)
|
|
639
|
+
self._mixed.discard(n)
|
|
640
|
+
else:
|
|
641
|
+
self._selected.discard(n)
|
|
642
|
+
self._mixed.discard(n)
|
|
643
|
+
|
|
644
|
+
def _recompute_ancestors(self, node: TreeNode) -> None:
|
|
645
|
+
"""Recompute each ancestor's tri-state from its children."""
|
|
646
|
+
for anc in node.ancestors():
|
|
647
|
+
kids = anc.children
|
|
648
|
+
if not kids:
|
|
649
|
+
continue
|
|
650
|
+
n_selected = sum(1 for c in kids if c in self._selected)
|
|
651
|
+
any_mixed = any(c in self._mixed for c in kids)
|
|
652
|
+
if not any_mixed and n_selected == len(kids):
|
|
653
|
+
self._selected.add(anc)
|
|
654
|
+
self._mixed.discard(anc)
|
|
655
|
+
elif not any_mixed and n_selected == 0:
|
|
656
|
+
self._selected.discard(anc)
|
|
657
|
+
self._mixed.discard(anc)
|
|
658
|
+
else:
|
|
659
|
+
self._selected.discard(anc)
|
|
660
|
+
self._mixed.add(anc)
|
|
661
|
+
|
|
662
|
+
def select(self, node: TreeNode) -> None:
|
|
663
|
+
if self._selection_mode == "none":
|
|
664
|
+
return
|
|
665
|
+
before = (frozenset(self._selected), frozenset(self._mixed))
|
|
666
|
+
if self._selection_mode == "single":
|
|
667
|
+
self._selected = {node}
|
|
668
|
+
self._mixed.clear()
|
|
669
|
+
else:
|
|
670
|
+
# Tri-state cascade: toggle the node's whole subtree, then refresh
|
|
671
|
+
# ancestors. A mixed (partial) parent toggles ON (selects all).
|
|
672
|
+
self._set_subtree_selected(node, node not in self._selected)
|
|
673
|
+
self._recompute_ancestors(node)
|
|
674
|
+
self._focused_node = node
|
|
675
|
+
self._update_rows()
|
|
676
|
+
# Only announce a change if the selection set actually moved (re-clicking
|
|
677
|
+
# an already-selected single node shouldn't re-fire the event).
|
|
678
|
+
if before != (frozenset(self._selected), frozenset(self._mixed)):
|
|
679
|
+
self._emit_selection_changed()
|
|
680
|
+
|
|
681
|
+
def deselect(self, node: TreeNode) -> None:
|
|
682
|
+
if node in self._selected or node in self._mixed:
|
|
683
|
+
self._set_subtree_selected(node, False)
|
|
684
|
+
self._recompute_ancestors(node)
|
|
685
|
+
self._update_rows()
|
|
686
|
+
self._emit_selection_changed()
|
|
687
|
+
|
|
688
|
+
def select_all(self) -> None:
|
|
689
|
+
if self._selection_mode != "multi":
|
|
690
|
+
return
|
|
691
|
+
self._selected = set(self._iter_all_nodes())
|
|
692
|
+
self._mixed.clear()
|
|
693
|
+
self._update_rows()
|
|
694
|
+
self._emit_selection_changed()
|
|
695
|
+
|
|
696
|
+
def clear_selection(self) -> None:
|
|
697
|
+
if self._selected or self._mixed:
|
|
698
|
+
self._selected.clear()
|
|
699
|
+
self._mixed.clear()
|
|
700
|
+
self._update_rows()
|
|
701
|
+
self._emit_selection_changed()
|
|
702
|
+
|
|
703
|
+
def get_selected(self) -> list[TreeNode]:
|
|
704
|
+
sel = self._selected
|
|
705
|
+
return [n for n in self._iter_all_nodes() if n in sel]
|
|
706
|
+
|
|
707
|
+
# ================= keyboard navigation =================
|
|
708
|
+
|
|
709
|
+
def _bind_keys(self, widget) -> None:
|
|
710
|
+
widget.bind("<Down>", self._on_down, add="+")
|
|
711
|
+
widget.bind("<Up>", self._on_up, add="+")
|
|
712
|
+
widget.bind("<Left>", self._on_left, add="+")
|
|
713
|
+
widget.bind("<Right>", self._on_right, add="+")
|
|
714
|
+
widget.bind("<Home>", self._on_home, add="+")
|
|
715
|
+
widget.bind("<End>", self._on_end, add="+")
|
|
716
|
+
widget.bind("<Return>", self._on_return, add="+")
|
|
717
|
+
widget.bind("<space>", self._on_space, add="+")
|
|
718
|
+
widget.bind("<Key>", self._on_typeahead, add="+")
|
|
719
|
+
|
|
720
|
+
def _scroll_into_view(self, index: int) -> None:
|
|
721
|
+
if index < self._start_index:
|
|
722
|
+
self._start_index = index
|
|
723
|
+
self._clamp_indices()
|
|
724
|
+
elif index >= self._start_index + self._visible_rows:
|
|
725
|
+
self._start_index = index - self._visible_rows + 1
|
|
726
|
+
self._clamp_indices()
|
|
727
|
+
|
|
728
|
+
def _focus_index(self, index: int) -> str:
|
|
729
|
+
if self.count == 0 or index < 0 or index >= self.count:
|
|
730
|
+
return "break"
|
|
731
|
+
self._scroll_into_view(index)
|
|
732
|
+
self._focused_node = self._visible[index]["node"]
|
|
733
|
+
self._update_rows()
|
|
734
|
+
visual = index - self._start_index
|
|
735
|
+
if 0 <= visual < len(self._rows):
|
|
736
|
+
try:
|
|
737
|
+
# Keyboard navigation -> show the focus ring (visual_focus).
|
|
738
|
+
self._rows[visual].focus_set(visual_focus=True)
|
|
739
|
+
except TclError:
|
|
740
|
+
pass
|
|
741
|
+
return "break"
|
|
742
|
+
|
|
743
|
+
def _focused_index(self) -> int:
|
|
744
|
+
if self._focused_node is None:
|
|
745
|
+
return -1
|
|
746
|
+
return self._index_of(self._focused_node)
|
|
747
|
+
|
|
748
|
+
def _on_down(self, event) -> str:
|
|
749
|
+
if self.count == 0:
|
|
750
|
+
return "break"
|
|
751
|
+
cur = self._focused_index()
|
|
752
|
+
nxt = self._start_index if cur < 0 else min(cur + 1, self.count - 1)
|
|
753
|
+
return self._focus_index(nxt)
|
|
754
|
+
|
|
755
|
+
def _on_up(self, event) -> str:
|
|
756
|
+
if self.count == 0:
|
|
757
|
+
return "break"
|
|
758
|
+
cur = self._focused_index()
|
|
759
|
+
nxt = self._start_index if cur < 0 else max(cur - 1, 0)
|
|
760
|
+
return self._focus_index(nxt)
|
|
761
|
+
|
|
762
|
+
def _on_left(self, event) -> str:
|
|
763
|
+
node = self._focused_node
|
|
764
|
+
if node is None:
|
|
765
|
+
return "break"
|
|
766
|
+
if node.expandable and node.expanded:
|
|
767
|
+
self.collapse(node)
|
|
768
|
+
return self._focus_index(self._index_of(node))
|
|
769
|
+
if node.parent is not None:
|
|
770
|
+
return self._focus_index(self._index_of(node.parent))
|
|
771
|
+
return "break"
|
|
772
|
+
|
|
773
|
+
def _on_right(self, event) -> str:
|
|
774
|
+
node = self._focused_node
|
|
775
|
+
if node is None:
|
|
776
|
+
return "break"
|
|
777
|
+
if node.expandable and not node.expanded:
|
|
778
|
+
self.expand(node)
|
|
779
|
+
return self._focus_index(self._index_of(node))
|
|
780
|
+
if node.children:
|
|
781
|
+
return self._focus_index(self._index_of(node.children[0]))
|
|
782
|
+
return "break"
|
|
783
|
+
|
|
784
|
+
def _on_home(self, event) -> str:
|
|
785
|
+
return self._focus_index(0)
|
|
786
|
+
|
|
787
|
+
def _on_end(self, event) -> str:
|
|
788
|
+
return self._focus_index(self.count - 1)
|
|
789
|
+
|
|
790
|
+
def _on_return(self, event) -> str:
|
|
791
|
+
node = self._focused_node
|
|
792
|
+
if node is not None:
|
|
793
|
+
self._emit("<<TreeActivate>>", node)
|
|
794
|
+
return "break"
|
|
795
|
+
|
|
796
|
+
def _on_space(self, event) -> str:
|
|
797
|
+
node = self._focused_node
|
|
798
|
+
if node is None:
|
|
799
|
+
return "break"
|
|
800
|
+
# Space toggles selection (in multi, this is the checklist toggle).
|
|
801
|
+
self.select(node)
|
|
802
|
+
return "break"
|
|
803
|
+
|
|
804
|
+
def _on_typeahead(self, event) -> Optional[str]:
|
|
805
|
+
ch = event.char
|
|
806
|
+
if not ch or not ch.isprintable() or ch in (" ",):
|
|
807
|
+
return None
|
|
808
|
+
self._typeahead += ch.lower()
|
|
809
|
+
if self._typeahead_after is not None:
|
|
810
|
+
try:
|
|
811
|
+
self.after_cancel(self._typeahead_after)
|
|
812
|
+
except Exception:
|
|
813
|
+
pass
|
|
814
|
+
self._typeahead_after = self.after(700, self._clear_typeahead)
|
|
815
|
+
start = max(0, self._focused_index())
|
|
816
|
+
order = list(range(start + 1, self.count)) + list(range(0, start + 1))
|
|
817
|
+
for i in order:
|
|
818
|
+
label = (self._visible[i]["label"] or "").lower()
|
|
819
|
+
if label.startswith(self._typeahead):
|
|
820
|
+
return self._focus_index(i)
|
|
821
|
+
return "break"
|
|
822
|
+
|
|
823
|
+
def _clear_typeahead(self) -> None:
|
|
824
|
+
self._typeahead = ""
|
|
825
|
+
self._typeahead_after = None
|
|
826
|
+
|
|
827
|
+
@property
|
|
828
|
+
def roots(self) -> list[TreeNode]:
|
|
829
|
+
return self._roots
|
|
830
|
+
|
|
831
|
+
def destroy(self) -> None:
|
|
832
|
+
"""Cancel pending `after` callbacks before tearing down."""
|
|
833
|
+
for attr in ("_relayout_after", "_typeahead_after"):
|
|
834
|
+
after_id = getattr(self, attr, None)
|
|
835
|
+
if after_id is not None:
|
|
836
|
+
try:
|
|
837
|
+
self.after_cancel(after_id)
|
|
838
|
+
except Exception:
|
|
839
|
+
pass
|
|
840
|
+
setattr(self, attr, None)
|
|
841
|
+
super().destroy()
|