bootstack 0.1.0a1__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 +249 -0
- bootstack/__main__.py +5 -0
- bootstack/api/__init__.py +127 -0
- bootstack/api/app.py +30 -0
- bootstack/api/constants.py +3 -0
- bootstack/api/data.py +23 -0
- bootstack/api/dialogs.py +44 -0
- bootstack/api/i18n.py +17 -0
- bootstack/api/localization.py +16 -0
- bootstack/api/menu.py +7 -0
- bootstack/api/style.py +23 -0
- bootstack/api/utils.py +24 -0
- bootstack/api/widgets.py +137 -0
- bootstack/assets/__init__.py +24 -0
- bootstack/assets/bootstack-transparent.png +0 -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/border.png +0 -0
- bootstack/assets/elements/button-compact.png +0 -0
- bootstack/assets/elements/button-default.png +0 -0
- bootstack/assets/elements/button-group-horizontal-after-compact.png +0 -0
- bootstack/assets/elements/button-group-horizontal-after-default.png +0 -0
- bootstack/assets/elements/button-group-horizontal-before-compact.png +0 -0
- bootstack/assets/elements/button-group-horizontal-before-default.png +0 -0
- bootstack/assets/elements/button-group-horizontal-center-compact.png +0 -0
- bootstack/assets/elements/button-group-horizontal-center-default.png +0 -0
- bootstack/assets/elements/button-group-vertical-after-compact.png +0 -0
- bootstack/assets/elements/button-group-vertical-after-default.png +0 -0
- bootstack/assets/elements/button-group-vertical-before-compact.png +0 -0
- bootstack/assets/elements/button-group-vertical-before-default.png +0 -0
- bootstack/assets/elements/button-group-vertical-center-compact.png +0 -0
- bootstack/assets/elements/button-group-vertical-center-default.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-after-compact.png +0 -0
- bootstack/assets/elements/input-after-default.png +0 -0
- bootstack/assets/elements/input-before-compact.png +0 -0
- bootstack/assets/elements/input-before-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/manifest.toml +480 -0
- bootstack/assets/elements/menu-item.png +0 -0
- bootstack/assets/elements/nav-button-compact.png +0 -0
- bootstack/assets/elements/nav-button-default.png +0 -0
- bootstack/assets/elements/nav-icon-button-compact.png +0 -0
- bootstack/assets/elements/nav-icon-button-default.png +0 -0
- bootstack/assets/elements/notebook-client-border.png +0 -0
- bootstack/assets/elements/notebook-tab-active.png +0 -0
- bootstack/assets/elements/notebook-tab-bar.png +0 -0
- bootstack/assets/elements/notebook-tab-normal.png +0 -0
- bootstack/assets/elements/notebook-tab-pill.png +0 -0
- bootstack/assets/elements/progress-bar-horizontal-striped.png +0 -0
- bootstack/assets/elements/progress-bar-solid.png +0 -0
- bootstack/assets/elements/progress-bar-thin.png +0 -0
- bootstack/assets/elements/progress-bar-vertical-striped.png +0 -0
- bootstack/assets/elements/radio-selected.png +0 -0
- bootstack/assets/elements/radio-unselected.png +0 -0
- bootstack/assets/elements/scrollbar-horizontal.png +0 -0
- bootstack/assets/elements/scrollbar-vertical.png +0 -0
- bootstack/assets/elements/slider-handle-focus.png +0 -0
- bootstack/assets/elements/slider-handle.png +0 -0
- bootstack/assets/elements/slider-track-horizontal.png +0 -0
- bootstack/assets/elements/slider-track-vertical.png +0 -0
- bootstack/assets/elements/switch-off.png +0 -0
- bootstack/assets/elements/switch-on.png +0 -0
- bootstack/assets/elements/tabs-bar-horizontal.png +0 -0
- bootstack/assets/elements/tabs-bar-vertical.png +0 -0
- bootstack/assets/elements/tabs-pill.png +0 -0
- bootstack/assets/locales/ar/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/ar/LC_MESSAGES/bootstack.po +853 -0
- bootstack/assets/locales/bg/LC_MESSAGES/bootstack.po +875 -0
- bootstack/assets/locales/cs/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/cs/LC_MESSAGES/bootstack.po +853 -0
- bootstack/assets/locales/da/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/da/LC_MESSAGES/bootstack.po +853 -0
- bootstack/assets/locales/de/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/de/LC_MESSAGES/bootstack.po +853 -0
- bootstack/assets/locales/en/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/en/LC_MESSAGES/bootstack.po +875 -0
- bootstack/assets/locales/es/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/es/LC_MESSAGES/bootstack.po +853 -0
- bootstack/assets/locales/fr/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/fr/LC_MESSAGES/bootstack.po +853 -0
- bootstack/assets/locales/he/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/he/LC_MESSAGES/bootstack.po +851 -0
- bootstack/assets/locales/hi/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/hi/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/it/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/it/LC_MESSAGES/bootstack.po +841 -0
- bootstack/assets/locales/ja/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/ja/LC_MESSAGES/bootstack.po +914 -0
- bootstack/assets/locales/ko/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/ko/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/nb/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/nb/LC_MESSAGES/bootstack.po +841 -0
- bootstack/assets/locales/nl/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/nl/LC_MESSAGES/bootstack.po +841 -0
- bootstack/assets/locales/pl/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/pl/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/pt/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/pt/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/sl/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/sl/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/sv/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/sv/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/tr/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/tr/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/themes/__init__.py +0 -0
- bootstack/assets/themes/amber-dark.json +32 -0
- bootstack/assets/themes/amber-light.json +32 -0
- bootstack/assets/themes/aurora-dark.json +32 -0
- bootstack/assets/themes/aurora-light.json +32 -0
- bootstack/assets/themes/bootstrap-dark.json +32 -0
- bootstack/assets/themes/bootstrap-light.json +32 -0
- bootstack/assets/themes/classic-dark.json +32 -0
- bootstack/assets/themes/classic-light.json +32 -0
- bootstack/assets/themes/docs-dark.json +32 -0
- bootstack/assets/themes/docs-light.json +32 -0
- bootstack/assets/themes/forest-dark.json +32 -0
- bootstack/assets/themes/forest-light.json +32 -0
- bootstack/assets/themes/ocean-dark.json +32 -0
- bootstack/assets/themes/ocean-light.json +32 -0
- bootstack/assets/themes/rose-dark.json +32 -0
- bootstack/assets/themes/rose-light.json +32 -0
- bootstack/assets/widgets/__init__.py +0 -0
- bootstack/assets/widgets/badge-default.png +0 -0
- bootstack/assets/widgets/badge-pill.png +0 -0
- bootstack/assets/widgets/border.png +0 -0
- bootstack/assets/widgets/button-group-horizontal-after.png +0 -0
- bootstack/assets/widgets/button-group-horizontal-before.png +0 -0
- bootstack/assets/widgets/button-group-horizontal-center.png +0 -0
- bootstack/assets/widgets/button-group-vertical-after.png +0 -0
- bootstack/assets/widgets/button-group-vertical-before.png +0 -0
- bootstack/assets/widgets/button-group-vertical-center.png +0 -0
- bootstack/assets/widgets/button.png +0 -0
- bootstack/assets/widgets/checkbox-checked.png +0 -0
- bootstack/assets/widgets/checkbox-indeterminate.png +0 -0
- bootstack/assets/widgets/checkbox-unchecked.png +0 -0
- bootstack/assets/widgets/field.png +0 -0
- bootstack/assets/widgets/icon-button.png +0 -0
- bootstack/assets/widgets/input-inner.png +0 -0
- bootstack/assets/widgets/input-prefix.png +0 -0
- bootstack/assets/widgets/input-suffix.png +0 -0
- bootstack/assets/widgets/input.png +0 -0
- bootstack/assets/widgets/list-item-focus.png +0 -0
- bootstack/assets/widgets/list-item-separated.png +0 -0
- bootstack/assets/widgets/menu-item-separated.png +0 -0
- bootstack/assets/widgets/notebook-client-border.png +0 -0
- bootstack/assets/widgets/notebook-pill-active.png +0 -0
- bootstack/assets/widgets/notebook-pill-inactive.png +0 -0
- bootstack/assets/widgets/notebook-tab-active.png +0 -0
- bootstack/assets/widgets/notebook-tab-border.png +0 -0
- bootstack/assets/widgets/notebook-tab-normal.png +0 -0
- bootstack/assets/widgets/notebook-underline.png +0 -0
- bootstack/assets/widgets/progress-bar-horizontal-default.png +0 -0
- bootstack/assets/widgets/progress-bar-horizontal-striped.png +0 -0
- bootstack/assets/widgets/progress-bar-vertical-default.png +0 -0
- bootstack/assets/widgets/progress-bar-vertical-striped.png +0 -0
- bootstack/assets/widgets/progress-trough-horizontal.png +0 -0
- bootstack/assets/widgets/progress-trough-vertical.png +0 -0
- bootstack/assets/widgets/radio-selected.png +0 -0
- bootstack/assets/widgets/radio-unselected.png +0 -0
- bootstack/assets/widgets/scrollbar-horizontal-rounded.png +0 -0
- bootstack/assets/widgets/scrollbar-vertical-rounded.png +0 -0
- bootstack/assets/widgets/separator-horizontal.png +0 -0
- bootstack/assets/widgets/separator-vertical.png +0 -0
- bootstack/assets/widgets/slider-handle-focus.png +0 -0
- bootstack/assets/widgets/slider-handle.png +0 -0
- bootstack/assets/widgets/slider-track-horizontal.png +0 -0
- bootstack/assets/widgets/slider-track-vertical.png +0 -0
- bootstack/assets/widgets/switch-off.png +0 -0
- bootstack/assets/widgets/switch-on.png +0 -0
- bootstack/assets/widgets/tabs-bar-horizontal.png +0 -0
- bootstack/assets/widgets/tabs-bar-vertical.png +0 -0
- bootstack/assets/widgets/tabs-pill.png +0 -0
- bootstack/cli/__init__.py +124 -0
- bootstack/cli/__main__.py +6 -0
- bootstack/cli/add.py +439 -0
- bootstack/cli/build.py +115 -0
- bootstack/cli/config.py +287 -0
- bootstack/cli/demo.py +1267 -0
- bootstack/cli/doctor.py +195 -0
- bootstack/cli/list_cmd.py +71 -0
- bootstack/cli/promote.py +120 -0
- bootstack/cli/pyinstaller.py +246 -0
- bootstack/cli/run.py +99 -0
- bootstack/cli/start.py +105 -0
- bootstack/cli/templates/__init__.py +861 -0
- bootstack/constants.py +325 -0
- bootstack/core/__init__.py +34 -0
- bootstack/core/capabilities/__init__.py +45 -0
- bootstack/core/capabilities/after.py +103 -0
- bootstack/core/capabilities/bind.py +154 -0
- bootstack/core/capabilities/bindtags.py +112 -0
- bootstack/core/capabilities/busy.py +61 -0
- bootstack/core/capabilities/clipboard.py +88 -0
- bootstack/core/capabilities/focus.py +118 -0
- bootstack/core/capabilities/grab.py +65 -0
- bootstack/core/capabilities/grid.py +188 -0
- bootstack/core/capabilities/localization.py +231 -0
- bootstack/core/capabilities/pack.py +119 -0
- bootstack/core/capabilities/place.py +92 -0
- bootstack/core/capabilities/selection.py +136 -0
- bootstack/core/capabilities/signals.py +242 -0
- bootstack/core/capabilities/winfo.py +315 -0
- bootstack/core/colorutils.py +234 -0
- bootstack/core/exceptions.py +95 -0
- bootstack/core/images.py +283 -0
- bootstack/core/localization/README.md +90 -0
- bootstack/core/localization/__init__.py +13 -0
- bootstack/core/localization/intl_format.py +580 -0
- bootstack/core/localization/msgcat.py +425 -0
- bootstack/core/localization/specs.py +143 -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/publisher.py +147 -0
- bootstack/core/signals/README.md +112 -0
- bootstack/core/signals/__init__.py +8 -0
- bootstack/core/signals/integration.py +100 -0
- bootstack/core/signals/signal.py +317 -0
- bootstack/core/signals/types.py +4 -0
- bootstack/core/validation/__init__.py +5 -0
- bootstack/core/validation/types.py +13 -0
- bootstack/core/validation/validation_result.py +17 -0
- bootstack/core/validation/validation_rules.py +112 -0
- bootstack/core/variables.py +62 -0
- bootstack/datasource/README.md +607 -0
- bootstack/datasource/__init__.py +51 -0
- bootstack/datasource/base.py +474 -0
- bootstack/datasource/file_source.py +541 -0
- bootstack/datasource/memory_source.py +482 -0
- bootstack/datasource/sqlite_source.py +453 -0
- bootstack/datasource/types.py +259 -0
- bootstack/dialogs/__init__.py +56 -0
- bootstack/dialogs/colorchooser.py +674 -0
- bootstack/dialogs/colordropper.py +257 -0
- bootstack/dialogs/datedialog.py +404 -0
- bootstack/dialogs/dialog.py +514 -0
- bootstack/dialogs/filterdialog.py +358 -0
- bootstack/dialogs/fontdialog.py +339 -0
- bootstack/dialogs/formdialog.py +541 -0
- bootstack/dialogs/message.py +489 -0
- bootstack/dialogs/query.py +561 -0
- bootstack/py.typed +1 -0
- bootstack/runtime/__init__.py +3 -0
- bootstack/runtime/app.py +879 -0
- bootstack/runtime/base_window.py +786 -0
- bootstack/runtime/events.py +399 -0
- bootstack/runtime/menu.py +510 -0
- bootstack/runtime/shortcuts.py +423 -0
- bootstack/runtime/tk_patch.py +31 -0
- bootstack/runtime/toplevel.py +131 -0
- bootstack/runtime/utility.py +371 -0
- bootstack/runtime/visual_focus.py +228 -0
- bootstack/runtime/window_utilities.py +1043 -0
- bootstack/style/__init__.py +5498 -0
- bootstack/style/bootstyle.py +507 -0
- bootstack/style/bootstyle_builder_base.py +752 -0
- bootstack/style/bootstyle_builder_mixed.py +93 -0
- bootstack/style/bootstyle_builder_tk.py +109 -0
- bootstack/style/bootstyle_builder_ttk.py +354 -0
- bootstack/style/builders/__init__.py +51 -0
- bootstack/style/builders/badge.py +44 -0
- bootstack/style/builders/button.py +453 -0
- bootstack/style/builders/buttongroup.py +344 -0
- bootstack/style/builders/calendar.py +271 -0
- bootstack/style/builders/checkbutton.py +95 -0
- bootstack/style/builders/combobox.py +112 -0
- bootstack/style/builders/contextmenu.py +268 -0
- bootstack/style/builders/entry.py +83 -0
- bootstack/style/builders/expander.py +171 -0
- bootstack/style/builders/field.py +312 -0
- bootstack/style/builders/frame.py +27 -0
- bootstack/style/builders/label.py +28 -0
- bootstack/style/builders/labelframe.py +41 -0
- bootstack/style/builders/listview.py +267 -0
- bootstack/style/builders/menubar.py +74 -0
- bootstack/style/builders/menubutton.py +408 -0
- bootstack/style/builders/notebook.py +316 -0
- bootstack/style/builders/panedwindow.py +25 -0
- bootstack/style/builders/progressbar.py +71 -0
- bootstack/style/builders/radiobutton.py +68 -0
- bootstack/style/builders/scale.py +66 -0
- bootstack/style/builders/scrollbar.py +360 -0
- bootstack/style/builders/separator.py +45 -0
- bootstack/style/builders/sidenav.py +313 -0
- bootstack/style/builders/sizegrip.py +15 -0
- bootstack/style/builders/spinbox.py +119 -0
- bootstack/style/builders/switch.py +67 -0
- bootstack/style/builders/tabitem.py +205 -0
- bootstack/style/builders/toolbutton.py +260 -0
- bootstack/style/builders/tooltip.py +26 -0
- bootstack/style/builders/treeview.py +269 -0
- bootstack/style/builders/utils.py +404 -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/style.py +499 -0
- bootstack/style/theme_provider.py +449 -0
- bootstack/style/tk_patch.py +5 -0
- bootstack/style/token_maps.py +42 -0
- bootstack/style/types.py +32 -0
- bootstack/style/typography.py +527 -0
- bootstack/style/utility.py +696 -0
- bootstack/themes/__init__.py +12 -0
- bootstack/themes/standard.py +415 -0
- bootstack/themes/user.py +45 -0
- bootstack/widgets/__init__.py +53 -0
- bootstack/widgets/composites/__init__.py +38 -0
- bootstack/widgets/composites/accordion.py +385 -0
- bootstack/widgets/composites/appshell.py +445 -0
- bootstack/widgets/composites/buttongroup.py +391 -0
- bootstack/widgets/composites/calendar.py +914 -0
- bootstack/widgets/composites/compositeframe.py +282 -0
- bootstack/widgets/composites/contextmenu.py +1754 -0
- bootstack/widgets/composites/dateentry.py +261 -0
- bootstack/widgets/composites/dropdownbutton.py +190 -0
- bootstack/widgets/composites/expander.py +508 -0
- bootstack/widgets/composites/field.py +448 -0
- bootstack/widgets/composites/floodgauge.py +434 -0
- bootstack/widgets/composites/form.py +983 -0
- bootstack/widgets/composites/labeledscale.py +209 -0
- bootstack/widgets/composites/list/__init__.py +15 -0
- bootstack/widgets/composites/list/listitem.py +733 -0
- bootstack/widgets/composites/list/listview.py +1507 -0
- bootstack/widgets/composites/menubar.py +303 -0
- bootstack/widgets/composites/meter.py +882 -0
- bootstack/widgets/composites/numericentry.py +183 -0
- bootstack/widgets/composites/pagestack.py +330 -0
- bootstack/widgets/composites/passwordentry.py +149 -0
- bootstack/widgets/composites/pathentry.py +223 -0
- bootstack/widgets/composites/radiogroup.py +466 -0
- bootstack/widgets/composites/scrolledtext.py +388 -0
- bootstack/widgets/composites/scrolledtext.pyi +186 -0
- bootstack/widgets/composites/scrollview.py +675 -0
- bootstack/widgets/composites/selectbox.py +544 -0
- bootstack/widgets/composites/sidenav/__init__.py +24 -0
- bootstack/widgets/composites/sidenav/group.py +485 -0
- bootstack/widgets/composites/sidenav/header.py +83 -0
- bootstack/widgets/composites/sidenav/item.py +413 -0
- bootstack/widgets/composites/sidenav/separator.py +51 -0
- bootstack/widgets/composites/sidenav/view.py +919 -0
- bootstack/widgets/composites/spinnerentry.py +232 -0
- bootstack/widgets/composites/tableview/__init__.py +5 -0
- bootstack/widgets/composites/tableview/tableview.py +2254 -0
- bootstack/widgets/composites/tableview/types.py +169 -0
- bootstack/widgets/composites/tabs/__init__.py +6 -0
- bootstack/widgets/composites/tabs/tabitem.py +372 -0
- bootstack/widgets/composites/tabs/tabs.py +478 -0
- bootstack/widgets/composites/tabs/tabview.py +352 -0
- bootstack/widgets/composites/textentry.py +90 -0
- bootstack/widgets/composites/timeentry.py +189 -0
- bootstack/widgets/composites/toast.py +364 -0
- bootstack/widgets/composites/togglegroup.py +382 -0
- bootstack/widgets/composites/toolbar.py +393 -0
- bootstack/widgets/composites/tooltip.py +404 -0
- bootstack/widgets/internal/__init__.py +0 -0
- bootstack/widgets/internal/wrapper_base.py +304 -0
- bootstack/widgets/mixins/__init__.py +25 -0
- bootstack/widgets/mixins/configure_mixin.py +186 -0
- bootstack/widgets/mixins/entry_mixin.py +70 -0
- bootstack/widgets/mixins/font_mixin.py +346 -0
- bootstack/widgets/mixins/icon_mixin.py +38 -0
- bootstack/widgets/mixins/localization_mixin.py +255 -0
- bootstack/widgets/mixins/signal_mixin.py +272 -0
- bootstack/widgets/mixins/validation_mixin.py +204 -0
- bootstack/widgets/parts/__init__.py +11 -0
- bootstack/widgets/parts/numberentry_part.py +345 -0
- bootstack/widgets/parts/spinnerentry_part.py +394 -0
- bootstack/widgets/parts/textentry_part.py +344 -0
- bootstack/widgets/primitives/__init__.py +55 -0
- bootstack/widgets/primitives/badge.py +44 -0
- bootstack/widgets/primitives/button.py +89 -0
- bootstack/widgets/primitives/card.py +66 -0
- bootstack/widgets/primitives/checkbutton.py +124 -0
- bootstack/widgets/primitives/checktoggle.py +53 -0
- bootstack/widgets/primitives/combobox.py +165 -0
- bootstack/widgets/primitives/entry.py +98 -0
- bootstack/widgets/primitives/frame.py +206 -0
- bootstack/widgets/primitives/gridframe.py +479 -0
- bootstack/widgets/primitives/label.py +95 -0
- bootstack/widgets/primitives/labelframe.py +63 -0
- bootstack/widgets/primitives/menubutton.py +118 -0
- bootstack/widgets/primitives/notebook.py +551 -0
- bootstack/widgets/primitives/optionmenu.py +248 -0
- bootstack/widgets/primitives/packframe.py +228 -0
- bootstack/widgets/primitives/panedwindow.py +58 -0
- bootstack/widgets/primitives/progressbar.py +95 -0
- bootstack/widgets/primitives/radiobutton.py +115 -0
- bootstack/widgets/primitives/radiotoggle.py +50 -0
- bootstack/widgets/primitives/scale.py +85 -0
- bootstack/widgets/primitives/scrollbar.py +56 -0
- bootstack/widgets/primitives/separator.py +56 -0
- bootstack/widgets/primitives/sizegrip.py +47 -0
- bootstack/widgets/primitives/spinbox.py +91 -0
- bootstack/widgets/primitives/switch.py +41 -0
- bootstack/widgets/primitives/treeview.py +77 -0
- bootstack/widgets/types.py +20 -0
- bootstack-0.1.0a1.dist-info/METADATA +196 -0
- bootstack-0.1.0a1.dist-info/RECORD +419 -0
- bootstack-0.1.0a1.dist-info/WHEEL +5 -0
- bootstack-0.1.0a1.dist-info/entry_points.txt +2 -0
- bootstack-0.1.0a1.dist-info/licenses/LICENSE +22 -0
- bootstack-0.1.0a1.dist-info/licenses/NOTICE +10 -0
- bootstack-0.1.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1507 @@
|
|
|
1
|
+
"""ListView widget for displaying large lists with virtual scrolling."""
|
|
2
|
+
|
|
3
|
+
from tkinter import TclError
|
|
4
|
+
from typing import Protocol, Any, Callable, Literal, runtime_checkable
|
|
5
|
+
|
|
6
|
+
from bootstack.widgets.composites.list.listitem import ListItem
|
|
7
|
+
from bootstack.widgets.primitives.frame import Frame
|
|
8
|
+
from bootstack.widgets.primitives.scrollbar import Scrollbar
|
|
9
|
+
from bootstack.widgets.mixins import configure_delegate
|
|
10
|
+
|
|
11
|
+
# Constants
|
|
12
|
+
VISIBLE_ROWS = 20
|
|
13
|
+
ROW_HEIGHT = 40
|
|
14
|
+
OVERSCAN_ROWS = 2
|
|
15
|
+
EMPTY = {"__empty__": True, "id": "__empty__"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class DataSourceProtocol(Protocol):
|
|
20
|
+
"""Protocol for data sources used by ListView.
|
|
21
|
+
|
|
22
|
+
Implementations provide paging, selection, and CRUD operations for records.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def total_count(self) -> int:
|
|
26
|
+
"""Return total number of records.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Total record count.
|
|
30
|
+
"""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
def get_page_from_index(self, start: int, count: int) -> list[dict]:
|
|
34
|
+
"""Get a page of records starting at an index.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
start: Zero-based index for the first record.
|
|
38
|
+
count: Maximum number of records to return.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
List of record dictionaries.
|
|
42
|
+
"""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
def is_selected(self, record_id: Any) -> bool:
|
|
46
|
+
"""Check if a record is selected.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
record_id: Record identifier to check.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
True if the record is selected.
|
|
53
|
+
"""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
def select_record(self, record_id: Any) -> None:
|
|
57
|
+
"""Select a record.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
record_id: Record identifier to select.
|
|
61
|
+
"""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
def deselect_record(self, record_id: Any) -> None:
|
|
65
|
+
"""Deselect a record.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
record_id: Record identifier to deselect.
|
|
69
|
+
"""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
def deselect_all(self) -> None:
|
|
73
|
+
"""Deselect all records."""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
def get_selected(self) -> list[Any]:
|
|
77
|
+
"""Get all selected record IDs.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
List of selected record identifiers.
|
|
81
|
+
"""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
def delete_record(self, record_id: Any) -> None:
|
|
85
|
+
"""Delete a record.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
record_id: Record identifier to delete.
|
|
89
|
+
"""
|
|
90
|
+
...
|
|
91
|
+
|
|
92
|
+
def create_record(self, data: dict) -> Any:
|
|
93
|
+
"""Create a new record and return its ID.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
data: Record data to insert.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
The new record identifier.
|
|
100
|
+
"""
|
|
101
|
+
...
|
|
102
|
+
|
|
103
|
+
def update_record(self, record_id: Any, data: dict) -> bool:
|
|
104
|
+
"""Update a record.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
record_id: Record identifier to update.
|
|
108
|
+
data: Record data to merge into the existing record.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
True if the record was updated.
|
|
112
|
+
"""
|
|
113
|
+
...
|
|
114
|
+
|
|
115
|
+
def reload(self) -> None:
|
|
116
|
+
"""Reload data from the data source."""
|
|
117
|
+
...
|
|
118
|
+
|
|
119
|
+
def move_record(self, record_id: Any, target_index: int) -> bool:
|
|
120
|
+
"""Move a record to a new position.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
record_id: Record identifier to move.
|
|
124
|
+
target_index: Zero-based index to move the record to.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
True if the record was moved.
|
|
128
|
+
"""
|
|
129
|
+
...
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class MemoryDataSource:
|
|
133
|
+
"""In-memory data source implementation for ListView.
|
|
134
|
+
|
|
135
|
+
Stores records in a list and tracks selected record IDs.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self):
|
|
139
|
+
"""Initialize an empty data source."""
|
|
140
|
+
self._data: list[dict] = []
|
|
141
|
+
self._selected_ids: set = set()
|
|
142
|
+
self._id_index: dict[Any, int] = {} # Maps record ID to index for O(1) lookups
|
|
143
|
+
|
|
144
|
+
def set_data(self, data: list) -> 'MemoryDataSource':
|
|
145
|
+
"""Set the data and return self for chaining.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
data: List of dicts or primitive values to convert to records.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
This instance for chaining.
|
|
152
|
+
"""
|
|
153
|
+
self._data = []
|
|
154
|
+
self._id_index = {}
|
|
155
|
+
for i, item in enumerate(data or []):
|
|
156
|
+
if isinstance(item, dict):
|
|
157
|
+
if 'id' not in item:
|
|
158
|
+
item['id'] = i
|
|
159
|
+
self._data.append(item)
|
|
160
|
+
self._id_index[item['id']] = i
|
|
161
|
+
else:
|
|
162
|
+
# Convert primitives to dict
|
|
163
|
+
record = {'id': i, 'value': str(item)}
|
|
164
|
+
self._data.append(record)
|
|
165
|
+
self._id_index[i] = i
|
|
166
|
+
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
def total_count(self) -> int:
|
|
170
|
+
"""Return total number of records.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Total record count.
|
|
174
|
+
"""
|
|
175
|
+
return len(self._data)
|
|
176
|
+
|
|
177
|
+
def get_page_from_index(self, start: int, count: int) -> list[dict]:
|
|
178
|
+
"""Get a page of records starting at an index.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
start: Zero-based index for the first record.
|
|
182
|
+
count: Maximum number of records to return.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
List of record dictionaries.
|
|
186
|
+
"""
|
|
187
|
+
end = min(start + count, len(self._data))
|
|
188
|
+
return self._data[start:end]
|
|
189
|
+
|
|
190
|
+
def is_selected(self, record_id: Any) -> bool:
|
|
191
|
+
"""Check if a record is selected.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
record_id: Record identifier to check.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
True if the record is selected.
|
|
198
|
+
"""
|
|
199
|
+
return record_id in self._selected_ids
|
|
200
|
+
|
|
201
|
+
def select_record(self, record_id: Any) -> None:
|
|
202
|
+
"""Select a record.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
record_id: Record identifier to select.
|
|
206
|
+
"""
|
|
207
|
+
self._selected_ids.add(record_id)
|
|
208
|
+
|
|
209
|
+
def deselect_record(self, record_id: Any) -> None:
|
|
210
|
+
"""Deselect a record.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
record_id: Record identifier to deselect.
|
|
214
|
+
"""
|
|
215
|
+
self._selected_ids.discard(record_id)
|
|
216
|
+
|
|
217
|
+
def deselect_all(self) -> None:
|
|
218
|
+
"""Deselect all records."""
|
|
219
|
+
self._selected_ids.clear()
|
|
220
|
+
|
|
221
|
+
def get_selected(self) -> list[Any]:
|
|
222
|
+
"""Get all selected record IDs.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
List of selected record identifiers.
|
|
226
|
+
"""
|
|
227
|
+
return list(self._selected_ids)
|
|
228
|
+
|
|
229
|
+
def delete_record(self, record_id: Any) -> None:
|
|
230
|
+
"""Delete a record.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
record_id: Record identifier to delete.
|
|
234
|
+
"""
|
|
235
|
+
# Use index for O(1) lookup
|
|
236
|
+
index = self._id_index.get(record_id)
|
|
237
|
+
if index is not None:
|
|
238
|
+
# Remove from data
|
|
239
|
+
del self._data[index]
|
|
240
|
+
# Remove from index
|
|
241
|
+
del self._id_index[record_id]
|
|
242
|
+
# Rebuild index for all records after the deleted one
|
|
243
|
+
for i in range(index, len(self._data)):
|
|
244
|
+
self._id_index[self._data[i]['id']] = i
|
|
245
|
+
self._selected_ids.discard(record_id)
|
|
246
|
+
|
|
247
|
+
def create_record(self, data: dict) -> Any:
|
|
248
|
+
"""Create a new record and return its ID.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
data: Record data to insert.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
The new record identifier.
|
|
255
|
+
"""
|
|
256
|
+
max_id = max((r.get('id', 0) for r in self._data), default=0)
|
|
257
|
+
new_id = max_id + 1 if isinstance(max_id, int) else len(self._data)
|
|
258
|
+
data['id'] = new_id
|
|
259
|
+
new_index = len(self._data)
|
|
260
|
+
self._data.append(data)
|
|
261
|
+
self._id_index[new_id] = new_index
|
|
262
|
+
return new_id
|
|
263
|
+
|
|
264
|
+
def update_record(self, record_id: Any, data: dict) -> bool:
|
|
265
|
+
"""Update a record.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
record_id: Record identifier to update.
|
|
269
|
+
data: Record data to merge into the existing record.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
True if the record was updated.
|
|
273
|
+
"""
|
|
274
|
+
# Use index for O(1) lookup
|
|
275
|
+
index = self._id_index.get(record_id)
|
|
276
|
+
if index is not None:
|
|
277
|
+
self._data[index].update(data)
|
|
278
|
+
return True
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
def reload(self) -> None:
|
|
282
|
+
"""Reload data from source.
|
|
283
|
+
|
|
284
|
+
This is a no-op for the in-memory data source.
|
|
285
|
+
"""
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
def move_record(self, record_id: Any, target_index: int) -> bool:
|
|
289
|
+
"""Move a record to a new position.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
record_id: Record identifier to move.
|
|
293
|
+
target_index: Zero-based index to move the record to.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
True if the record was moved.
|
|
297
|
+
"""
|
|
298
|
+
if not self._data:
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
# Use index for O(1) lookup
|
|
302
|
+
source_index = self._id_index.get(record_id)
|
|
303
|
+
if source_index is None:
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
clamped_target = max(0, min(target_index, len(self._data) - 1))
|
|
307
|
+
if source_index == clamped_target:
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
# Move the record
|
|
311
|
+
record = self._data.pop(source_index)
|
|
312
|
+
if clamped_target > source_index:
|
|
313
|
+
clamped_target -= 1
|
|
314
|
+
self._data.insert(clamped_target, record)
|
|
315
|
+
|
|
316
|
+
# Rebuild index for affected range
|
|
317
|
+
start = min(source_index, clamped_target)
|
|
318
|
+
end = max(source_index, clamped_target) + 1
|
|
319
|
+
for i in range(start, end):
|
|
320
|
+
self._id_index[self._data[i]['id']] = i
|
|
321
|
+
|
|
322
|
+
return True
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class ListView(Frame):
|
|
326
|
+
"""A virtual scrolling list widget for efficiently displaying large datasets.
|
|
327
|
+
|
|
328
|
+
ListView uses virtual scrolling to render only visible items, allowing it to
|
|
329
|
+
handle thousands of records efficiently. It supports multiple selection modes,
|
|
330
|
+
item deletion, drag and drop, and custom styling.
|
|
331
|
+
|
|
332
|
+
The widget works with either a simple list/dict data or a custom DataSource
|
|
333
|
+
implementation for more complex scenarios (database, API, etc.).
|
|
334
|
+
|
|
335
|
+
!!! note "Events"
|
|
336
|
+
- `<<SelectionChange>>`: Fired when selection state changes. `event.data = None` (use `get_selected()` to get current selection)
|
|
337
|
+
- `<<ItemDelete>>`: Fired when an item is deleted. `event.data = {'record': dict}`
|
|
338
|
+
- `<<ItemDeleteFail>>`: Fired when item deletion fails. `event.data = {'record': dict, 'error': str}`
|
|
339
|
+
- `<<ItemInsert>>`: Fired when a new item is inserted. `event.data = {'record': dict}`
|
|
340
|
+
- `<<ItemUpdate>>`: Fired when an item is updated. `event.data = {'record': dict}`
|
|
341
|
+
- `<<ItemClick>>`: Fired when an item is clicked. `event.data = {'record': dict}`
|
|
342
|
+
- `<<ItemDragStart>>`: Fired when a drag begins. `event.data = {'record': dict, 'index': int}`
|
|
343
|
+
- `<<ItemDrag>>`: Fired when an item is being dragged. `event.data = {'source_index': int, 'target_index': int, 'x': int, 'y': int}`
|
|
344
|
+
- `<<ItemDragEnd>>`: Fired when a drag ends. `event.data = {'moved': bool, 'source_index': int, 'target_index': int}`
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
def __init__(
|
|
348
|
+
self,
|
|
349
|
+
master=None,
|
|
350
|
+
items: list = None,
|
|
351
|
+
datasource: DataSourceProtocol = None,
|
|
352
|
+
row_factory: Callable = None,
|
|
353
|
+
selection_mode: Literal['none', 'single', 'multi'] = 'none',
|
|
354
|
+
show_selection_controls: bool = False,
|
|
355
|
+
show_chevron: bool = False,
|
|
356
|
+
enable_removing: bool = False,
|
|
357
|
+
enable_dragging: bool = False,
|
|
358
|
+
striped: bool = False,
|
|
359
|
+
striped_background: str = 'background[+1]',
|
|
360
|
+
show_separator: bool = True,
|
|
361
|
+
scrollbar_visibility: Literal['always', 'never'] = 'always',
|
|
362
|
+
enable_focus: bool = True,
|
|
363
|
+
enable_hover: bool = True,
|
|
364
|
+
focus_color: str = None,
|
|
365
|
+
selected_background: str = 'primary',
|
|
366
|
+
select_on_click: bool = None,
|
|
367
|
+
density: Literal['default', 'compact'] = 'default',
|
|
368
|
+
**kwargs
|
|
369
|
+
):
|
|
370
|
+
"""Initialize a ListView widget.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
master: Parent widget.
|
|
374
|
+
items: List of items or dicts to display (alternative to `datasource`).
|
|
375
|
+
datasource: DataSource implementation for data access.
|
|
376
|
+
row_factory: Callable that creates custom `ListItem` widgets.
|
|
377
|
+
selection_mode: Selection mode (`none`, `single`, `multi`).
|
|
378
|
+
show_selection_controls: Show checkboxes/radio buttons for selection.
|
|
379
|
+
show_chevron: Show chevron indicators on items.
|
|
380
|
+
enable_removing: Allow items to be removed; shows remove button on items.
|
|
381
|
+
enable_dragging: Allow row dragging; shows drag handle on items.
|
|
382
|
+
striped: Whether to show alternating row colors.
|
|
383
|
+
striped_background: The background color for striped rows.
|
|
384
|
+
show_separator: Show separator line between items.
|
|
385
|
+
scrollbar_visibility: Scrollbar visibility - 'always' to show scrollbar,
|
|
386
|
+
'never' to hide (mousewheel only). Defaults to 'always'.
|
|
387
|
+
enable_focus: Whether items can receive keyboard focus.
|
|
388
|
+
enable_hover: Whether items show hover state.
|
|
389
|
+
focus_color: Color for the focus indicator.
|
|
390
|
+
selected_background: Background color for selected items.
|
|
391
|
+
select_on_click: Whether clicking an item selects it. Defaults to True when
|
|
392
|
+
selection_mode is 'single' or 'multi', False otherwise. Can be explicitly
|
|
393
|
+
set to override the default behavior.
|
|
394
|
+
density: Visual density ('default' or 'compact'). Defaults to 'default'.
|
|
395
|
+
**kwargs: Additional keyword arguments forwarded to `Frame`.
|
|
396
|
+
"""
|
|
397
|
+
super().__init__(master, variant='container', ttk_class='ListView.TFrame', **kwargs)
|
|
398
|
+
|
|
399
|
+
# Cache the windowing system so scroll bindings can dispatch
|
|
400
|
+
# platform-correctly: Aqua/Win send <MouseWheel>, X11 sends
|
|
401
|
+
# <Button-4>/<Button-5>.
|
|
402
|
+
self.winsys = self.tk.call('tk', 'windowingsystem')
|
|
403
|
+
|
|
404
|
+
# Configuration
|
|
405
|
+
self._selection_mode = selection_mode
|
|
406
|
+
self._show_selection_controls = show_selection_controls
|
|
407
|
+
self._show_chevron = show_chevron
|
|
408
|
+
self._enable_removing = enable_removing
|
|
409
|
+
self._enable_dragging = enable_dragging
|
|
410
|
+
self._show_separator = show_separator
|
|
411
|
+
self._scrollbar_visibility = scrollbar_visibility
|
|
412
|
+
self._select_on_click = select_on_click
|
|
413
|
+
self._enable_focus = enable_focus
|
|
414
|
+
self._enable_hover = enable_hover
|
|
415
|
+
self._striped = striped
|
|
416
|
+
self._striped_background = striped_background
|
|
417
|
+
self._focus_color = focus_color
|
|
418
|
+
self._selected_background = selected_background
|
|
419
|
+
self._density = density
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# Data source
|
|
423
|
+
if datasource:
|
|
424
|
+
self._datasource = datasource
|
|
425
|
+
elif items:
|
|
426
|
+
self._datasource = MemoryDataSource().set_data(items)
|
|
427
|
+
else:
|
|
428
|
+
self._datasource = MemoryDataSource().set_data([])
|
|
429
|
+
|
|
430
|
+
# Virtual scrolling state
|
|
431
|
+
self._start_index = 0
|
|
432
|
+
self._prev_start_index = 0
|
|
433
|
+
self._visible_rows = VISIBLE_ROWS
|
|
434
|
+
self._row_height = ROW_HEIGHT
|
|
435
|
+
self._page_size = VISIBLE_ROWS + OVERSCAN_ROWS
|
|
436
|
+
self._rows: list[ListItem] = []
|
|
437
|
+
self._focused_record_id = None
|
|
438
|
+
self._drag_state: dict | None = None
|
|
439
|
+
self._drag_indicator: Frame | None = None
|
|
440
|
+
self._drag_scroll_counter = 0
|
|
441
|
+
self._mousewheel_bound_widgets: set = set() # Track bound widgets to avoid cycles
|
|
442
|
+
|
|
443
|
+
# Row factory
|
|
444
|
+
self._row_factory = row_factory or self._default_row_factory
|
|
445
|
+
|
|
446
|
+
# Create container frame for list items
|
|
447
|
+
self._container = Frame(self, variant='container', ttk_class='ListView.TFrame')
|
|
448
|
+
self._container.pack(side='left', fill='both', expand=True)
|
|
449
|
+
|
|
450
|
+
# Create scrollbar
|
|
451
|
+
self._scrollbar = Scrollbar(self, orient='vertical', command=self._on_scroll)
|
|
452
|
+
if self._scrollbar_visibility == 'always':
|
|
453
|
+
self._scrollbar.pack(side='right', fill='y')
|
|
454
|
+
|
|
455
|
+
# Create row pool
|
|
456
|
+
self._ensure_row_pool(self._page_size)
|
|
457
|
+
|
|
458
|
+
# Bind events
|
|
459
|
+
self.bind('<Configure>', self._on_resize, add='+')
|
|
460
|
+
self._bind_scroll_events(self)
|
|
461
|
+
self._bind_scroll_events(self._container)
|
|
462
|
+
|
|
463
|
+
# Bind ListItem events
|
|
464
|
+
self._container.bind('<<ItemSelecting>>', self._on_item_selecting, add='+')
|
|
465
|
+
self._container.bind('<<ItemRemoving>>', self._on_item_removing, add='+')
|
|
466
|
+
self._container.bind('<<ItemFocus>>', self._on_item_focused, add='+')
|
|
467
|
+
self._container.bind('<<ItemClick>>', self._on_item_click, add='+')
|
|
468
|
+
self._container.bind('<<ItemDragStart>>', self._on_item_drag_start, add='+')
|
|
469
|
+
self._container.bind('<<ItemDrag>>', self._on_item_dragging, add='+')
|
|
470
|
+
self._container.bind('<<ItemDragEnd>>', self._on_item_drag_end, add='+')
|
|
471
|
+
|
|
472
|
+
# Bind keyboard navigation
|
|
473
|
+
self.bind('<Down>', self._on_arrow_down, add='+')
|
|
474
|
+
self.bind('<Up>', self._on_arrow_up, add='+')
|
|
475
|
+
self._container.bind('<Down>', self._on_arrow_down, add='+')
|
|
476
|
+
self._container.bind('<Up>', self._on_arrow_up, add='+')
|
|
477
|
+
|
|
478
|
+
# Initial update
|
|
479
|
+
self.after(10, self._remeasure_and_relayout)
|
|
480
|
+
|
|
481
|
+
@configure_delegate('selection_mode')
|
|
482
|
+
def _delegate_selection_mode(self, value=None):
|
|
483
|
+
"""Get or set the selection mode.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
value: If provided, sets the selection mode to 'none', 'single', or 'multi'.
|
|
487
|
+
If None, returns the current selection mode.
|
|
488
|
+
|
|
489
|
+
Returns:
|
|
490
|
+
Current selection mode when called without arguments.
|
|
491
|
+
"""
|
|
492
|
+
if value is None:
|
|
493
|
+
return self._selection_mode
|
|
494
|
+
else:
|
|
495
|
+
self._selection_mode = value
|
|
496
|
+
# Recreate row pool to apply new selection mode
|
|
497
|
+
self._ensure_row_pool(self._page_size)
|
|
498
|
+
self._update_rows()
|
|
499
|
+
return None
|
|
500
|
+
|
|
501
|
+
@configure_delegate('scrollbar_visibility')
|
|
502
|
+
def _delegate_scrollbar_visibility(self, value=None):
|
|
503
|
+
"""Get or set scrollbar visibility.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
value: If provided ('always' or 'never'), shows or hides the scrollbar.
|
|
507
|
+
If None, returns current visibility setting.
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
Current scrollbar_visibility value when called without arguments.
|
|
511
|
+
"""
|
|
512
|
+
if value is None:
|
|
513
|
+
return self._scrollbar_visibility
|
|
514
|
+
else:
|
|
515
|
+
old_value = self._scrollbar_visibility
|
|
516
|
+
self._scrollbar_visibility = value
|
|
517
|
+
if old_value != self._scrollbar_visibility:
|
|
518
|
+
if self._scrollbar_visibility == 'always':
|
|
519
|
+
self._scrollbar.pack(side='right', fill='y')
|
|
520
|
+
else:
|
|
521
|
+
self._scrollbar.pack_forget()
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
@configure_delegate('striped')
|
|
525
|
+
def _delegate_striped(self, value=None):
|
|
526
|
+
"""Get or set striped mode.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
value: If provided, enables or disables striped rows.
|
|
530
|
+
If None, returns the current mode.
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
Current striped value when called without arguments.
|
|
534
|
+
"""
|
|
535
|
+
if value is None:
|
|
536
|
+
return self._striped
|
|
537
|
+
else:
|
|
538
|
+
self._striped = bool(value)
|
|
539
|
+
# Reapply surface colors to all rows
|
|
540
|
+
for i, row in enumerate(self._rows):
|
|
541
|
+
self._apply_widget_surface(row, i)
|
|
542
|
+
return None
|
|
543
|
+
|
|
544
|
+
@configure_delegate('striped_background')
|
|
545
|
+
def _delegate_striped_background(self, value=None):
|
|
546
|
+
"""Get or set striped row background color.
|
|
547
|
+
|
|
548
|
+
Args:
|
|
549
|
+
value: If provided, sets the striped row background color.
|
|
550
|
+
If None, returns the current color.
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
Current striped_background when called without arguments.
|
|
554
|
+
"""
|
|
555
|
+
if value is None:
|
|
556
|
+
return self._striped_background
|
|
557
|
+
else:
|
|
558
|
+
self._striped_background = value
|
|
559
|
+
# Reapply surface colors to all rows
|
|
560
|
+
for i, row in enumerate(self._rows):
|
|
561
|
+
self._apply_widget_surface(row, i)
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
@staticmethod
|
|
565
|
+
def _default_row_factory(master, **kwargs):
|
|
566
|
+
"""Create a default `ListItem`.
|
|
567
|
+
|
|
568
|
+
Args:
|
|
569
|
+
master: Parent widget.
|
|
570
|
+
**kwargs: Keyword arguments for `ListItem`.
|
|
571
|
+
|
|
572
|
+
Returns:
|
|
573
|
+
A new `ListItem` instance.
|
|
574
|
+
"""
|
|
575
|
+
return ListItem(master, **kwargs)
|
|
576
|
+
|
|
577
|
+
def _ensure_row_pool(self, needed: int):
|
|
578
|
+
"""Create/destroy `ListItem` widgets to match pool size.
|
|
579
|
+
|
|
580
|
+
Args:
|
|
581
|
+
needed: Desired number of row widgets.
|
|
582
|
+
"""
|
|
583
|
+
while len(self._rows) < needed:
|
|
584
|
+
# Build kwargs for row factory (using item-level names)
|
|
585
|
+
row_kwargs = dict(
|
|
586
|
+
selection_mode=self._selection_mode,
|
|
587
|
+
show_selection_controls=self._show_selection_controls,
|
|
588
|
+
show_chevron=self._show_chevron,
|
|
589
|
+
removable=self._enable_removing,
|
|
590
|
+
draggable=self._enable_dragging,
|
|
591
|
+
show_separator=self._show_separator,
|
|
592
|
+
focusable=self._enable_focus,
|
|
593
|
+
hoverable=self._enable_hover,
|
|
594
|
+
focus_color=self._focus_color,
|
|
595
|
+
selected_background=self._selected_background,
|
|
596
|
+
density=self._density
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
# Only pass select_on_click if explicitly set
|
|
600
|
+
if self._select_on_click is not None:
|
|
601
|
+
row_kwargs['select_on_click'] = self._select_on_click
|
|
602
|
+
|
|
603
|
+
row = self._row_factory(self._container, **row_kwargs)
|
|
604
|
+
row.pack(fill='x')
|
|
605
|
+
self._rows.append(row)
|
|
606
|
+
|
|
607
|
+
# Bind keyboard navigation to each row and its children
|
|
608
|
+
self._bind_arrow_keys_recursive(row)
|
|
609
|
+
|
|
610
|
+
# Apply surface color once based on widget position
|
|
611
|
+
widget_index = len(self._rows) - 1
|
|
612
|
+
self._apply_widget_surface(row, widget_index)
|
|
613
|
+
|
|
614
|
+
while len(self._rows) > needed:
|
|
615
|
+
row = self._rows.pop()
|
|
616
|
+
row.pack_forget()
|
|
617
|
+
try:
|
|
618
|
+
row.destroy()
|
|
619
|
+
except TclError:
|
|
620
|
+
pass
|
|
621
|
+
|
|
622
|
+
def _clamp_indices(self):
|
|
623
|
+
"""Ensure `self._start_index` is within valid range."""
|
|
624
|
+
total = self._datasource.total_count()
|
|
625
|
+
max_start = max(0, total - self._visible_rows)
|
|
626
|
+
self._start_index = max(0, min(self._start_index, max_start))
|
|
627
|
+
|
|
628
|
+
def _update_rows(self):
|
|
629
|
+
"""Update visible rows with current data using row recycling for efficiency."""
|
|
630
|
+
self._clamp_indices()
|
|
631
|
+
|
|
632
|
+
# Calculate scroll distance to determine if we can use recycling
|
|
633
|
+
scroll_distance = self._start_index - self._prev_start_index
|
|
634
|
+
can_recycle = abs(scroll_distance) <= 3 and scroll_distance != 0
|
|
635
|
+
|
|
636
|
+
if can_recycle:
|
|
637
|
+
# Use row recycling for small scrolls
|
|
638
|
+
self._recycle_rows(scroll_distance)
|
|
639
|
+
else:
|
|
640
|
+
# Full update for large scrolls or initial render
|
|
641
|
+
self._full_update_rows()
|
|
642
|
+
|
|
643
|
+
# Remember current position for next scroll
|
|
644
|
+
self._prev_start_index = self._start_index
|
|
645
|
+
|
|
646
|
+
# Update scrollbar
|
|
647
|
+
total = max(1, self._datasource.total_count())
|
|
648
|
+
first = self._start_index / total
|
|
649
|
+
last = min(1.0, (self._start_index + self._visible_rows) / total)
|
|
650
|
+
self._scrollbar.set(first, last)
|
|
651
|
+
|
|
652
|
+
def _recycle_rows(self, scroll_distance: int):
|
|
653
|
+
"""Recycle rows by moving them from one end to the other.
|
|
654
|
+
|
|
655
|
+
Args:
|
|
656
|
+
scroll_distance: Positive for scrolling down, negative for scrolling up.
|
|
657
|
+
"""
|
|
658
|
+
if scroll_distance > 0:
|
|
659
|
+
# Scrolling down: move top rows to bottom
|
|
660
|
+
for _ in range(scroll_distance):
|
|
661
|
+
if not self._rows:
|
|
662
|
+
break
|
|
663
|
+
|
|
664
|
+
# Remove top row
|
|
665
|
+
top_row = self._rows.pop(0)
|
|
666
|
+
|
|
667
|
+
# Calculate new data index
|
|
668
|
+
data_index = self._start_index + len(self._rows)
|
|
669
|
+
|
|
670
|
+
# Update data BEFORE moving widget to prevent focus tracking widget
|
|
671
|
+
self._update_single_row(top_row, data_index)
|
|
672
|
+
|
|
673
|
+
# Move to bottom
|
|
674
|
+
top_row.pack_forget()
|
|
675
|
+
top_row.pack(side='top', fill='x')
|
|
676
|
+
self._rows.append(top_row)
|
|
677
|
+
|
|
678
|
+
elif scroll_distance < 0:
|
|
679
|
+
# Scrolling up: move bottom rows to top
|
|
680
|
+
for _ in range(abs(scroll_distance)):
|
|
681
|
+
if not self._rows:
|
|
682
|
+
break
|
|
683
|
+
|
|
684
|
+
# Remove bottom row
|
|
685
|
+
bottom_row = self._rows.pop()
|
|
686
|
+
|
|
687
|
+
# Calculate new data index
|
|
688
|
+
data_index = self._start_index
|
|
689
|
+
|
|
690
|
+
# Update data BEFORE moving widget to prevent focus tracking widget
|
|
691
|
+
self._update_single_row(bottom_row, data_index)
|
|
692
|
+
|
|
693
|
+
# Move to top
|
|
694
|
+
bottom_row.pack_forget()
|
|
695
|
+
if self._rows:
|
|
696
|
+
bottom_row.pack(side='top', fill='x', before=self._rows[0])
|
|
697
|
+
else:
|
|
698
|
+
bottom_row.pack(side='top', fill='x')
|
|
699
|
+
self._rows.insert(0, bottom_row)
|
|
700
|
+
|
|
701
|
+
def _full_update_rows(self):
|
|
702
|
+
"""Perform a full update of all visible rows."""
|
|
703
|
+
page_data = self._datasource.get_page_from_index(self._start_index, self._page_size)
|
|
704
|
+
|
|
705
|
+
for i, row in enumerate(self._rows):
|
|
706
|
+
data_index = self._start_index + i
|
|
707
|
+
if i < len(page_data):
|
|
708
|
+
self._update_single_row(row, data_index, page_data[i])
|
|
709
|
+
else:
|
|
710
|
+
row.update_data(EMPTY)
|
|
711
|
+
|
|
712
|
+
def _update_single_row(self, row: ListItem, data_index: int, record: dict = None):
|
|
713
|
+
"""Update a single row widget with data at the given index.
|
|
714
|
+
|
|
715
|
+
Args:
|
|
716
|
+
row: The ListItem widget to update.
|
|
717
|
+
data_index: The data index to fetch and display.
|
|
718
|
+
record: Optional pre-fetched record data. If None, will fetch from datasource.
|
|
719
|
+
"""
|
|
720
|
+
if record is None:
|
|
721
|
+
# Fetch the record from datasource
|
|
722
|
+
page_data = self._datasource.get_page_from_index(data_index, 1)
|
|
723
|
+
if not page_data:
|
|
724
|
+
row.update_data(EMPTY)
|
|
725
|
+
# Bind mousewheel after update to ensure all child widgets exist
|
|
726
|
+
self._bind_mousewheel_recursive(row)
|
|
727
|
+
return
|
|
728
|
+
record = page_data[0]
|
|
729
|
+
|
|
730
|
+
record = record.copy()
|
|
731
|
+
record_id = record.get('id')
|
|
732
|
+
|
|
733
|
+
# Add selection state
|
|
734
|
+
if record_id is not None:
|
|
735
|
+
try:
|
|
736
|
+
record['selected'] = self._datasource.is_selected(record_id)
|
|
737
|
+
except Exception:
|
|
738
|
+
record['selected'] = False
|
|
739
|
+
record['focused'] = (record_id == self._focused_record_id)
|
|
740
|
+
else:
|
|
741
|
+
record['selected'] = False
|
|
742
|
+
record['focused'] = False
|
|
743
|
+
|
|
744
|
+
# Add index
|
|
745
|
+
record['item_index'] = data_index
|
|
746
|
+
|
|
747
|
+
# Update the row
|
|
748
|
+
row.update_data(record)
|
|
749
|
+
|
|
750
|
+
# Bind mousewheel after update to ensure all child widgets exist
|
|
751
|
+
self._bind_mousewheel_recursive(row)
|
|
752
|
+
|
|
753
|
+
def _on_scroll(self, *args):
|
|
754
|
+
"""Handle scrollbar movement.
|
|
755
|
+
|
|
756
|
+
Args:
|
|
757
|
+
*args: Tkinter scrollbar arguments.
|
|
758
|
+
"""
|
|
759
|
+
if args[0] == 'moveto':
|
|
760
|
+
fraction = float(args[1])
|
|
761
|
+
total = self._datasource.total_count()
|
|
762
|
+
max_start = max(0, total - self._visible_rows)
|
|
763
|
+
self._start_index = int(round(fraction * max_start))
|
|
764
|
+
elif args[0] == 'scroll':
|
|
765
|
+
amount = int(args[1])
|
|
766
|
+
unit = args[2]
|
|
767
|
+
# Use smaller step size for smoother scrolling
|
|
768
|
+
step = max(1, self._visible_rows // 2) if unit == 'pages' else 1
|
|
769
|
+
self._start_index += amount * step
|
|
770
|
+
|
|
771
|
+
self._clamp_indices()
|
|
772
|
+
self._update_rows()
|
|
773
|
+
|
|
774
|
+
def _bind_mousewheel_recursive(self, widget):
|
|
775
|
+
"""Recursively bind mousewheel event to a widget and all its children.
|
|
776
|
+
|
|
777
|
+
Only binds if the widget hasn't been bound already to avoid duplicate bindings.
|
|
778
|
+
|
|
779
|
+
Args:
|
|
780
|
+
widget: The widget to bind mousewheel event to.
|
|
781
|
+
"""
|
|
782
|
+
# Use widget string representation as identifier
|
|
783
|
+
widget_id = str(widget)
|
|
784
|
+
|
|
785
|
+
# Only bind if we haven't already bound this widget
|
|
786
|
+
if widget_id not in self._mousewheel_bound_widgets:
|
|
787
|
+
self._bind_scroll_events(widget)
|
|
788
|
+
self._mousewheel_bound_widgets.add(widget_id)
|
|
789
|
+
|
|
790
|
+
try:
|
|
791
|
+
for child in widget.winfo_children():
|
|
792
|
+
self._bind_mousewheel_recursive(child)
|
|
793
|
+
except Exception:
|
|
794
|
+
pass
|
|
795
|
+
|
|
796
|
+
def _bind_arrow_keys_recursive(self, widget):
|
|
797
|
+
"""Recursively bind arrow key events to a widget and all its children.
|
|
798
|
+
|
|
799
|
+
Args:
|
|
800
|
+
widget: The widget to bind arrow key events to.
|
|
801
|
+
"""
|
|
802
|
+
widget.bind('<Down>', self._on_arrow_down, add='+')
|
|
803
|
+
widget.bind('<Up>', self._on_arrow_up, add='+')
|
|
804
|
+
|
|
805
|
+
try:
|
|
806
|
+
for child in widget.winfo_children():
|
|
807
|
+
self._bind_arrow_keys_recursive(child)
|
|
808
|
+
except Exception:
|
|
809
|
+
pass
|
|
810
|
+
|
|
811
|
+
def _bind_scroll_events(self, widget) -> None:
|
|
812
|
+
"""Bind the platform-correct scroll-wheel events to `widget`.
|
|
813
|
+
|
|
814
|
+
On Aqua/Win the event is `<MouseWheel>` with `event.delta`
|
|
815
|
+
carrying direction and magnitude. On X11 there's no MouseWheel —
|
|
816
|
+
scroll up is `<Button-4>` and scroll down is `<Button-5>`.
|
|
817
|
+
"""
|
|
818
|
+
if self.winsys.lower() == 'x11':
|
|
819
|
+
widget.bind('<Button-4>', self._on_mousewheel, add='+')
|
|
820
|
+
widget.bind('<Button-5>', self._on_mousewheel, add='+')
|
|
821
|
+
else:
|
|
822
|
+
widget.bind('<MouseWheel>', self._on_mousewheel, add='+')
|
|
823
|
+
|
|
824
|
+
def _on_mousewheel(self, event):
|
|
825
|
+
"""Handle mouse wheel scrolling.
|
|
826
|
+
|
|
827
|
+
Args:
|
|
828
|
+
event: Tkinter mouse wheel event.
|
|
829
|
+
"""
|
|
830
|
+
# Check if mouse is over this widget
|
|
831
|
+
widget_under_mouse = self.winfo_containing(event.x_root, event.y_root)
|
|
832
|
+
if widget_under_mouse is None:
|
|
833
|
+
return
|
|
834
|
+
|
|
835
|
+
# Check if the widget under mouse is a child of this ListView
|
|
836
|
+
current = widget_under_mouse
|
|
837
|
+
is_child = False
|
|
838
|
+
while current is not None:
|
|
839
|
+
if current == self:
|
|
840
|
+
is_child = True
|
|
841
|
+
break
|
|
842
|
+
try:
|
|
843
|
+
current = current.master
|
|
844
|
+
except AttributeError:
|
|
845
|
+
break
|
|
846
|
+
|
|
847
|
+
if not is_child:
|
|
848
|
+
return
|
|
849
|
+
|
|
850
|
+
# Resolve scroll direction per platform: X11 carries it in event.num
|
|
851
|
+
# (4=up, 5=down) and has no event.delta; Aqua/Win use event.delta.
|
|
852
|
+
if self.winsys.lower() == 'x11':
|
|
853
|
+
delta = -1 if getattr(event, 'num', 0) == 4 else 1
|
|
854
|
+
else:
|
|
855
|
+
delta = -1 if event.delta > 0 else 1
|
|
856
|
+
self._start_index += delta
|
|
857
|
+
self._clamp_indices()
|
|
858
|
+
self._update_rows()
|
|
859
|
+
|
|
860
|
+
def _on_resize(self, event):
|
|
861
|
+
"""Handle widget resize and recalculate visible rows.
|
|
862
|
+
|
|
863
|
+
Args:
|
|
864
|
+
event: Tkinter configure event.
|
|
865
|
+
"""
|
|
866
|
+
if event.widget == self:
|
|
867
|
+
self.after_idle(self._remeasure_and_relayout)
|
|
868
|
+
|
|
869
|
+
def _remeasure_and_relayout(self):
|
|
870
|
+
"""Measure row height, then recompute sizes and repaint."""
|
|
871
|
+
if not self._rows:
|
|
872
|
+
return
|
|
873
|
+
|
|
874
|
+
# Measure actual widget height
|
|
875
|
+
rh = self._rows[0].winfo_height()
|
|
876
|
+
if rh <= 1:
|
|
877
|
+
rh = self._rows[0].winfo_reqheight()
|
|
878
|
+
|
|
879
|
+
if rh and rh != self._row_height:
|
|
880
|
+
self._row_height = rh
|
|
881
|
+
|
|
882
|
+
# Calculate how many rows fit
|
|
883
|
+
container_height = self._container.winfo_height()
|
|
884
|
+
if container_height > 0:
|
|
885
|
+
visible = max(1, container_height // max(1, self._row_height))
|
|
886
|
+
page_size = visible + OVERSCAN_ROWS
|
|
887
|
+
|
|
888
|
+
if visible != self._visible_rows or page_size != self._page_size:
|
|
889
|
+
self._visible_rows = visible
|
|
890
|
+
self._page_size = page_size
|
|
891
|
+
self._ensure_row_pool(self._page_size)
|
|
892
|
+
|
|
893
|
+
self._clamp_indices()
|
|
894
|
+
self._update_rows()
|
|
895
|
+
|
|
896
|
+
def _on_item_selecting(self, event: Any):
|
|
897
|
+
"""Handle item selection event from `ListItem`.
|
|
898
|
+
|
|
899
|
+
Args:
|
|
900
|
+
event: Event with `data` for the item being selected.
|
|
901
|
+
"""
|
|
902
|
+
record_id = event.data.get('id')
|
|
903
|
+
if record_id is not None and record_id != '__empty__':
|
|
904
|
+
if self._selection_mode == 'single':
|
|
905
|
+
self._datasource.deselect_all()
|
|
906
|
+
self._datasource.select_record(record_id)
|
|
907
|
+
elif self._selection_mode == 'multi':
|
|
908
|
+
if self._datasource.is_selected(record_id):
|
|
909
|
+
self._datasource.deselect_record(record_id)
|
|
910
|
+
else:
|
|
911
|
+
self._datasource.select_record(record_id)
|
|
912
|
+
|
|
913
|
+
self._update_rows()
|
|
914
|
+
self.event_generate('<<SelectionChange>>')
|
|
915
|
+
|
|
916
|
+
def _on_item_removing(self, event: Any):
|
|
917
|
+
"""Handle item remove event from `ListItem`.
|
|
918
|
+
|
|
919
|
+
Args:
|
|
920
|
+
event: Event with `data` for the item being removed.
|
|
921
|
+
"""
|
|
922
|
+
record_id = event.data.get('id')
|
|
923
|
+
if record_id is not None and record_id != '__empty__':
|
|
924
|
+
try:
|
|
925
|
+
self._datasource.delete_record(record_id)
|
|
926
|
+
self._update_rows()
|
|
927
|
+
self.event_generate('<<ItemDelete>>')
|
|
928
|
+
except Exception as e:
|
|
929
|
+
self.event_generate('<<ItemDeleteFail>>')
|
|
930
|
+
|
|
931
|
+
def _on_item_focused(self, event: Any):
|
|
932
|
+
"""Handle item focus event from `ListItem`.
|
|
933
|
+
|
|
934
|
+
Args:
|
|
935
|
+
event: Event with `data` for the item being focused.
|
|
936
|
+
"""
|
|
937
|
+
record_id = event.data.get('id')
|
|
938
|
+
if record_id is not None and record_id != '__empty__':
|
|
939
|
+
self._focused_record_id = record_id
|
|
940
|
+
self._update_rows()
|
|
941
|
+
|
|
942
|
+
def _get_focused_index(self) -> int:
|
|
943
|
+
"""Get the data index of the currently focused item.
|
|
944
|
+
|
|
945
|
+
Returns:
|
|
946
|
+
The index of the focused item, or -1 if none is focused.
|
|
947
|
+
"""
|
|
948
|
+
if self._focused_record_id is None:
|
|
949
|
+
return -1
|
|
950
|
+
|
|
951
|
+
# Search visible rows for the focused record
|
|
952
|
+
for i, row in enumerate(self._rows):
|
|
953
|
+
if hasattr(row, '_data') and row._data.get('id') == self._focused_record_id:
|
|
954
|
+
return self._start_index + i
|
|
955
|
+
|
|
956
|
+
return -1
|
|
957
|
+
|
|
958
|
+
def _focus_item_at_index(self, index: int) -> None:
|
|
959
|
+
"""Focus the item at the given data index.
|
|
960
|
+
|
|
961
|
+
Args:
|
|
962
|
+
index: The data index of the item to focus.
|
|
963
|
+
"""
|
|
964
|
+
total = self._datasource.total_count()
|
|
965
|
+
if total == 0 or index < 0 or index >= total:
|
|
966
|
+
return
|
|
967
|
+
|
|
968
|
+
# Scroll if needed to make the item visible
|
|
969
|
+
if index < self._start_index:
|
|
970
|
+
self._start_index = index
|
|
971
|
+
self._clamp_indices()
|
|
972
|
+
self._update_rows()
|
|
973
|
+
elif index >= self._start_index + len(self._rows):
|
|
974
|
+
self._start_index = index - len(self._rows) + 1
|
|
975
|
+
self._clamp_indices()
|
|
976
|
+
self._update_rows()
|
|
977
|
+
|
|
978
|
+
# Get the record at the index
|
|
979
|
+
page_data = self._datasource.get_page_from_index(index, 1)
|
|
980
|
+
if page_data:
|
|
981
|
+
record_id = page_data[0].get('id')
|
|
982
|
+
if record_id is not None:
|
|
983
|
+
self._focused_record_id = record_id
|
|
984
|
+
self._update_rows()
|
|
985
|
+
|
|
986
|
+
# Focus the visible row widget
|
|
987
|
+
visual_index = index - self._start_index
|
|
988
|
+
if 0 <= visual_index < len(self._rows):
|
|
989
|
+
self._rows[visual_index].focus_set()
|
|
990
|
+
|
|
991
|
+
def _on_arrow_down(self, event) -> str:
|
|
992
|
+
"""Handle arrow down key for keyboard navigation.
|
|
993
|
+
|
|
994
|
+
Args:
|
|
995
|
+
event: Tkinter key event.
|
|
996
|
+
|
|
997
|
+
Returns:
|
|
998
|
+
'break' to prevent default handling.
|
|
999
|
+
"""
|
|
1000
|
+
total = self._datasource.total_count()
|
|
1001
|
+
if total == 0:
|
|
1002
|
+
return 'break'
|
|
1003
|
+
|
|
1004
|
+
current_index = self._get_focused_index()
|
|
1005
|
+
if current_index < 0:
|
|
1006
|
+
# No item focused, focus the first visible item
|
|
1007
|
+
next_index = self._start_index
|
|
1008
|
+
else:
|
|
1009
|
+
# Move to next item
|
|
1010
|
+
next_index = min(current_index + 1, total - 1)
|
|
1011
|
+
|
|
1012
|
+
self._focus_item_at_index(next_index)
|
|
1013
|
+
return 'break'
|
|
1014
|
+
|
|
1015
|
+
def _on_arrow_up(self, event) -> str:
|
|
1016
|
+
"""Handle arrow up key for keyboard navigation.
|
|
1017
|
+
|
|
1018
|
+
Args:
|
|
1019
|
+
event: Tkinter key event.
|
|
1020
|
+
|
|
1021
|
+
Returns:
|
|
1022
|
+
'break' to prevent default handling.
|
|
1023
|
+
"""
|
|
1024
|
+
total = self._datasource.total_count()
|
|
1025
|
+
if total == 0:
|
|
1026
|
+
return 'break'
|
|
1027
|
+
|
|
1028
|
+
current_index = self._get_focused_index()
|
|
1029
|
+
if current_index < 0:
|
|
1030
|
+
# No item focused, focus the last visible item
|
|
1031
|
+
next_index = min(self._start_index + len(self._rows) - 1, total - 1)
|
|
1032
|
+
else:
|
|
1033
|
+
# Move to previous item
|
|
1034
|
+
next_index = max(current_index - 1, 0)
|
|
1035
|
+
|
|
1036
|
+
self._focus_item_at_index(next_index)
|
|
1037
|
+
return 'break'
|
|
1038
|
+
|
|
1039
|
+
def _on_item_click(self, event: Any):
|
|
1040
|
+
"""Handle item click event from `ListItem`.
|
|
1041
|
+
|
|
1042
|
+
Args:
|
|
1043
|
+
event: Event with `data` for the clicked item.
|
|
1044
|
+
"""
|
|
1045
|
+
# Fetch fresh state from datasource to ensure accurate selection/focus state
|
|
1046
|
+
record_id = event.data.get('id')
|
|
1047
|
+
if record_id is not None and record_id != '__empty__':
|
|
1048
|
+
item_index = event.data.get('item_index')
|
|
1049
|
+
if item_index is not None:
|
|
1050
|
+
page_data = self._datasource.get_page_from_index(item_index, 1)
|
|
1051
|
+
if page_data:
|
|
1052
|
+
record = page_data[0].copy()
|
|
1053
|
+
record['selected'] = self._datasource.is_selected(record_id)
|
|
1054
|
+
record['focused'] = (record_id == self._focused_record_id)
|
|
1055
|
+
record['item_index'] = item_index
|
|
1056
|
+
self.event_generate('<<ItemClick>>', data=record)
|
|
1057
|
+
return
|
|
1058
|
+
|
|
1059
|
+
# Fallback to original data if we can't fetch fresh state
|
|
1060
|
+
self.event_generate('<<ItemClick>>', data=event.data)
|
|
1061
|
+
|
|
1062
|
+
def _apply_widget_surface(self, row: ListItem, widget_index: int) -> None:
|
|
1063
|
+
"""Apply surface color to a row widget based on its position in the pool.
|
|
1064
|
+
|
|
1065
|
+
This is called once when a widget is created. The color is based on the
|
|
1066
|
+
widget's position (not the data index), creating a stable alternating
|
|
1067
|
+
pattern during scrolling without needing to recalculate colors.
|
|
1068
|
+
|
|
1069
|
+
Args:
|
|
1070
|
+
row: The ListItem widget to color.
|
|
1071
|
+
widget_index: The position of this widget in the row pool (0-based).
|
|
1072
|
+
"""
|
|
1073
|
+
base_surface = getattr(self, "_surface", "background")
|
|
1074
|
+
if not self._striped:
|
|
1075
|
+
surface = base_surface
|
|
1076
|
+
else:
|
|
1077
|
+
# Apply striped background to odd rows
|
|
1078
|
+
is_odd = (widget_index % 2) == 1
|
|
1079
|
+
surface = self._striped_background if is_odd else base_surface
|
|
1080
|
+
|
|
1081
|
+
if hasattr(row, "set_surface"):
|
|
1082
|
+
row.set_surface(surface)
|
|
1083
|
+
|
|
1084
|
+
def _on_item_drag_start(self, event: Any):
|
|
1085
|
+
"""Handle item drag start event from `ListItem`.
|
|
1086
|
+
|
|
1087
|
+
Args:
|
|
1088
|
+
event: Event with `data` for the dragged item.
|
|
1089
|
+
"""
|
|
1090
|
+
record_id = event.data.get('id')
|
|
1091
|
+
source_index = event.data.get('source_index')
|
|
1092
|
+
if record_id is None or record_id == '__empty__' or source_index is None:
|
|
1093
|
+
return
|
|
1094
|
+
|
|
1095
|
+
self._drag_state = dict(
|
|
1096
|
+
record_id=record_id,
|
|
1097
|
+
source_index=source_index,
|
|
1098
|
+
target_index=source_index,
|
|
1099
|
+
record_data=dict(event.data),
|
|
1100
|
+
)
|
|
1101
|
+
self._drag_scroll_counter = 0
|
|
1102
|
+
self._show_drag_indicator()
|
|
1103
|
+
self._update_drag_indicator_position(source_index)
|
|
1104
|
+
self.event_generate('<<ItemDragStart>>', data=event.data)
|
|
1105
|
+
|
|
1106
|
+
def _on_item_dragging(self, event: Any):
|
|
1107
|
+
"""Handle item dragging event from `ListItem`.
|
|
1108
|
+
|
|
1109
|
+
Args:
|
|
1110
|
+
event: Event with `data` for the dragged item.
|
|
1111
|
+
"""
|
|
1112
|
+
if not self._drag_state:
|
|
1113
|
+
return
|
|
1114
|
+
|
|
1115
|
+
y_current = event.data.get('y_current')
|
|
1116
|
+
target_index = self._get_drop_index(y_current)
|
|
1117
|
+
self._drag_state['target_index'] = target_index
|
|
1118
|
+
self._auto_scroll_for_drag(y_current)
|
|
1119
|
+
self._update_drag_indicator_position(target_index)
|
|
1120
|
+
payload = dict(self._drag_state.get('record_data', {}))
|
|
1121
|
+
payload.update(
|
|
1122
|
+
dict(
|
|
1123
|
+
source_index=self._drag_state.get('source_index'),
|
|
1124
|
+
target_index=target_index,
|
|
1125
|
+
y_current=y_current,
|
|
1126
|
+
)
|
|
1127
|
+
)
|
|
1128
|
+
self.event_generate('<<ItemDrag>>', data=payload)
|
|
1129
|
+
|
|
1130
|
+
def _on_item_drag_end(self, event: Any):
|
|
1131
|
+
"""Handle item drag end event from `ListItem`.
|
|
1132
|
+
|
|
1133
|
+
Args:
|
|
1134
|
+
event: Event with `data` for the dragged item.
|
|
1135
|
+
"""
|
|
1136
|
+
if not self._drag_state:
|
|
1137
|
+
return
|
|
1138
|
+
|
|
1139
|
+
self._hide_drag_indicator()
|
|
1140
|
+
|
|
1141
|
+
record_id = self._drag_state.get('record_id')
|
|
1142
|
+
target_index = self._drag_state.get('target_index')
|
|
1143
|
+
moved = self._move_record(record_id, target_index)
|
|
1144
|
+
if moved:
|
|
1145
|
+
self._update_rows()
|
|
1146
|
+
|
|
1147
|
+
payload = dict(self._drag_state.get('record_data', {}))
|
|
1148
|
+
payload.update(
|
|
1149
|
+
dict(
|
|
1150
|
+
source_index=self._drag_state.get('source_index'),
|
|
1151
|
+
target_index=target_index,
|
|
1152
|
+
y_end=event.data.get('y_end'),
|
|
1153
|
+
y_start=event.data.get('y_start'),
|
|
1154
|
+
)
|
|
1155
|
+
)
|
|
1156
|
+
payload['target_index'] = target_index
|
|
1157
|
+
payload['moved'] = moved
|
|
1158
|
+
self.event_generate('<<ItemDragEnd>>', data=payload)
|
|
1159
|
+
self._drag_state = None
|
|
1160
|
+
|
|
1161
|
+
def _get_drop_index(self, y_root: int | None) -> int:
|
|
1162
|
+
"""Calculate the drop index for a drag operation.
|
|
1163
|
+
|
|
1164
|
+
Args:
|
|
1165
|
+
y_root: Screen Y coordinate.
|
|
1166
|
+
|
|
1167
|
+
Returns:
|
|
1168
|
+
Zero-based index for the drop position.
|
|
1169
|
+
"""
|
|
1170
|
+
total = self._datasource.total_count()
|
|
1171
|
+
if total <= 0:
|
|
1172
|
+
return 0
|
|
1173
|
+
|
|
1174
|
+
if y_root is None:
|
|
1175
|
+
return max(0, min(self._start_index, total - 1))
|
|
1176
|
+
|
|
1177
|
+
container_top = self._container.winfo_rooty()
|
|
1178
|
+
container_height = self._container.winfo_height()
|
|
1179
|
+
if container_height <= 0:
|
|
1180
|
+
return max(0, min(self._start_index, total - 1))
|
|
1181
|
+
|
|
1182
|
+
y_local = y_root - container_top
|
|
1183
|
+
y_local = max(0, min(y_local, container_height - 1))
|
|
1184
|
+
offset = int(y_local // max(1, self._row_height))
|
|
1185
|
+
target = self._start_index + offset
|
|
1186
|
+
return max(0, min(target, total - 1))
|
|
1187
|
+
|
|
1188
|
+
def _auto_scroll_for_drag(self, y_root: int | None) -> None:
|
|
1189
|
+
"""Auto-scroll while dragging near the list edges.
|
|
1190
|
+
|
|
1191
|
+
Args:
|
|
1192
|
+
y_root: Screen Y coordinate.
|
|
1193
|
+
"""
|
|
1194
|
+
if y_root is None:
|
|
1195
|
+
return
|
|
1196
|
+
|
|
1197
|
+
container_top = self._container.winfo_rooty()
|
|
1198
|
+
container_height = self._container.winfo_height()
|
|
1199
|
+
if container_height <= 0:
|
|
1200
|
+
return
|
|
1201
|
+
|
|
1202
|
+
scroll_zone_height = max(10, int(container_height * 0.2))
|
|
1203
|
+
container_bottom = container_top + container_height
|
|
1204
|
+
self._drag_scroll_counter += 1
|
|
1205
|
+
should_scroll = self._drag_scroll_counter % 8 == 0
|
|
1206
|
+
if should_scroll:
|
|
1207
|
+
if y_root < container_top + scroll_zone_height:
|
|
1208
|
+
self._start_index -= 1
|
|
1209
|
+
elif y_root > container_bottom - scroll_zone_height:
|
|
1210
|
+
self._start_index += 1
|
|
1211
|
+
else:
|
|
1212
|
+
return
|
|
1213
|
+
else:
|
|
1214
|
+
return
|
|
1215
|
+
|
|
1216
|
+
self._clamp_indices()
|
|
1217
|
+
self._update_rows()
|
|
1218
|
+
|
|
1219
|
+
def _move_record(self, record_id: Any, target_index: int | None) -> bool:
|
|
1220
|
+
"""Move a record in the data source if supported.
|
|
1221
|
+
|
|
1222
|
+
Args:
|
|
1223
|
+
record_id: Record identifier to move.
|
|
1224
|
+
target_index: Target index to move the record to.
|
|
1225
|
+
|
|
1226
|
+
Returns:
|
|
1227
|
+
True if the record was moved.
|
|
1228
|
+
"""
|
|
1229
|
+
if record_id is None or target_index is None:
|
|
1230
|
+
return False
|
|
1231
|
+
|
|
1232
|
+
mover = getattr(self._datasource, 'move_record', None)
|
|
1233
|
+
if callable(mover):
|
|
1234
|
+
return bool(mover(record_id, target_index))
|
|
1235
|
+
|
|
1236
|
+
# Fallback for simple in-memory lists
|
|
1237
|
+
try:
|
|
1238
|
+
total = self._datasource.total_count()
|
|
1239
|
+
all_records = self._datasource.get_page_from_index(0, total)
|
|
1240
|
+
source_index = None
|
|
1241
|
+
for i, record in enumerate(all_records):
|
|
1242
|
+
if record.get('id') == record_id:
|
|
1243
|
+
source_index = i
|
|
1244
|
+
break
|
|
1245
|
+
if source_index is None:
|
|
1246
|
+
return False
|
|
1247
|
+
clamped_target = max(0, min(target_index, len(all_records) - 1))
|
|
1248
|
+
if source_index == clamped_target:
|
|
1249
|
+
return False
|
|
1250
|
+
record = all_records.pop(source_index)
|
|
1251
|
+
if clamped_target > source_index:
|
|
1252
|
+
clamped_target -= 1
|
|
1253
|
+
all_records.insert(clamped_target, record)
|
|
1254
|
+
setter = getattr(self._datasource, 'set_data', None)
|
|
1255
|
+
if callable(setter):
|
|
1256
|
+
setter(all_records)
|
|
1257
|
+
return True
|
|
1258
|
+
except Exception:
|
|
1259
|
+
return False
|
|
1260
|
+
|
|
1261
|
+
return False
|
|
1262
|
+
|
|
1263
|
+
def _show_drag_indicator(self) -> None:
|
|
1264
|
+
"""Create and show the drag drop indicator line."""
|
|
1265
|
+
if self._drag_indicator is None:
|
|
1266
|
+
self._drag_indicator = Frame(self._container, accent=self._selected_background)
|
|
1267
|
+
|
|
1268
|
+
def _update_drag_indicator_position(self, target_index: int) -> None:
|
|
1269
|
+
"""Update the drag indicator to show drop location."""
|
|
1270
|
+
if self._drag_indicator is None:
|
|
1271
|
+
return
|
|
1272
|
+
|
|
1273
|
+
try:
|
|
1274
|
+
visual_index = target_index - self._start_index
|
|
1275
|
+
if 0 <= visual_index < len(self._rows):
|
|
1276
|
+
y_pos = visual_index * max(1, self._row_height)
|
|
1277
|
+
self._drag_indicator.place(
|
|
1278
|
+
x=0,
|
|
1279
|
+
y=y_pos,
|
|
1280
|
+
width=self._container.winfo_width(),
|
|
1281
|
+
height=3,
|
|
1282
|
+
)
|
|
1283
|
+
self._drag_indicator.lift()
|
|
1284
|
+
else:
|
|
1285
|
+
self._drag_indicator.place_forget()
|
|
1286
|
+
except Exception:
|
|
1287
|
+
pass
|
|
1288
|
+
|
|
1289
|
+
def _hide_drag_indicator(self) -> None:
|
|
1290
|
+
"""Hide and destroy the drag indicator."""
|
|
1291
|
+
if self._drag_indicator is not None:
|
|
1292
|
+
try:
|
|
1293
|
+
self._drag_indicator.place_forget()
|
|
1294
|
+
self._drag_indicator.destroy()
|
|
1295
|
+
except Exception:
|
|
1296
|
+
pass
|
|
1297
|
+
self._drag_indicator = None
|
|
1298
|
+
|
|
1299
|
+
# Public API
|
|
1300
|
+
|
|
1301
|
+
def reload(self):
|
|
1302
|
+
"""Reload data from the datasource and refresh the display.
|
|
1303
|
+
|
|
1304
|
+
Calls the datasource's `reload()` method and updates all visible rows
|
|
1305
|
+
with the refreshed data. Useful when the underlying data has changed
|
|
1306
|
+
externally.
|
|
1307
|
+
"""
|
|
1308
|
+
self._datasource.reload()
|
|
1309
|
+
self._update_rows()
|
|
1310
|
+
|
|
1311
|
+
def get_selected(self) -> list:
|
|
1312
|
+
"""Get list of selected record IDs.
|
|
1313
|
+
|
|
1314
|
+
Returns:
|
|
1315
|
+
List of record IDs that are currently selected. Empty list if
|
|
1316
|
+
no items are selected.
|
|
1317
|
+
|
|
1318
|
+
Examples:
|
|
1319
|
+
>>> selected = listview.get_selected()
|
|
1320
|
+
>>> print(f"Selected {len(selected)} items")
|
|
1321
|
+
"""
|
|
1322
|
+
return self._datasource.get_selected()
|
|
1323
|
+
|
|
1324
|
+
def select_all(self):
|
|
1325
|
+
"""Select all items in the list.
|
|
1326
|
+
|
|
1327
|
+
Only works when selection_mode is 'multi'. Generates a
|
|
1328
|
+
<<SelectionChanged>> event after completion.
|
|
1329
|
+
|
|
1330
|
+
Note:
|
|
1331
|
+
For large datasets, this may be slow as it loads all records.
|
|
1332
|
+
"""
|
|
1333
|
+
if self._selection_mode == 'multi':
|
|
1334
|
+
total = self._datasource.total_count()
|
|
1335
|
+
all_records = self._datasource.get_page_from_index(0, total)
|
|
1336
|
+
for record in all_records:
|
|
1337
|
+
record_id = record.get('id')
|
|
1338
|
+
if record_id:
|
|
1339
|
+
self._datasource.select_record(record_id)
|
|
1340
|
+
self._update_rows()
|
|
1341
|
+
self.event_generate('<<SelectionChange>>')
|
|
1342
|
+
|
|
1343
|
+
def clear_selection(self):
|
|
1344
|
+
"""Clear all item selections.
|
|
1345
|
+
|
|
1346
|
+
Deselects all items and generates a <<SelectionChange>> event.
|
|
1347
|
+
"""
|
|
1348
|
+
self._datasource.deselect_all()
|
|
1349
|
+
self._update_rows()
|
|
1350
|
+
self.event_generate('<<SelectionChange>>')
|
|
1351
|
+
|
|
1352
|
+
def scroll_to_top(self):
|
|
1353
|
+
"""Scroll to the beginning of the list.
|
|
1354
|
+
|
|
1355
|
+
Instantly scrolls to show the first item in the list.
|
|
1356
|
+
"""
|
|
1357
|
+
self._start_index = 0
|
|
1358
|
+
self._update_rows()
|
|
1359
|
+
|
|
1360
|
+
def scroll_to_bottom(self):
|
|
1361
|
+
"""Scroll to the end of the list.
|
|
1362
|
+
|
|
1363
|
+
Instantly scrolls to show the last items in the list.
|
|
1364
|
+
"""
|
|
1365
|
+
total = self._datasource.total_count()
|
|
1366
|
+
self._start_index = max(0, total - self._visible_rows)
|
|
1367
|
+
self._update_rows()
|
|
1368
|
+
|
|
1369
|
+
def insert_item(self, data: dict):
|
|
1370
|
+
"""Insert a new item into the list.
|
|
1371
|
+
|
|
1372
|
+
Args:
|
|
1373
|
+
data: Dictionary containing the item data. An 'id' will be
|
|
1374
|
+
auto-generated if not provided.
|
|
1375
|
+
|
|
1376
|
+
Note:
|
|
1377
|
+
Generates a <<ItemInserted>> event after the item is added.
|
|
1378
|
+
|
|
1379
|
+
Examples:
|
|
1380
|
+
>>> listview.insert_item({
|
|
1381
|
+
... 'title': 'New Item',
|
|
1382
|
+
... 'text': 'Description'
|
|
1383
|
+
... })
|
|
1384
|
+
"""
|
|
1385
|
+
self._datasource.create_record(data)
|
|
1386
|
+
self._update_rows()
|
|
1387
|
+
self.event_generate('<<ItemInsert>>')
|
|
1388
|
+
|
|
1389
|
+
def update_item(self, record_id: Any, data: dict):
|
|
1390
|
+
"""Update an existing item's data.
|
|
1391
|
+
|
|
1392
|
+
Args:
|
|
1393
|
+
record_id: The ID of the record to update.
|
|
1394
|
+
data: Dictionary of fields to update. Will be merged with
|
|
1395
|
+
existing record data.
|
|
1396
|
+
|
|
1397
|
+
Note:
|
|
1398
|
+
Generates a <<ItemUpdated>> event if the update succeeds.
|
|
1399
|
+
|
|
1400
|
+
Examples:
|
|
1401
|
+
>>> listview.update_item(42, {'title': 'Updated Title'})
|
|
1402
|
+
"""
|
|
1403
|
+
if self._datasource.update_record(record_id, data):
|
|
1404
|
+
self._update_rows()
|
|
1405
|
+
self.event_generate('<<ItemUpdate>>')
|
|
1406
|
+
|
|
1407
|
+
def delete_item(self, record_id: Any):
|
|
1408
|
+
"""Delete an item from the list.
|
|
1409
|
+
|
|
1410
|
+
Args:
|
|
1411
|
+
record_id: The ID of the record to delete.
|
|
1412
|
+
|
|
1413
|
+
Note:
|
|
1414
|
+
Generates a <<ItemDeleted>> event after deletion.
|
|
1415
|
+
|
|
1416
|
+
Examples:
|
|
1417
|
+
>>> listview.delete_item(42)
|
|
1418
|
+
"""
|
|
1419
|
+
self._datasource.delete_record(record_id)
|
|
1420
|
+
self._update_rows()
|
|
1421
|
+
self.event_generate('<<ItemDelete>>')
|
|
1422
|
+
|
|
1423
|
+
def get_datasource(self) -> DataSourceProtocol:
|
|
1424
|
+
"""Get the underlying datasource.
|
|
1425
|
+
|
|
1426
|
+
Returns:
|
|
1427
|
+
The DataSource instance managing the list's data.
|
|
1428
|
+
|
|
1429
|
+
Examples:
|
|
1430
|
+
>>> ds = listview.get_datasource()
|
|
1431
|
+
>>> count = ds.total_count()
|
|
1432
|
+
"""
|
|
1433
|
+
return self._datasource
|
|
1434
|
+
|
|
1435
|
+
# Event handler API
|
|
1436
|
+
|
|
1437
|
+
def on_selection_changed(self, callback: Callable) -> str:
|
|
1438
|
+
"""Bind to `<<SelectionChange>>`. Callback receives `event.data = None` (use `get_selected()` to get current selection)."""
|
|
1439
|
+
return self.bind('<<SelectionChange>>', callback, add='+')
|
|
1440
|
+
|
|
1441
|
+
def off_selection_changed(self, bind_id: str | None = None) -> None:
|
|
1442
|
+
"""Unbind from `<<SelectionChange>>`."""
|
|
1443
|
+
self.unbind('<<SelectionChange>>', bind_id)
|
|
1444
|
+
|
|
1445
|
+
def on_item_delete(self, callback: Callable) -> str:
|
|
1446
|
+
"""Bind to `<<ItemDelete>>`. Callback receives `event.data = {'record': dict}`."""
|
|
1447
|
+
return self.bind('<<ItemDelete>>', callback, add='+')
|
|
1448
|
+
|
|
1449
|
+
def off_item_delete(self, bind_id: str | None = None) -> None:
|
|
1450
|
+
"""Unbind from `<<ItemDelete>>`."""
|
|
1451
|
+
self.unbind('<<ItemDelete>>', bind_id)
|
|
1452
|
+
|
|
1453
|
+
def on_item_delete_fail(self, callback: Callable) -> str:
|
|
1454
|
+
"""Bind to `<<ItemDeleteFail>>`. Callback receives `event.data = {'record': dict, 'error': str}`."""
|
|
1455
|
+
return self.bind('<<ItemDeleteFail>>', callback, add='+')
|
|
1456
|
+
|
|
1457
|
+
def off_item_delete_fail(self, bind_id: str | None = None) -> None:
|
|
1458
|
+
"""Unbind from `<<ItemDeleteFail>>`."""
|
|
1459
|
+
self.unbind('<<ItemDeleteFail>>', bind_id)
|
|
1460
|
+
|
|
1461
|
+
def on_item_insert(self, callback: Callable) -> str:
|
|
1462
|
+
"""Bind to `<<ItemInsert>>`. Callback receives `event.data = {'record': dict}`."""
|
|
1463
|
+
return self.bind('<<ItemInsert>>', callback, add='+')
|
|
1464
|
+
|
|
1465
|
+
def off_item_insert(self, bind_id: str | None = None) -> None:
|
|
1466
|
+
"""Unbind from `<<ItemInsert>>`."""
|
|
1467
|
+
self.unbind('<<ItemInsert>>', bind_id)
|
|
1468
|
+
|
|
1469
|
+
def on_item_update(self, callback: Callable) -> str:
|
|
1470
|
+
"""Bind to `<<ItemUpdate>>`. Callback receives `event.data = {'record': dict}`."""
|
|
1471
|
+
return self.bind('<<ItemUpdate>>', callback, add='+')
|
|
1472
|
+
|
|
1473
|
+
def off_item_update(self, bind_id: str | None = None) -> None:
|
|
1474
|
+
"""Unbind from `<<ItemUpdate>>`."""
|
|
1475
|
+
self.unbind('<<ItemUpdate>>', bind_id)
|
|
1476
|
+
|
|
1477
|
+
def on_item_click(self, callback: Callable) -> str:
|
|
1478
|
+
"""Bind to `<<ItemClick>>`. Callback receives `event.data = {'record': dict}`."""
|
|
1479
|
+
return self.bind('<<ItemClick>>', callback, add='+')
|
|
1480
|
+
|
|
1481
|
+
def off_item_click(self, bind_id: str | None = None) -> None:
|
|
1482
|
+
"""Unbind from `<<ItemClick>>`."""
|
|
1483
|
+
self.unbind('<<ItemClick>>', bind_id)
|
|
1484
|
+
|
|
1485
|
+
def on_item_drag_start(self, callback: Callable) -> str:
|
|
1486
|
+
"""Bind to `<<ItemDragStart>>`. Callback receives `event.data = {'record': dict, 'index': int}`."""
|
|
1487
|
+
return self.bind('<<ItemDragStart>>', callback, add='+')
|
|
1488
|
+
|
|
1489
|
+
def off_item_drag_start(self, bind_id: str | None = None) -> None:
|
|
1490
|
+
"""Unbind from `<<ItemDragStart>>`."""
|
|
1491
|
+
self.unbind('<<ItemDragStart>>', bind_id)
|
|
1492
|
+
|
|
1493
|
+
def on_item_drag(self, callback: Callable) -> str:
|
|
1494
|
+
"""Bind to `<<ItemDrag>>`. Callback receives `event.data = {'source_index': int, 'target_index': int, 'x': int, 'y': int}`."""
|
|
1495
|
+
return self.bind('<<ItemDrag>>', callback, add='+')
|
|
1496
|
+
|
|
1497
|
+
def off_item_drag(self, bind_id: str | None = None) -> None:
|
|
1498
|
+
"""Unbind from `<<ItemDrag>>`."""
|
|
1499
|
+
self.unbind('<<ItemDrag>>', bind_id)
|
|
1500
|
+
|
|
1501
|
+
def on_item_drag_end(self, callback: Callable) -> str:
|
|
1502
|
+
"""Bind to `<<ItemDragEnd>>`. Callback receives `event.data = {'moved': bool, 'source_index': int, 'target_index': int}`."""
|
|
1503
|
+
return self.bind('<<ItemDragEnd>>', callback, add='+')
|
|
1504
|
+
|
|
1505
|
+
def off_item_drag_end(self, bind_id: str | None = None) -> None:
|
|
1506
|
+
"""Unbind from `<<ItemDragEnd>>`."""
|
|
1507
|
+
self.unbind('<<ItemDragEnd>>', bind_id)
|