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,2254 @@
|
|
|
1
|
+
"""TableView widget backed by an in-memory SQLite datasource.
|
|
2
|
+
|
|
3
|
+
The datasource performs filtering, sorting, and pagination while the widget
|
|
4
|
+
renders the current page in a Treeview with optional grouping, striping, and
|
|
5
|
+
context menus.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
from tkinter import font as tkfont
|
|
13
|
+
|
|
14
|
+
from typing_extensions import Literal
|
|
15
|
+
|
|
16
|
+
from bootstack.widgets.types import Master
|
|
17
|
+
|
|
18
|
+
from ttkbootstrap_icons_bs import BootstrapIcon
|
|
19
|
+
from bootstack.style.style import get_style
|
|
20
|
+
from bootstack.datasource.sqlite_source import SqliteDataSource
|
|
21
|
+
from bootstack.widgets.primitives.button import Button
|
|
22
|
+
from bootstack.runtime.utility import bind_right_click
|
|
23
|
+
from bootstack.widgets.composites.contextmenu import ContextMenu
|
|
24
|
+
from bootstack.widgets.composites.dropdownbutton import DropdownButton
|
|
25
|
+
from bootstack.widgets.primitives.entry import Entry
|
|
26
|
+
from bootstack.widgets.primitives.frame import Frame
|
|
27
|
+
from bootstack.widgets.primitives.label import Label
|
|
28
|
+
from bootstack.widgets.primitives.scrollbar import Scrollbar
|
|
29
|
+
from bootstack.widgets.composites.selectbox import SelectBox
|
|
30
|
+
from bootstack.widgets.primitives.separator import Separator
|
|
31
|
+
from bootstack.widgets.composites.textentry import TextEntry
|
|
32
|
+
from bootstack.widgets.primitives.treeview import TreeView
|
|
33
|
+
from bootstack.core.localization import MessageCatalog
|
|
34
|
+
|
|
35
|
+
from .types import (
|
|
36
|
+
parse_selection_mode as _parse_selection_mode,
|
|
37
|
+
build_editing_options as _build_editing_options,
|
|
38
|
+
build_selection_options as _build_selection_options,
|
|
39
|
+
build_filtering_options as _build_filtering_options,
|
|
40
|
+
build_exporting_options as _build_exporting_options,
|
|
41
|
+
build_paging_options as _build_paging_options,
|
|
42
|
+
build_search_options as _build_search_options,
|
|
43
|
+
build_row_alternation_options as _build_row_alternation_options,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
_TABLE_SEARCH_MODE_OPTIONS = [
|
|
49
|
+
("table.search_mode_equals", "EQUALS"),
|
|
50
|
+
("table.search_mode_contains", "CONTAINS"),
|
|
51
|
+
("table.search_mode_starts_with", "STARTS WITH"),
|
|
52
|
+
("table.search_mode_ends_with", "ENDS WITH"),
|
|
53
|
+
("table.search_mode_sql", "SQL"),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TableView(Frame):
|
|
58
|
+
"""TableView backed by an in-memory SqliteDataSource.
|
|
59
|
+
|
|
60
|
+
Provides sortable headers, filtering/search, pagination or virtual scrolling,
|
|
61
|
+
optional grouping, column striping, and configurable exporting/editing.
|
|
62
|
+
|
|
63
|
+
!!! note "Events"
|
|
64
|
+
- `<<SelectionChange>>`: Fired when row selection changes. `event.data = {'records': list[dict], 'iids': list[str]}`
|
|
65
|
+
- `<<RowClick>>`: Fired on single row click. `event.data = {'record': dict, 'iid': str}`
|
|
66
|
+
- `<<RowDoubleClick>>`: Fired on row double-click. `event.data = {'record': dict, 'iid': str}`
|
|
67
|
+
- `<<RowRightClick>>`: Fired on row right-click. `event.data = {'record': dict, 'iid': str}`
|
|
68
|
+
- `<<RowInsert>>`: Fired when rows are inserted. `event.data = {'records': list[dict]}`
|
|
69
|
+
- `<<RowUpdate>>`: Fired when rows are updated. `event.data = {'records': list[dict]}`
|
|
70
|
+
- `<<RowDelete>>`: Fired when rows are deleted. `event.data = {'records': list[dict]}`
|
|
71
|
+
- `<<RowMove>>`: Fired when rows are moved/reordered. `event.data = {'records': list[dict]}`
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
master: Master = None,
|
|
77
|
+
# Core data
|
|
78
|
+
columns: list[str | dict] | None = None,
|
|
79
|
+
rows: list | None = None,
|
|
80
|
+
datasource: SqliteDataSource | None = None,
|
|
81
|
+
# Selection & sorting
|
|
82
|
+
selection_mode: Literal['none', 'single', 'multi'] = 'single',
|
|
83
|
+
allow_select_all: bool = True,
|
|
84
|
+
sorting_mode: Literal['single', 'none'] = 'single',
|
|
85
|
+
# Filtering & search
|
|
86
|
+
enable_filtering: bool = True,
|
|
87
|
+
enable_header_filtering: bool = True,
|
|
88
|
+
enable_row_filtering: bool = True,
|
|
89
|
+
enable_search: bool = True,
|
|
90
|
+
search_mode: Literal['standard', 'advanced'] = 'standard',
|
|
91
|
+
search_trigger: Literal['enter', 'input'] = 'enter',
|
|
92
|
+
# Paging & scrolling
|
|
93
|
+
paging_mode: Literal['standard', 'virtual'] = 'standard',
|
|
94
|
+
page_size: int = 25,
|
|
95
|
+
page_index: int = 0,
|
|
96
|
+
page_cache_size: int = 3,
|
|
97
|
+
show_vscrollbar: bool = True,
|
|
98
|
+
show_hscrollbar: bool = False,
|
|
99
|
+
# Editing
|
|
100
|
+
enable_adding: bool = False,
|
|
101
|
+
enable_editing: bool = False,
|
|
102
|
+
enable_deleting: bool = False,
|
|
103
|
+
form_options: dict | None = None,
|
|
104
|
+
# Exporting
|
|
105
|
+
enable_exporting: bool = False,
|
|
106
|
+
allow_export_selection: bool = True,
|
|
107
|
+
export_scope: Literal['page', 'all'] = 'page',
|
|
108
|
+
export_formats: tuple[str, ...] | None = None,
|
|
109
|
+
# Appearance & extras
|
|
110
|
+
striped: bool = False,
|
|
111
|
+
striped_background: str = 'background[+1]',
|
|
112
|
+
allow_grouping: bool = False,
|
|
113
|
+
show_table_status: bool = True,
|
|
114
|
+
show_column_chooser: bool = False,
|
|
115
|
+
context_menus: Literal['none', 'headers', 'rows', 'all'] = 'all',
|
|
116
|
+
column_min_width: int = 40,
|
|
117
|
+
column_auto_width: bool = False,
|
|
118
|
+
**kwargs,
|
|
119
|
+
):
|
|
120
|
+
"""
|
|
121
|
+
Create a TableView backed by an in-memory SqliteDataSource.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
master: Parent widget.
|
|
125
|
+
columns: Column definitions (list of strings or dicts with keys like
|
|
126
|
+
"text", "key", "width", "minwidth").
|
|
127
|
+
rows: Initial data to load (list of dicts or row-like sequences).
|
|
128
|
+
datasource: Custom SqliteDataSource; if omitted, an in-memory source is created.
|
|
129
|
+
selection_mode: Selection mode ('none', 'single', 'multi'). Defaults to 'single'.
|
|
130
|
+
allow_select_all: Whether select-all is allowed. Defaults to True.
|
|
131
|
+
sorting_mode: Sorting mode ('single' or 'none'). Defaults to 'single'.
|
|
132
|
+
enable_filtering: Enable filtering features. Defaults to True.
|
|
133
|
+
enable_header_filtering: Show filter option in header context menu. Defaults to True.
|
|
134
|
+
enable_row_filtering: Show filter option in row context menu. Defaults to True.
|
|
135
|
+
enable_search: Show search bar. Defaults to True.
|
|
136
|
+
search_mode: Search mode ('standard' or 'advanced'). Defaults to 'standard'.
|
|
137
|
+
search_trigger: When to trigger search ('enter' or 'input'). Defaults to 'enter'.
|
|
138
|
+
paging_mode: Paging mode ('standard' or 'virtual'). Defaults to 'standard'.
|
|
139
|
+
page_size: Number of rows per page. Defaults to 25.
|
|
140
|
+
page_index: Initial page index. Defaults to 0.
|
|
141
|
+
page_cache_size: Number of pages to cache. Defaults to 3.
|
|
142
|
+
show_vscrollbar: Show vertical scrollbar. Defaults to True.
|
|
143
|
+
show_hscrollbar: Show horizontal scrollbar. Defaults to False.
|
|
144
|
+
enable_adding: Allow adding new rows. Defaults to False.
|
|
145
|
+
enable_editing: Allow editing existing rows. Defaults to False.
|
|
146
|
+
enable_deleting: Allow deleting rows. Defaults to False.
|
|
147
|
+
form_options: Options dict for the edit form dialog.
|
|
148
|
+
enable_exporting: Enable export functionality. Defaults to False.
|
|
149
|
+
allow_export_selection: Allow exporting selected rows. Defaults to True.
|
|
150
|
+
export_scope: Export scope ('page' or 'all'). Defaults to 'page'.
|
|
151
|
+
export_formats: Tuple of export formats (e.g., ('csv', 'xlsx')).
|
|
152
|
+
striped: Show alternating row colors. Defaults to False.
|
|
153
|
+
striped_background: Background color for striped rows. Defaults to 'background[+1]'.
|
|
154
|
+
allow_grouping: Allow grouping rows via header context menu. Defaults to False.
|
|
155
|
+
show_table_status: Show filter/sort/group status labels and pager. Defaults to True.
|
|
156
|
+
show_column_chooser: Show column chooser button. Defaults to False.
|
|
157
|
+
context_menus: Context menu visibility ('none', 'headers', 'rows', 'all').
|
|
158
|
+
Defaults to 'all'.
|
|
159
|
+
column_min_width: Global minimum width for columns. Defaults to 40.
|
|
160
|
+
column_auto_width: Automatically size columns to widest visible text.
|
|
161
|
+
Defaults to False.
|
|
162
|
+
**kwargs: Additional arguments passed through to Frame.
|
|
163
|
+
"""
|
|
164
|
+
super().__init__(master, **kwargs)
|
|
165
|
+
|
|
166
|
+
# Build internal configuration dicts from flattened kwargs
|
|
167
|
+
self._editing = _build_editing_options(
|
|
168
|
+
enable_adding=enable_adding,
|
|
169
|
+
enable_editing=enable_editing,
|
|
170
|
+
enable_deleting=enable_deleting,
|
|
171
|
+
form_options=form_options,
|
|
172
|
+
)
|
|
173
|
+
self._paging = _build_paging_options(
|
|
174
|
+
paging_mode=paging_mode,
|
|
175
|
+
page_size=page_size,
|
|
176
|
+
page_index=page_index,
|
|
177
|
+
page_cache_size=page_cache_size,
|
|
178
|
+
show_vscrollbar=show_vscrollbar,
|
|
179
|
+
show_hscrollbar=show_hscrollbar,
|
|
180
|
+
)
|
|
181
|
+
self._exporting = _build_exporting_options(
|
|
182
|
+
enable_exporting=enable_exporting,
|
|
183
|
+
allow_export_selection=allow_export_selection,
|
|
184
|
+
export_scope=export_scope,
|
|
185
|
+
export_formats=export_formats,
|
|
186
|
+
)
|
|
187
|
+
self._filtering = _build_filtering_options(
|
|
188
|
+
enable_filtering=enable_filtering,
|
|
189
|
+
enable_header_filtering=enable_header_filtering,
|
|
190
|
+
enable_row_filtering=enable_row_filtering,
|
|
191
|
+
)
|
|
192
|
+
self._selection = _build_selection_options(
|
|
193
|
+
selection_mode=selection_mode,
|
|
194
|
+
allow_select_all=allow_select_all,
|
|
195
|
+
)
|
|
196
|
+
self._searchbar = _build_search_options(
|
|
197
|
+
enable_search=enable_search,
|
|
198
|
+
search_mode=search_mode,
|
|
199
|
+
search_trigger=search_trigger,
|
|
200
|
+
)
|
|
201
|
+
# User-facing filter description (e.g., "fin" or a SQL expression in
|
|
202
|
+
# advanced SQL mode). Falls back to the raw datasource WHERE clause
|
|
203
|
+
# when set externally.
|
|
204
|
+
self._filter_summary: str = ""
|
|
205
|
+
self._row_alternation = _build_row_alternation_options(
|
|
206
|
+
striped=striped,
|
|
207
|
+
striped_background=striped_background,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
self._search_mode_map: dict[str, str] = {}
|
|
211
|
+
self._sorting = sorting_mode
|
|
212
|
+
self._show_table_status = show_table_status
|
|
213
|
+
self._show_column_chooser = show_column_chooser
|
|
214
|
+
self._allow_grouping = allow_grouping
|
|
215
|
+
self._context_menus = (context_menus or 'all').lower()
|
|
216
|
+
self._column_min_width = max(0, column_min_width)
|
|
217
|
+
self._column_auto_width = column_auto_width
|
|
218
|
+
self._datasource = datasource or SqliteDataSource(':memory:', page_size=self._paging['page_size'])
|
|
219
|
+
|
|
220
|
+
self._page_cache: OrderedDict[int, list[dict]] = OrderedDict()
|
|
221
|
+
self._column_defs = columns or []
|
|
222
|
+
self._column_keys: list[str] = []
|
|
223
|
+
self._heading_texts: list[str] = []
|
|
224
|
+
self._sort_state: dict[str, bool] = {} # key -> ascending
|
|
225
|
+
self._current_page = self._paging['page_index']
|
|
226
|
+
self._loading_next = False
|
|
227
|
+
self._heading_fg: str | None = None
|
|
228
|
+
self._icon_sort_up = None
|
|
229
|
+
self._icon_sort_down = None
|
|
230
|
+
self._column_anchors: list[str] = []
|
|
231
|
+
self._column_filters: dict[str, list] = {} # key -> list of allowed values
|
|
232
|
+
self._column_types: dict[str, str] = {}
|
|
233
|
+
self._alignment_sample: list[dict] | None = None
|
|
234
|
+
self._row_map: dict[str, dict] = {}
|
|
235
|
+
self._row_menu: ContextMenu | None = None
|
|
236
|
+
self._display_columns: list[int] = []
|
|
237
|
+
self._header_menu: ContextMenu | None = None
|
|
238
|
+
self._header_menu_col: int | None = None
|
|
239
|
+
self._cached_total_count: int | None = None
|
|
240
|
+
self._group_by_key: str | None = None
|
|
241
|
+
self._group_parents: dict[str | None, str] = {}
|
|
242
|
+
self._hidden_rows: dict[str, tuple[str, int]] = {}
|
|
243
|
+
|
|
244
|
+
self._resolve_column_keys()
|
|
245
|
+
|
|
246
|
+
seeded_records: list[dict] | None = None
|
|
247
|
+
if rows:
|
|
248
|
+
try:
|
|
249
|
+
if self._column_keys:
|
|
250
|
+
# Avoid per-row dict conversion when we already know the column order
|
|
251
|
+
self._datasource.set_data(rows, column_keys=self._column_keys)
|
|
252
|
+
seeded_records = None
|
|
253
|
+
else:
|
|
254
|
+
seeded_records = self._to_records(rows)
|
|
255
|
+
self._datasource.set_data(seeded_records)
|
|
256
|
+
except Exception:
|
|
257
|
+
# Last-resort fallback to dict conversion if direct load fails
|
|
258
|
+
seeded_records = self._to_records(rows)
|
|
259
|
+
try:
|
|
260
|
+
self._datasource.set_data(seeded_records)
|
|
261
|
+
except Exception:
|
|
262
|
+
seeded_records = []
|
|
263
|
+
|
|
264
|
+
self._ensure_column_metadata(seeded_records)
|
|
265
|
+
|
|
266
|
+
# UI
|
|
267
|
+
self._build_toolbar()
|
|
268
|
+
self._build_tree()
|
|
269
|
+
if self._show_table_status or not self._paging['mode'] == 'virtual':
|
|
270
|
+
self._build_footer()
|
|
271
|
+
|
|
272
|
+
# Initial load
|
|
273
|
+
self._load_page(0)
|
|
274
|
+
|
|
275
|
+
# ------------------------------------------------------------------ Public API
|
|
276
|
+
def set_data(self, rows: list) -> None:
|
|
277
|
+
"""Replace data in the datasource and refresh the grid."""
|
|
278
|
+
if self._column_keys:
|
|
279
|
+
self._datasource.set_data(rows, column_keys=self._column_keys)
|
|
280
|
+
seeded_records = None
|
|
281
|
+
else:
|
|
282
|
+
seeded_records = self._to_records(rows)
|
|
283
|
+
self._datasource.set_data(seeded_records)
|
|
284
|
+
self._ensure_column_metadata(seeded_records)
|
|
285
|
+
self._clear_cache()
|
|
286
|
+
self._load_page(0)
|
|
287
|
+
|
|
288
|
+
# ------------------------------------------------------------------ Public event API
|
|
289
|
+
def on_selection_changed(self, callback) -> str:
|
|
290
|
+
"""Bind to `<<SelectionChange>>`. Callback receives `event.data = {'records': list[dict], 'iids': list[str]}`."""
|
|
291
|
+
return self.bind("<<SelectionChange>>", callback, add=True)
|
|
292
|
+
|
|
293
|
+
def off_selection_changed(self, bind_id: str | None = None) -> None:
|
|
294
|
+
"""Unbind from `<<SelectionChange>>`."""
|
|
295
|
+
self.unbind("<<SelectionChange>>", bind_id)
|
|
296
|
+
|
|
297
|
+
def on_row_click(self, callback) -> str:
|
|
298
|
+
"""Bind to `<<RowClick>>`. Callback receives `event.data = {'record': dict, 'iid': str}`."""
|
|
299
|
+
return self.bind("<<RowClick>>", callback, add=True)
|
|
300
|
+
|
|
301
|
+
def off_row_click(self, bind_id: str | None = None) -> None:
|
|
302
|
+
"""Unbind from `<<RowClick>>`."""
|
|
303
|
+
self.unbind("<<RowClick>>", bind_id)
|
|
304
|
+
|
|
305
|
+
def on_row_double_click(self, callback) -> str:
|
|
306
|
+
"""Bind to `<<RowDoubleClick>>`. Callback receives `event.data = {'record': dict, 'iid': str}`."""
|
|
307
|
+
return self.bind("<<RowDoubleClick>>", callback, add=True)
|
|
308
|
+
|
|
309
|
+
def off_row_double_click(self, bind_id: str | None = None) -> None:
|
|
310
|
+
"""Unbind from `<<RowDoubleClick>>`."""
|
|
311
|
+
self.unbind("<<RowDoubleClick>>", bind_id)
|
|
312
|
+
|
|
313
|
+
def on_row_right_click(self, callback) -> str:
|
|
314
|
+
"""Bind to `<<RowRightClick>>`. Callback receives `event.data = {'record': dict, 'iid': str}`."""
|
|
315
|
+
return self.bind("<<RowRightClick>>", callback, add=True)
|
|
316
|
+
|
|
317
|
+
def off_row_right_click(self, bind_id: str | None = None) -> None:
|
|
318
|
+
"""Unbind from `<<RowRightClick>>`."""
|
|
319
|
+
self.unbind("<<RowRightClick>>", bind_id)
|
|
320
|
+
|
|
321
|
+
def on_row_deleted(self, callback) -> str:
|
|
322
|
+
"""Bind to `<<RowDelete>>`. Callback receives `event.data = {'records': list[dict]}`."""
|
|
323
|
+
return self.bind("<<RowDelete>>", callback, add=True)
|
|
324
|
+
|
|
325
|
+
def off_row_deleted(self, bind_id: str | None = None) -> None:
|
|
326
|
+
"""Unbind from `<<RowDelete>>`."""
|
|
327
|
+
self.unbind("<<RowDelete>>", bind_id)
|
|
328
|
+
|
|
329
|
+
def on_row_inserted(self, callback) -> str:
|
|
330
|
+
"""Bind to `<<RowInsert>>`. Callback receives `event.data = {'records': list[dict]}`."""
|
|
331
|
+
return self.bind("<<RowInsert>>", callback, add=True)
|
|
332
|
+
|
|
333
|
+
def off_row_inserted(self, bind_id: str | None = None) -> None:
|
|
334
|
+
"""Unbind from `<<RowInsert>>`."""
|
|
335
|
+
self.unbind("<<RowInsert>>", bind_id)
|
|
336
|
+
|
|
337
|
+
def on_row_updated(self, callback) -> str:
|
|
338
|
+
"""Bind to `<<RowUpdate>>`. Callback receives `event.data = {'records': list[dict]}`."""
|
|
339
|
+
return self.bind("<<RowUpdate>>", callback, add=True)
|
|
340
|
+
|
|
341
|
+
def off_row_updated(self, bind_id: str | None = None) -> None:
|
|
342
|
+
"""Unbind from `<<RowUpdate>>`."""
|
|
343
|
+
self.unbind("<<RowUpdate>>", bind_id)
|
|
344
|
+
|
|
345
|
+
def on_row_moved(self, callback) -> str:
|
|
346
|
+
"""Bind to `<<RowMove>>`. Callback receives `event.data = {'records': list[dict]}`."""
|
|
347
|
+
return self.bind("<<RowMove>>", callback, add=True)
|
|
348
|
+
|
|
349
|
+
def off_row_moved(self, bind_id: str | None = None) -> None:
|
|
350
|
+
"""Unbind from `<<RowMove>>`."""
|
|
351
|
+
self.unbind("<<RowMove>>", bind_id)
|
|
352
|
+
|
|
353
|
+
# ------------------------------------------------------------------ Public data/selection API
|
|
354
|
+
@property
|
|
355
|
+
def selected_rows(self) -> list[dict]:
|
|
356
|
+
"""List of record dicts for the current Treeview selection."""
|
|
357
|
+
rows: list[dict] = []
|
|
358
|
+
for iid in self._tree.selection():
|
|
359
|
+
if iid in self._row_map:
|
|
360
|
+
rows.append(self._row_map[iid])
|
|
361
|
+
return rows
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def visible_rows(self) -> list[dict]:
|
|
365
|
+
"""List of record dicts for rows currently rendered (flat traversal)."""
|
|
366
|
+
rows: list[dict] = []
|
|
367
|
+
queue = list(self._tree.get_children(""))
|
|
368
|
+
while queue:
|
|
369
|
+
iid = queue.pop(0)
|
|
370
|
+
if iid in self._row_map:
|
|
371
|
+
rows.append(self._row_map[iid])
|
|
372
|
+
queue.extend(list(self._tree.get_children(iid)))
|
|
373
|
+
return rows
|
|
374
|
+
|
|
375
|
+
# ------------------------------------------------------------------ Public row/column manipulation
|
|
376
|
+
def insert_rows(self, rows: list) -> None:
|
|
377
|
+
"""Insert new rows via the datasource and refresh."""
|
|
378
|
+
recs = self._to_records(rows)
|
|
379
|
+
inserted: list[dict] = []
|
|
380
|
+
for rec in recs:
|
|
381
|
+
try:
|
|
382
|
+
new_id = self._datasource.create_record(dict(rec))
|
|
383
|
+
rec = dict(rec)
|
|
384
|
+
if new_id is not None:
|
|
385
|
+
rec["id"] = new_id
|
|
386
|
+
inserted.append(rec)
|
|
387
|
+
except Exception:
|
|
388
|
+
logger.exception("Failed to insert record")
|
|
389
|
+
if inserted:
|
|
390
|
+
self._clear_cache()
|
|
391
|
+
self._load_page(self._current_page)
|
|
392
|
+
self.event_generate("<<RowInsert>>", data={"records": inserted})
|
|
393
|
+
|
|
394
|
+
def update_rows(self, rows: list[dict]) -> None:
|
|
395
|
+
"""Update rows by id; each dict must include an 'id' key."""
|
|
396
|
+
updated: list[dict] = []
|
|
397
|
+
for rec in rows:
|
|
398
|
+
rec_id = rec.get("id")
|
|
399
|
+
if rec_id is None:
|
|
400
|
+
continue
|
|
401
|
+
updates = {k: v for k, v in rec.items() if k != "id"}
|
|
402
|
+
try:
|
|
403
|
+
self._datasource.update_record(rec_id, updates)
|
|
404
|
+
updated.append(rec)
|
|
405
|
+
except Exception:
|
|
406
|
+
logger.exception("Failed to update record id=%s", rec_id)
|
|
407
|
+
if updated:
|
|
408
|
+
self._clear_cache()
|
|
409
|
+
self._load_page(self._current_page)
|
|
410
|
+
self.event_generate("<<RowUpdate>>", data={"records": updated})
|
|
411
|
+
|
|
412
|
+
def delete_rows(self, rows_or_ids: list) -> None:
|
|
413
|
+
"""Delete rows by id or row dicts containing an id key."""
|
|
414
|
+
deleted: list[dict] = []
|
|
415
|
+
for item in rows_or_ids:
|
|
416
|
+
rec_id = None
|
|
417
|
+
rec = {}
|
|
418
|
+
if isinstance(item, dict):
|
|
419
|
+
rec = item
|
|
420
|
+
rec_id = item.get("id")
|
|
421
|
+
else:
|
|
422
|
+
rec_id = item
|
|
423
|
+
if rec_id is None:
|
|
424
|
+
continue
|
|
425
|
+
try:
|
|
426
|
+
self._datasource.delete_record(rec_id)
|
|
427
|
+
if not rec:
|
|
428
|
+
rec = {"id": rec_id}
|
|
429
|
+
deleted.append(rec)
|
|
430
|
+
except Exception:
|
|
431
|
+
logger.exception("Failed to delete record id=%s", rec_id)
|
|
432
|
+
if deleted:
|
|
433
|
+
self._clear_cache()
|
|
434
|
+
self._load_page(self._current_page)
|
|
435
|
+
self.event_generate("<<RowDelete>>", data={"records": deleted})
|
|
436
|
+
|
|
437
|
+
def insert_columns(self, *_args, **_kwargs) -> None:
|
|
438
|
+
"""Not currently supported; columns are defined at construction time."""
|
|
439
|
+
raise NotImplementedError("Dynamic column insertion is not supported yet")
|
|
440
|
+
|
|
441
|
+
def delete_columns(self, indices: list[int]) -> None:
|
|
442
|
+
"""Hide columns at the given indices."""
|
|
443
|
+
self.hide_columns(indices)
|
|
444
|
+
|
|
445
|
+
def move_rows(self, iids: list[str], to_index: int) -> None:
|
|
446
|
+
"""Move the given rows to a target index in the root list."""
|
|
447
|
+
children = list(self._tree.get_children(""))
|
|
448
|
+
to_index = max(0, min(len(children), to_index))
|
|
449
|
+
for offset, iid in enumerate(iids):
|
|
450
|
+
try:
|
|
451
|
+
self._tree.move(iid, "", to_index + offset)
|
|
452
|
+
except Exception:
|
|
453
|
+
pass
|
|
454
|
+
self._apply_row_alternation()
|
|
455
|
+
moved_recs = [self._row_map.get(i) for i in iids if i in self._row_map]
|
|
456
|
+
if moved_recs:
|
|
457
|
+
self.event_generate("<<RowMove>>", data={"records": moved_recs})
|
|
458
|
+
|
|
459
|
+
def move_columns(self, from_index: int, to_index: int) -> None:
|
|
460
|
+
"""Reorder a column from one index to another."""
|
|
461
|
+
if from_index < 0 or from_index >= len(self._display_columns):
|
|
462
|
+
return
|
|
463
|
+
to_index = max(0, min(len(self._display_columns) - 1, to_index))
|
|
464
|
+
col_id = self._display_columns.pop(from_index)
|
|
465
|
+
self._display_columns.insert(to_index, col_id)
|
|
466
|
+
self._tree.configure(displaycolumns=self._display_columns)
|
|
467
|
+
|
|
468
|
+
def hide_rows(self, iids: list[str]) -> None:
|
|
469
|
+
"""Hide rows from view (not removed from datasource)."""
|
|
470
|
+
for iid in iids:
|
|
471
|
+
try:
|
|
472
|
+
parent = self._tree.parent(iid)
|
|
473
|
+
children = list(self._tree.get_children(parent))
|
|
474
|
+
idx = children.index(iid)
|
|
475
|
+
self._hidden_rows[iid] = (parent, idx)
|
|
476
|
+
self._tree.detach(iid)
|
|
477
|
+
except Exception:
|
|
478
|
+
pass
|
|
479
|
+
|
|
480
|
+
def unhide_rows(self, iids: list[str] | None = None) -> None:
|
|
481
|
+
"""Restore previously hidden rows."""
|
|
482
|
+
targets = iids or list(self._hidden_rows.keys())
|
|
483
|
+
for iid in targets:
|
|
484
|
+
if iid not in self._hidden_rows:
|
|
485
|
+
continue
|
|
486
|
+
parent, idx = self._hidden_rows.pop(iid)
|
|
487
|
+
try:
|
|
488
|
+
self._tree.move(iid, parent, idx)
|
|
489
|
+
except Exception:
|
|
490
|
+
pass
|
|
491
|
+
self._apply_row_alternation()
|
|
492
|
+
|
|
493
|
+
def hide_columns(self, indices: list[int]) -> None:
|
|
494
|
+
"""Remove columns from the displayed set."""
|
|
495
|
+
for idx in indices:
|
|
496
|
+
if idx in self._display_columns:
|
|
497
|
+
self._display_columns.remove(idx)
|
|
498
|
+
if not self._display_columns and self._heading_texts:
|
|
499
|
+
self._display_columns = list(range(len(self._heading_texts)))
|
|
500
|
+
self._tree.configure(displaycolumns=self._display_columns)
|
|
501
|
+
|
|
502
|
+
def unhide_columns(self, indices: list[int]) -> None:
|
|
503
|
+
"""Add columns back into the displayed set."""
|
|
504
|
+
changed = False
|
|
505
|
+
for idx in indices:
|
|
506
|
+
if idx not in self._display_columns and 0 <= idx < len(self._heading_texts):
|
|
507
|
+
self._display_columns.append(idx)
|
|
508
|
+
changed = True
|
|
509
|
+
if changed:
|
|
510
|
+
self._display_columns = sorted(self._display_columns)
|
|
511
|
+
self._tree.configure(displaycolumns=self._display_columns)
|
|
512
|
+
|
|
513
|
+
def select_rows(self, iids: list[str]) -> None:
|
|
514
|
+
"""Select the given row ids."""
|
|
515
|
+
self._tree.selection_set(iids)
|
|
516
|
+
|
|
517
|
+
def deselect_rows(self, iids: list[str] | None = None) -> None:
|
|
518
|
+
"""Clear selection or remove specific iids from selection."""
|
|
519
|
+
if not iids:
|
|
520
|
+
self._tree.selection_remove(self._tree.selection())
|
|
521
|
+
else:
|
|
522
|
+
self._tree.selection_remove(iids)
|
|
523
|
+
|
|
524
|
+
def scroll_to_row(self, iid: str) -> None:
|
|
525
|
+
"""Ensure the given row is visible."""
|
|
526
|
+
try:
|
|
527
|
+
self._tree.see(iid)
|
|
528
|
+
except Exception:
|
|
529
|
+
pass
|
|
530
|
+
|
|
531
|
+
# ------------------------------------------------------------------ Pagination helpers
|
|
532
|
+
def next_page(self) -> None:
|
|
533
|
+
self._next_page()
|
|
534
|
+
|
|
535
|
+
def previous_page(self) -> None:
|
|
536
|
+
self._prev_page()
|
|
537
|
+
|
|
538
|
+
def first_page(self) -> None:
|
|
539
|
+
self._first_page()
|
|
540
|
+
|
|
541
|
+
def last_page(self) -> None:
|
|
542
|
+
self._last_page()
|
|
543
|
+
|
|
544
|
+
def go_to_page(self, index: int) -> None:
|
|
545
|
+
self._load_page(max(0, index))
|
|
546
|
+
|
|
547
|
+
# ------------------------------------------------------------------ Filter/Sort/Group API
|
|
548
|
+
def get_filters(self) -> str:
|
|
549
|
+
"""Return current SQL where clause string (if any)."""
|
|
550
|
+
try:
|
|
551
|
+
return getattr(self._datasource, "_where", "") or ""
|
|
552
|
+
except Exception:
|
|
553
|
+
return ""
|
|
554
|
+
|
|
555
|
+
def set_filters(self, where: str) -> None:
|
|
556
|
+
try:
|
|
557
|
+
self._datasource.set_filter(where or "")
|
|
558
|
+
except Exception:
|
|
559
|
+
return
|
|
560
|
+
self._clear_cache()
|
|
561
|
+
self._load_page(0)
|
|
562
|
+
self._update_status_labels()
|
|
563
|
+
|
|
564
|
+
def clear_filters(self) -> None:
|
|
565
|
+
self._clear_filter_cmd()
|
|
566
|
+
|
|
567
|
+
def get_sorting(self) -> dict[str, bool]:
|
|
568
|
+
"""Return a copy of the current sort state {column_key: ascending}."""
|
|
569
|
+
return dict(self._sort_state)
|
|
570
|
+
|
|
571
|
+
def set_sorting(self, key: str, ascending: bool = True) -> None:
|
|
572
|
+
quoted_key = self._quote_col(key)
|
|
573
|
+
order = "ASC" if ascending else "DESC"
|
|
574
|
+
try:
|
|
575
|
+
self._datasource.set_sort(f"{quoted_key} {order}")
|
|
576
|
+
except Exception:
|
|
577
|
+
return
|
|
578
|
+
self._sort_state = {key: ascending}
|
|
579
|
+
self._clear_cache()
|
|
580
|
+
self._update_heading_icons()
|
|
581
|
+
self._load_page(0)
|
|
582
|
+
self._update_status_labels()
|
|
583
|
+
|
|
584
|
+
def clear_sorting(self) -> None:
|
|
585
|
+
self._clear_sort()
|
|
586
|
+
|
|
587
|
+
def get_grouping(self) -> str | None:
|
|
588
|
+
return self._group_by_key
|
|
589
|
+
|
|
590
|
+
def set_grouping(self, key: str | None) -> None:
|
|
591
|
+
if not key:
|
|
592
|
+
self._ungroup_all()
|
|
593
|
+
return
|
|
594
|
+
if key not in self._column_keys:
|
|
595
|
+
return
|
|
596
|
+
self._group_by_key = key
|
|
597
|
+
self._group_parents.clear()
|
|
598
|
+
try:
|
|
599
|
+
quoted_key = self._quote_col(key)
|
|
600
|
+
self._datasource.set_sort(f"{quoted_key} ASC")
|
|
601
|
+
except Exception:
|
|
602
|
+
pass
|
|
603
|
+
self._sort_state = {key: True}
|
|
604
|
+
self._clear_cache()
|
|
605
|
+
self._update_heading_icons()
|
|
606
|
+
self._load_page(0)
|
|
607
|
+
self._update_status_labels()
|
|
608
|
+
|
|
609
|
+
def clear_grouping(self) -> None:
|
|
610
|
+
self._ungroup_all()
|
|
611
|
+
|
|
612
|
+
# ------------------------------------------------------------------ Group expand/collapse
|
|
613
|
+
def expand_all(self) -> None:
|
|
614
|
+
for iid in self._tree.get_children(""):
|
|
615
|
+
try:
|
|
616
|
+
self._tree.item(iid, open=True)
|
|
617
|
+
except Exception:
|
|
618
|
+
pass
|
|
619
|
+
|
|
620
|
+
def collapse_all(self) -> None:
|
|
621
|
+
for iid in self._tree.get_children(""):
|
|
622
|
+
try:
|
|
623
|
+
self._tree.item(iid, open=False)
|
|
624
|
+
except Exception:
|
|
625
|
+
pass
|
|
626
|
+
|
|
627
|
+
def expand_group(self, group_value) -> None:
|
|
628
|
+
parent = self._group_parents.get(group_value)
|
|
629
|
+
if parent:
|
|
630
|
+
try:
|
|
631
|
+
self._tree.item(parent, open=True)
|
|
632
|
+
except Exception:
|
|
633
|
+
pass
|
|
634
|
+
|
|
635
|
+
def collapse_group(self, group_value) -> None:
|
|
636
|
+
parent = self._group_parents.get(group_value)
|
|
637
|
+
if parent:
|
|
638
|
+
try:
|
|
639
|
+
self._tree.item(parent, open=False)
|
|
640
|
+
except Exception:
|
|
641
|
+
pass
|
|
642
|
+
|
|
643
|
+
def select_all(self) -> None:
|
|
644
|
+
"""Select all visible rows."""
|
|
645
|
+
self._tree.selection_set(self._tree.get_children(""))
|
|
646
|
+
|
|
647
|
+
def deselect_all(self) -> None:
|
|
648
|
+
"""Clear the selection."""
|
|
649
|
+
self._tree.selection_remove(self._tree.selection())
|
|
650
|
+
|
|
651
|
+
# ------------------------------------------------------------------ UI
|
|
652
|
+
|
|
653
|
+
def _resolve_alternating_row_color(self):
|
|
654
|
+
style = get_style()
|
|
655
|
+
color_token = self._row_alternation.get('accent', 'background[+1]')
|
|
656
|
+
|
|
657
|
+
try:
|
|
658
|
+
background = style.style_builder.color(color_token)
|
|
659
|
+
except Exception:
|
|
660
|
+
background = style.style_builder.color('background')
|
|
661
|
+
|
|
662
|
+
try:
|
|
663
|
+
foreground = style.style_builder.on_color(background)
|
|
664
|
+
except Exception:
|
|
665
|
+
foreground = style.style_builder.color('foreground')
|
|
666
|
+
return background, foreground
|
|
667
|
+
|
|
668
|
+
def _resolve_column_keys(self) -> None:
|
|
669
|
+
if not self._column_defs:
|
|
670
|
+
return
|
|
671
|
+
for idx, col in enumerate(self._column_defs):
|
|
672
|
+
if isinstance(col, str):
|
|
673
|
+
self._column_keys.append(col)
|
|
674
|
+
elif isinstance(col, dict):
|
|
675
|
+
self._column_keys.append(col.get("key") or col.get("text") or str(idx))
|
|
676
|
+
else:
|
|
677
|
+
self._column_keys.append(str(col))
|
|
678
|
+
|
|
679
|
+
def _ensure_column_metadata(self, sample_records: list[dict] | None) -> None:
|
|
680
|
+
"""Guarantee we have column keys/defs before the Treeview is built."""
|
|
681
|
+
if self._column_keys:
|
|
682
|
+
return
|
|
683
|
+
|
|
684
|
+
inferred: list[str] = []
|
|
685
|
+
if sample_records:
|
|
686
|
+
first = sample_records[0]
|
|
687
|
+
if isinstance(first, dict):
|
|
688
|
+
inferred = list(first.keys())
|
|
689
|
+
if not inferred:
|
|
690
|
+
inferred = getattr(self._datasource, "_columns", []) or []
|
|
691
|
+
|
|
692
|
+
inferred = [c for c in inferred if c not in ("id", "selected")]
|
|
693
|
+
if not inferred:
|
|
694
|
+
inferred = ["value"]
|
|
695
|
+
|
|
696
|
+
self._column_keys = inferred
|
|
697
|
+
if not self._column_defs:
|
|
698
|
+
self._column_defs = [{"text": c} for c in self._column_keys]
|
|
699
|
+
|
|
700
|
+
def _build_toolbar(self) -> None:
|
|
701
|
+
bar = Frame(self, name="toolbar")
|
|
702
|
+
# Grid in column 0 only so the toolbar's right edge stops at the
|
|
703
|
+
# tree's right edge instead of extending past the vsb.
|
|
704
|
+
bar.grid(row=0, column=0, sticky="ew", pady=(0, 4))
|
|
705
|
+
|
|
706
|
+
if self._searchbar['enabled']:
|
|
707
|
+
self._search_entry = TextEntry(bar)
|
|
708
|
+
self._search_entry.insert_addon(Label, 'before', icon="search", icon_only=True)
|
|
709
|
+
self._search_entry.insert_addon(Button, 'after', icon="x-lg", icon_only=True, command=self._clear_search)
|
|
710
|
+
# Only reserve a 6 px right gap when the advanced-mode SelectBox
|
|
711
|
+
# follows the entry; otherwise the entry hugs the toolbar edge.
|
|
712
|
+
search_padx = (0, 6) if self._searchbar['mode'] == 'advanced' else 0
|
|
713
|
+
self._search_entry.pack(side="left", fill="x", expand=True, padx=search_padx)
|
|
714
|
+
trigger = str(self._searchbar.get('event', 'enter')).lower()
|
|
715
|
+
if trigger == 'input':
|
|
716
|
+
self._search_entry.on_input(lambda _e: self._run_search())
|
|
717
|
+
else:
|
|
718
|
+
self._search_entry.on_enter(lambda _e: self._run_search())
|
|
719
|
+
# Clear filter when the box is emptied, but do not search on every keystroke
|
|
720
|
+
self._search_entry.on_input(lambda _e: self._clear_search() if not self._search_entry.get() else None)
|
|
721
|
+
|
|
722
|
+
if self._searchbar['mode'] == 'advanced':
|
|
723
|
+
search_items = []
|
|
724
|
+
self._search_mode_map = {}
|
|
725
|
+
for token, code in _TABLE_SEARCH_MODE_OPTIONS:
|
|
726
|
+
label = MessageCatalog.translate(token)
|
|
727
|
+
search_items.append(label)
|
|
728
|
+
self._search_mode_map[label] = code
|
|
729
|
+
default_value = search_items[0] if search_items else "EQUALS"
|
|
730
|
+
self._search_mode = SelectBox(
|
|
731
|
+
bar,
|
|
732
|
+
items=search_items,
|
|
733
|
+
value=default_value,
|
|
734
|
+
width=14,
|
|
735
|
+
allow_custom_values=False,
|
|
736
|
+
enable_search=False,
|
|
737
|
+
)
|
|
738
|
+
self._search_mode.pack(side="left", padx=(0, 6))
|
|
739
|
+
|
|
740
|
+
if self._show_column_chooser:
|
|
741
|
+
self._column_chooser_btn = Button(
|
|
742
|
+
bar,
|
|
743
|
+
icon="layout-three-columns",
|
|
744
|
+
icon_only=True,
|
|
745
|
+
accent="foreground",
|
|
746
|
+
variant="ghost",
|
|
747
|
+
command=self._show_column_chooser_dialog,
|
|
748
|
+
)
|
|
749
|
+
self._column_chooser_btn.pack(side="right", padx=(4, 0))
|
|
750
|
+
|
|
751
|
+
if self._exporting['enabled']:
|
|
752
|
+
export_items = []
|
|
753
|
+
if self._exporting['export_scope'] == 'all':
|
|
754
|
+
export_items.append({"type": "command", "text": "table.export_all", "command": self._export_all})
|
|
755
|
+
if self._exporting["allow_export_selection"]:
|
|
756
|
+
export_items.append({"type": "command", "text": "table.export_selection", "command": self._export_selection})
|
|
757
|
+
if self._exporting['export_scope'] == "page":
|
|
758
|
+
export_items.append({"type": "command", "text": "table.export_page", "command": self._export_page})
|
|
759
|
+
if not export_items:
|
|
760
|
+
export_items.append({"type": "command", "text": "table.export_all", "command": self._export_all})
|
|
761
|
+
DropdownButton(
|
|
762
|
+
bar,
|
|
763
|
+
icon="download",
|
|
764
|
+
icon_only=True,
|
|
765
|
+
accent="foreground",
|
|
766
|
+
variant="ghost",
|
|
767
|
+
compound="image",
|
|
768
|
+
items=export_items,
|
|
769
|
+
show_dropdown_button=False,
|
|
770
|
+
).pack(side="right")
|
|
771
|
+
|
|
772
|
+
if self._editing['adding']:
|
|
773
|
+
Button(
|
|
774
|
+
bar,
|
|
775
|
+
icon="plus-lg",
|
|
776
|
+
text="table.add_record",
|
|
777
|
+
accent="foreground",
|
|
778
|
+
variant="ghost",
|
|
779
|
+
command=self._open_new_record,
|
|
780
|
+
).pack(side="right", padx=(0, 4))
|
|
781
|
+
|
|
782
|
+
def _build_tree(self) -> None:
|
|
783
|
+
cols = [self._col_text(c) for c in self._column_defs] or self._column_keys
|
|
784
|
+
|
|
785
|
+
# Grid layout for the TableView body:
|
|
786
|
+
# row 0: toolbar (col 0)
|
|
787
|
+
# row 1: tree (col 0) | vsb (col 1, only this row)
|
|
788
|
+
# row 2: hsb (col 0)
|
|
789
|
+
# row 3: footer (col 0)
|
|
790
|
+
# Column 0 expands; column 1 takes the vsb's natural width when present.
|
|
791
|
+
self.grid_columnconfigure(0, weight=1)
|
|
792
|
+
self.grid_rowconfigure(1, weight=1)
|
|
793
|
+
|
|
794
|
+
self._tree = TreeView(
|
|
795
|
+
self,
|
|
796
|
+
columns=list(range(len(cols))),
|
|
797
|
+
selectmode=_parse_selection_mode(self._selection['mode']),
|
|
798
|
+
show="headings"
|
|
799
|
+
)
|
|
800
|
+
# Inset the tree by the focus-ring affordance baked into sibling
|
|
801
|
+
# entry images so the tree's content edge lines up with the visible
|
|
802
|
+
# edge of the toolbar/footer entries (search box, pagination input).
|
|
803
|
+
from bootstack.style.bootstyle_builder_base import BootstyleBuilderBase
|
|
804
|
+
affordance = BootstyleBuilderBase.scale_from_source(8)
|
|
805
|
+
self._tree.grid(row=1, column=0, sticky="nsew", padx=affordance)
|
|
806
|
+
self._display_columns = list(range(len(cols)))
|
|
807
|
+
|
|
808
|
+
if self._paging['yscroll']:
|
|
809
|
+
self._vsb = Scrollbar(self, orient="vertical", command=self._tree.yview)
|
|
810
|
+
self._vsb.grid(row=1, column=1, sticky="ns")
|
|
811
|
+
if self._paging['mode'] == "virtual":
|
|
812
|
+
self._tree.configure(yscrollcommand=self._on_scroll)
|
|
813
|
+
else:
|
|
814
|
+
self._tree.configure(yscrollcommand=self._vsb.set)
|
|
815
|
+
else:
|
|
816
|
+
self._vsb = None
|
|
817
|
+
|
|
818
|
+
if self._paging['xscroll']:
|
|
819
|
+
self._hsb = Scrollbar(self, orient="horizontal", command=self._tree.xview)
|
|
820
|
+
# Mirror the tree's affordance inset so the hsb aligns with the
|
|
821
|
+
# tree content and stops at the same right edge.
|
|
822
|
+
self._hsb.grid(row=2, column=0, sticky="ew", padx=affordance)
|
|
823
|
+
self._tree.configure(xscrollcommand=self._hsb.set)
|
|
824
|
+
else:
|
|
825
|
+
self._hsb = None
|
|
826
|
+
|
|
827
|
+
self._heading_texts = []
|
|
828
|
+
self._column_anchors = []
|
|
829
|
+
stretch_columns = not self._paging['xscroll'] # allow natural width when xscroll is enabled
|
|
830
|
+
for idx, text in enumerate(cols):
|
|
831
|
+
self._heading_texts.append(text)
|
|
832
|
+
anchor = self._determine_anchor(idx)
|
|
833
|
+
self._column_anchors.append(anchor)
|
|
834
|
+
heading_kwargs = {"text": text, "anchor": anchor}
|
|
835
|
+
# Don't use heading command - we'll handle clicks via Button-1 binding
|
|
836
|
+
self._tree.heading(idx, **heading_kwargs)
|
|
837
|
+
# Apply per-column width overrides, fall back to global defaults
|
|
838
|
+
width = 120
|
|
839
|
+
minwidth = self._column_min_width
|
|
840
|
+
if idx < len(self._column_defs):
|
|
841
|
+
coldef = self._column_defs[idx]
|
|
842
|
+
if isinstance(coldef, dict):
|
|
843
|
+
width = coldef.get("width", width)
|
|
844
|
+
minwidth = coldef.get("minwidth", coldef.get("min_width", minwidth))
|
|
845
|
+
self._tree.column(idx, anchor=anchor, width=width, minwidth=minwidth, stretch=stretch_columns)
|
|
846
|
+
self._update_heading_icons()
|
|
847
|
+
self._tree.bind("<Button-1>", self._on_header_click)
|
|
848
|
+
self._tree.bind("<<TreeviewSelect>>", self._on_selection_event)
|
|
849
|
+
self._tree.bind("<ButtonRelease-1>", self._on_row_click_event)
|
|
850
|
+
if self._context_menus != "none":
|
|
851
|
+
bind_right_click(self._tree, self._on_tree_context)
|
|
852
|
+
if self._editing['updating']:
|
|
853
|
+
self._tree.bind("<Double-1>", self._on_row_double_click)
|
|
854
|
+
# Track resize events to rebalance grouped layouts
|
|
855
|
+
self._tree.bind("<Configure>", self._on_tree_configure)
|
|
856
|
+
|
|
857
|
+
def _build_footer(self) -> None:
|
|
858
|
+
bar = Frame(self)
|
|
859
|
+
# Same column 0 as the toolbar so the footer aligns with the table
|
|
860
|
+
# content and stops at the vsb edge.
|
|
861
|
+
bar.grid(row=3, column=0, sticky="ew", pady=(4, 0))
|
|
862
|
+
status_frame = Frame(bar)
|
|
863
|
+
status_frame.pack(side="left", fill="x", expand=True)
|
|
864
|
+
self._filter_label = Label(status_frame, text="", anchor="w", accent="secondary")
|
|
865
|
+
self._filter_label.pack(side="left", padx=(0, 4))
|
|
866
|
+
self._sort_label = Label(status_frame, text="", anchor="w", accent="secondary")
|
|
867
|
+
self._sort_label.pack(side="left", padx=(8, 4))
|
|
868
|
+
|
|
869
|
+
if not self._show_table_status:
|
|
870
|
+
status_frame.pack_forget()
|
|
871
|
+
Frame(bar).pack(side='left', fill='x', expand=True) # spacer
|
|
872
|
+
info_frame = Frame(bar)
|
|
873
|
+
info_frame.pack(side='left')
|
|
874
|
+
Label(info_frame, text="table.page").pack(side='left')
|
|
875
|
+
self._page_entry = Entry(info_frame, width=6, justify="center")
|
|
876
|
+
self._page_entry.bind("<Return>", self._jump_page)
|
|
877
|
+
self._page_entry.pack(side="left", padx=8)
|
|
878
|
+
self._page_label = Label(info_frame, text="")
|
|
879
|
+
self._page_label.pack(side="left", padx=(0, 8))
|
|
880
|
+
|
|
881
|
+
sep = Separator(bar, orient="vertical")
|
|
882
|
+
sep.pack(side="left", fill="y", padx=8)
|
|
883
|
+
|
|
884
|
+
btn_frame = Frame(bar)
|
|
885
|
+
btn_frame.pack(side="right")
|
|
886
|
+
Button(btn_frame, icon="chevron-double-left", accent="foreground", variant="ghost", icon_only=True, command=self._first_page).pack(
|
|
887
|
+
side="left")
|
|
888
|
+
Button(btn_frame, icon="chevron-left", icon_only=True, accent="foreground", variant="ghost", command=self._prev_page).pack(
|
|
889
|
+
side="left")
|
|
890
|
+
Button(btn_frame, icon="chevron-right", icon_only=True, accent="foreground", variant="ghost", command=self._next_page).pack(
|
|
891
|
+
side="left")
|
|
892
|
+
Button(btn_frame, icon="chevron-double-right", icon_only=True, accent="foreground", variant="ghost", command=self._last_page).pack(
|
|
893
|
+
side="left")
|
|
894
|
+
|
|
895
|
+
# ------------------------------------------------------------------ Helpers
|
|
896
|
+
def _col_text(self, col) -> str:
|
|
897
|
+
if isinstance(col, str):
|
|
898
|
+
return col
|
|
899
|
+
if isinstance(col, dict):
|
|
900
|
+
return col.get("text") or col.get("key") or ""
|
|
901
|
+
return str(col)
|
|
902
|
+
|
|
903
|
+
def _header_context_enabled(self) -> bool:
|
|
904
|
+
return self._context_menus in ("all", "headers")
|
|
905
|
+
|
|
906
|
+
def _row_context_enabled(self) -> bool:
|
|
907
|
+
return self._context_menus in ("all", "rows")
|
|
908
|
+
|
|
909
|
+
def _quote_col(self, key: str) -> str:
|
|
910
|
+
"""Quote column identifiers for safe SQL usage (handles reserved names)."""
|
|
911
|
+
try:
|
|
912
|
+
quote_fn = getattr(self._datasource, "_quote_identifier", None)
|
|
913
|
+
if callable(quote_fn):
|
|
914
|
+
return quote_fn(key)
|
|
915
|
+
except Exception:
|
|
916
|
+
pass
|
|
917
|
+
text = str(key).replace('"', '""')
|
|
918
|
+
return f'"{text}"'
|
|
919
|
+
|
|
920
|
+
def _determine_anchor(self, idx: int) -> str:
|
|
921
|
+
"""Pick an anchor for the given column index.
|
|
922
|
+
|
|
923
|
+
Priority:
|
|
924
|
+
1) Explicit anchor/align in column definition
|
|
925
|
+
2) Explicit dtype/type hint in column definition (numeric -> right)
|
|
926
|
+
3) Numeric columns -> right
|
|
927
|
+
4) Default -> left
|
|
928
|
+
"""
|
|
929
|
+
if idx < len(self._column_defs):
|
|
930
|
+
coldef = self._column_defs[idx]
|
|
931
|
+
if isinstance(coldef, dict):
|
|
932
|
+
anchor = coldef.get("anchor") or coldef.get("align")
|
|
933
|
+
if anchor:
|
|
934
|
+
return anchor
|
|
935
|
+
# Allow a dtype/type hint on the column definition
|
|
936
|
+
dtype = coldef.get("dtype") or coldef.get("type")
|
|
937
|
+
if dtype:
|
|
938
|
+
dtype_upper = str(dtype).upper()
|
|
939
|
+
if any(t in dtype_upper for t in ("INT", "REAL", "NUM", "DECIMAL", "DOUBLE", "FLOAT")):
|
|
940
|
+
return "e"
|
|
941
|
+
if "TEXT" in dtype_upper or "STR" in dtype_upper or "CHAR" in dtype_upper:
|
|
942
|
+
return "w"
|
|
943
|
+
# Infer from type
|
|
944
|
+
key = self._column_keys[idx] if idx < len(self._column_keys) else None
|
|
945
|
+
ctype = self._get_column_type(key) if key else ""
|
|
946
|
+
if ctype and any(t in ctype.upper() for t in ("INT", "REAL", "NUM", "DECIMAL", "DOUBLE", "FLOAT")):
|
|
947
|
+
return "e"
|
|
948
|
+
# Fallback: sample values to detect numeric strings
|
|
949
|
+
if self._is_numeric_sample(idx):
|
|
950
|
+
return "e"
|
|
951
|
+
return "w"
|
|
952
|
+
|
|
953
|
+
def _get_column_type(self, key: str | None) -> str:
|
|
954
|
+
if not key:
|
|
955
|
+
return ""
|
|
956
|
+
if key in self._column_types:
|
|
957
|
+
return self._column_types[key]
|
|
958
|
+
# Try PRAGMA table_info
|
|
959
|
+
try:
|
|
960
|
+
cur = self._datasource.conn.execute(f"PRAGMA table_info({self._datasource._table})")
|
|
961
|
+
for cid, name, ctype, *_rest in cur.fetchall():
|
|
962
|
+
if name == key:
|
|
963
|
+
self._column_types[key] = ctype or ""
|
|
964
|
+
return self._column_types[key]
|
|
965
|
+
except Exception:
|
|
966
|
+
pass
|
|
967
|
+
return ""
|
|
968
|
+
|
|
969
|
+
def _load_alignment_sample(self) -> list[dict]:
|
|
970
|
+
if self._alignment_sample is not None:
|
|
971
|
+
return self._alignment_sample
|
|
972
|
+
try:
|
|
973
|
+
sample = self._datasource.get_page(0)
|
|
974
|
+
except Exception:
|
|
975
|
+
sample = []
|
|
976
|
+
self._alignment_sample = sample or []
|
|
977
|
+
return self._alignment_sample
|
|
978
|
+
|
|
979
|
+
def _is_numeric_sample(self, idx: int) -> bool:
|
|
980
|
+
"""Check sample values to decide if a column with text storage is numeric-like."""
|
|
981
|
+
key = self._column_keys[idx] if idx < len(self._column_keys) else None
|
|
982
|
+
if not key:
|
|
983
|
+
return False
|
|
984
|
+
sample = self._load_alignment_sample()
|
|
985
|
+
if not sample:
|
|
986
|
+
return False
|
|
987
|
+
|
|
988
|
+
def is_num(val) -> bool:
|
|
989
|
+
if val is None or val == "":
|
|
990
|
+
return True
|
|
991
|
+
try:
|
|
992
|
+
float(val)
|
|
993
|
+
return True
|
|
994
|
+
except Exception:
|
|
995
|
+
return False
|
|
996
|
+
|
|
997
|
+
seen = 0
|
|
998
|
+
for rec in sample[: min(20, len(sample))]:
|
|
999
|
+
if key not in rec:
|
|
1000
|
+
continue
|
|
1001
|
+
seen += 1
|
|
1002
|
+
if not is_num(rec.get(key)):
|
|
1003
|
+
return False
|
|
1004
|
+
return seen > 0
|
|
1005
|
+
|
|
1006
|
+
def _to_records(self, rows: list) -> list[dict]:
|
|
1007
|
+
records: list[dict] = []
|
|
1008
|
+
if not rows:
|
|
1009
|
+
return records
|
|
1010
|
+
keys = self._column_keys or [str(i) for i in range(len(rows[0]))]
|
|
1011
|
+
for rec in rows:
|
|
1012
|
+
if isinstance(rec, dict):
|
|
1013
|
+
records.append(rec)
|
|
1014
|
+
else:
|
|
1015
|
+
records.append({k: rec[i] if i < len(rec) else "" for i, k in enumerate(keys)})
|
|
1016
|
+
return records
|
|
1017
|
+
|
|
1018
|
+
def _refresh_tree(self, records: list[dict]) -> None:
|
|
1019
|
+
self._tree.delete(*self._tree.get_children())
|
|
1020
|
+
self._row_map.clear()
|
|
1021
|
+
if not self._column_keys and records:
|
|
1022
|
+
self._column_keys = list(records[0].keys())
|
|
1023
|
+
grouped = bool(self._group_by_key) and self._group_by_key in self._column_keys
|
|
1024
|
+
self._apply_group_show_state(grouped)
|
|
1025
|
+
if grouped:
|
|
1026
|
+
self._render_grouped(records)
|
|
1027
|
+
else:
|
|
1028
|
+
self._render_flat(records)
|
|
1029
|
+
self._apply_row_alternation()
|
|
1030
|
+
|
|
1031
|
+
def _append_tree(self, records: list[dict]) -> None:
|
|
1032
|
+
# Grouped mode rebuilds the view instead of appending to keep hierarchy consistent
|
|
1033
|
+
if self._group_by_key:
|
|
1034
|
+
self._refresh_tree(records)
|
|
1035
|
+
return
|
|
1036
|
+
stripe = self._row_alternation.get('enabled', False) and not self._group_by_key
|
|
1037
|
+
start_idx = len(self._tree.get_children(""))
|
|
1038
|
+
for offset, rec in enumerate(records):
|
|
1039
|
+
values = [rec.get(k, "") for k in self._column_keys]
|
|
1040
|
+
tags = ("altrow",) if stripe and (start_idx + offset) % 2 == 1 else ()
|
|
1041
|
+
iid = self._tree.insert("", "end", values=values, tags=tags)
|
|
1042
|
+
self._row_map[iid] = rec
|
|
1043
|
+
self._apply_row_alternation()
|
|
1044
|
+
|
|
1045
|
+
def _total_pages(self) -> int:
|
|
1046
|
+
try:
|
|
1047
|
+
# Use cached count to avoid expensive COUNT(*) queries on every navigation
|
|
1048
|
+
if self._cached_total_count is None:
|
|
1049
|
+
self._cached_total_count = self._datasource.total_count()
|
|
1050
|
+
total = self._cached_total_count
|
|
1051
|
+
size = getattr(self._datasource, "page_size", self._paging['page_size']) or 1
|
|
1052
|
+
return max(1, (total + size - 1) // size)
|
|
1053
|
+
except Exception:
|
|
1054
|
+
return 1
|
|
1055
|
+
|
|
1056
|
+
# ------------------------------------------------------------------ Paging
|
|
1057
|
+
def _load_page(self, page: int, append: bool = False) -> None:
|
|
1058
|
+
if not append and page in self._page_cache:
|
|
1059
|
+
records = self._page_cache[page]
|
|
1060
|
+
else:
|
|
1061
|
+
try:
|
|
1062
|
+
records = self._datasource.get_page(page)
|
|
1063
|
+
except Exception:
|
|
1064
|
+
records = []
|
|
1065
|
+
if not append:
|
|
1066
|
+
self._remember_page(page, records)
|
|
1067
|
+
self._current_page = max(0, page)
|
|
1068
|
+
try:
|
|
1069
|
+
if append:
|
|
1070
|
+
self._append_tree(records)
|
|
1071
|
+
else:
|
|
1072
|
+
self._refresh_tree(records)
|
|
1073
|
+
if self._column_auto_width:
|
|
1074
|
+
self._auto_size_columns(records if not append else None)
|
|
1075
|
+
self._update_page_label()
|
|
1076
|
+
finally:
|
|
1077
|
+
self._loading_next = False
|
|
1078
|
+
|
|
1079
|
+
def _update_page_label(self) -> None:
|
|
1080
|
+
if hasattr(self, "_page_entry"):
|
|
1081
|
+
self._page_entry.delete(0, 'end')
|
|
1082
|
+
self._page_entry.insert(0, str(self._current_page + 1))
|
|
1083
|
+
if hasattr(self, "_page_label"):
|
|
1084
|
+
of_text = MessageCatalog.translate("table.of")
|
|
1085
|
+
self._page_label.configure(text=f"{of_text} {self._total_pages()}")
|
|
1086
|
+
if self._show_table_status:
|
|
1087
|
+
self._update_status_labels()
|
|
1088
|
+
|
|
1089
|
+
def _first_page(self) -> None:
|
|
1090
|
+
self._load_page(0)
|
|
1091
|
+
|
|
1092
|
+
def _prev_page(self) -> None:
|
|
1093
|
+
self._load_page(max(0, self._current_page - 1))
|
|
1094
|
+
|
|
1095
|
+
def _next_page(self) -> None:
|
|
1096
|
+
self._load_page(min(self._total_pages() - 1, self._current_page + 1))
|
|
1097
|
+
|
|
1098
|
+
def _last_page(self) -> None:
|
|
1099
|
+
self._load_page(self._total_pages() - 1)
|
|
1100
|
+
|
|
1101
|
+
def _jump_page(self, _event=None) -> None:
|
|
1102
|
+
try:
|
|
1103
|
+
target = int(self._page_entry.get()) - 1
|
|
1104
|
+
except Exception:
|
|
1105
|
+
return
|
|
1106
|
+
target = max(0, min(self._total_pages() - 1, target))
|
|
1107
|
+
self._load_page(target)
|
|
1108
|
+
|
|
1109
|
+
def _on_scroll(self, first: float, last: float) -> None:
|
|
1110
|
+
"""Drive scrollbar and trigger lazy loading when near the bottom."""
|
|
1111
|
+
# Grouped mode disables virtual scroll append to avoid breaking hierarchy
|
|
1112
|
+
if self._group_by_key:
|
|
1113
|
+
self._vsb.set(first, last)
|
|
1114
|
+
return
|
|
1115
|
+
try:
|
|
1116
|
+
first_f = float(first)
|
|
1117
|
+
last_f = float(last)
|
|
1118
|
+
except Exception:
|
|
1119
|
+
self._vsb.set(first, last)
|
|
1120
|
+
return
|
|
1121
|
+
|
|
1122
|
+
self._vsb.set(first_f, last_f)
|
|
1123
|
+
if (
|
|
1124
|
+
self._paging['mode'] == "virtual"
|
|
1125
|
+
and last_f >= 0.85 # prefetch a bit earlier for smoother scrolling
|
|
1126
|
+
and not self._loading_next
|
|
1127
|
+
and hasattr(self._datasource, "has_next_page")
|
|
1128
|
+
and self._datasource.has_next_page()
|
|
1129
|
+
):
|
|
1130
|
+
# Load next page and keep appending rows
|
|
1131
|
+
self._loading_next = True
|
|
1132
|
+
self._load_page(self._current_page + 1, append=True)
|
|
1133
|
+
|
|
1134
|
+
# ------------------------------------------------------------------ Search & sort
|
|
1135
|
+
def _run_search(self) -> None:
|
|
1136
|
+
text = self._search_entry.get()
|
|
1137
|
+
if hasattr(self, "_search_mode") and self._search_mode_map:
|
|
1138
|
+
display_mode = self._search_mode.get()
|
|
1139
|
+
mode = self._search_mode_map.get(display_mode, "CONTAINS")
|
|
1140
|
+
else:
|
|
1141
|
+
mode = "CONTAINS"
|
|
1142
|
+
colnames = self._column_keys
|
|
1143
|
+
quoted_cols = [self._quote_col(c) for c in colnames]
|
|
1144
|
+
where = ""
|
|
1145
|
+
mode_upper = mode.upper().replace(" ", "_")
|
|
1146
|
+
if text and quoted_cols:
|
|
1147
|
+
crit = text.replace("'", "''")
|
|
1148
|
+
if mode_upper == "CONTAINS":
|
|
1149
|
+
where = " OR ".join([f"{c} LIKE '%{crit}%'" for c in quoted_cols])
|
|
1150
|
+
elif mode_upper == "STARTS_WITH":
|
|
1151
|
+
where = " OR ".join([f"{c} LIKE '{crit}%'" for c in quoted_cols])
|
|
1152
|
+
elif mode_upper == "ENDS_WITH":
|
|
1153
|
+
where = " OR ".join([f"{c} LIKE '%{crit}'" for c in quoted_cols])
|
|
1154
|
+
elif mode_upper == "SQL":
|
|
1155
|
+
where = text
|
|
1156
|
+
else: # equals
|
|
1157
|
+
where = " OR ".join([f"{c} = '{crit}'" for c in quoted_cols])
|
|
1158
|
+
# In SQL mode the user typed the expression themselves, so showing it
|
|
1159
|
+
# back is meaningful. For all other modes, show just the search term.
|
|
1160
|
+
if mode_upper == "SQL":
|
|
1161
|
+
self._filter_summary = text
|
|
1162
|
+
else:
|
|
1163
|
+
self._filter_summary = repr(text) if text else ""
|
|
1164
|
+
try:
|
|
1165
|
+
self._datasource.set_filter(where)
|
|
1166
|
+
except Exception:
|
|
1167
|
+
logger.exception("Failed to apply search filter: %s", where)
|
|
1168
|
+
self._clear_cache()
|
|
1169
|
+
self._load_page(0)
|
|
1170
|
+
self._update_status_labels()
|
|
1171
|
+
|
|
1172
|
+
def _clear_search(self) -> None:
|
|
1173
|
+
self._search_entry.delete(0, 'end')
|
|
1174
|
+
self._filter_summary = ""
|
|
1175
|
+
try:
|
|
1176
|
+
self._datasource.set_filter("")
|
|
1177
|
+
except Exception:
|
|
1178
|
+
pass
|
|
1179
|
+
self._clear_cache()
|
|
1180
|
+
self._load_page(0)
|
|
1181
|
+
self._update_status_labels()
|
|
1182
|
+
|
|
1183
|
+
def _on_sort(self, column_index: int) -> None:
|
|
1184
|
+
if column_index >= len(self._column_keys):
|
|
1185
|
+
return
|
|
1186
|
+
key = self._column_keys[column_index]
|
|
1187
|
+
quoted_key = self._quote_col(key)
|
|
1188
|
+
asc = not self._sort_state.get(key, True)
|
|
1189
|
+
# Clear other sort states to keep single-column sort
|
|
1190
|
+
self._sort_state = {key: asc}
|
|
1191
|
+
order = "ASC" if asc else "DESC"
|
|
1192
|
+
try:
|
|
1193
|
+
self._datasource.set_sort(f"{quoted_key} {order}")
|
|
1194
|
+
except Exception:
|
|
1195
|
+
pass
|
|
1196
|
+
self._clear_cache()
|
|
1197
|
+
self._update_heading_icons()
|
|
1198
|
+
self._load_page(0)
|
|
1199
|
+
self._update_status_labels()
|
|
1200
|
+
|
|
1201
|
+
def _update_status_labels(self) -> None:
|
|
1202
|
+
# Filter — prefer the user-friendly summary set by the search bar.
|
|
1203
|
+
# Fall back to the raw WHERE clause only when an external caller set
|
|
1204
|
+
# the filter (so we have no friendlier description).
|
|
1205
|
+
filter_txt = ""
|
|
1206
|
+
try:
|
|
1207
|
+
description = self._filter_summary
|
|
1208
|
+
if not description:
|
|
1209
|
+
description = getattr(self._datasource, "_where", "") or ""
|
|
1210
|
+
if description:
|
|
1211
|
+
filter_txt = MessageCatalog.translate("table.filter_status", description)
|
|
1212
|
+
except Exception:
|
|
1213
|
+
pass
|
|
1214
|
+
# Sort
|
|
1215
|
+
sort_txt = ""
|
|
1216
|
+
try:
|
|
1217
|
+
order = getattr(self._datasource, "_order_by", "")
|
|
1218
|
+
if order:
|
|
1219
|
+
sort_txt = MessageCatalog.translate("table.sort_status", order)
|
|
1220
|
+
except Exception:
|
|
1221
|
+
pass
|
|
1222
|
+
group_txt = ""
|
|
1223
|
+
if self._group_by_key:
|
|
1224
|
+
try:
|
|
1225
|
+
col_idx = self._column_keys.index(self._group_by_key)
|
|
1226
|
+
heading_text = self._heading_texts[col_idx] if col_idx < len(
|
|
1227
|
+
self._heading_texts) else self._group_by_key
|
|
1228
|
+
except Exception:
|
|
1229
|
+
heading_text = self._group_by_key
|
|
1230
|
+
group_txt = MessageCatalog.translate("table.group_status", heading_text)
|
|
1231
|
+
|
|
1232
|
+
if hasattr(self, "_filter_label"):
|
|
1233
|
+
self._filter_label.configure(text=filter_txt)
|
|
1234
|
+
if hasattr(self, "_sort_label"):
|
|
1235
|
+
joined = " | ".join([t for t in (sort_txt, group_txt) if t])
|
|
1236
|
+
self._sort_label.configure(text=joined)
|
|
1237
|
+
|
|
1238
|
+
# ------------------------------------------------------------------ Row context menu
|
|
1239
|
+
def _ensure_row_menu(self) -> None:
|
|
1240
|
+
if not self._row_context_enabled():
|
|
1241
|
+
return
|
|
1242
|
+
if self._row_menu:
|
|
1243
|
+
return
|
|
1244
|
+
# Activation is wired upstream by bind_right_click on the tree so the
|
|
1245
|
+
# row vs header dispatch can run before the (lazily built) menu
|
|
1246
|
+
# decides which one to show.
|
|
1247
|
+
menu = ContextMenu(master=self, target=self._tree, attach='sw', trigger=None)
|
|
1248
|
+
if not self._sorting == 'none':
|
|
1249
|
+
menu.add_command(text="table.sort_asc", command=lambda: self._sort_selection(True))
|
|
1250
|
+
menu.add_command(text="table.sort_desc", command=lambda: self._sort_selection(False))
|
|
1251
|
+
|
|
1252
|
+
if self._filtering['row_menu_filtering']:
|
|
1253
|
+
menu.add_separator()
|
|
1254
|
+
menu.add_command(text="table.filter_by_value", command=self._filter_by_value)
|
|
1255
|
+
menu.add_command(text="table.hide_select", command=self._hide_selection)
|
|
1256
|
+
menu.add_command(text="table.clear_filters", command=self._clear_filter_cmd)
|
|
1257
|
+
|
|
1258
|
+
menu.add_separator()
|
|
1259
|
+
menu.add_command(text="table.move_up", command=self._move_row_up)
|
|
1260
|
+
menu.add_command(text="table.move_down", command=self._move_row_down)
|
|
1261
|
+
menu.add_command(text="table.move_top", command=self._move_row_top)
|
|
1262
|
+
menu.add_command(text="table.move_bottom", command=self._move_row_bottom)
|
|
1263
|
+
|
|
1264
|
+
if self._editing['updating'] or self._editing['deleting']:
|
|
1265
|
+
menu.add_separator()
|
|
1266
|
+
if self._editing['updating']:
|
|
1267
|
+
menu.add_command(text="table.edit", command=self._edit_selected_row)
|
|
1268
|
+
if self._editing['deleting']:
|
|
1269
|
+
menu.add_command(text="table.delete_row", command=self._delete_selected_row)
|
|
1270
|
+
self._row_menu = menu
|
|
1271
|
+
|
|
1272
|
+
def _on_row_context(self, event) -> None:
|
|
1273
|
+
if not self._row_context_enabled():
|
|
1274
|
+
return
|
|
1275
|
+
iid = self._tree.identify_row(event.y)
|
|
1276
|
+
col_id = self._tree.identify_column(event.x)
|
|
1277
|
+
try:
|
|
1278
|
+
col_idx = int(col_id.strip("#")) - 1
|
|
1279
|
+
except Exception:
|
|
1280
|
+
col_idx = 0
|
|
1281
|
+
if iid:
|
|
1282
|
+
if iid not in self._tree.selection():
|
|
1283
|
+
self._tree.selection_set(iid)
|
|
1284
|
+
rec = self._row_map.get(iid, {})
|
|
1285
|
+
self.event_generate("<<RowRightClick>>", data={"record": rec, "iid": iid})
|
|
1286
|
+
if not self._tree.selection():
|
|
1287
|
+
return
|
|
1288
|
+
self._row_menu_col = col_idx
|
|
1289
|
+
self._ensure_row_menu()
|
|
1290
|
+
self._row_menu.show(position=(event.x_root, event.y_root))
|
|
1291
|
+
|
|
1292
|
+
def _on_row_double_click(self, event) -> None:
|
|
1293
|
+
region = self._tree.identify_region(event.x, event.y)
|
|
1294
|
+
if region == "heading":
|
|
1295
|
+
return
|
|
1296
|
+
iid = self._tree.identify_row(event.y)
|
|
1297
|
+
if not iid:
|
|
1298
|
+
return
|
|
1299
|
+
rec = self._row_map.get(iid, {})
|
|
1300
|
+
self.event_generate("<<RowDoubleClick>>", data={"record": rec, "iid": iid})
|
|
1301
|
+
if self._editing['updating']:
|
|
1302
|
+
self._open_form_dialog(rec)
|
|
1303
|
+
|
|
1304
|
+
def _open_new_record(self) -> None:
|
|
1305
|
+
if not self._editing['adding']:
|
|
1306
|
+
return
|
|
1307
|
+
self._open_form_dialog(None)
|
|
1308
|
+
|
|
1309
|
+
def _open_form_dialog(self, record: dict | None) -> None:
|
|
1310
|
+
from bootstack.dialogs.formdialog import FormDialog
|
|
1311
|
+
|
|
1312
|
+
try:
|
|
1313
|
+
# Ensure geometry info is current so centering uses real widget bounds
|
|
1314
|
+
self.update_idletasks()
|
|
1315
|
+
except Exception:
|
|
1316
|
+
pass
|
|
1317
|
+
dialog_master = self.winfo_toplevel() if hasattr(self, "winfo_toplevel") else self
|
|
1318
|
+
|
|
1319
|
+
form_items = self._build_form_items()
|
|
1320
|
+
initial_data = dict(record) if record else {}
|
|
1321
|
+
|
|
1322
|
+
form_options = dict(self._editing['form'])
|
|
1323
|
+
form_options.setdefault('col_count', 2)
|
|
1324
|
+
form_options.setdefault('min_col_width', 260)
|
|
1325
|
+
form_options.setdefault('scrollable', True)
|
|
1326
|
+
form_options.setdefault('resizable', True)
|
|
1327
|
+
|
|
1328
|
+
# Build buttons: Cancel, Delete (only for existing records), Save
|
|
1329
|
+
if record and "id" in record:
|
|
1330
|
+
buttons: list[str | dict] = ['Cancel']
|
|
1331
|
+
if self._editing['deleting']:
|
|
1332
|
+
buttons.append({"text": "Delete", "role": "secondary", "result": "delete"})
|
|
1333
|
+
buttons.append("Save")
|
|
1334
|
+
else:
|
|
1335
|
+
buttons = ["Cancel", "Save"]
|
|
1336
|
+
|
|
1337
|
+
dialog = FormDialog(
|
|
1338
|
+
master=dialog_master,
|
|
1339
|
+
title="Edit Record" if record else "New Record",
|
|
1340
|
+
data=initial_data,
|
|
1341
|
+
items=form_items,
|
|
1342
|
+
col_count=form_options.get('col_count', 2),
|
|
1343
|
+
min_col_width=form_options.get('min_col_width', 260),
|
|
1344
|
+
scrollable=form_options.get('scrollable', True),
|
|
1345
|
+
buttons=buttons,
|
|
1346
|
+
resizable=(True, True) if form_options.get('resizable', True) else (False, False),
|
|
1347
|
+
)
|
|
1348
|
+
|
|
1349
|
+
dialog.show(anchor_to="screen")
|
|
1350
|
+
result = dialog.result
|
|
1351
|
+
|
|
1352
|
+
if result is None:
|
|
1353
|
+
return
|
|
1354
|
+
|
|
1355
|
+
# Handle delete action
|
|
1356
|
+
if result == "delete" and record and "id" in record:
|
|
1357
|
+
try:
|
|
1358
|
+
self._datasource.delete_record(record["id"])
|
|
1359
|
+
self._clear_cache()
|
|
1360
|
+
self._load_page(self._current_page)
|
|
1361
|
+
except Exception:
|
|
1362
|
+
logger.exception("Failed to delete record id=%s", record["id"])
|
|
1363
|
+
return
|
|
1364
|
+
|
|
1365
|
+
data = result
|
|
1366
|
+
new_id = None
|
|
1367
|
+
if record and "id" in record:
|
|
1368
|
+
rec_id = record["id"]
|
|
1369
|
+
updates = dict(data)
|
|
1370
|
+
updates.pop("id", None)
|
|
1371
|
+
try:
|
|
1372
|
+
logger.debug("Updating record id=%s with %s", rec_id, updates)
|
|
1373
|
+
self._datasource.update_record(rec_id, updates)
|
|
1374
|
+
except Exception:
|
|
1375
|
+
logger.exception("Failed to update record id=%s", rec_id)
|
|
1376
|
+
return
|
|
1377
|
+
else:
|
|
1378
|
+
try:
|
|
1379
|
+
logger.debug("Creating record %s", data)
|
|
1380
|
+
new_id = self._datasource.create_record(dict(data))
|
|
1381
|
+
logger.debug("Created record id=%s (total=%s)", new_id, self._datasource.total_count())
|
|
1382
|
+
except Exception:
|
|
1383
|
+
logger.exception("Failed to create record from %s", data)
|
|
1384
|
+
return
|
|
1385
|
+
self._clear_cache()
|
|
1386
|
+
target_page = self._current_page
|
|
1387
|
+
if not record:
|
|
1388
|
+
# After creating, compute last page using fresh count so the new row is visible
|
|
1389
|
+
located_page = self._find_record_page(new_id) if new_id is not None else None
|
|
1390
|
+
target_page = located_page if located_page is not None else max(0, self._total_pages() - 1)
|
|
1391
|
+
self._load_page(target_page)
|
|
1392
|
+
if new_id is not None:
|
|
1393
|
+
self._focus_record(new_id)
|
|
1394
|
+
|
|
1395
|
+
def _build_form_items(self) -> list[dict]:
|
|
1396
|
+
items: list[dict] = []
|
|
1397
|
+
for idx, key in enumerate(self._column_keys):
|
|
1398
|
+
coldef = self._column_defs[idx] if idx < len(self._column_defs) else key
|
|
1399
|
+
label = self._col_text(coldef)
|
|
1400
|
+
editor_opts = {}
|
|
1401
|
+
editor = None
|
|
1402
|
+
dtype = None
|
|
1403
|
+
readonly = False
|
|
1404
|
+
if isinstance(coldef, dict):
|
|
1405
|
+
editor_opts = dict(coldef.get("editor_options", {}))
|
|
1406
|
+
editor = coldef.get("editor")
|
|
1407
|
+
dtype = coldef.get("dtype") or coldef.get("type")
|
|
1408
|
+
readonly = bool(coldef.get("readonly", False))
|
|
1409
|
+
if coldef.get("required"):
|
|
1410
|
+
editor_opts.setdefault("required", True)
|
|
1411
|
+
# Show validation messages to avoid layout jump on first error
|
|
1412
|
+
editor_opts.setdefault("show_message", True)
|
|
1413
|
+
items.append(
|
|
1414
|
+
{
|
|
1415
|
+
"key": key,
|
|
1416
|
+
"label": label,
|
|
1417
|
+
"dtype": dtype,
|
|
1418
|
+
"editor": editor,
|
|
1419
|
+
"editor_options": {**editor_opts},
|
|
1420
|
+
"readonly": readonly,
|
|
1421
|
+
"type": "field",
|
|
1422
|
+
}
|
|
1423
|
+
)
|
|
1424
|
+
return items
|
|
1425
|
+
|
|
1426
|
+
def _filter_by_value(self) -> None:
|
|
1427
|
+
selection = self._tree.selection()
|
|
1428
|
+
if not selection:
|
|
1429
|
+
return
|
|
1430
|
+
iid = selection[0]
|
|
1431
|
+
col_idx = max(0, min(self._row_menu_col or 0, len(self._column_keys) - 1))
|
|
1432
|
+
key = self._column_keys[col_idx]
|
|
1433
|
+
quoted_key = self._quote_col(key)
|
|
1434
|
+
values = self._tree.item(iid, "values")
|
|
1435
|
+
if col_idx >= len(values):
|
|
1436
|
+
return
|
|
1437
|
+
val = values[col_idx]
|
|
1438
|
+
crit = str(val).replace("'", "''")
|
|
1439
|
+
where = f"{quoted_key} = '{crit}'"
|
|
1440
|
+
try:
|
|
1441
|
+
self._datasource.set_filter(where)
|
|
1442
|
+
except Exception:
|
|
1443
|
+
return
|
|
1444
|
+
self._clear_cache()
|
|
1445
|
+
self._load_page(0)
|
|
1446
|
+
|
|
1447
|
+
def _sort_selection(self, ascending: bool) -> None:
|
|
1448
|
+
selection = self._tree.selection()
|
|
1449
|
+
if not selection:
|
|
1450
|
+
return
|
|
1451
|
+
iid = selection[0]
|
|
1452
|
+
col_idx = max(0, min(self._row_menu_col or 0, len(self._column_keys) - 1))
|
|
1453
|
+
key = self._column_keys[col_idx]
|
|
1454
|
+
quoted_key = self._quote_col(key)
|
|
1455
|
+
self._sort_state = {key: ascending}
|
|
1456
|
+
order = "ASC" if ascending else "DESC"
|
|
1457
|
+
try:
|
|
1458
|
+
self._datasource.set_sort(f"{quoted_key} {order}")
|
|
1459
|
+
except Exception:
|
|
1460
|
+
pass
|
|
1461
|
+
self._clear_cache()
|
|
1462
|
+
self._update_heading_icons()
|
|
1463
|
+
self._load_page(0)
|
|
1464
|
+
|
|
1465
|
+
def _clear_filter_cmd(self) -> None:
|
|
1466
|
+
try:
|
|
1467
|
+
self._datasource.set_filter("")
|
|
1468
|
+
except Exception:
|
|
1469
|
+
pass
|
|
1470
|
+
self._clear_cache()
|
|
1471
|
+
self._load_page(0)
|
|
1472
|
+
self._update_status_labels()
|
|
1473
|
+
|
|
1474
|
+
def _move_row_up(self) -> None:
|
|
1475
|
+
self._move_row_relative(-1)
|
|
1476
|
+
|
|
1477
|
+
def _move_row_down(self) -> None:
|
|
1478
|
+
self._move_row_relative(1)
|
|
1479
|
+
|
|
1480
|
+
def _move_row_top(self) -> None:
|
|
1481
|
+
self._move_row_absolute(0)
|
|
1482
|
+
|
|
1483
|
+
def _move_row_bottom(self) -> None:
|
|
1484
|
+
children = list(self._tree.get_children())
|
|
1485
|
+
if children:
|
|
1486
|
+
self._move_row_absolute(len(children) - 1)
|
|
1487
|
+
|
|
1488
|
+
def _move_row_relative(self, delta: int) -> None:
|
|
1489
|
+
sel = list(self._tree.selection())
|
|
1490
|
+
if not sel:
|
|
1491
|
+
return
|
|
1492
|
+
target_iid = sel[0]
|
|
1493
|
+
children = list(self._tree.get_children())
|
|
1494
|
+
try:
|
|
1495
|
+
idx = children.index(target_iid)
|
|
1496
|
+
except ValueError:
|
|
1497
|
+
return
|
|
1498
|
+
new_idx = max(0, min(len(children) - 1, idx + delta))
|
|
1499
|
+
if new_idx == idx:
|
|
1500
|
+
return
|
|
1501
|
+
self._tree.move(target_iid, "", new_idx)
|
|
1502
|
+
self._apply_row_alternation()
|
|
1503
|
+
rec = self._row_map.get(target_iid)
|
|
1504
|
+
if rec:
|
|
1505
|
+
self.event_generate("<<RowMove>>", data={"records": [rec]})
|
|
1506
|
+
|
|
1507
|
+
def _move_row_absolute(self, new_idx: int) -> None:
|
|
1508
|
+
sel = list(self._tree.selection())
|
|
1509
|
+
if not sel:
|
|
1510
|
+
return
|
|
1511
|
+
target_iid = sel[0]
|
|
1512
|
+
children = list(self._tree.get_children())
|
|
1513
|
+
new_idx = max(0, min(len(children) - 1, new_idx))
|
|
1514
|
+
self._tree.move(target_iid, "", new_idx)
|
|
1515
|
+
self._apply_row_alternation()
|
|
1516
|
+
rec = self._row_map.get(target_iid)
|
|
1517
|
+
if rec:
|
|
1518
|
+
self.event_generate("<<RowMove>>", data={"records": [rec]})
|
|
1519
|
+
|
|
1520
|
+
def _hide_selection(self) -> None:
|
|
1521
|
+
sel = list(self._tree.selection())
|
|
1522
|
+
for iid in sel:
|
|
1523
|
+
self._tree.delete(iid)
|
|
1524
|
+
self._row_map.pop(iid, None)
|
|
1525
|
+
|
|
1526
|
+
def _edit_selected_row(self) -> None:
|
|
1527
|
+
"""Open the form dialog for the first selected row."""
|
|
1528
|
+
sel = list(self._tree.selection())
|
|
1529
|
+
if not sel:
|
|
1530
|
+
return
|
|
1531
|
+
iid = sel[0]
|
|
1532
|
+
rec = self._row_map.get(iid, {})
|
|
1533
|
+
self._open_form_dialog(rec)
|
|
1534
|
+
|
|
1535
|
+
def _delete_selected_row(self) -> None:
|
|
1536
|
+
"""Delete the first selected row from the datasource."""
|
|
1537
|
+
sel = list(self._tree.selection())
|
|
1538
|
+
if not sel:
|
|
1539
|
+
return
|
|
1540
|
+
iid = sel[0]
|
|
1541
|
+
rec = self._row_map.get(iid, {})
|
|
1542
|
+
rec_id = rec.get("id")
|
|
1543
|
+
if rec_id is not None:
|
|
1544
|
+
try:
|
|
1545
|
+
self._datasource.delete_record(rec_id)
|
|
1546
|
+
self._clear_cache()
|
|
1547
|
+
self._load_page(self._current_page)
|
|
1548
|
+
self.event_generate("<<RowDelete>>", data={"records": [rec]})
|
|
1549
|
+
except Exception:
|
|
1550
|
+
logger.exception("Failed to delete record id=%s", rec_id)
|
|
1551
|
+
|
|
1552
|
+
def _delete_selection(self) -> None:
|
|
1553
|
+
sel = list(self._tree.selection())
|
|
1554
|
+
deleted_records: list[dict] = []
|
|
1555
|
+
changed = False
|
|
1556
|
+
for iid in sel:
|
|
1557
|
+
rec = dict(self._row_map.get(iid) or {})
|
|
1558
|
+
if rec:
|
|
1559
|
+
deleted_records.append(rec)
|
|
1560
|
+
rec_id = rec.get("id")
|
|
1561
|
+
if rec_id is not None:
|
|
1562
|
+
try:
|
|
1563
|
+
self._datasource.delete_record(rec_id)
|
|
1564
|
+
changed = True
|
|
1565
|
+
except Exception:
|
|
1566
|
+
pass
|
|
1567
|
+
self._row_map.pop(iid, None)
|
|
1568
|
+
if changed:
|
|
1569
|
+
self._clear_cache()
|
|
1570
|
+
self._load_page(self._current_page)
|
|
1571
|
+
if deleted_records:
|
|
1572
|
+
self.event_generate("<<RowDelete>>", data={"records": deleted_records})
|
|
1573
|
+
|
|
1574
|
+
# ------------------------------------------------------------------ Cache helpers
|
|
1575
|
+
def _clear_cache(self) -> None:
|
|
1576
|
+
if self._page_cache:
|
|
1577
|
+
self._page_cache.clear()
|
|
1578
|
+
# Invalidate total count cache when data/filter/sort changes
|
|
1579
|
+
self._cached_total_count = None
|
|
1580
|
+
|
|
1581
|
+
def _load_heading_icons(self) -> None:
|
|
1582
|
+
"""Load and cache heading icons (sort arrows) sized to match the heading color."""
|
|
1583
|
+
try:
|
|
1584
|
+
fg = self._get_heading_fg()
|
|
1585
|
+
if fg == self._heading_fg and self._icon_sort_up:
|
|
1586
|
+
return
|
|
1587
|
+
self._heading_fg = fg
|
|
1588
|
+
self._icon_sort_up = BootstrapIcon("sort-up", 20, fg)
|
|
1589
|
+
self._icon_sort_down = BootstrapIcon("sort-down", 20, fg)
|
|
1590
|
+
except Exception:
|
|
1591
|
+
self._icon_sort_up = None
|
|
1592
|
+
self._icon_sort_down = None
|
|
1593
|
+
|
|
1594
|
+
def _get_heading_fg(self) -> str:
|
|
1595
|
+
"""Resolve a heading foreground color with light-biased fallbacks."""
|
|
1596
|
+
style = get_style()
|
|
1597
|
+
ttk_style = self._tree.cget('style')
|
|
1598
|
+
# Try configured value first
|
|
1599
|
+
return style.configure(f"{ttk_style}.Heading", 'foreground')
|
|
1600
|
+
|
|
1601
|
+
def _update_heading_icons(self) -> None:
|
|
1602
|
+
"""Apply sort direction icons to headings."""
|
|
1603
|
+
if not self._heading_texts:
|
|
1604
|
+
return
|
|
1605
|
+
self._load_heading_icons()
|
|
1606
|
+
for idx, text in enumerate(self._heading_texts):
|
|
1607
|
+
image = ""
|
|
1608
|
+
if idx < len(self._column_keys):
|
|
1609
|
+
key = self._column_keys[idx]
|
|
1610
|
+
state = self._sort_state.get(key)
|
|
1611
|
+
if state is True:
|
|
1612
|
+
image = self._icon_sort_up if self._icon_sort_up else ""
|
|
1613
|
+
elif state is False:
|
|
1614
|
+
image = self._icon_sort_down if self._icon_sort_down else ""
|
|
1615
|
+
self._tree.heading(idx, text=text, image=image)
|
|
1616
|
+
|
|
1617
|
+
def _remember_page(self, page: int, records: list[dict]) -> None:
|
|
1618
|
+
if self._paging['cache_size'] <= 0:
|
|
1619
|
+
return
|
|
1620
|
+
# Move/update LRU cache
|
|
1621
|
+
if page in self._page_cache:
|
|
1622
|
+
self._page_cache.pop(page)
|
|
1623
|
+
self._page_cache[page] = records
|
|
1624
|
+
if len(self._page_cache) > self._paging['cache_size']:
|
|
1625
|
+
self._page_cache.popitem(last=False)
|
|
1626
|
+
|
|
1627
|
+
def _focus_record(self, record_id) -> None:
|
|
1628
|
+
"""Select and scroll to a record by id if it's on the current page."""
|
|
1629
|
+
try:
|
|
1630
|
+
rid = str(record_id)
|
|
1631
|
+
for iid, rec in self._row_map.items():
|
|
1632
|
+
if str(rec.get("id")) == rid:
|
|
1633
|
+
self._tree.selection_set(iid)
|
|
1634
|
+
self._tree.see(iid)
|
|
1635
|
+
break
|
|
1636
|
+
except Exception:
|
|
1637
|
+
pass
|
|
1638
|
+
|
|
1639
|
+
def _find_record_page(self, record_id) -> int | None:
|
|
1640
|
+
"""Locate the page index containing the given record id, if available."""
|
|
1641
|
+
try:
|
|
1642
|
+
rid = str(record_id)
|
|
1643
|
+
total_pages = self._total_pages()
|
|
1644
|
+
for page_idx in range(total_pages):
|
|
1645
|
+
try:
|
|
1646
|
+
rows = self._datasource.get_page(page_idx)
|
|
1647
|
+
except Exception:
|
|
1648
|
+
break
|
|
1649
|
+
if any(str(rec.get("id")) == rid for rec in rows):
|
|
1650
|
+
return page_idx
|
|
1651
|
+
except Exception:
|
|
1652
|
+
pass
|
|
1653
|
+
return None
|
|
1654
|
+
|
|
1655
|
+
def _auto_size_columns(self, records: list[dict] | None = None) -> None:
|
|
1656
|
+
"""Auto-size columns to the widest value among current rows/headings."""
|
|
1657
|
+
if not self._column_keys:
|
|
1658
|
+
return
|
|
1659
|
+
try:
|
|
1660
|
+
style = get_style()
|
|
1661
|
+
# Prefer the Treeview body font; fall back to TLabel/body or default
|
|
1662
|
+
tv_style = self._tree.cget("style") or "Treeview"
|
|
1663
|
+
body_font = (
|
|
1664
|
+
style.lookup(tv_style, "font")
|
|
1665
|
+
or style.lookup("TLabel", "font")
|
|
1666
|
+
or getattr(style, "fonts", {}).get("body")
|
|
1667
|
+
or "TkDefaultFont"
|
|
1668
|
+
)
|
|
1669
|
+
content_font = tkfont.nametofont(body_font)
|
|
1670
|
+
except Exception:
|
|
1671
|
+
content_font = None
|
|
1672
|
+
|
|
1673
|
+
pad_px = 20
|
|
1674
|
+
|
|
1675
|
+
# Gather samples from headings, provided records, and current tree values
|
|
1676
|
+
tree_samples = []
|
|
1677
|
+
for iid in self._tree.get_children(""):
|
|
1678
|
+
tree_samples.append(self._tree.item(iid, "values"))
|
|
1679
|
+
for ciid in self._tree.get_children(iid):
|
|
1680
|
+
tree_samples.append(self._tree.item(ciid, "values"))
|
|
1681
|
+
|
|
1682
|
+
for idx, key in enumerate(self._column_keys):
|
|
1683
|
+
samples = []
|
|
1684
|
+
if idx < len(self._heading_texts):
|
|
1685
|
+
samples.append(str(self._heading_texts[idx]))
|
|
1686
|
+
if records:
|
|
1687
|
+
for rec in records:
|
|
1688
|
+
samples.append(str(rec.get(key, "")))
|
|
1689
|
+
for vals in tree_samples:
|
|
1690
|
+
if idx < len(vals):
|
|
1691
|
+
samples.append(str(vals[idx]))
|
|
1692
|
+
|
|
1693
|
+
# Honor explicit column width if provided
|
|
1694
|
+
explicit_width = None
|
|
1695
|
+
if idx < len(self._column_defs):
|
|
1696
|
+
coldef = self._column_defs[idx]
|
|
1697
|
+
if isinstance(coldef, dict):
|
|
1698
|
+
explicit_width = coldef.get("width")
|
|
1699
|
+
|
|
1700
|
+
if explicit_width is not None:
|
|
1701
|
+
try:
|
|
1702
|
+
self._tree.column(idx, width=explicit_width, minwidth=self._column_min_width)
|
|
1703
|
+
except Exception:
|
|
1704
|
+
pass
|
|
1705
|
+
continue
|
|
1706
|
+
|
|
1707
|
+
text = max(samples, key=len) if samples else ""
|
|
1708
|
+
if content_font:
|
|
1709
|
+
width = content_font.measure(text) + pad_px
|
|
1710
|
+
else:
|
|
1711
|
+
width = 0
|
|
1712
|
+
# Fallback to simple char-based estimate to avoid under-measuring
|
|
1713
|
+
char_estimate = len(text) * 10 + pad_px
|
|
1714
|
+
width = max(width, char_estimate, self._column_min_width)
|
|
1715
|
+
# Cap width to available viewport so we don't force the tree wider than its frame
|
|
1716
|
+
try:
|
|
1717
|
+
avail = max(0, int(self._tree.winfo_width()) - pad_px)
|
|
1718
|
+
if avail > 0:
|
|
1719
|
+
width = min(width, avail)
|
|
1720
|
+
except Exception:
|
|
1721
|
+
pass
|
|
1722
|
+
try:
|
|
1723
|
+
self._tree.column(idx, width=width, minwidth=self._column_min_width)
|
|
1724
|
+
except Exception:
|
|
1725
|
+
pass
|
|
1726
|
+
|
|
1727
|
+
def _apply_row_alternation(self) -> None:
|
|
1728
|
+
"""Apply alternating row colors via a tag."""
|
|
1729
|
+
enabled = self._row_alternation.get('enabled', False)
|
|
1730
|
+
if not enabled or self._group_by_key:
|
|
1731
|
+
return
|
|
1732
|
+
bg, fg = self._resolve_alternating_row_color()
|
|
1733
|
+
try:
|
|
1734
|
+
self._tree.tag_configure("altrow", background=bg, foreground=fg)
|
|
1735
|
+
# Some themes honor the "striped" tag name; configure it too
|
|
1736
|
+
self._tree.tag_configure("striped", background=bg, foreground=fg)
|
|
1737
|
+
except Exception:
|
|
1738
|
+
return
|
|
1739
|
+
|
|
1740
|
+
queue = list(self._tree.get_children(""))
|
|
1741
|
+
idx = 0
|
|
1742
|
+
while queue:
|
|
1743
|
+
iid = queue.pop(0)
|
|
1744
|
+
try:
|
|
1745
|
+
tags = list(self._tree.item(iid, "tags") or [])
|
|
1746
|
+
if idx % 2 == 1:
|
|
1747
|
+
if "altrow" not in tags:
|
|
1748
|
+
tags.append("altrow")
|
|
1749
|
+
if "striped" not in tags:
|
|
1750
|
+
tags.append("striped")
|
|
1751
|
+
else:
|
|
1752
|
+
tags = [t for t in tags if t not in ("altrow", "striped")]
|
|
1753
|
+
self._tree.item(iid, tags=tags)
|
|
1754
|
+
except Exception:
|
|
1755
|
+
pass
|
|
1756
|
+
queue.extend(list(self._tree.get_children(iid)))
|
|
1757
|
+
idx += 1
|
|
1758
|
+
|
|
1759
|
+
def _rebalance_grouped_widths(self) -> None:
|
|
1760
|
+
"""Distribute available width across data columns when grouped so the left tree column is included."""
|
|
1761
|
+
# Only rebalance when grouping is active and xscroll is off (otherwise user can scroll)
|
|
1762
|
+
if not self._group_by_key or self._paging['xscroll']:
|
|
1763
|
+
return
|
|
1764
|
+
try:
|
|
1765
|
+
tree_width = max(0, int(self._tree.winfo_width()))
|
|
1766
|
+
group_width = max(0, int(self._tree.column("#0", option="width") or 0))
|
|
1767
|
+
vsb_width = 0
|
|
1768
|
+
if getattr(self, "_vsb", None):
|
|
1769
|
+
try:
|
|
1770
|
+
self._vsb.update_idletasks()
|
|
1771
|
+
if self._vsb.winfo_ismapped():
|
|
1772
|
+
vsb_width = int(self._vsb.winfo_width())
|
|
1773
|
+
except Exception:
|
|
1774
|
+
vsb_width = 0
|
|
1775
|
+
# Leave a small cushion to avoid oscillating scrollbar
|
|
1776
|
+
available = tree_width - group_width - vsb_width - 8
|
|
1777
|
+
if available <= 0:
|
|
1778
|
+
return
|
|
1779
|
+
cols = [c for c in self._display_columns if c < len(self._heading_texts)]
|
|
1780
|
+
if not cols:
|
|
1781
|
+
return
|
|
1782
|
+
width = max(self._column_min_width, available // len(cols))
|
|
1783
|
+
for c in cols:
|
|
1784
|
+
self._tree.column(c, width=width, stretch=True)
|
|
1785
|
+
# Keep the group column fixed so only data columns flex
|
|
1786
|
+
self._tree.column("#0", stretch=False)
|
|
1787
|
+
except Exception:
|
|
1788
|
+
pass
|
|
1789
|
+
|
|
1790
|
+
def _on_tree_configure(self, _event=None) -> None:
|
|
1791
|
+
"""Handle resize events to keep grouped layouts sized to the available width."""
|
|
1792
|
+
self._rebalance_grouped_widths()
|
|
1793
|
+
|
|
1794
|
+
# ------------------------------------------------------------------ Export helpers
|
|
1795
|
+
def _export_all(self) -> None:
|
|
1796
|
+
try:
|
|
1797
|
+
rows = self._datasource.get_page_from_index(0, self._datasource.total_count())
|
|
1798
|
+
self._tree.event_generate("<<TableViewExportAll>>", data=rows)
|
|
1799
|
+
except Exception:
|
|
1800
|
+
pass
|
|
1801
|
+
|
|
1802
|
+
def _export_selection(self) -> None:
|
|
1803
|
+
try:
|
|
1804
|
+
selected = [self._row_map[iid] for iid in self._tree.selection() if iid in self._row_map]
|
|
1805
|
+
self._tree.event_generate("<<TableViewExportSelection>>", data=selected)
|
|
1806
|
+
except Exception:
|
|
1807
|
+
pass
|
|
1808
|
+
|
|
1809
|
+
def _export_page(self) -> None:
|
|
1810
|
+
try:
|
|
1811
|
+
start_index = self._current_page * self._paging['page_size']
|
|
1812
|
+
rows = self._datasource.get_page_from_index(start_index, self._paging['page_size'])
|
|
1813
|
+
self._tree.event_generate("<<TableViewExportPage>>", data=rows)
|
|
1814
|
+
except Exception:
|
|
1815
|
+
pass
|
|
1816
|
+
|
|
1817
|
+
# ------------------------------------------------------------------ Header click handling
|
|
1818
|
+
def _on_header_click(self, event) -> None:
|
|
1819
|
+
"""Handle left-click on headers for sorting."""
|
|
1820
|
+
region = self._tree.identify_region(event.x, event.y)
|
|
1821
|
+
if region != "heading":
|
|
1822
|
+
return
|
|
1823
|
+
|
|
1824
|
+
if self._sorting == 'none':
|
|
1825
|
+
return
|
|
1826
|
+
|
|
1827
|
+
col_id = self._tree.identify_column(event.x) # e.g. "#1"
|
|
1828
|
+
try:
|
|
1829
|
+
display_idx = int(col_id.strip("#")) - 1
|
|
1830
|
+
except Exception:
|
|
1831
|
+
return
|
|
1832
|
+
|
|
1833
|
+
if display_idx < 0 or display_idx >= len(self._display_columns):
|
|
1834
|
+
return
|
|
1835
|
+
|
|
1836
|
+
column_idx = self._display_columns[display_idx]
|
|
1837
|
+
self._on_sort(column_idx)
|
|
1838
|
+
|
|
1839
|
+
def _filter_header_column(self) -> None:
|
|
1840
|
+
"""Show filter dialog for the currently selected header column."""
|
|
1841
|
+
col = self._header_menu_col
|
|
1842
|
+
if col is None or col >= len(self._column_keys):
|
|
1843
|
+
return
|
|
1844
|
+
self._show_column_filter_dialog(col)
|
|
1845
|
+
|
|
1846
|
+
def _show_column_filter_dialog(self, column_idx: int) -> None:
|
|
1847
|
+
"""Show FilterDialog with distinct values for the column."""
|
|
1848
|
+
from bootstack.dialogs.filterdialog import FilterDialog
|
|
1849
|
+
|
|
1850
|
+
if column_idx >= len(self._column_keys):
|
|
1851
|
+
return
|
|
1852
|
+
|
|
1853
|
+
key = self._column_keys[column_idx]
|
|
1854
|
+
heading_text = self._heading_texts[column_idx] if column_idx < len(self._heading_texts) else key
|
|
1855
|
+
|
|
1856
|
+
# Get distinct values from datasource
|
|
1857
|
+
try:
|
|
1858
|
+
distinct_values = self._datasource.get_distinct_values(key)
|
|
1859
|
+
except Exception:
|
|
1860
|
+
distinct_values = []
|
|
1861
|
+
|
|
1862
|
+
if not distinct_values:
|
|
1863
|
+
return
|
|
1864
|
+
|
|
1865
|
+
empty_text = MessageCatalog.translate("table.empty")
|
|
1866
|
+
# Build items for the filter dialog
|
|
1867
|
+
current_filter = self._column_filters.get(key)
|
|
1868
|
+
items = []
|
|
1869
|
+
for val in distinct_values:
|
|
1870
|
+
display_text = str(val) if val is not None else empty_text
|
|
1871
|
+
selected = current_filter is None or val in current_filter
|
|
1872
|
+
items.append(
|
|
1873
|
+
{
|
|
1874
|
+
"text": display_text,
|
|
1875
|
+
"value": val,
|
|
1876
|
+
"selected": selected
|
|
1877
|
+
})
|
|
1878
|
+
|
|
1879
|
+
# Position dialog below the header
|
|
1880
|
+
col_id = f"#{self._display_columns.index(column_idx) + 1}" if column_idx in self._display_columns else "#1"
|
|
1881
|
+
pos_x = self._tree.winfo_rootx()
|
|
1882
|
+
pos_y = self._tree.winfo_rooty()
|
|
1883
|
+
|
|
1884
|
+
tree_items = self._tree.get_children()
|
|
1885
|
+
if tree_items:
|
|
1886
|
+
bbox = self._tree.bbox(tree_items[0], col_id)
|
|
1887
|
+
if bbox:
|
|
1888
|
+
pos_x = self._tree.winfo_rootx() + bbox[0]
|
|
1889
|
+
pos_y = self._tree.winfo_rooty() + bbox[1] + 2
|
|
1890
|
+
|
|
1891
|
+
dialog = FilterDialog(
|
|
1892
|
+
master=self.winfo_toplevel(),
|
|
1893
|
+
title=MessageCatalog.translate("table.filter_column", heading_text),
|
|
1894
|
+
items=items,
|
|
1895
|
+
allow_search=True,
|
|
1896
|
+
allow_select_all=True,
|
|
1897
|
+
frameless=True
|
|
1898
|
+
)
|
|
1899
|
+
|
|
1900
|
+
result = dialog.show(position=(pos_x, pos_y))
|
|
1901
|
+
|
|
1902
|
+
if result is not None:
|
|
1903
|
+
self._apply_column_filter(key, result, distinct_values)
|
|
1904
|
+
|
|
1905
|
+
def _apply_column_filter(self, key: str, selected_values: list, all_values: list) -> None:
|
|
1906
|
+
"""Apply column filter based on selected values."""
|
|
1907
|
+
# If all values selected, clear the filter for this column
|
|
1908
|
+
if set(selected_values) == set(all_values):
|
|
1909
|
+
self._column_filters.pop(key, None)
|
|
1910
|
+
else:
|
|
1911
|
+
self._column_filters[key] = selected_values
|
|
1912
|
+
|
|
1913
|
+
# Build combined WHERE clause from all column filters
|
|
1914
|
+
self._rebuild_filter_where()
|
|
1915
|
+
|
|
1916
|
+
def _rebuild_filter_where(self) -> None:
|
|
1917
|
+
"""Rebuild WHERE clause from all active column filters."""
|
|
1918
|
+
clauses = []
|
|
1919
|
+
for key, values in self._column_filters.items():
|
|
1920
|
+
if not values:
|
|
1921
|
+
# No values selected = filter out everything
|
|
1922
|
+
clauses.append("1=0")
|
|
1923
|
+
else:
|
|
1924
|
+
quoted_key = self._quote_col(key)
|
|
1925
|
+
# Build IN clause
|
|
1926
|
+
quoted_values = []
|
|
1927
|
+
for v in values:
|
|
1928
|
+
if v is None:
|
|
1929
|
+
quoted_values.append("NULL")
|
|
1930
|
+
else:
|
|
1931
|
+
escaped = str(v).replace("'", "''")
|
|
1932
|
+
quoted_values.append(f"'{escaped}'")
|
|
1933
|
+
# Handle NULL separately since IN doesn't work with NULL
|
|
1934
|
+
null_check = ""
|
|
1935
|
+
if None in values:
|
|
1936
|
+
quoted_values = [qv for qv in quoted_values if qv != "NULL"]
|
|
1937
|
+
null_check = f" OR {quoted_key} IS NULL"
|
|
1938
|
+
if quoted_values:
|
|
1939
|
+
clauses.append(f"({quoted_key} IN ({','.join(quoted_values)}){null_check})")
|
|
1940
|
+
elif null_check:
|
|
1941
|
+
clauses.append(f"({quoted_key} IS NULL)")
|
|
1942
|
+
|
|
1943
|
+
where = " AND ".join(clauses) if clauses else ""
|
|
1944
|
+
try:
|
|
1945
|
+
self._datasource.set_filter(where)
|
|
1946
|
+
except Exception:
|
|
1947
|
+
pass
|
|
1948
|
+
self._clear_cache()
|
|
1949
|
+
self._load_page(0)
|
|
1950
|
+
self._update_status_labels()
|
|
1951
|
+
|
|
1952
|
+
# ------------------------------------------------------------------ Context dispatch
|
|
1953
|
+
def _on_tree_context(self, event) -> None:
|
|
1954
|
+
if self._context_menus == "none":
|
|
1955
|
+
return
|
|
1956
|
+
region = self._tree.identify_region(event.x, event.y)
|
|
1957
|
+
if region == "heading":
|
|
1958
|
+
if not self._header_context_enabled():
|
|
1959
|
+
return
|
|
1960
|
+
self._on_header_context(event)
|
|
1961
|
+
else:
|
|
1962
|
+
if not self._row_context_enabled():
|
|
1963
|
+
return
|
|
1964
|
+
self._on_row_context(event)
|
|
1965
|
+
|
|
1966
|
+
def _on_selection_event(self, _event=None) -> None:
|
|
1967
|
+
"""Forward selection changes to subscribers."""
|
|
1968
|
+
rows = self.selected_rows
|
|
1969
|
+
self.event_generate("<<SelectionChange>>", data={"records": rows, "iids": list(self._tree.selection())})
|
|
1970
|
+
|
|
1971
|
+
def _on_row_click_event(self, event) -> None:
|
|
1972
|
+
region = self._tree.identify_region(event.x, event.y)
|
|
1973
|
+
if region == "heading":
|
|
1974
|
+
return
|
|
1975
|
+
iid = self._tree.identify_row(event.y)
|
|
1976
|
+
if not iid:
|
|
1977
|
+
return
|
|
1978
|
+
rec = self._row_map.get(iid, {})
|
|
1979
|
+
self.event_generate("<<RowClick>>", data={"record": rec, "iid": iid})
|
|
1980
|
+
|
|
1981
|
+
# ------------------------------------------------------------------ Header context menu
|
|
1982
|
+
def _ensure_header_menu(self) -> None:
|
|
1983
|
+
if not self._header_context_enabled():
|
|
1984
|
+
return
|
|
1985
|
+
if self._header_menu:
|
|
1986
|
+
return
|
|
1987
|
+
menu = ContextMenu(master=self, target=self._tree, trigger=None)
|
|
1988
|
+
menu.add_command(text="table.align_left", icon="align-start", command=self._align_header_left)
|
|
1989
|
+
menu.add_command(text="table.align_center", icon="align-center", command=self._align_header_center)
|
|
1990
|
+
menu.add_command(text="table.align_right", icon="align-end", command=self._align_header_right)
|
|
1991
|
+
menu.add_separator()
|
|
1992
|
+
menu.add_command(text="table.move_left", icon="arrow-left", command=self._move_header_left)
|
|
1993
|
+
menu.add_command(text="table.move_right", icon="arrow-right", command=self._move_header_right)
|
|
1994
|
+
menu.add_command(text="table.move_first", icon="arrow-bar-left", command=self._move_header_first)
|
|
1995
|
+
menu.add_command(text="table.move_last", icon="arrow-bar-right", command=self._move_header_last)
|
|
1996
|
+
menu.add_separator()
|
|
1997
|
+
menu.add_command(text="table.hide_column", icon="eye-slash", command=self._hide_header_column)
|
|
1998
|
+
menu.add_command(text="table.show_all", icon="eye", command=self._show_all_columns)
|
|
1999
|
+
if self._allow_grouping:
|
|
2000
|
+
menu.add_separator()
|
|
2001
|
+
menu.add_command(text="table.group_by_column", command=self._group_header_column)
|
|
2002
|
+
menu.add_command(text="table.ungroup_all", command=self._ungroup_all)
|
|
2003
|
+
menu.add_separator()
|
|
2004
|
+
menu.add_command(text="table.reset", icon="arrow-counterclockwise", command=self._reset_table)
|
|
2005
|
+
menu.add_separator()
|
|
2006
|
+
if not self._sorting == 'none':
|
|
2007
|
+
menu.add_command(text="table.clear_sort", icon="x-lg", command=self._clear_sort)
|
|
2008
|
+
self._header_menu = menu
|
|
2009
|
+
|
|
2010
|
+
def _on_header_context(self, event) -> None:
|
|
2011
|
+
if not self._header_context_enabled():
|
|
2012
|
+
return
|
|
2013
|
+
# Only handle header clicks
|
|
2014
|
+
if self._tree.identify_region(event.x, event.y) != "heading":
|
|
2015
|
+
return
|
|
2016
|
+
col_id = self._tree.identify_column(event.x) # e.g. "#1"
|
|
2017
|
+
try:
|
|
2018
|
+
idx = int(col_id.strip("#")) - 1
|
|
2019
|
+
except Exception:
|
|
2020
|
+
return
|
|
2021
|
+
if idx < 0 or idx >= len(self._display_columns):
|
|
2022
|
+
return
|
|
2023
|
+
self._header_menu_col = self._display_columns[idx]
|
|
2024
|
+
self._ensure_header_menu()
|
|
2025
|
+
|
|
2026
|
+
# Try to position at bottom-left of the clicked header
|
|
2027
|
+
pos_x, pos_y = event.x_root, event.y_root
|
|
2028
|
+
items = self._tree.get_children()
|
|
2029
|
+
if items:
|
|
2030
|
+
bbox = self._tree.bbox(items[0], col_id)
|
|
2031
|
+
if bbox:
|
|
2032
|
+
# bbox is relative to the widget; bbox[1] is header height offset
|
|
2033
|
+
pos_x = self._tree.winfo_rootx() + bbox[0]
|
|
2034
|
+
pos_y = self._tree.winfo_rooty() + bbox[1] + 2
|
|
2035
|
+
self._header_menu.show(position=(pos_x, pos_y))
|
|
2036
|
+
|
|
2037
|
+
def _align_header_left(self) -> None:
|
|
2038
|
+
self._set_heading_anchor("w")
|
|
2039
|
+
|
|
2040
|
+
def _align_header_center(self) -> None:
|
|
2041
|
+
self._set_heading_anchor("center")
|
|
2042
|
+
|
|
2043
|
+
def _align_header_right(self) -> None:
|
|
2044
|
+
self._set_heading_anchor("e")
|
|
2045
|
+
|
|
2046
|
+
def _set_heading_anchor(self, anchor: str) -> None:
|
|
2047
|
+
"""Align only the header text for the selected column."""
|
|
2048
|
+
col = self._header_menu_col
|
|
2049
|
+
if col is None:
|
|
2050
|
+
return
|
|
2051
|
+
self._tree.heading(col, anchor=anchor)
|
|
2052
|
+
self._tree.column(col, anchor=anchor)
|
|
2053
|
+
|
|
2054
|
+
def _move_header_left(self) -> None:
|
|
2055
|
+
self._move_column(-1)
|
|
2056
|
+
|
|
2057
|
+
def _move_header_right(self) -> None:
|
|
2058
|
+
self._move_column(1)
|
|
2059
|
+
|
|
2060
|
+
def _move_header_first(self) -> None:
|
|
2061
|
+
self._move_column(to_index=0)
|
|
2062
|
+
|
|
2063
|
+
def _move_header_last(self) -> None:
|
|
2064
|
+
self._move_column(to_index=len(self._display_columns) - 1)
|
|
2065
|
+
|
|
2066
|
+
def _move_column(self, delta: int | None = None, to_index: int | None = None) -> None:
|
|
2067
|
+
col = self._header_menu_col
|
|
2068
|
+
if col is None or col not in self._display_columns:
|
|
2069
|
+
return
|
|
2070
|
+
current_pos = self._display_columns.index(col)
|
|
2071
|
+
if to_index is not None:
|
|
2072
|
+
new_pos = max(0, min(len(self._display_columns) - 1, to_index))
|
|
2073
|
+
else:
|
|
2074
|
+
new_pos = current_pos + (delta or 0)
|
|
2075
|
+
new_pos = max(0, min(len(self._display_columns) - 1, new_pos))
|
|
2076
|
+
if new_pos == current_pos:
|
|
2077
|
+
return
|
|
2078
|
+
self._display_columns.pop(current_pos)
|
|
2079
|
+
self._display_columns.insert(new_pos, col)
|
|
2080
|
+
self._tree.configure(displaycolumns=self._display_columns)
|
|
2081
|
+
|
|
2082
|
+
def _hide_header_column(self) -> None:
|
|
2083
|
+
col = self._header_menu_col
|
|
2084
|
+
if col is None or col not in self._display_columns:
|
|
2085
|
+
return
|
|
2086
|
+
self._display_columns.remove(col)
|
|
2087
|
+
if not self._display_columns:
|
|
2088
|
+
self._display_columns = list(range(len(self._heading_texts)))
|
|
2089
|
+
self._tree.configure(displaycolumns=self._display_columns)
|
|
2090
|
+
|
|
2091
|
+
def _show_all_columns(self) -> None:
|
|
2092
|
+
if not self._heading_texts:
|
|
2093
|
+
return
|
|
2094
|
+
self._display_columns = list(range(len(self._heading_texts)))
|
|
2095
|
+
self._tree.configure(displaycolumns=self._display_columns)
|
|
2096
|
+
|
|
2097
|
+
def _show_column_chooser_dialog(self) -> None:
|
|
2098
|
+
"""Show a dialog to select which columns are visible."""
|
|
2099
|
+
from bootstack.dialogs.filterdialog import FilterDialog
|
|
2100
|
+
|
|
2101
|
+
if not self._heading_texts:
|
|
2102
|
+
return
|
|
2103
|
+
|
|
2104
|
+
# Build items for the filter dialog
|
|
2105
|
+
items = []
|
|
2106
|
+
for idx, text in enumerate(self._heading_texts):
|
|
2107
|
+
items.append(
|
|
2108
|
+
{
|
|
2109
|
+
"text": text,
|
|
2110
|
+
"value": idx,
|
|
2111
|
+
"selected": idx in self._display_columns
|
|
2112
|
+
})
|
|
2113
|
+
|
|
2114
|
+
# Calculate position: align dialog's top-right to button's bottom-right
|
|
2115
|
+
btn = self._column_chooser_btn
|
|
2116
|
+
btn.update_idletasks()
|
|
2117
|
+
btn_right = btn.winfo_rootx() + btn.winfo_width()
|
|
2118
|
+
btn_bottom = btn.winfo_rooty() + btn.winfo_height()
|
|
2119
|
+
dialog_width = 250 # FilterDialog has fixed width of 250
|
|
2120
|
+
pos_x = btn_right - dialog_width - 2 # 2px west
|
|
2121
|
+
pos_y = btn_bottom + 2 # 2px south
|
|
2122
|
+
|
|
2123
|
+
dialog = FilterDialog(
|
|
2124
|
+
master=self.winfo_toplevel(),
|
|
2125
|
+
title="Columns",
|
|
2126
|
+
items=items,
|
|
2127
|
+
allow_search=False,
|
|
2128
|
+
allow_select_all=True,
|
|
2129
|
+
frameless=True
|
|
2130
|
+
)
|
|
2131
|
+
|
|
2132
|
+
result = dialog.show(position=(pos_x, pos_y))
|
|
2133
|
+
|
|
2134
|
+
if result is not None:
|
|
2135
|
+
# Update display columns based on selection
|
|
2136
|
+
self._display_columns = [idx for idx in result if isinstance(idx, int)]
|
|
2137
|
+
if not self._display_columns:
|
|
2138
|
+
# Ensure at least one column is visible
|
|
2139
|
+
self._display_columns = list(range(len(self._heading_texts)))
|
|
2140
|
+
self._tree.configure(displaycolumns=self._display_columns)
|
|
2141
|
+
|
|
2142
|
+
def _reset_table(self) -> None:
|
|
2143
|
+
# Reset sort, columns visibility/order, and reload first page
|
|
2144
|
+
self._display_columns = list(range(len(self._heading_texts)))
|
|
2145
|
+
self._tree.configure(displaycolumns=self._display_columns)
|
|
2146
|
+
self._clear_sort()
|
|
2147
|
+
|
|
2148
|
+
# ------------------------------------------------------------------ Grouping
|
|
2149
|
+
def _group_header_column(self) -> None:
|
|
2150
|
+
"""Group current view by the selected header column."""
|
|
2151
|
+
col = self._header_menu_col
|
|
2152
|
+
if col is None or col >= len(self._column_keys):
|
|
2153
|
+
return
|
|
2154
|
+
key = self._column_keys[col]
|
|
2155
|
+
quoted_key = self._quote_col(key)
|
|
2156
|
+
self._group_by_key = key
|
|
2157
|
+
self._group_parents.clear()
|
|
2158
|
+
# Sort entire datasource by the grouping column so grouping reflects full dataset order
|
|
2159
|
+
try:
|
|
2160
|
+
self._datasource.set_sort(f"{quoted_key} ASC")
|
|
2161
|
+
except Exception:
|
|
2162
|
+
pass
|
|
2163
|
+
self._sort_state = {key: True}
|
|
2164
|
+
self._clear_cache()
|
|
2165
|
+
self._update_heading_icons()
|
|
2166
|
+
# Restart at first page to reflect new ordering
|
|
2167
|
+
self._load_page(0)
|
|
2168
|
+
self._update_status_labels()
|
|
2169
|
+
|
|
2170
|
+
def _ungroup_all(self) -> None:
|
|
2171
|
+
"""Return to flat view."""
|
|
2172
|
+
if not self._group_by_key:
|
|
2173
|
+
return
|
|
2174
|
+
self._group_by_key = None
|
|
2175
|
+
self._group_parents.clear()
|
|
2176
|
+
self._apply_group_show_state(False)
|
|
2177
|
+
self._load_page(self._current_page)
|
|
2178
|
+
self._update_status_labels()
|
|
2179
|
+
|
|
2180
|
+
def _apply_group_show_state(self, grouped: bool) -> None:
|
|
2181
|
+
"""Toggle tree column visibility when grouping."""
|
|
2182
|
+
if grouped:
|
|
2183
|
+
self._tree.configure(show="tree headings")
|
|
2184
|
+
heading = "Group"
|
|
2185
|
+
try:
|
|
2186
|
+
if self._group_by_key and self._group_by_key in self._column_keys:
|
|
2187
|
+
col_idx = self._column_keys.index(self._group_by_key)
|
|
2188
|
+
heading = self._heading_texts[col_idx] if col_idx < len(self._heading_texts) else heading
|
|
2189
|
+
except Exception:
|
|
2190
|
+
pass
|
|
2191
|
+
self._tree.heading("#0", text=heading, anchor="w")
|
|
2192
|
+
# Fix the group column width so it stays visible even when space is tight
|
|
2193
|
+
self._tree.column("#0", width=200, minwidth=120, anchor="w", stretch=False)
|
|
2194
|
+
try:
|
|
2195
|
+
# Reset horizontal view so the group column is not scrolled out
|
|
2196
|
+
self._tree.xview_moveto(0)
|
|
2197
|
+
except Exception:
|
|
2198
|
+
pass
|
|
2199
|
+
self._rebalance_grouped_widths()
|
|
2200
|
+
else:
|
|
2201
|
+
self._tree.configure(show="headings")
|
|
2202
|
+
# Keep the tree column narrow/inert when unused
|
|
2203
|
+
self._tree.heading("#0", text="")
|
|
2204
|
+
self._tree.column("#0", width=0, minwidth=0, stretch=False)
|
|
2205
|
+
# Restore stretch behavior for data columns based on scroll mode
|
|
2206
|
+
try:
|
|
2207
|
+
stretch_cols = not self._paging['xscroll']
|
|
2208
|
+
for idx in range(len(self._heading_texts)):
|
|
2209
|
+
self._tree.column(idx, stretch=stretch_cols)
|
|
2210
|
+
except Exception:
|
|
2211
|
+
pass
|
|
2212
|
+
|
|
2213
|
+
def _render_flat(self, records: list[dict]) -> None:
|
|
2214
|
+
"""Insert records as flat rows."""
|
|
2215
|
+
stripe = self._row_alternation.get('enabled', False) and not self._group_by_key
|
|
2216
|
+
for idx, rec in enumerate(records):
|
|
2217
|
+
values = [rec.get(k, "") for k in self._column_keys]
|
|
2218
|
+
tags = ("altrow",) if stripe and idx % 2 == 1 else ()
|
|
2219
|
+
iid = self._tree.insert("", "end", values=values, tags=tags)
|
|
2220
|
+
self._row_map[iid] = rec
|
|
2221
|
+
|
|
2222
|
+
def _render_grouped(self, records: list[dict]) -> None:
|
|
2223
|
+
"""Insert records under parent nodes for the active group."""
|
|
2224
|
+
key = self._group_by_key
|
|
2225
|
+
if not key or key not in self._column_keys:
|
|
2226
|
+
self._render_flat(records)
|
|
2227
|
+
return
|
|
2228
|
+
col_idx = self._column_keys.index(key)
|
|
2229
|
+
heading_text = self._heading_texts[col_idx] if col_idx < len(self._heading_texts) else key
|
|
2230
|
+
groups: OrderedDict[str | None, list[dict]] = OrderedDict()
|
|
2231
|
+
for rec in records:
|
|
2232
|
+
groups.setdefault(rec.get(key), []).append(rec)
|
|
2233
|
+
self._group_parents.clear()
|
|
2234
|
+
for val, items in groups.items():
|
|
2235
|
+
label_val = "(None)" if val is None else str(val)
|
|
2236
|
+
label = f"{heading_text}: {label_val} ({len(items)})"
|
|
2237
|
+
parent_iid = self._tree.insert("", "end", text=label, open=True)
|
|
2238
|
+
self._group_parents[val] = parent_iid
|
|
2239
|
+
for rec in items:
|
|
2240
|
+
values = [rec.get(k, "") for k in self._column_keys]
|
|
2241
|
+
iid = self._tree.insert(parent_iid, "end", values=values)
|
|
2242
|
+
self._row_map[iid] = rec
|
|
2243
|
+
|
|
2244
|
+
def _clear_sort(self) -> None:
|
|
2245
|
+
self._sort_state.clear()
|
|
2246
|
+
self._datasource.set_sort("")
|
|
2247
|
+
self._clear_cache()
|
|
2248
|
+
self._update_heading_icons()
|
|
2249
|
+
self._load_page(0)
|
|
2250
|
+
self._update_status_labels()
|
|
2251
|
+
|
|
2252
|
+
|
|
2253
|
+
# Backwards-compatible alias for the legacy Tableview name
|
|
2254
|
+
Tableview = TableView
|