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,1433 @@
|
|
|
1
|
+
"""ListView widget for displaying large lists with virtual scrolling."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
from tkinter import TclError
|
|
5
|
+
from typing import Any, Callable, Literal
|
|
6
|
+
from typing_extensions import Unpack
|
|
7
|
+
|
|
8
|
+
from bootstack.data import MemoryDataSource, DataSourceProtocol
|
|
9
|
+
from bootstack.widgets._impl.composites.list.listitem import ListItem
|
|
10
|
+
from bootstack.widgets._impl.primitives.frame import Frame, FrameKwargs
|
|
11
|
+
from bootstack.widgets.types import Master, ScrollbarVariant
|
|
12
|
+
from bootstack.widgets._impl.primitives.scrollbar import Scrollbar
|
|
13
|
+
from bootstack.widgets._impl.mixins import configure_delegate
|
|
14
|
+
|
|
15
|
+
# Constants
|
|
16
|
+
VISIBLE_ROWS = 20
|
|
17
|
+
ROW_HEIGHT = 40
|
|
18
|
+
OVERSCAN_ROWS = 2
|
|
19
|
+
EMPTY = {"__empty__": True, "id": "__empty__"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ListView(Frame):
|
|
24
|
+
"""A virtual scrolling list widget for efficiently displaying large datasets.
|
|
25
|
+
|
|
26
|
+
ListView uses virtual scrolling to render only visible items, allowing it to
|
|
27
|
+
handle thousands of records efficiently. It supports multiple selection modes,
|
|
28
|
+
item deletion, drag and drop, and custom styling.
|
|
29
|
+
|
|
30
|
+
The widget works with either a simple list/dict data or a custom DataSource
|
|
31
|
+
implementation for more complex scenarios (database, API, etc.).
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, master: Master = None,
|
|
36
|
+
items: list = None,
|
|
37
|
+
datasource: DataSourceProtocol = None,
|
|
38
|
+
_row_factory: Callable = None,
|
|
39
|
+
selection_mode: Literal['none', 'single', 'multi'] = 'none',
|
|
40
|
+
show_selection_controls: bool = False,
|
|
41
|
+
show_chevron: bool = False,
|
|
42
|
+
enable_removing: bool = False,
|
|
43
|
+
enable_dragging: bool = False,
|
|
44
|
+
striped: bool = False,
|
|
45
|
+
striped_background: str = 'background[+0.85]',
|
|
46
|
+
show_separator: bool = True,
|
|
47
|
+
scrollbar_visibility: Literal['always', 'never'] = 'always',
|
|
48
|
+
enable_focus: bool = True,
|
|
49
|
+
enable_hover: bool = True,
|
|
50
|
+
select_on_click: bool = None,
|
|
51
|
+
density: Literal['default', 'compact'] = 'default',
|
|
52
|
+
accent_selection: bool = False,
|
|
53
|
+
scrollbar_variant: ScrollbarVariant = 'thin',
|
|
54
|
+
**kwargs: Unpack[FrameKwargs]
|
|
55
|
+
):
|
|
56
|
+
"""Initialize a ListView widget.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
master: Parent widget.
|
|
60
|
+
items: List of items or dicts to display (alternative to `datasource`).
|
|
61
|
+
datasource: DataSource implementation for data access.
|
|
62
|
+
selection_mode: Selection mode (`none`, `single`, `multi`).
|
|
63
|
+
show_selection_controls: Show checkboxes/radio buttons for selection.
|
|
64
|
+
show_chevron: Show chevron indicators on items.
|
|
65
|
+
enable_removing: Allow items to be removed; shows remove button on items.
|
|
66
|
+
enable_dragging: Allow row dragging; shows drag handle on items.
|
|
67
|
+
striped: Whether to show alternating row colors.
|
|
68
|
+
striped_background: The background color for striped rows.
|
|
69
|
+
show_separator: Show separator line between items.
|
|
70
|
+
scrollbar_visibility: Scrollbar visibility - 'always' to show scrollbar,
|
|
71
|
+
'never' to hide (mousewheel only). Defaults to 'always'.
|
|
72
|
+
enable_focus: Whether items can receive keyboard focus.
|
|
73
|
+
enable_hover: Whether items show hover state.
|
|
74
|
+
select_on_click: Whether clicking an item selects it. Defaults to True when
|
|
75
|
+
selection_mode is 'single' or 'multi', False otherwise. Can be explicitly
|
|
76
|
+
set to override the default behavior.
|
|
77
|
+
density: Visual density ('default' or 'compact'). Defaults to 'default'.
|
|
78
|
+
**kwargs: Additional keyword arguments forwarded to `Frame`.
|
|
79
|
+
"""
|
|
80
|
+
# Capture user-provided accent for row propagation and the drag indicator.
|
|
81
|
+
# super().__init__ runs through the style wrapper which sets self._accent
|
|
82
|
+
# to whatever's in kwargs (None if absent), so we re-assert with a 'primary'
|
|
83
|
+
# fallback after super() completes.
|
|
84
|
+
_user_accent = kwargs.get('accent')
|
|
85
|
+
|
|
86
|
+
super().__init__(master, variant='container', ttk_class='ListView.TFrame', **kwargs)
|
|
87
|
+
|
|
88
|
+
self._accent = _user_accent or 'primary'
|
|
89
|
+
|
|
90
|
+
# Cache the windowing system so scroll bindings can dispatch
|
|
91
|
+
# platform-correctly: Aqua/Win send <MouseWheel>, X11 sends
|
|
92
|
+
# <Button-4>/<Button-5>.
|
|
93
|
+
self.winsys = self.tk.call('tk', 'windowingsystem')
|
|
94
|
+
|
|
95
|
+
# Configuration
|
|
96
|
+
self._selection_mode = selection_mode
|
|
97
|
+
self._show_selection_controls = show_selection_controls
|
|
98
|
+
self._show_chevron = show_chevron
|
|
99
|
+
self._enable_removing = enable_removing
|
|
100
|
+
self._enable_dragging = enable_dragging
|
|
101
|
+
self._show_separator = show_separator
|
|
102
|
+
self._scrollbar_visibility = scrollbar_visibility
|
|
103
|
+
self._scrollbar_variant = scrollbar_variant
|
|
104
|
+
self._select_on_click = select_on_click
|
|
105
|
+
self._enable_focus = enable_focus
|
|
106
|
+
self._enable_hover = enable_hover
|
|
107
|
+
self._striped = striped
|
|
108
|
+
self._striped_background = striped_background
|
|
109
|
+
self._density = density
|
|
110
|
+
self._accent_selection = accent_selection
|
|
111
|
+
|
|
112
|
+
# Virtual scrolling state
|
|
113
|
+
self._start_index = 0
|
|
114
|
+
self._prev_start_index = 0
|
|
115
|
+
self._visible_rows = VISIBLE_ROWS
|
|
116
|
+
self._row_height = ROW_HEIGHT
|
|
117
|
+
self._page_size = VISIBLE_ROWS + OVERSCAN_ROWS
|
|
118
|
+
|
|
119
|
+
# Data source
|
|
120
|
+
if datasource:
|
|
121
|
+
self._datasource = datasource
|
|
122
|
+
elif items:
|
|
123
|
+
self._datasource = MemoryDataSource(page_size=self._page_size).load(items)
|
|
124
|
+
else:
|
|
125
|
+
self._datasource = MemoryDataSource(page_size=self._page_size).load([])
|
|
126
|
+
|
|
127
|
+
self._rows: list[ListItem] = []
|
|
128
|
+
self._focused_record_id = None
|
|
129
|
+
self._drag_state: dict | None = None
|
|
130
|
+
self._drag_indicator: Frame | None = None
|
|
131
|
+
self._drag_scroll_counter = 0
|
|
132
|
+
self._mousewheel_bound_widgets: set = set() # Track bound widgets to avoid cycles
|
|
133
|
+
|
|
134
|
+
# Row factory
|
|
135
|
+
self._row_factory = _row_factory or self._default_row_factory
|
|
136
|
+
|
|
137
|
+
# Create the scrollbar first and pack it on the right (with a small left
|
|
138
|
+
# pad so it sits in its own gutter, not flush against the rows); the
|
|
139
|
+
# container then fills the space to its left.
|
|
140
|
+
from bootstack.style.style import get_style
|
|
141
|
+
self._scrollbar_gutter = get_style().style_builder.scale(6)
|
|
142
|
+
sb_surface = getattr(self, '_surface', None)
|
|
143
|
+
sb_kw = {'surface': sb_surface} if sb_surface else {}
|
|
144
|
+
self._scrollbar = Scrollbar(
|
|
145
|
+
self, orient='vertical', command=self._on_scroll,
|
|
146
|
+
variant=self._scrollbar_variant, **sb_kw)
|
|
147
|
+
if self._scrollbar_visibility == 'always':
|
|
148
|
+
self._scrollbar.pack(side='right', fill='y', padx=(self._scrollbar_gutter, 0))
|
|
149
|
+
|
|
150
|
+
# Create container frame for list items (fills the area left of the gutter)
|
|
151
|
+
self._container = Frame(self, variant='container', ttk_class='ListView.TFrame')
|
|
152
|
+
self._container.pack(side='left', fill='both', expand=True)
|
|
153
|
+
|
|
154
|
+
# Create row pool
|
|
155
|
+
self._ensure_row_pool(self._page_size)
|
|
156
|
+
|
|
157
|
+
# Bind events
|
|
158
|
+
self.bind('<Configure>', self._on_resize, add='+')
|
|
159
|
+
# The striped row surface is applied imperatively at pool-creation time,
|
|
160
|
+
# so it must be re-resolved on theme change — gated to on-screen by the
|
|
161
|
+
# Frame base hook (an off-screen list defers its per-row repaint to <Map>)
|
|
162
|
+
# and released on destroy automatically.
|
|
163
|
+
self._bind_scroll_events(self)
|
|
164
|
+
self._bind_scroll_events(self._container)
|
|
165
|
+
|
|
166
|
+
# Bind ListItem events
|
|
167
|
+
self._container.bind('<<ItemSelecting>>', self._on_item_selecting, add='+')
|
|
168
|
+
self._container.bind('<<ItemRemoving>>', self._on_item_removing, add='+')
|
|
169
|
+
self._container.bind('<<ItemFocus>>', self._on_item_focused, add='+')
|
|
170
|
+
self._container.bind('<<ItemClick>>', self._on_item_click, add='+')
|
|
171
|
+
self._container.bind('<<ItemDragStart>>', self._on_item_drag_start, add='+')
|
|
172
|
+
self._container.bind('<<ItemDrag>>', self._on_item_dragging, add='+')
|
|
173
|
+
self._container.bind('<<ItemDragEnd>>', self._on_item_drag_end, add='+')
|
|
174
|
+
|
|
175
|
+
# Bind keyboard navigation
|
|
176
|
+
self.bind('<Down>', self._on_arrow_down, add='+')
|
|
177
|
+
self.bind('<Up>', self._on_arrow_up, add='+')
|
|
178
|
+
self._container.bind('<Down>', self._on_arrow_down, add='+')
|
|
179
|
+
self._container.bind('<Up>', self._on_arrow_up, add='+')
|
|
180
|
+
|
|
181
|
+
# Auto-refresh when the data source changes from the outside (a shared
|
|
182
|
+
# source mutated directly, or a background-thread feed). The hub marshals
|
|
183
|
+
# the callback onto the main thread; mutations this widget makes itself
|
|
184
|
+
# are wrapped in `_silence_source()` so they do not loop back here.
|
|
185
|
+
self._change_sub = None
|
|
186
|
+
on_change = getattr(self._datasource, 'on_change', None)
|
|
187
|
+
if callable(on_change):
|
|
188
|
+
try:
|
|
189
|
+
self._change_sub = on_change(self._on_source_change)
|
|
190
|
+
except Exception:
|
|
191
|
+
self._change_sub = None
|
|
192
|
+
self.bind('<Destroy>', self._on_listview_destroy, add='+')
|
|
193
|
+
|
|
194
|
+
# Initial update
|
|
195
|
+
self.after(10, self._remeasure_and_relayout)
|
|
196
|
+
|
|
197
|
+
def _silence_source(self):
|
|
198
|
+
"""Context manager suppressing source change broadcasts for our own writes."""
|
|
199
|
+
silence = getattr(self._datasource, '_silence', None)
|
|
200
|
+
if callable(silence):
|
|
201
|
+
return silence()
|
|
202
|
+
return contextlib.nullcontext()
|
|
203
|
+
|
|
204
|
+
def _on_source_change(self, event: Any = None) -> None:
|
|
205
|
+
"""Refresh the visible rows after an external data-source change.
|
|
206
|
+
|
|
207
|
+
Re-measures and relays out (not just `_update_rows`) so the visible-row
|
|
208
|
+
count, the row-widget pool, and the scrollbar all track a changed row
|
|
209
|
+
count — the same path a window resize takes. This also self-corrects the
|
|
210
|
+
pool if the initial measurement ran before the geometry had settled.
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
self._remeasure_and_relayout()
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
def _on_listview_destroy(self, event: Any = None) -> None:
|
|
218
|
+
"""Cancel the data-source subscription when the widget is destroyed."""
|
|
219
|
+
if event is not None and getattr(event, 'widget', None) is not self:
|
|
220
|
+
return
|
|
221
|
+
# The theme subscription is released by the Frame base hook's own
|
|
222
|
+
# <Destroy> handler (publisher unsubscribe).
|
|
223
|
+
sub = self._change_sub
|
|
224
|
+
self._change_sub = None
|
|
225
|
+
if sub is not None:
|
|
226
|
+
try:
|
|
227
|
+
sub.cancel()
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
@configure_delegate('selection_mode')
|
|
232
|
+
def _delegate_selection_mode(self, value=None):
|
|
233
|
+
"""Get or set the selection mode.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
value: If provided, sets the selection mode to 'none', 'single', or 'multi'.
|
|
237
|
+
If None, returns the current selection mode.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Current selection mode when called without arguments.
|
|
241
|
+
"""
|
|
242
|
+
if value is None:
|
|
243
|
+
return self._selection_mode
|
|
244
|
+
else:
|
|
245
|
+
self._selection_mode = value
|
|
246
|
+
# Recreate row pool to apply new selection mode
|
|
247
|
+
self._ensure_row_pool(self._page_size)
|
|
248
|
+
self._update_rows()
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
@configure_delegate('scrollbar_visibility')
|
|
252
|
+
def _delegate_scrollbar_visibility(self, value=None):
|
|
253
|
+
"""Get or set scrollbar visibility.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
value: If provided ('always' or 'never'), shows or hides the scrollbar.
|
|
257
|
+
If None, returns current visibility setting.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Current scrollbar_visibility value when called without arguments.
|
|
261
|
+
"""
|
|
262
|
+
if value is None:
|
|
263
|
+
return self._scrollbar_visibility
|
|
264
|
+
else:
|
|
265
|
+
old_value = self._scrollbar_visibility
|
|
266
|
+
self._scrollbar_visibility = value
|
|
267
|
+
if old_value != self._scrollbar_visibility:
|
|
268
|
+
if self._scrollbar_visibility == 'always':
|
|
269
|
+
# before=container keeps the scrollbar in its right gutter
|
|
270
|
+
# rather than overlaying the already-packed rows.
|
|
271
|
+
self._scrollbar.pack(side='right', fill='y',
|
|
272
|
+
padx=(self._scrollbar_gutter, 0), before=self._container)
|
|
273
|
+
else:
|
|
274
|
+
self._scrollbar.pack_forget()
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
@configure_delegate('striped')
|
|
278
|
+
def _delegate_striped(self, value=None):
|
|
279
|
+
"""Get or set striped mode.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
value: If provided, enables or disables striped rows.
|
|
283
|
+
If None, returns the current mode.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Current striped value when called without arguments.
|
|
287
|
+
"""
|
|
288
|
+
if value is None:
|
|
289
|
+
return self._striped
|
|
290
|
+
else:
|
|
291
|
+
self._striped = bool(value)
|
|
292
|
+
# Reapply surface colors to all rows
|
|
293
|
+
for i, row in enumerate(self._rows):
|
|
294
|
+
self._apply_widget_surface(row, i)
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
@configure_delegate('striped_background')
|
|
298
|
+
def _delegate_striped_background(self, value=None):
|
|
299
|
+
"""Get or set striped row background color.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
value: If provided, sets the striped row background color.
|
|
303
|
+
If None, returns the current color.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Current striped_background when called without arguments.
|
|
307
|
+
"""
|
|
308
|
+
if value is None:
|
|
309
|
+
return self._striped_background
|
|
310
|
+
else:
|
|
311
|
+
self._striped_background = value
|
|
312
|
+
# Reapply surface colors to all rows
|
|
313
|
+
for i, row in enumerate(self._rows):
|
|
314
|
+
self._apply_widget_surface(row, i)
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
@staticmethod
|
|
318
|
+
def _default_row_factory(master, **kwargs: Unpack[FrameKwargs]):
|
|
319
|
+
"""Create a default `ListItem`.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
master: Parent widget.
|
|
323
|
+
**kwargs: Keyword arguments for `ListItem`.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
A new `ListItem` instance.
|
|
327
|
+
"""
|
|
328
|
+
return ListItem(master, **kwargs)
|
|
329
|
+
|
|
330
|
+
def _ensure_row_pool(self, needed: int):
|
|
331
|
+
"""Create/destroy `ListItem` widgets to match pool size.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
needed: Desired number of row widgets.
|
|
335
|
+
"""
|
|
336
|
+
while len(self._rows) < needed:
|
|
337
|
+
# Build kwargs for row factory (using item-level names)
|
|
338
|
+
row_kwargs = dict(
|
|
339
|
+
selection_mode=self._selection_mode,
|
|
340
|
+
show_selection_controls=self._show_selection_controls,
|
|
341
|
+
show_chevron=self._show_chevron,
|
|
342
|
+
removable=self._enable_removing,
|
|
343
|
+
draggable=self._enable_dragging,
|
|
344
|
+
show_separator=self._show_separator,
|
|
345
|
+
focusable=self._enable_focus,
|
|
346
|
+
hoverable=self._enable_hover,
|
|
347
|
+
accent=self._accent,
|
|
348
|
+
density=self._density,
|
|
349
|
+
accent_selection=self._accent_selection,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# When striped AND separated, draw the row divider in the stripe
|
|
353
|
+
# color so the two read as one subtle tone instead of a darker grid
|
|
354
|
+
# of lines competing with the bands.
|
|
355
|
+
if self._striped and self._show_separator:
|
|
356
|
+
row_kwargs['separator_color'] = self._striped_background
|
|
357
|
+
|
|
358
|
+
# Only pass select_on_click if explicitly set
|
|
359
|
+
if self._select_on_click is not None:
|
|
360
|
+
row_kwargs['select_on_click'] = self._select_on_click
|
|
361
|
+
|
|
362
|
+
row = self._row_factory(self._container, **row_kwargs)
|
|
363
|
+
row.pack(fill='x')
|
|
364
|
+
self._rows.append(row)
|
|
365
|
+
|
|
366
|
+
# Bind keyboard navigation to each row and its children
|
|
367
|
+
self._bind_arrow_keys_recursive(row)
|
|
368
|
+
|
|
369
|
+
# Apply surface color once based on widget position
|
|
370
|
+
widget_index = len(self._rows) - 1
|
|
371
|
+
self._apply_widget_surface(row, widget_index)
|
|
372
|
+
|
|
373
|
+
while len(self._rows) > needed:
|
|
374
|
+
row = self._rows.pop()
|
|
375
|
+
row.pack_forget()
|
|
376
|
+
try:
|
|
377
|
+
row.destroy()
|
|
378
|
+
except TclError:
|
|
379
|
+
pass
|
|
380
|
+
|
|
381
|
+
def _clamp_indices(self):
|
|
382
|
+
"""Ensure `self._start_index` is within valid range."""
|
|
383
|
+
total = self._datasource.count
|
|
384
|
+
max_start = max(0, total - self._visible_rows)
|
|
385
|
+
self._start_index = max(0, min(self._start_index, max_start))
|
|
386
|
+
|
|
387
|
+
def _update_rows(self):
|
|
388
|
+
"""Update visible rows with current data using row recycling for efficiency."""
|
|
389
|
+
self._clamp_indices()
|
|
390
|
+
|
|
391
|
+
# Calculate scroll distance to determine if we can use recycling
|
|
392
|
+
scroll_distance = self._start_index - self._prev_start_index
|
|
393
|
+
can_recycle = abs(scroll_distance) <= 3 and scroll_distance != 0
|
|
394
|
+
|
|
395
|
+
if can_recycle:
|
|
396
|
+
# Use row recycling for small scrolls
|
|
397
|
+
self._recycle_rows(scroll_distance)
|
|
398
|
+
else:
|
|
399
|
+
# Full update for large scrolls or initial render
|
|
400
|
+
self._full_update_rows()
|
|
401
|
+
|
|
402
|
+
# Remember current position for next scroll
|
|
403
|
+
self._prev_start_index = self._start_index
|
|
404
|
+
|
|
405
|
+
# Update scrollbar
|
|
406
|
+
total = max(1, self._datasource.count)
|
|
407
|
+
first = self._start_index / total
|
|
408
|
+
last = min(1.0, (self._start_index + self._visible_rows) / total)
|
|
409
|
+
self._scrollbar.set(first, last)
|
|
410
|
+
|
|
411
|
+
def _recycle_rows(self, scroll_distance: int):
|
|
412
|
+
"""Recycle rows by moving them from one end to the other.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
scroll_distance: Positive for scrolling down, negative for scrolling up.
|
|
416
|
+
"""
|
|
417
|
+
if scroll_distance > 0:
|
|
418
|
+
# Scrolling down: move top rows to bottom
|
|
419
|
+
for _ in range(scroll_distance):
|
|
420
|
+
if not self._rows:
|
|
421
|
+
break
|
|
422
|
+
|
|
423
|
+
# Remove top row
|
|
424
|
+
top_row = self._rows.pop(0)
|
|
425
|
+
|
|
426
|
+
# Calculate new data index
|
|
427
|
+
data_index = self._start_index + len(self._rows)
|
|
428
|
+
|
|
429
|
+
# Update data BEFORE moving widget to prevent focus tracking widget
|
|
430
|
+
self._update_single_row(top_row, data_index)
|
|
431
|
+
|
|
432
|
+
# Move to bottom
|
|
433
|
+
top_row.pack_forget()
|
|
434
|
+
top_row.pack(side='top', fill='x')
|
|
435
|
+
self._rows.append(top_row)
|
|
436
|
+
|
|
437
|
+
elif scroll_distance < 0:
|
|
438
|
+
# Scrolling up: move bottom rows to top
|
|
439
|
+
for _ in range(abs(scroll_distance)):
|
|
440
|
+
if not self._rows:
|
|
441
|
+
break
|
|
442
|
+
|
|
443
|
+
# Remove bottom row
|
|
444
|
+
bottom_row = self._rows.pop()
|
|
445
|
+
|
|
446
|
+
# Calculate new data index
|
|
447
|
+
data_index = self._start_index
|
|
448
|
+
|
|
449
|
+
# Update data BEFORE moving widget to prevent focus tracking widget
|
|
450
|
+
self._update_single_row(bottom_row, data_index)
|
|
451
|
+
|
|
452
|
+
# Move to top
|
|
453
|
+
bottom_row.pack_forget()
|
|
454
|
+
if self._rows:
|
|
455
|
+
bottom_row.pack(side='top', fill='x', before=self._rows[0])
|
|
456
|
+
else:
|
|
457
|
+
bottom_row.pack(side='top', fill='x')
|
|
458
|
+
self._rows.insert(0, bottom_row)
|
|
459
|
+
|
|
460
|
+
def _full_update_rows(self):
|
|
461
|
+
"""Perform a full update of all visible rows."""
|
|
462
|
+
page_data = self._datasource.page_slice(self._start_index, self._page_size)
|
|
463
|
+
|
|
464
|
+
for i, row in enumerate(self._rows):
|
|
465
|
+
data_index = self._start_index + i
|
|
466
|
+
if i < len(page_data):
|
|
467
|
+
self._update_single_row(row, data_index, page_data[i])
|
|
468
|
+
else:
|
|
469
|
+
row.update_data(EMPTY)
|
|
470
|
+
|
|
471
|
+
def _update_single_row(self, row: ListItem, data_index: int, record: dict = None):
|
|
472
|
+
"""Update a single row widget with data at the given index.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
row: The ListItem widget to update.
|
|
476
|
+
data_index: The data index to fetch and display.
|
|
477
|
+
record: Optional pre-fetched record data. If None, will fetch from datasource.
|
|
478
|
+
"""
|
|
479
|
+
if record is None:
|
|
480
|
+
# Fetch the record from datasource
|
|
481
|
+
page_data = self._datasource.page_slice(data_index, 1)
|
|
482
|
+
if not page_data:
|
|
483
|
+
row.update_data(EMPTY)
|
|
484
|
+
# Bind mousewheel after update to ensure all child widgets exist
|
|
485
|
+
self._bind_mousewheel_recursive(row)
|
|
486
|
+
return
|
|
487
|
+
record = page_data[0]
|
|
488
|
+
|
|
489
|
+
record = record.copy()
|
|
490
|
+
record_id = record.get('id')
|
|
491
|
+
|
|
492
|
+
# Add selection state
|
|
493
|
+
if record_id is not None:
|
|
494
|
+
try:
|
|
495
|
+
record['selected'] = self._datasource.is_selected(record_id)
|
|
496
|
+
except Exception:
|
|
497
|
+
record['selected'] = False
|
|
498
|
+
record['focused'] = (record_id == self._focused_record_id)
|
|
499
|
+
else:
|
|
500
|
+
record['selected'] = False
|
|
501
|
+
record['focused'] = False
|
|
502
|
+
|
|
503
|
+
# Add index
|
|
504
|
+
record['item_index'] = data_index
|
|
505
|
+
|
|
506
|
+
# Update the row
|
|
507
|
+
row.update_data(record)
|
|
508
|
+
|
|
509
|
+
# Bind mousewheel after update to ensure all child widgets exist
|
|
510
|
+
self._bind_mousewheel_recursive(row)
|
|
511
|
+
|
|
512
|
+
def _on_scroll(self, *args):
|
|
513
|
+
"""Handle scrollbar movement.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
*args: Tkinter scrollbar arguments.
|
|
517
|
+
"""
|
|
518
|
+
if args[0] == 'moveto':
|
|
519
|
+
fraction = float(args[1])
|
|
520
|
+
total = self._datasource.count
|
|
521
|
+
max_start = max(0, total - self._visible_rows)
|
|
522
|
+
self._start_index = int(round(fraction * max_start))
|
|
523
|
+
elif args[0] == 'scroll':
|
|
524
|
+
amount = int(args[1])
|
|
525
|
+
unit = args[2]
|
|
526
|
+
# Use smaller step size for smoother scrolling
|
|
527
|
+
step = max(1, self._visible_rows // 2) if unit == 'pages' else 1
|
|
528
|
+
self._start_index += amount * step
|
|
529
|
+
|
|
530
|
+
self._clamp_indices()
|
|
531
|
+
self._update_rows()
|
|
532
|
+
|
|
533
|
+
def _bind_mousewheel_recursive(self, widget):
|
|
534
|
+
"""Recursively bind mousewheel event to a widget and all its children.
|
|
535
|
+
|
|
536
|
+
Only binds if the widget hasn't been bound already to avoid duplicate bindings.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
widget: The widget to bind mousewheel event to.
|
|
540
|
+
"""
|
|
541
|
+
# Use widget string representation as identifier
|
|
542
|
+
widget_id = str(widget)
|
|
543
|
+
|
|
544
|
+
# Only bind if we haven't already bound this widget
|
|
545
|
+
if widget_id not in self._mousewheel_bound_widgets:
|
|
546
|
+
self._bind_scroll_events(widget)
|
|
547
|
+
self._mousewheel_bound_widgets.add(widget_id)
|
|
548
|
+
|
|
549
|
+
try:
|
|
550
|
+
for child in widget.winfo_children():
|
|
551
|
+
self._bind_mousewheel_recursive(child)
|
|
552
|
+
except Exception:
|
|
553
|
+
pass
|
|
554
|
+
|
|
555
|
+
def _bind_arrow_keys_recursive(self, widget):
|
|
556
|
+
"""Recursively bind arrow key events to a widget and all its children.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
widget: The widget to bind arrow key events to.
|
|
560
|
+
"""
|
|
561
|
+
widget.bind('<Down>', self._on_arrow_down, add='+')
|
|
562
|
+
widget.bind('<Up>', self._on_arrow_up, add='+')
|
|
563
|
+
|
|
564
|
+
try:
|
|
565
|
+
for child in widget.winfo_children():
|
|
566
|
+
self._bind_arrow_keys_recursive(child)
|
|
567
|
+
except Exception:
|
|
568
|
+
pass
|
|
569
|
+
|
|
570
|
+
def _bind_scroll_events(self, widget) -> None:
|
|
571
|
+
"""Bind the platform-correct scroll-wheel events to `widget`.
|
|
572
|
+
|
|
573
|
+
On Aqua/Win the event is `<MouseWheel>` with `event.delta`
|
|
574
|
+
carrying direction and magnitude. On X11 there's no MouseWheel —
|
|
575
|
+
scroll up is `<Button-4>` and scroll down is `<Button-5>`.
|
|
576
|
+
"""
|
|
577
|
+
if self.winsys.lower() == 'x11':
|
|
578
|
+
widget.bind('<Button-4>', self._on_mousewheel, add='+')
|
|
579
|
+
widget.bind('<Button-5>', self._on_mousewheel, add='+')
|
|
580
|
+
else:
|
|
581
|
+
widget.bind('<MouseWheel>', self._on_mousewheel, add='+')
|
|
582
|
+
|
|
583
|
+
def _on_mousewheel(self, event):
|
|
584
|
+
"""Handle mouse wheel scrolling.
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
event: Tkinter mouse wheel event.
|
|
588
|
+
"""
|
|
589
|
+
# Check if mouse is over this widget
|
|
590
|
+
widget_under_mouse = self.winfo_containing(event.x_root, event.y_root)
|
|
591
|
+
if widget_under_mouse is None:
|
|
592
|
+
return
|
|
593
|
+
|
|
594
|
+
# Check if the widget under mouse is a child of this ListView
|
|
595
|
+
current = widget_under_mouse
|
|
596
|
+
is_child = False
|
|
597
|
+
while current is not None:
|
|
598
|
+
if current == self:
|
|
599
|
+
is_child = True
|
|
600
|
+
break
|
|
601
|
+
try:
|
|
602
|
+
current = current.master
|
|
603
|
+
except AttributeError:
|
|
604
|
+
break
|
|
605
|
+
|
|
606
|
+
if not is_child:
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
# Resolve scroll direction per platform: X11 carries it in event.num
|
|
610
|
+
# (4=up, 5=down) and has no event.delta; Aqua/Win use event.delta.
|
|
611
|
+
if self.winsys.lower() == 'x11':
|
|
612
|
+
delta = -1 if getattr(event, 'num', 0) == 4 else 1
|
|
613
|
+
else:
|
|
614
|
+
delta = -1 if event.delta > 0 else 1
|
|
615
|
+
self._start_index += delta
|
|
616
|
+
self._clamp_indices()
|
|
617
|
+
self._update_rows()
|
|
618
|
+
|
|
619
|
+
def _on_resize(self, event):
|
|
620
|
+
"""Handle widget resize and recalculate visible rows.
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
event: Tkinter configure event.
|
|
624
|
+
"""
|
|
625
|
+
if event.widget == self:
|
|
626
|
+
self.after_idle(self._remeasure_and_relayout)
|
|
627
|
+
|
|
628
|
+
def _remeasure_and_relayout(self):
|
|
629
|
+
"""Measure row height, then recompute sizes and repaint."""
|
|
630
|
+
if not self._rows:
|
|
631
|
+
return
|
|
632
|
+
|
|
633
|
+
# Measure actual widget height
|
|
634
|
+
rh = self._rows[0].winfo_height()
|
|
635
|
+
if rh <= 1:
|
|
636
|
+
rh = self._rows[0].winfo_reqheight()
|
|
637
|
+
|
|
638
|
+
if rh and rh != self._row_height:
|
|
639
|
+
self._row_height = rh
|
|
640
|
+
|
|
641
|
+
# Calculate how many rows fit
|
|
642
|
+
container_height = self._container.winfo_height()
|
|
643
|
+
if container_height > 0:
|
|
644
|
+
visible = max(1, container_height // max(1, self._row_height))
|
|
645
|
+
page_size = visible + OVERSCAN_ROWS
|
|
646
|
+
|
|
647
|
+
if visible != self._visible_rows or page_size != self._page_size:
|
|
648
|
+
self._visible_rows = visible
|
|
649
|
+
self._page_size = page_size
|
|
650
|
+
self._ensure_row_pool(self._page_size)
|
|
651
|
+
|
|
652
|
+
self._clamp_indices()
|
|
653
|
+
self._update_rows()
|
|
654
|
+
|
|
655
|
+
def _on_item_selecting(self, event: Any):
|
|
656
|
+
"""Handle item selection event from `ListItem`.
|
|
657
|
+
|
|
658
|
+
Args:
|
|
659
|
+
event: Event with `data` for the item being selected.
|
|
660
|
+
"""
|
|
661
|
+
record_id = event.data.get('id')
|
|
662
|
+
if record_id is not None and record_id != '__empty__':
|
|
663
|
+
with self._silence_source():
|
|
664
|
+
if self._selection_mode == 'single':
|
|
665
|
+
self._datasource.deselect_all()
|
|
666
|
+
self._datasource.select(record_id)
|
|
667
|
+
elif self._selection_mode == 'multi':
|
|
668
|
+
if self._datasource.is_selected(record_id):
|
|
669
|
+
self._datasource.deselect(record_id)
|
|
670
|
+
else:
|
|
671
|
+
self._datasource.select(record_id)
|
|
672
|
+
|
|
673
|
+
self._update_rows()
|
|
674
|
+
self.event_generate('<<SelectionChange>>')
|
|
675
|
+
|
|
676
|
+
def _on_item_removing(self, event: Any):
|
|
677
|
+
"""Handle item remove event from `ListItem`.
|
|
678
|
+
|
|
679
|
+
Args:
|
|
680
|
+
event: Event with `data` for the item being removed.
|
|
681
|
+
"""
|
|
682
|
+
record_id = event.data.get('id')
|
|
683
|
+
if record_id is not None and record_id != '__empty__':
|
|
684
|
+
try:
|
|
685
|
+
with self._silence_source():
|
|
686
|
+
self._datasource.delete(record_id)
|
|
687
|
+
self._update_rows()
|
|
688
|
+
self.event_generate('<<ItemDelete>>', data=event.data)
|
|
689
|
+
except Exception as exc:
|
|
690
|
+
payload = dict(event.data)
|
|
691
|
+
payload['error'] = str(exc)
|
|
692
|
+
self.event_generate('<<ItemDeleteFail>>', data=payload)
|
|
693
|
+
|
|
694
|
+
def _on_item_focused(self, event: Any):
|
|
695
|
+
"""Handle item focus event from `ListItem`.
|
|
696
|
+
|
|
697
|
+
Args:
|
|
698
|
+
event: Event with `data` for the item being focused.
|
|
699
|
+
"""
|
|
700
|
+
record_id = event.data.get('id')
|
|
701
|
+
if record_id is not None and record_id != '__empty__':
|
|
702
|
+
self._focused_record_id = record_id
|
|
703
|
+
self._update_rows()
|
|
704
|
+
|
|
705
|
+
def _get_focused_index(self) -> int:
|
|
706
|
+
"""Get the data index of the currently focused item.
|
|
707
|
+
|
|
708
|
+
Returns:
|
|
709
|
+
The index of the focused item, or -1 if none is focused.
|
|
710
|
+
"""
|
|
711
|
+
if self._focused_record_id is None:
|
|
712
|
+
return -1
|
|
713
|
+
|
|
714
|
+
# Search visible rows for the focused record
|
|
715
|
+
for i, row in enumerate(self._rows):
|
|
716
|
+
if hasattr(row, '_data') and row._data.get('id') == self._focused_record_id:
|
|
717
|
+
return self._start_index + i
|
|
718
|
+
|
|
719
|
+
return -1
|
|
720
|
+
|
|
721
|
+
def _focus_item_at_index(self, index: int) -> None:
|
|
722
|
+
"""Focus the item at the given data index.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
index: The data index of the item to focus.
|
|
726
|
+
"""
|
|
727
|
+
total = self._datasource.count
|
|
728
|
+
if total == 0 or index < 0 or index >= total:
|
|
729
|
+
return
|
|
730
|
+
|
|
731
|
+
# Scroll if needed to make the item visible
|
|
732
|
+
if index < self._start_index:
|
|
733
|
+
self._start_index = index
|
|
734
|
+
self._clamp_indices()
|
|
735
|
+
self._update_rows()
|
|
736
|
+
elif index >= self._start_index + len(self._rows):
|
|
737
|
+
self._start_index = index - len(self._rows) + 1
|
|
738
|
+
self._clamp_indices()
|
|
739
|
+
self._update_rows()
|
|
740
|
+
|
|
741
|
+
# Get the record at the index
|
|
742
|
+
page_data = self._datasource.page_slice(index, 1)
|
|
743
|
+
if page_data:
|
|
744
|
+
record_id = page_data[0].get('id')
|
|
745
|
+
if record_id is not None:
|
|
746
|
+
self._focused_record_id = record_id
|
|
747
|
+
self._update_rows()
|
|
748
|
+
|
|
749
|
+
# Focus the visible row widget (keyboard nav -> show focus ring)
|
|
750
|
+
visual_index = index - self._start_index
|
|
751
|
+
if 0 <= visual_index < len(self._rows):
|
|
752
|
+
self._rows[visual_index].focus_set(visual_focus=True)
|
|
753
|
+
|
|
754
|
+
def _on_arrow_down(self, event) -> str:
|
|
755
|
+
"""Handle arrow down key for keyboard navigation.
|
|
756
|
+
|
|
757
|
+
Args:
|
|
758
|
+
event: Tkinter key event.
|
|
759
|
+
|
|
760
|
+
Returns:
|
|
761
|
+
'break' to prevent default handling.
|
|
762
|
+
"""
|
|
763
|
+
total = self._datasource.count
|
|
764
|
+
if total == 0:
|
|
765
|
+
return 'break'
|
|
766
|
+
|
|
767
|
+
current_index = self._get_focused_index()
|
|
768
|
+
if current_index < 0:
|
|
769
|
+
# No item focused, focus the first visible item
|
|
770
|
+
next_index = self._start_index
|
|
771
|
+
else:
|
|
772
|
+
# Move to next item
|
|
773
|
+
next_index = min(current_index + 1, total - 1)
|
|
774
|
+
|
|
775
|
+
self._focus_item_at_index(next_index)
|
|
776
|
+
return 'break'
|
|
777
|
+
|
|
778
|
+
def _on_arrow_up(self, event) -> str:
|
|
779
|
+
"""Handle arrow up key for keyboard navigation.
|
|
780
|
+
|
|
781
|
+
Args:
|
|
782
|
+
event: Tkinter key event.
|
|
783
|
+
|
|
784
|
+
Returns:
|
|
785
|
+
'break' to prevent default handling.
|
|
786
|
+
"""
|
|
787
|
+
total = self._datasource.count
|
|
788
|
+
if total == 0:
|
|
789
|
+
return 'break'
|
|
790
|
+
|
|
791
|
+
current_index = self._get_focused_index()
|
|
792
|
+
if current_index < 0:
|
|
793
|
+
# No item focused, focus the last visible item
|
|
794
|
+
next_index = min(self._start_index + len(self._rows) - 1, total - 1)
|
|
795
|
+
else:
|
|
796
|
+
# Move to previous item
|
|
797
|
+
next_index = max(current_index - 1, 0)
|
|
798
|
+
|
|
799
|
+
self._focus_item_at_index(next_index)
|
|
800
|
+
return 'break'
|
|
801
|
+
|
|
802
|
+
def _on_item_click(self, event: Any):
|
|
803
|
+
"""Handle item click event from `ListItem`.
|
|
804
|
+
|
|
805
|
+
Args:
|
|
806
|
+
event: Event with `data` for the clicked item.
|
|
807
|
+
"""
|
|
808
|
+
# Fetch fresh state from datasource to ensure accurate selection/focus state
|
|
809
|
+
record_id = event.data.get('id')
|
|
810
|
+
if record_id is not None and record_id != '__empty__':
|
|
811
|
+
item_index = event.data.get('item_index')
|
|
812
|
+
if item_index is not None:
|
|
813
|
+
page_data = self._datasource.page_slice(item_index, 1)
|
|
814
|
+
if page_data:
|
|
815
|
+
record = page_data[0].copy()
|
|
816
|
+
record['selected'] = self._datasource.is_selected(record_id)
|
|
817
|
+
record['focused'] = (record_id == self._focused_record_id)
|
|
818
|
+
record['item_index'] = item_index
|
|
819
|
+
self.event_generate('<<ItemClick>>', data=record)
|
|
820
|
+
return
|
|
821
|
+
|
|
822
|
+
# Fallback to original data if we can't fetch fresh state
|
|
823
|
+
self.event_generate('<<ItemClick>>', data=event.data)
|
|
824
|
+
|
|
825
|
+
def _bs_apply_theme(self, _event: Any = None) -> None:
|
|
826
|
+
"""Re-resolve theme-dependent per-row surfaces on a theme change.
|
|
827
|
+
|
|
828
|
+
The striped background is applied imperatively (not via a ttk style), so
|
|
829
|
+
it does not update when the theme rebuilds — re-apply it to every pooled
|
|
830
|
+
row and re-render so the new theme's colors take effect.
|
|
831
|
+
"""
|
|
832
|
+
try:
|
|
833
|
+
for i, row in enumerate(self._rows):
|
|
834
|
+
self._apply_widget_surface(row, i)
|
|
835
|
+
self._full_update_rows()
|
|
836
|
+
except Exception:
|
|
837
|
+
pass
|
|
838
|
+
|
|
839
|
+
def _apply_widget_surface(self, row: ListItem, widget_index: int) -> None:
|
|
840
|
+
"""Apply surface color to a row widget based on its position in the pool.
|
|
841
|
+
|
|
842
|
+
This is called once when a widget is created. The color is based on the
|
|
843
|
+
widget's position (not the data index), creating a stable alternating
|
|
844
|
+
pattern during scrolling without needing to recalculate colors.
|
|
845
|
+
|
|
846
|
+
Args:
|
|
847
|
+
row: The ListItem widget to color.
|
|
848
|
+
widget_index: The position of this widget in the row pool (0-based).
|
|
849
|
+
"""
|
|
850
|
+
base_surface = getattr(self, "_surface", "background")
|
|
851
|
+
if not self._striped:
|
|
852
|
+
surface = base_surface
|
|
853
|
+
else:
|
|
854
|
+
# Apply striped background to odd rows
|
|
855
|
+
is_odd = (widget_index % 2) == 1
|
|
856
|
+
surface = self._striped_background if is_odd else base_surface
|
|
857
|
+
|
|
858
|
+
if hasattr(row, "set_surface"):
|
|
859
|
+
row.set_surface(surface)
|
|
860
|
+
|
|
861
|
+
def _on_item_drag_start(self, event: Any):
|
|
862
|
+
"""Handle item drag start event from `ListItem`.
|
|
863
|
+
|
|
864
|
+
Args:
|
|
865
|
+
event: Event with `data` for the dragged item.
|
|
866
|
+
"""
|
|
867
|
+
record_id = event.data.get('id')
|
|
868
|
+
source_index = event.data.get('source_index')
|
|
869
|
+
if record_id is None or record_id == '__empty__' or source_index is None:
|
|
870
|
+
return
|
|
871
|
+
|
|
872
|
+
self._drag_state = dict(
|
|
873
|
+
record_id=record_id,
|
|
874
|
+
source_index=source_index,
|
|
875
|
+
target_index=source_index,
|
|
876
|
+
record_data=dict(event.data),
|
|
877
|
+
)
|
|
878
|
+
self._drag_scroll_counter = 0
|
|
879
|
+
self._show_drag_indicator()
|
|
880
|
+
self._update_drag_indicator_position(source_index)
|
|
881
|
+
self.event_generate('<<ItemDragStart>>', data=event.data)
|
|
882
|
+
|
|
883
|
+
def _on_item_dragging(self, event: Any):
|
|
884
|
+
"""Handle item dragging event from `ListItem`.
|
|
885
|
+
|
|
886
|
+
Args:
|
|
887
|
+
event: Event with `data` for the dragged item.
|
|
888
|
+
"""
|
|
889
|
+
if not self._drag_state:
|
|
890
|
+
return
|
|
891
|
+
|
|
892
|
+
y_current = event.data.get('y_current')
|
|
893
|
+
target_index = self._get_drop_index(y_current)
|
|
894
|
+
self._drag_state['target_index'] = target_index
|
|
895
|
+
self._auto_scroll_for_drag(y_current)
|
|
896
|
+
self._update_drag_indicator_position(target_index)
|
|
897
|
+
payload = dict(self._drag_state.get('record_data', {}))
|
|
898
|
+
payload.update(
|
|
899
|
+
dict(
|
|
900
|
+
source_index=self._drag_state.get('source_index'),
|
|
901
|
+
target_index=target_index,
|
|
902
|
+
y_current=y_current,
|
|
903
|
+
)
|
|
904
|
+
)
|
|
905
|
+
self.event_generate('<<ItemDrag>>', data=payload)
|
|
906
|
+
|
|
907
|
+
def _on_item_drag_end(self, event: Any):
|
|
908
|
+
"""Handle item drag end event from `ListItem`.
|
|
909
|
+
|
|
910
|
+
Args:
|
|
911
|
+
event: Event with `data` for the dragged item.
|
|
912
|
+
"""
|
|
913
|
+
if not self._drag_state:
|
|
914
|
+
return
|
|
915
|
+
|
|
916
|
+
self._hide_drag_indicator()
|
|
917
|
+
|
|
918
|
+
record_id = self._drag_state.get('record_id')
|
|
919
|
+
target_index = self._drag_state.get('target_index')
|
|
920
|
+
moved = self._move_record(record_id, target_index)
|
|
921
|
+
if moved:
|
|
922
|
+
self._update_rows()
|
|
923
|
+
|
|
924
|
+
payload = dict(self._drag_state.get('record_data', {}))
|
|
925
|
+
payload.update(
|
|
926
|
+
dict(
|
|
927
|
+
source_index=self._drag_state.get('source_index'),
|
|
928
|
+
target_index=target_index,
|
|
929
|
+
y_end=event.data.get('y_end'),
|
|
930
|
+
y_start=event.data.get('y_start'),
|
|
931
|
+
)
|
|
932
|
+
)
|
|
933
|
+
payload['target_index'] = target_index
|
|
934
|
+
payload['moved'] = moved
|
|
935
|
+
self.event_generate('<<ItemDragEnd>>', data=payload)
|
|
936
|
+
self._drag_state = None
|
|
937
|
+
|
|
938
|
+
def _get_drop_index(self, y_root: int | None) -> int:
|
|
939
|
+
"""Calculate the drop index for a drag operation.
|
|
940
|
+
|
|
941
|
+
Args:
|
|
942
|
+
y_root: Screen Y coordinate.
|
|
943
|
+
|
|
944
|
+
Returns:
|
|
945
|
+
Zero-based index for the drop position.
|
|
946
|
+
"""
|
|
947
|
+
total = self._datasource.count
|
|
948
|
+
if total <= 0:
|
|
949
|
+
return 0
|
|
950
|
+
|
|
951
|
+
if y_root is None:
|
|
952
|
+
return max(0, min(self._start_index, total - 1))
|
|
953
|
+
|
|
954
|
+
container_top = self._container.winfo_rooty()
|
|
955
|
+
container_height = self._container.winfo_height()
|
|
956
|
+
if container_height <= 0:
|
|
957
|
+
return max(0, min(self._start_index, total - 1))
|
|
958
|
+
|
|
959
|
+
y_local = y_root - container_top
|
|
960
|
+
y_local = max(0, min(y_local, container_height - 1))
|
|
961
|
+
offset = int(y_local // max(1, self._row_height))
|
|
962
|
+
target = self._start_index + offset
|
|
963
|
+
return max(0, min(target, total - 1))
|
|
964
|
+
|
|
965
|
+
def _auto_scroll_for_drag(self, y_root: int | None) -> None:
|
|
966
|
+
"""Auto-scroll while dragging near the list edges.
|
|
967
|
+
|
|
968
|
+
Args:
|
|
969
|
+
y_root: Screen Y coordinate.
|
|
970
|
+
"""
|
|
971
|
+
if y_root is None:
|
|
972
|
+
return
|
|
973
|
+
|
|
974
|
+
container_top = self._container.winfo_rooty()
|
|
975
|
+
container_height = self._container.winfo_height()
|
|
976
|
+
if container_height <= 0:
|
|
977
|
+
return
|
|
978
|
+
|
|
979
|
+
scroll_zone_height = max(10, int(container_height * 0.2))
|
|
980
|
+
container_bottom = container_top + container_height
|
|
981
|
+
self._drag_scroll_counter += 1
|
|
982
|
+
should_scroll = self._drag_scroll_counter % 8 == 0
|
|
983
|
+
if should_scroll:
|
|
984
|
+
if y_root < container_top + scroll_zone_height:
|
|
985
|
+
self._start_index -= 1
|
|
986
|
+
elif y_root > container_bottom - scroll_zone_height:
|
|
987
|
+
self._start_index += 1
|
|
988
|
+
else:
|
|
989
|
+
return
|
|
990
|
+
else:
|
|
991
|
+
return
|
|
992
|
+
|
|
993
|
+
self._clamp_indices()
|
|
994
|
+
self._update_rows()
|
|
995
|
+
|
|
996
|
+
def _move_record(self, record_id: Any, target_index: int | None) -> bool:
|
|
997
|
+
"""Move a record in the data source if supported.
|
|
998
|
+
|
|
999
|
+
Args:
|
|
1000
|
+
record_id: Record identifier to move.
|
|
1001
|
+
target_index: Target index to move the record to.
|
|
1002
|
+
|
|
1003
|
+
Returns:
|
|
1004
|
+
True if the record was moved.
|
|
1005
|
+
"""
|
|
1006
|
+
if record_id is None or target_index is None:
|
|
1007
|
+
return False
|
|
1008
|
+
|
|
1009
|
+
with self._silence_source():
|
|
1010
|
+
mover = getattr(self._datasource, 'move', None)
|
|
1011
|
+
if callable(mover):
|
|
1012
|
+
return bool(mover(record_id, target_index))
|
|
1013
|
+
|
|
1014
|
+
# Fallback for simple in-memory lists
|
|
1015
|
+
try:
|
|
1016
|
+
total = self._datasource.count
|
|
1017
|
+
all_records = self._datasource.page_slice(0, total)
|
|
1018
|
+
source_index = None
|
|
1019
|
+
for i, record in enumerate(all_records):
|
|
1020
|
+
if record.get('id') == record_id:
|
|
1021
|
+
source_index = i
|
|
1022
|
+
break
|
|
1023
|
+
if source_index is None:
|
|
1024
|
+
return False
|
|
1025
|
+
clamped_target = max(0, min(target_index, len(all_records) - 1))
|
|
1026
|
+
if source_index == clamped_target:
|
|
1027
|
+
return False
|
|
1028
|
+
record = all_records.pop(source_index)
|
|
1029
|
+
if clamped_target > source_index:
|
|
1030
|
+
clamped_target -= 1
|
|
1031
|
+
all_records.insert(clamped_target, record)
|
|
1032
|
+
setter = getattr(self._datasource, 'load', None)
|
|
1033
|
+
if callable(setter):
|
|
1034
|
+
setter(all_records)
|
|
1035
|
+
return True
|
|
1036
|
+
except Exception:
|
|
1037
|
+
return False
|
|
1038
|
+
|
|
1039
|
+
return False
|
|
1040
|
+
|
|
1041
|
+
def _show_drag_indicator(self) -> None:
|
|
1042
|
+
"""Create and show the drag drop indicator line."""
|
|
1043
|
+
if self._drag_indicator is None:
|
|
1044
|
+
self._drag_indicator = Frame(self._container, accent=self._accent)
|
|
1045
|
+
|
|
1046
|
+
def _update_drag_indicator_position(self, target_index: int) -> None:
|
|
1047
|
+
"""Update the drag indicator to show drop location."""
|
|
1048
|
+
if self._drag_indicator is None:
|
|
1049
|
+
return
|
|
1050
|
+
|
|
1051
|
+
try:
|
|
1052
|
+
visual_index = target_index - self._start_index
|
|
1053
|
+
if 0 <= visual_index < len(self._rows):
|
|
1054
|
+
y_pos = visual_index * max(1, self._row_height)
|
|
1055
|
+
self._drag_indicator.place(
|
|
1056
|
+
x=0,
|
|
1057
|
+
y=y_pos,
|
|
1058
|
+
width=self._container.winfo_width(),
|
|
1059
|
+
height=3,
|
|
1060
|
+
)
|
|
1061
|
+
self._drag_indicator.lift()
|
|
1062
|
+
else:
|
|
1063
|
+
self._drag_indicator.place_forget()
|
|
1064
|
+
except Exception:
|
|
1065
|
+
pass
|
|
1066
|
+
|
|
1067
|
+
def _hide_drag_indicator(self) -> None:
|
|
1068
|
+
"""Hide and destroy the drag indicator."""
|
|
1069
|
+
if self._drag_indicator is not None:
|
|
1070
|
+
try:
|
|
1071
|
+
self._drag_indicator.place_forget()
|
|
1072
|
+
self._drag_indicator.destroy()
|
|
1073
|
+
except Exception:
|
|
1074
|
+
pass
|
|
1075
|
+
self._drag_indicator = None
|
|
1076
|
+
|
|
1077
|
+
# Public API
|
|
1078
|
+
|
|
1079
|
+
def reload(self):
|
|
1080
|
+
"""Reload data from the datasource and refresh the display.
|
|
1081
|
+
|
|
1082
|
+
Calls the datasource's `reload()` method and updates all visible rows
|
|
1083
|
+
with the refreshed data. Useful when the underlying data has changed
|
|
1084
|
+
externally.
|
|
1085
|
+
"""
|
|
1086
|
+
self._datasource.reload()
|
|
1087
|
+
self._update_rows()
|
|
1088
|
+
|
|
1089
|
+
def get_selected(self) -> list[dict]:
|
|
1090
|
+
"""Get the currently selected records.
|
|
1091
|
+
|
|
1092
|
+
Returns:
|
|
1093
|
+
List of selected record dictionaries (each with `id` and the
|
|
1094
|
+
record's data). Empty list if no items are selected.
|
|
1095
|
+
|
|
1096
|
+
Examples:
|
|
1097
|
+
>>> selected = listview.get_selected()
|
|
1098
|
+
>>> for record in selected:
|
|
1099
|
+
... print(record["id"], record.get("title"))
|
|
1100
|
+
"""
|
|
1101
|
+
return self._datasource.selected()
|
|
1102
|
+
|
|
1103
|
+
def select_all(self):
|
|
1104
|
+
"""Select all items in the list.
|
|
1105
|
+
|
|
1106
|
+
Only works when selection_mode is 'multi'. Generates a
|
|
1107
|
+
<<SelectionChange>> event after completion.
|
|
1108
|
+
|
|
1109
|
+
Note:
|
|
1110
|
+
For large datasets, this may be slow as it loads all records.
|
|
1111
|
+
"""
|
|
1112
|
+
if self._selection_mode == 'multi':
|
|
1113
|
+
total = self._datasource.count
|
|
1114
|
+
all_records = self._datasource.page_slice(0, total)
|
|
1115
|
+
with self._silence_source():
|
|
1116
|
+
for record in all_records:
|
|
1117
|
+
record_id = record.get('id')
|
|
1118
|
+
if record_id is not None: # id 0 is valid, don't skip it
|
|
1119
|
+
self._datasource.select(record_id)
|
|
1120
|
+
self._update_rows()
|
|
1121
|
+
self.event_generate('<<SelectionChange>>')
|
|
1122
|
+
|
|
1123
|
+
def clear_selection(self):
|
|
1124
|
+
"""Clear all item selections.
|
|
1125
|
+
|
|
1126
|
+
Deselects all items and generates a <<SelectionChange>> event.
|
|
1127
|
+
"""
|
|
1128
|
+
with self._silence_source():
|
|
1129
|
+
self._datasource.deselect_all()
|
|
1130
|
+
self._update_rows()
|
|
1131
|
+
self.event_generate('<<SelectionChange>>')
|
|
1132
|
+
|
|
1133
|
+
def select_items(self, ids: list) -> None:
|
|
1134
|
+
"""Select items by record `id`.
|
|
1135
|
+
|
|
1136
|
+
In single-selection mode the current selection is replaced; in multi
|
|
1137
|
+
mode the given items are added. No-op when selection is disabled.
|
|
1138
|
+
Generates a <<SelectionChange>> event.
|
|
1139
|
+
"""
|
|
1140
|
+
if self._selection_mode == 'none':
|
|
1141
|
+
return
|
|
1142
|
+
with self._silence_source():
|
|
1143
|
+
if self._selection_mode == 'single':
|
|
1144
|
+
self._datasource.deselect_all()
|
|
1145
|
+
for record_id in ids:
|
|
1146
|
+
if record_id is not None: # id 0 is valid, don't skip it
|
|
1147
|
+
self._datasource.select(record_id)
|
|
1148
|
+
self._update_rows()
|
|
1149
|
+
self.event_generate('<<SelectionChange>>')
|
|
1150
|
+
|
|
1151
|
+
def deselect_items(self, ids: list) -> None:
|
|
1152
|
+
"""Remove the given items from the selection by record `id`.
|
|
1153
|
+
|
|
1154
|
+
Generates a <<SelectionChange>> event.
|
|
1155
|
+
"""
|
|
1156
|
+
with self._silence_source():
|
|
1157
|
+
for record_id in ids:
|
|
1158
|
+
if record_id is not None:
|
|
1159
|
+
self._datasource.deselect(record_id)
|
|
1160
|
+
self._update_rows()
|
|
1161
|
+
self.event_generate('<<SelectionChange>>')
|
|
1162
|
+
|
|
1163
|
+
def scroll_to_top(self):
|
|
1164
|
+
"""Scroll to the beginning of the list.
|
|
1165
|
+
|
|
1166
|
+
Instantly scrolls to show the first item in the list.
|
|
1167
|
+
"""
|
|
1168
|
+
self._start_index = 0
|
|
1169
|
+
self._update_rows()
|
|
1170
|
+
|
|
1171
|
+
def scroll_to_bottom(self):
|
|
1172
|
+
"""Scroll to the end of the list.
|
|
1173
|
+
|
|
1174
|
+
Instantly scrolls to show the last items in the list.
|
|
1175
|
+
"""
|
|
1176
|
+
total = self._datasource.count
|
|
1177
|
+
self._start_index = max(0, total - self._visible_rows)
|
|
1178
|
+
self._update_rows()
|
|
1179
|
+
|
|
1180
|
+
def insert_item(self, data: dict):
|
|
1181
|
+
"""Insert a new item into the list.
|
|
1182
|
+
|
|
1183
|
+
Args:
|
|
1184
|
+
data: Dictionary containing the item data. An 'id' will be
|
|
1185
|
+
auto-generated if not provided.
|
|
1186
|
+
|
|
1187
|
+
Note:
|
|
1188
|
+
Generates a <<ItemInsert>> event after the item is added.
|
|
1189
|
+
`event.data` is the inserted record dict with `id` populated.
|
|
1190
|
+
|
|
1191
|
+
Examples:
|
|
1192
|
+
>>> listview.insert_item({
|
|
1193
|
+
... 'title': 'New Item',
|
|
1194
|
+
... 'text': 'Description'
|
|
1195
|
+
... })
|
|
1196
|
+
"""
|
|
1197
|
+
with self._silence_source():
|
|
1198
|
+
record_id = self._datasource.insert(data)
|
|
1199
|
+
self._update_rows()
|
|
1200
|
+
record = dict(data)
|
|
1201
|
+
record.setdefault('id', record_id)
|
|
1202
|
+
self.event_generate('<<ItemInsert>>', data=record)
|
|
1203
|
+
|
|
1204
|
+
def update_item(self, record_id: Any, data: dict):
|
|
1205
|
+
"""Update an existing item's data.
|
|
1206
|
+
|
|
1207
|
+
Args:
|
|
1208
|
+
record_id: The ID of the record to update.
|
|
1209
|
+
data: Dictionary of fields to update. Will be merged with
|
|
1210
|
+
existing record data.
|
|
1211
|
+
|
|
1212
|
+
Note:
|
|
1213
|
+
Generates a <<ItemUpdate>> event if the update succeeds.
|
|
1214
|
+
`event.data` is the patch dict with `id` set to `record_id`.
|
|
1215
|
+
|
|
1216
|
+
Examples:
|
|
1217
|
+
>>> listview.update_item(42, {'title': 'Updated Title'})
|
|
1218
|
+
"""
|
|
1219
|
+
with self._silence_source():
|
|
1220
|
+
updated = self._datasource.update(record_id, data)
|
|
1221
|
+
if updated:
|
|
1222
|
+
self._update_rows()
|
|
1223
|
+
record = dict(data)
|
|
1224
|
+
record['id'] = record_id
|
|
1225
|
+
self.event_generate('<<ItemUpdate>>', data=record)
|
|
1226
|
+
|
|
1227
|
+
def delete_item(self, record_id: Any):
|
|
1228
|
+
"""Delete an item from the list.
|
|
1229
|
+
|
|
1230
|
+
Args:
|
|
1231
|
+
record_id: The ID of the record to delete.
|
|
1232
|
+
|
|
1233
|
+
Note:
|
|
1234
|
+
Generates a <<ItemDelete>> event after deletion.
|
|
1235
|
+
`event.data = {'id': record_id}`.
|
|
1236
|
+
|
|
1237
|
+
Examples:
|
|
1238
|
+
>>> listview.delete_item(42)
|
|
1239
|
+
"""
|
|
1240
|
+
with self._silence_source():
|
|
1241
|
+
self._datasource.delete(record_id)
|
|
1242
|
+
self._update_rows()
|
|
1243
|
+
self.event_generate('<<ItemDelete>>', data={'id': record_id})
|
|
1244
|
+
|
|
1245
|
+
def get_datasource(self) -> DataSourceProtocol:
|
|
1246
|
+
"""Get the underlying datasource.
|
|
1247
|
+
|
|
1248
|
+
Returns:
|
|
1249
|
+
The DataSource instance managing the list's data.
|
|
1250
|
+
|
|
1251
|
+
Examples:
|
|
1252
|
+
>>> ds = listview.get_datasource()
|
|
1253
|
+
>>> count = ds.count
|
|
1254
|
+
"""
|
|
1255
|
+
return self._datasource
|
|
1256
|
+
|
|
1257
|
+
# Event handler API
|
|
1258
|
+
|
|
1259
|
+
def on_selection_changed(self, callback: Callable) -> str:
|
|
1260
|
+
"""Register a callback for `<<SelectionChange>>` events.
|
|
1261
|
+
|
|
1262
|
+
Args:
|
|
1263
|
+
callback: Called when selection changes. `event.data` is None — use
|
|
1264
|
+
`get_selected()` to read the current selection.
|
|
1265
|
+
|
|
1266
|
+
Returns:
|
|
1267
|
+
Bind ID — pass to `off_selection_changed()` to unsubscribe.
|
|
1268
|
+
"""
|
|
1269
|
+
return self.bind('<<SelectionChange>>', callback, add='+')
|
|
1270
|
+
|
|
1271
|
+
def off_selection_changed(self, bind_id: str | None = None) -> None:
|
|
1272
|
+
"""Unsubscribe from `<<SelectionChange>>`.
|
|
1273
|
+
|
|
1274
|
+
Args:
|
|
1275
|
+
bind_id: ID returned by `on_selection_changed()`. If None, removes all.
|
|
1276
|
+
"""
|
|
1277
|
+
self.unbind('<<SelectionChange>>', bind_id)
|
|
1278
|
+
|
|
1279
|
+
def on_item_delete(self, callback: Callable) -> str:
|
|
1280
|
+
"""Register a callback for `<<ItemDelete>>` events (fires when an item is removed).
|
|
1281
|
+
|
|
1282
|
+
Args:
|
|
1283
|
+
callback: Receives `event.data` — the deleted record dict with at least `id`.
|
|
1284
|
+
|
|
1285
|
+
Returns:
|
|
1286
|
+
Bind ID — pass to `off_item_delete()` to unsubscribe.
|
|
1287
|
+
"""
|
|
1288
|
+
return self.bind('<<ItemDelete>>', callback, add='+')
|
|
1289
|
+
|
|
1290
|
+
def off_item_delete(self, bind_id: str | None = None) -> None:
|
|
1291
|
+
"""Unsubscribe from `<<ItemDelete>>`.
|
|
1292
|
+
|
|
1293
|
+
Args:
|
|
1294
|
+
bind_id: ID returned by `on_item_delete()`. If None, removes all.
|
|
1295
|
+
"""
|
|
1296
|
+
self.unbind('<<ItemDelete>>', bind_id)
|
|
1297
|
+
|
|
1298
|
+
def on_item_delete_fail(self, callback: Callable) -> str:
|
|
1299
|
+
"""Register a callback for `<<ItemDeleteFail>>` events (fires when deletion raises an exception).
|
|
1300
|
+
|
|
1301
|
+
Args:
|
|
1302
|
+
callback: Receives `event.data` — the record dict plus an `error: str` key.
|
|
1303
|
+
|
|
1304
|
+
Returns:
|
|
1305
|
+
Bind ID — pass to `off_item_delete_fail()` to unsubscribe.
|
|
1306
|
+
"""
|
|
1307
|
+
return self.bind('<<ItemDeleteFail>>', callback, add='+')
|
|
1308
|
+
|
|
1309
|
+
def off_item_delete_fail(self, bind_id: str | None = None) -> None:
|
|
1310
|
+
"""Unsubscribe from `<<ItemDeleteFail>>`.
|
|
1311
|
+
|
|
1312
|
+
Args:
|
|
1313
|
+
bind_id: ID returned by `on_item_delete_fail()`. If None, removes all.
|
|
1314
|
+
"""
|
|
1315
|
+
self.unbind('<<ItemDeleteFail>>', bind_id)
|
|
1316
|
+
|
|
1317
|
+
def on_item_insert(self, callback: Callable) -> str:
|
|
1318
|
+
"""Register a callback for `<<ItemInsert>>` events (fires when an item is added).
|
|
1319
|
+
|
|
1320
|
+
Args:
|
|
1321
|
+
callback: Receives `event.data` — the inserted record dict with `id` populated.
|
|
1322
|
+
|
|
1323
|
+
Returns:
|
|
1324
|
+
Bind ID — pass to `off_item_insert()` to unsubscribe.
|
|
1325
|
+
"""
|
|
1326
|
+
return self.bind('<<ItemInsert>>', callback, add='+')
|
|
1327
|
+
|
|
1328
|
+
def off_item_insert(self, bind_id: str | None = None) -> None:
|
|
1329
|
+
"""Unsubscribe from `<<ItemInsert>>`.
|
|
1330
|
+
|
|
1331
|
+
Args:
|
|
1332
|
+
bind_id: ID returned by `on_item_insert()`. If None, removes all.
|
|
1333
|
+
"""
|
|
1334
|
+
self.unbind('<<ItemInsert>>', bind_id)
|
|
1335
|
+
|
|
1336
|
+
def on_item_update(self, callback: Callable) -> str:
|
|
1337
|
+
"""Register a callback for `<<ItemUpdate>>` events (fires when an item is updated).
|
|
1338
|
+
|
|
1339
|
+
Args:
|
|
1340
|
+
callback: Receives `event.data` — the patch dict with `id` set to the
|
|
1341
|
+
updated record's ID.
|
|
1342
|
+
|
|
1343
|
+
Returns:
|
|
1344
|
+
Bind ID — pass to `off_item_update()` to unsubscribe.
|
|
1345
|
+
"""
|
|
1346
|
+
return self.bind('<<ItemUpdate>>', callback, add='+')
|
|
1347
|
+
|
|
1348
|
+
def off_item_update(self, bind_id: str | None = None) -> None:
|
|
1349
|
+
"""Unsubscribe from `<<ItemUpdate>>`.
|
|
1350
|
+
|
|
1351
|
+
Args:
|
|
1352
|
+
bind_id: ID returned by `on_item_update()`. If None, removes all.
|
|
1353
|
+
"""
|
|
1354
|
+
self.unbind('<<ItemUpdate>>', bind_id)
|
|
1355
|
+
|
|
1356
|
+
def on_item_click(self, callback: Callable) -> str:
|
|
1357
|
+
"""Register a callback for `<<ItemClick>>` events (fires when an item is clicked).
|
|
1358
|
+
|
|
1359
|
+
Args:
|
|
1360
|
+
callback: Receives `event.data` — the record dict with `selected`,
|
|
1361
|
+
`focused`, and `item_index` injected.
|
|
1362
|
+
|
|
1363
|
+
Returns:
|
|
1364
|
+
Bind ID — pass to `off_item_click()` to unsubscribe.
|
|
1365
|
+
"""
|
|
1366
|
+
return self.bind('<<ItemClick>>', callback, add='+')
|
|
1367
|
+
|
|
1368
|
+
def off_item_click(self, bind_id: str | None = None) -> None:
|
|
1369
|
+
"""Unsubscribe from `<<ItemClick>>`.
|
|
1370
|
+
|
|
1371
|
+
Args:
|
|
1372
|
+
bind_id: ID returned by `on_item_click()`. If None, removes all.
|
|
1373
|
+
"""
|
|
1374
|
+
self.unbind('<<ItemClick>>', bind_id)
|
|
1375
|
+
|
|
1376
|
+
def on_item_drag_start(self, callback: Callable) -> str:
|
|
1377
|
+
"""Register a callback for `<<ItemDragStart>>` events (fires when a drag begins).
|
|
1378
|
+
|
|
1379
|
+
Args:
|
|
1380
|
+
callback: Receives `event.data` — the dragged record dict.
|
|
1381
|
+
|
|
1382
|
+
Returns:
|
|
1383
|
+
Bind ID — pass to `off_item_drag_start()` to unsubscribe.
|
|
1384
|
+
"""
|
|
1385
|
+
return self.bind('<<ItemDragStart>>', callback, add='+')
|
|
1386
|
+
|
|
1387
|
+
def off_item_drag_start(self, bind_id: str | None = None) -> None:
|
|
1388
|
+
"""Unsubscribe from `<<ItemDragStart>>`.
|
|
1389
|
+
|
|
1390
|
+
Args:
|
|
1391
|
+
bind_id: ID returned by `on_item_drag_start()`. If None, removes all.
|
|
1392
|
+
"""
|
|
1393
|
+
self.unbind('<<ItemDragStart>>', bind_id)
|
|
1394
|
+
|
|
1395
|
+
def on_item_drag(self, callback: Callable) -> str:
|
|
1396
|
+
"""Register a callback for `<<ItemDrag>>` events (fires during a drag).
|
|
1397
|
+
|
|
1398
|
+
Args:
|
|
1399
|
+
callback: Receives `event.data` — the record dict plus `source_index`,
|
|
1400
|
+
`target_index`, and `y_current`.
|
|
1401
|
+
|
|
1402
|
+
Returns:
|
|
1403
|
+
Bind ID — pass to `off_item_drag()` to unsubscribe.
|
|
1404
|
+
"""
|
|
1405
|
+
return self.bind('<<ItemDrag>>', callback, add='+')
|
|
1406
|
+
|
|
1407
|
+
def off_item_drag(self, bind_id: str | None = None) -> None:
|
|
1408
|
+
"""Unsubscribe from `<<ItemDrag>>`.
|
|
1409
|
+
|
|
1410
|
+
Args:
|
|
1411
|
+
bind_id: ID returned by `on_item_drag()`. If None, removes all.
|
|
1412
|
+
"""
|
|
1413
|
+
self.unbind('<<ItemDrag>>', bind_id)
|
|
1414
|
+
|
|
1415
|
+
def on_item_drag_end(self, callback: Callable) -> str:
|
|
1416
|
+
"""Register a callback for `<<ItemDragEnd>>` events (fires when a drag completes).
|
|
1417
|
+
|
|
1418
|
+
Args:
|
|
1419
|
+
callback: Receives `event.data` — the record dict plus `source_index`,
|
|
1420
|
+
`target_index`, `moved`, `y_start`, and `y_end`.
|
|
1421
|
+
|
|
1422
|
+
Returns:
|
|
1423
|
+
Bind ID — pass to `off_item_drag_end()` to unsubscribe.
|
|
1424
|
+
"""
|
|
1425
|
+
return self.bind('<<ItemDragEnd>>', callback, add='+')
|
|
1426
|
+
|
|
1427
|
+
def off_item_drag_end(self, bind_id: str | None = None) -> None:
|
|
1428
|
+
"""Unsubscribe from `<<ItemDragEnd>>`.
|
|
1429
|
+
|
|
1430
|
+
Args:
|
|
1431
|
+
bind_id: ID returned by `on_item_drag_end()`. If None, removes all.
|
|
1432
|
+
"""
|
|
1433
|
+
self.unbind('<<ItemDragEnd>>', bind_id)
|