bootstack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bootstack/__init__.py +157 -0
- bootstack/__main__.py +5 -0
- bootstack/_core/__init__.py +21 -0
- bootstack/_core/capabilities/__init__.py +45 -0
- bootstack/_core/capabilities/after.py +103 -0
- bootstack/_core/capabilities/bind.py +192 -0
- bootstack/_core/capabilities/bindtags.py +112 -0
- bootstack/_core/capabilities/busy.py +72 -0
- bootstack/_core/capabilities/clipboard.py +89 -0
- bootstack/_core/capabilities/focus.py +118 -0
- bootstack/_core/capabilities/grab.py +65 -0
- bootstack/_core/capabilities/grid.py +211 -0
- bootstack/_core/capabilities/localization.py +231 -0
- bootstack/_core/capabilities/pack.py +140 -0
- bootstack/_core/capabilities/place.py +113 -0
- bootstack/_core/capabilities/selection.py +136 -0
- bootstack/_core/capabilities/signals.py +244 -0
- bootstack/_core/capabilities/winfo.py +315 -0
- bootstack/_core/colorutils.py +234 -0
- bootstack/_core/exceptions.py +25 -0
- bootstack/_core/images.py +463 -0
- bootstack/_core/mixins/__init__.py +1 -0
- bootstack/_core/mixins/ttk_state.py +35 -0
- bootstack/_core/mixins/widget.py +132 -0
- bootstack/_core/paths.py +49 -0
- bootstack/_core/publisher.py +149 -0
- bootstack/_core/variables.py +62 -0
- bootstack/_runtime/__init__.py +3 -0
- bootstack/_runtime/app.py +930 -0
- bootstack/_runtime/base_window.py +945 -0
- bootstack/_runtime/events.py +399 -0
- bootstack/_runtime/shortcuts.py +496 -0
- bootstack/_runtime/tk_patch.py +43 -0
- bootstack/_runtime/toplevel.py +276 -0
- bootstack/_runtime/utility.py +457 -0
- bootstack/_runtime/visual_focus.py +240 -0
- bootstack/_runtime/window_utilities.py +1123 -0
- bootstack/assets/__init__.py +21 -0
- bootstack/assets/bootstack.ico +0 -0
- bootstack/assets/bootstack.png +0 -0
- bootstack/assets/elements/__init__.py +0 -0
- bootstack/assets/elements/badge-pill.png +0 -0
- bootstack/assets/elements/badge-square.png +0 -0
- bootstack/assets/elements/button-compact.png +0 -0
- bootstack/assets/elements/button-default.png +0 -0
- bootstack/assets/elements/buttongroup-after-h-compact.png +0 -0
- bootstack/assets/elements/buttongroup-after-h-default.png +0 -0
- bootstack/assets/elements/buttongroup-after-v-compact.png +0 -0
- bootstack/assets/elements/buttongroup-after-v-default.png +0 -0
- bootstack/assets/elements/buttongroup-before-h-compact.png +0 -0
- bootstack/assets/elements/buttongroup-before-h-default.png +0 -0
- bootstack/assets/elements/buttongroup-before-v-compact.png +0 -0
- bootstack/assets/elements/buttongroup-before-v-default.png +0 -0
- bootstack/assets/elements/buttongroup-center-h-compact.png +0 -0
- bootstack/assets/elements/buttongroup-center-h-default.png +0 -0
- bootstack/assets/elements/buttongroup-center-v-compact.png +0 -0
- bootstack/assets/elements/buttongroup-center-v-default.png +0 -0
- bootstack/assets/elements/card.png +0 -0
- bootstack/assets/elements/checkbox-checked.png +0 -0
- bootstack/assets/elements/checkbox-indeterminate.png +0 -0
- bootstack/assets/elements/checkbox-unchecked.png +0 -0
- bootstack/assets/elements/field.png +0 -0
- bootstack/assets/elements/input-addon-compact.png +0 -0
- bootstack/assets/elements/input-addon-default.png +0 -0
- bootstack/assets/elements/input-compact.png +0 -0
- bootstack/assets/elements/input-default.png +0 -0
- bootstack/assets/elements/list-item-separated.png +0 -0
- bootstack/assets/elements/list-item.png +0 -0
- bootstack/assets/elements/listrow-compact.png +0 -0
- bootstack/assets/elements/listrow-default.png +0 -0
- bootstack/assets/elements/manifest.toml +361 -0
- bootstack/assets/elements/menu-item.png +0 -0
- bootstack/assets/elements/navitem-compact.png +0 -0
- bootstack/assets/elements/navitem-default.png +0 -0
- bootstack/assets/elements/progressbar-h-compact.png +0 -0
- bootstack/assets/elements/progressbar-h-default.png +0 -0
- bootstack/assets/elements/progressbar-v-compact.png +0 -0
- bootstack/assets/elements/progressbar-v-default.png +0 -0
- bootstack/assets/elements/radiobutton.png +0 -0
- bootstack/assets/elements/scrollbar-horizontal.png +0 -0
- bootstack/assets/elements/scrollbar-vertical.png +0 -0
- bootstack/assets/elements/slider-handle.png +0 -0
- bootstack/assets/elements/slider-track-h.png +0 -0
- bootstack/assets/elements/slider-track-v.png +0 -0
- bootstack/assets/elements/switch-off.png +0 -0
- bootstack/assets/elements/switch-on.png +0 -0
- bootstack/assets/elements/tab-h.png +0 -0
- bootstack/assets/elements/tab-v.png +0 -0
- bootstack/assets/icons/bootstrap.ttf +0 -0
- bootstack/assets/icons/glyphmap.json +2080 -0
- bootstack/assets/icons/icon_metrics.json +12470 -0
- bootstack/assets/locales/ar/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/ar/LC_MESSAGES/bootstack.po +856 -0
- bootstack/assets/locales/bg/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/bg/LC_MESSAGES/bootstack.po +878 -0
- bootstack/assets/locales/cs/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/cs/LC_MESSAGES/bootstack.po +856 -0
- bootstack/assets/locales/da/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/da/LC_MESSAGES/bootstack.po +856 -0
- bootstack/assets/locales/de/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/de/LC_MESSAGES/bootstack.po +856 -0
- bootstack/assets/locales/en/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/en/LC_MESSAGES/bootstack.po +878 -0
- bootstack/assets/locales/es/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/es/LC_MESSAGES/bootstack.po +856 -0
- bootstack/assets/locales/fr/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/fr/LC_MESSAGES/bootstack.po +856 -0
- bootstack/assets/locales/he/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/he/LC_MESSAGES/bootstack.po +854 -0
- bootstack/assets/locales/hi/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/hi/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/it/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/it/LC_MESSAGES/bootstack.po +844 -0
- bootstack/assets/locales/ja/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/ja/LC_MESSAGES/bootstack.po +917 -0
- bootstack/assets/locales/ko/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/ko/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/nb/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/nb/LC_MESSAGES/bootstack.po +844 -0
- bootstack/assets/locales/nl/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/nl/LC_MESSAGES/bootstack.po +844 -0
- bootstack/assets/locales/pl/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/pl/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/pt/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/pt/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/sl/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/sl/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/sv/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/sv/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/tr/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/tr/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.po +845 -0
- bootstack/cli/__init__.py +133 -0
- bootstack/cli/__main__.py +6 -0
- bootstack/cli/add.py +395 -0
- bootstack/cli/appicon.py +285 -0
- bootstack/cli/build.py +115 -0
- bootstack/cli/config.py +313 -0
- bootstack/cli/demo.py +564 -0
- bootstack/cli/dev.py +153 -0
- bootstack/cli/doctor.py +195 -0
- bootstack/cli/icons.py +98 -0
- bootstack/cli/promote.py +120 -0
- bootstack/cli/pyinstaller.py +268 -0
- bootstack/cli/run.py +95 -0
- bootstack/cli/start.py +117 -0
- bootstack/cli/templates/__init__.py +931 -0
- bootstack/clipboard.py +48 -0
- bootstack/constants.py +318 -0
- bootstack/data/README.md +615 -0
- bootstack/data/__init__.py +78 -0
- bootstack/data/_observable.py +276 -0
- bootstack/data/base.py +780 -0
- bootstack/data/file_source.py +367 -0
- bootstack/data/memory_source.py +450 -0
- bootstack/data/query.py +367 -0
- bootstack/data/readers.py +289 -0
- bootstack/data/sqlite_source.py +869 -0
- bootstack/data/types.py +354 -0
- bootstack/data/writers.py +232 -0
- bootstack/dev/__init__.py +36 -0
- bootstack/dev/_capture.py +141 -0
- bootstack/dev/_env.py +43 -0
- bootstack/dev/_registry.py +140 -0
- bootstack/dev/_reloader.py +351 -0
- bootstack/dev/_reset.py +50 -0
- bootstack/dev/_watcher.py +91 -0
- bootstack/dialogs/__init__.py +948 -0
- bootstack/dialogs/_impl/__init__.py +47 -0
- bootstack/dialogs/_impl/colorchooser.py +588 -0
- bootstack/dialogs/_impl/datedialog.py +450 -0
- bootstack/dialogs/_impl/dialog.py +594 -0
- bootstack/dialogs/_impl/filterdialog.py +358 -0
- bootstack/dialogs/_impl/fontdialog.py +364 -0
- bootstack/dialogs/_impl/formdialog.py +564 -0
- bootstack/dialogs/_impl/message.py +486 -0
- bootstack/dialogs/_impl/query.py +570 -0
- bootstack/errors.py +67 -0
- bootstack/events/__init__.py +111 -0
- bootstack/events/_event.py +135 -0
- bootstack/events/_payloads.py +539 -0
- bootstack/events/_subscription.py +38 -0
- bootstack/i18n/README.md +77 -0
- bootstack/i18n/__init__.py +23 -0
- bootstack/i18n/catalog.py +121 -0
- bootstack/i18n/intl_format.py +584 -0
- bootstack/i18n/msgcat.py +425 -0
- bootstack/i18n/specs.py +156 -0
- bootstack/images.py +563 -0
- bootstack/py.typed +1 -0
- bootstack/scheduling/__init__.py +11 -0
- bootstack/scheduling/_schedule.py +218 -0
- bootstack/shortcuts.py +21 -0
- bootstack/signals/README.md +98 -0
- bootstack/signals/__init__.py +10 -0
- bootstack/signals/integration.py +100 -0
- bootstack/signals/signal.py +353 -0
- bootstack/signals/types.py +6 -0
- bootstack/store.py +286 -0
- bootstack/streams/__init__.py +12 -0
- bootstack/streams/_stream.py +321 -0
- bootstack/style/__init__.py +38 -0
- bootstack/style/builders/__init__.py +51 -0
- bootstack/style/builders/badge.py +46 -0
- bootstack/style/builders/button.py +339 -0
- bootstack/style/builders/buttongroup.py +311 -0
- bootstack/style/builders/calendar.py +271 -0
- bootstack/style/builders/checkbutton.py +110 -0
- bootstack/style/builders/combobox.py +113 -0
- bootstack/style/builders/contextmenu.py +268 -0
- bootstack/style/builders/entry.py +82 -0
- bootstack/style/builders/expander.py +148 -0
- bootstack/style/builders/field.py +335 -0
- bootstack/style/builders/frame.py +50 -0
- bootstack/style/builders/label.py +28 -0
- bootstack/style/builders/labelframe.py +34 -0
- bootstack/style/builders/listview.py +369 -0
- bootstack/style/builders/menubar.py +91 -0
- bootstack/style/builders/menubutton.py +359 -0
- bootstack/style/builders/panedwindow.py +25 -0
- bootstack/style/builders/progressbar.py +67 -0
- bootstack/style/builders/radiobutton.py +99 -0
- bootstack/style/builders/scale.py +64 -0
- bootstack/style/builders/scrollbar.py +225 -0
- bootstack/style/builders/separator.py +49 -0
- bootstack/style/builders/sidenav.py +643 -0
- bootstack/style/builders/sizegrip.py +15 -0
- bootstack/style/builders/spinbox.py +119 -0
- bootstack/style/builders/switch.py +70 -0
- bootstack/style/builders/tabitem.py +204 -0
- bootstack/style/builders/togglegroup.py +294 -0
- bootstack/style/builders/toolbutton.py +275 -0
- bootstack/style/builders/tooltip.py +26 -0
- bootstack/style/builders/treeview.py +193 -0
- bootstack/style/builders/utils.py +455 -0
- bootstack/style/builders_tk/__init__.py +16 -0
- bootstack/style/builders_tk/defaults.py +229 -0
- bootstack/style/element.py +173 -0
- bootstack/style/fonts.py +123 -0
- bootstack/style/style.py +609 -0
- bootstack/style/style_builder_base.py +716 -0
- bootstack/style/style_builder_mixed.py +93 -0
- bootstack/style/style_builder_tk.py +109 -0
- bootstack/style/style_builder_ttk.py +353 -0
- bootstack/style/style_resolver.py +447 -0
- bootstack/style/theme.py +245 -0
- bootstack/style/theme_provider.py +471 -0
- bootstack/style/themes/__init__.py +128 -0
- bootstack/style/tk_patch.py +5 -0
- bootstack/style/token_maps.py +41 -0
- bootstack/style/types.py +32 -0
- bootstack/style/typography.py +523 -0
- bootstack/style/utility.py +746 -0
- bootstack/types.py +39 -0
- bootstack/validation/__init__.py +6 -0
- bootstack/validation/types.py +15 -0
- bootstack/validation/validation_result.py +17 -0
- bootstack/validation/validation_rules.py +205 -0
- bootstack/widgets/__init__.py +74 -0
- bootstack/widgets/_core/__init__.py +31 -0
- bootstack/widgets/_core/app_config.py +266 -0
- bootstack/widgets/_core/base.py +461 -0
- bootstack/widgets/_core/container.py +334 -0
- bootstack/widgets/_core/context.py +35 -0
- bootstack/widgets/_core/events.py +130 -0
- bootstack/widgets/_core/field_mixin.py +353 -0
- bootstack/widgets/_core/icon_image_props.py +72 -0
- bootstack/widgets/_core/image_binding.py +93 -0
- bootstack/widgets/_core/navmodel.py +345 -0
- bootstack/widgets/_core/options.py +210 -0
- bootstack/widgets/_core/selection_group.py +130 -0
- bootstack/widgets/_core/window_controls.py +96 -0
- bootstack/widgets/_core/window_menu.py +246 -0
- bootstack/widgets/_impl/__init__.py +1 -0
- bootstack/widgets/_impl/_internal/__init__.py +0 -0
- bootstack/widgets/_impl/_internal/wrapper_base.py +307 -0
- bootstack/widgets/_impl/_parts/__init__.py +11 -0
- bootstack/widgets/_impl/_parts/numberentry_part.py +385 -0
- bootstack/widgets/_impl/_parts/spinnerentry_part.py +434 -0
- bootstack/widgets/_impl/_parts/textentry_part.py +406 -0
- bootstack/widgets/_impl/composites/__init__.py +33 -0
- bootstack/widgets/_impl/composites/_dateutils.py +33 -0
- bootstack/widgets/_impl/composites/_image_fit.py +105 -0
- bootstack/widgets/_impl/composites/accordion.py +381 -0
- bootstack/widgets/_impl/composites/avatar.py +191 -0
- bootstack/widgets/_impl/composites/buttongroup.py +371 -0
- bootstack/widgets/_impl/composites/calendar.py +972 -0
- bootstack/widgets/_impl/composites/carousel.py +567 -0
- bootstack/widgets/_impl/composites/chart.py +882 -0
- bootstack/widgets/_impl/composites/compositeframe.py +298 -0
- bootstack/widgets/_impl/composites/contextmenu.py +1951 -0
- bootstack/widgets/_impl/composites/dateentry.py +404 -0
- bootstack/widgets/_impl/composites/dropdownbutton.py +325 -0
- bootstack/widgets/_impl/composites/expander.py +515 -0
- bootstack/widgets/_impl/composites/field.py +670 -0
- bootstack/widgets/_impl/composites/form.py +1066 -0
- bootstack/widgets/_impl/composites/gallery.py +551 -0
- bootstack/widgets/_impl/composites/list/__init__.py +15 -0
- bootstack/widgets/_impl/composites/list/listitem.py +802 -0
- bootstack/widgets/_impl/composites/list/listview.py +1433 -0
- bootstack/widgets/_impl/composites/menu/__init__.py +18 -0
- bootstack/widgets/_impl/composites/menu/model.py +358 -0
- bootstack/widgets/_impl/composites/menu/render_native.py +134 -0
- bootstack/widgets/_impl/composites/menu/render_themed.py +134 -0
- bootstack/widgets/_impl/composites/meter.py +860 -0
- bootstack/widgets/_impl/composites/numericentry.py +201 -0
- bootstack/widgets/_impl/composites/pagestack.py +395 -0
- bootstack/widgets/_impl/composites/passwordentry.py +142 -0
- bootstack/widgets/_impl/composites/pathentry.py +168 -0
- bootstack/widgets/_impl/composites/picture.py +289 -0
- bootstack/widgets/_impl/composites/radiogroup.py +511 -0
- bootstack/widgets/_impl/composites/scrolledtext.py +375 -0
- bootstack/widgets/_impl/composites/scrolledtext.pyi +186 -0
- bootstack/widgets/_impl/composites/scrollview.py +764 -0
- bootstack/widgets/_impl/composites/selectbox.py +1026 -0
- bootstack/widgets/_impl/composites/shell/__init__.py +40 -0
- bootstack/widgets/_impl/composites/shell/content_host.py +52 -0
- bootstack/widgets/_impl/composites/shell/layout.py +335 -0
- bootstack/widgets/_impl/composites/shell/nav_panel.py +345 -0
- bootstack/widgets/_impl/composites/shell/providers.py +558 -0
- bootstack/widgets/_impl/composites/shell/rail.py +117 -0
- bootstack/widgets/_impl/composites/shell/shell.py +581 -0
- bootstack/widgets/_impl/composites/shell/workspace.py +273 -0
- bootstack/widgets/_impl/composites/sidenav/__init__.py +16 -0
- bootstack/widgets/_impl/composites/sidenav/header.py +81 -0
- bootstack/widgets/_impl/composites/sidenav/separator.py +44 -0
- bootstack/widgets/_impl/composites/slider/__init__.py +7 -0
- bootstack/widgets/_impl/composites/slider/_shared.py +195 -0
- bootstack/widgets/_impl/composites/slider/rangeslider.py +982 -0
- bootstack/widgets/_impl/composites/slider/slider.py +851 -0
- bootstack/widgets/_impl/composites/spinnerentry.py +185 -0
- bootstack/widgets/_impl/composites/tableview/__init__.py +5 -0
- bootstack/widgets/_impl/composites/tableview/tableview.py +3388 -0
- bootstack/widgets/_impl/composites/tableview/types.py +169 -0
- bootstack/widgets/_impl/composites/tabs/__init__.py +23 -0
- bootstack/widgets/_impl/composites/tabs/tabitem.py +389 -0
- bootstack/widgets/_impl/composites/tabs/tabs.py +974 -0
- bootstack/widgets/_impl/composites/tabs/tabview.py +650 -0
- bootstack/widgets/_impl/composites/textarea/__init__.py +26 -0
- bootstack/widgets/_impl/composites/textarea/change.py +46 -0
- bootstack/widgets/_impl/composites/textarea/codeeditor.py +526 -0
- bootstack/widgets/_impl/composites/textarea/core.py +495 -0
- bootstack/widgets/_impl/composites/textarea/decoration.py +42 -0
- bootstack/widgets/_impl/composites/textarea/diff.py +127 -0
- bootstack/widgets/_impl/composites/textarea/extensions/__init__.py +1 -0
- bootstack/widgets/_impl/composites/textarea/extensions/bracket_matcher.py +138 -0
- bootstack/widgets/_impl/composites/textarea/extensions/indent_guides.py +158 -0
- bootstack/widgets/_impl/composites/textarea/extensions/line_numbers.py +138 -0
- bootstack/widgets/_impl/composites/textarea/extensions/pygments_highlighter.py +312 -0
- bootstack/widgets/_impl/composites/textarea/extensions/smart_indent.py +177 -0
- bootstack/widgets/_impl/composites/textarea/filter.py +171 -0
- bootstack/widgets/_impl/composites/textarea/search_overlay.py +459 -0
- bootstack/widgets/_impl/composites/textarea/sidebar.py +88 -0
- bootstack/widgets/_impl/composites/textarea/style_registry.py +178 -0
- bootstack/widgets/_impl/composites/textarea/textarea.py +606 -0
- bootstack/widgets/_impl/composites/textarea/undo.py +217 -0
- bootstack/widgets/_impl/composites/textentry.py +57 -0
- bootstack/widgets/_impl/composites/timeentry.py +176 -0
- bootstack/widgets/_impl/composites/toast.py +390 -0
- bootstack/widgets/_impl/composites/toast_stack.py +156 -0
- bootstack/widgets/_impl/composites/togglegroup.py +418 -0
- bootstack/widgets/_impl/composites/toolbar.py +608 -0
- bootstack/widgets/_impl/composites/tooltip.py +491 -0
- bootstack/widgets/_impl/composites/tree/__init__.py +7 -0
- bootstack/widgets/_impl/composites/tree/source_binding.py +136 -0
- bootstack/widgets/_impl/composites/tree/treeitem.py +393 -0
- bootstack/widgets/_impl/composites/tree/treenode.py +174 -0
- bootstack/widgets/_impl/composites/tree/treeview.py +841 -0
- bootstack/widgets/_impl/mixins/__init__.py +24 -0
- bootstack/widgets/_impl/mixins/configure_mixin.py +216 -0
- bootstack/widgets/_impl/mixins/entry_mixin.py +134 -0
- bootstack/widgets/_impl/mixins/font_mixin.py +368 -0
- bootstack/widgets/_impl/mixins/icon_mixin.py +61 -0
- bootstack/widgets/_impl/mixins/localization_mixin.py +253 -0
- bootstack/widgets/_impl/mixins/signal_mixin.py +268 -0
- bootstack/widgets/_impl/mixins/validation_mixin.py +226 -0
- bootstack/widgets/_impl/primitives/__init__.py +49 -0
- bootstack/widgets/_impl/primitives/_menubutton.py +107 -0
- bootstack/widgets/_impl/primitives/badge.py +45 -0
- bootstack/widgets/_impl/primitives/button.py +76 -0
- bootstack/widgets/_impl/primitives/card.py +45 -0
- bootstack/widgets/_impl/primitives/checkbutton.py +124 -0
- bootstack/widgets/_impl/primitives/checktoggle.py +62 -0
- bootstack/widgets/_impl/primitives/combobox.py +156 -0
- bootstack/widgets/_impl/primitives/entry.py +87 -0
- bootstack/widgets/_impl/primitives/flexframe.py +448 -0
- bootstack/widgets/_impl/primitives/frame.py +185 -0
- bootstack/widgets/_impl/primitives/gridframe.py +546 -0
- bootstack/widgets/_impl/primitives/label.py +84 -0
- bootstack/widgets/_impl/primitives/labelframe.py +54 -0
- bootstack/widgets/_impl/primitives/optionmenu.py +387 -0
- bootstack/widgets/_impl/primitives/packframe.py +227 -0
- bootstack/widgets/_impl/primitives/panedwindow.py +45 -0
- bootstack/widgets/_impl/primitives/progressbar.py +83 -0
- bootstack/widgets/_impl/primitives/radiobutton.py +115 -0
- bootstack/widgets/_impl/primitives/radiotoggle.py +54 -0
- bootstack/widgets/_impl/primitives/scrollbar.py +42 -0
- bootstack/widgets/_impl/primitives/separator.py +43 -0
- bootstack/widgets/_impl/primitives/sizegrip.py +33 -0
- bootstack/widgets/_impl/primitives/spinbox.py +95 -0
- bootstack/widgets/_impl/primitives/switch.py +44 -0
- bootstack/widgets/_impl/primitives/treeview.py +69 -0
- bootstack/widgets/app.py +371 -0
- bootstack/widgets/appshell.py +1179 -0
- bootstack/widgets/avatar.py +140 -0
- bootstack/widgets/boolean_controls.py +455 -0
- bootstack/widgets/button.py +224 -0
- bootstack/widgets/buttongroup.py +239 -0
- bootstack/widgets/calendar.py +195 -0
- bootstack/widgets/card.py +159 -0
- bootstack/widgets/carousel.py +241 -0
- bootstack/widgets/chart.py +302 -0
- bootstack/widgets/codeeditor.py +675 -0
- bootstack/widgets/contextmenu.py +371 -0
- bootstack/widgets/datatable.py +688 -0
- bootstack/widgets/datefield.py +395 -0
- bootstack/widgets/divider.py +60 -0
- bootstack/widgets/expander.py +579 -0
- bootstack/widgets/form.py +200 -0
- bootstack/widgets/gallery.py +250 -0
- bootstack/widgets/gauge.py +168 -0
- bootstack/widgets/grid.py +121 -0
- bootstack/widgets/groupbox.py +162 -0
- bootstack/widgets/label.py +229 -0
- bootstack/widgets/listview.py +354 -0
- bootstack/widgets/menubutton.py +419 -0
- bootstack/widgets/numberfield.py +431 -0
- bootstack/widgets/pagestack.py +345 -0
- bootstack/widgets/passwordfield.py +383 -0
- bootstack/widgets/pathfield.py +454 -0
- bootstack/widgets/picture.py +251 -0
- bootstack/widgets/progressbar.py +106 -0
- bootstack/widgets/radio_variants.py +271 -0
- bootstack/widgets/radiogroup.py +228 -0
- bootstack/widgets/scrollbar.py +64 -0
- bootstack/widgets/scrollview.py +186 -0
- bootstack/widgets/select.py +302 -0
- bootstack/widgets/selectbutton.py +182 -0
- bootstack/widgets/sidebar_toggle.py +130 -0
- bootstack/widgets/sizegrip.py +42 -0
- bootstack/widgets/slider.py +408 -0
- bootstack/widgets/spinbox.py +147 -0
- bootstack/widgets/spinnerfield.py +413 -0
- bootstack/widgets/splash.py +367 -0
- bootstack/widgets/splitview.py +482 -0
- bootstack/widgets/stacks.py +249 -0
- bootstack/widgets/statusbar.py +189 -0
- bootstack/widgets/tabs.py +425 -0
- bootstack/widgets/textarea.py +459 -0
- bootstack/widgets/textfield.py +410 -0
- bootstack/widgets/theme_toggle.py +92 -0
- bootstack/widgets/timefield.py +340 -0
- bootstack/widgets/toast.py +314 -0
- bootstack/widgets/togglegroup.py +231 -0
- bootstack/widgets/toolbar.py +347 -0
- bootstack/widgets/tooltip.py +94 -0
- bootstack/widgets/tree.py +648 -0
- bootstack/widgets/types.py +305 -0
- bootstack/widgets/window.py +307 -0
- bootstack-0.1.0.dist-info/METADATA +301 -0
- bootstack-0.1.0.dist-info/RECORD +471 -0
- bootstack-0.1.0.dist-info/WHEEL +5 -0
- bootstack-0.1.0.dist-info/entry_points.txt +2 -0
- bootstack-0.1.0.dist-info/licenses/LICENSE +21 -0
- bootstack-0.1.0.dist-info/licenses/NOTICE +10 -0
- bootstack-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,3388 @@
|
|
|
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 contextlib
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from collections import OrderedDict
|
|
14
|
+
from tkinter import font as tkfont
|
|
15
|
+
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
from typing_extensions import Literal, TypedDict, Unpack
|
|
18
|
+
|
|
19
|
+
from bootstack.widgets.types import Master, WidgetDensity
|
|
20
|
+
|
|
21
|
+
from bootstack.events import RowEvent, RowsEvent, SelectionEvent, ExportEvent
|
|
22
|
+
from bootstack._core.images import _ImageService
|
|
23
|
+
from bootstack.style.style import get_style
|
|
24
|
+
# Row identity and the set of hidden internal columns are read through the
|
|
25
|
+
# datasource's protocol-level helpers (`_record_id`, `_public_record`,
|
|
26
|
+
# `_internal_fields`) so the table works with any `DataSourceProtocol` source,
|
|
27
|
+
# not just SqliteDataSource. SqliteDataSource is imported only to create the
|
|
28
|
+
# default in-memory source when no `datasource=` is supplied.
|
|
29
|
+
from bootstack.data.sqlite_source import SqliteDataSource
|
|
30
|
+
from bootstack.data.query import col, any_of, all_of
|
|
31
|
+
from bootstack.widgets._impl.primitives.button import Button
|
|
32
|
+
from bootstack._runtime.utility import bind_right_click
|
|
33
|
+
from bootstack.widgets._impl.composites.contextmenu import ContextMenu
|
|
34
|
+
from bootstack.widgets._impl.composites.tooltip import ToolTip
|
|
35
|
+
from bootstack.widgets._impl.composites.dropdownbutton import DropdownButton
|
|
36
|
+
from bootstack.widgets._impl.primitives.entry import Entry
|
|
37
|
+
from bootstack.widgets._impl.primitives.frame import Frame, FrameKwargs
|
|
38
|
+
from bootstack.widgets._impl.primitives.label import Label
|
|
39
|
+
from bootstack.widgets._impl.primitives.scrollbar import Scrollbar
|
|
40
|
+
from bootstack.widgets._impl.primitives.progressbar import Progressbar
|
|
41
|
+
from bootstack.widgets._impl.composites.selectbox import SelectBox
|
|
42
|
+
from bootstack.widgets._impl.primitives.separator import Separator
|
|
43
|
+
from bootstack.widgets._impl.composites.textentry import TextEntry
|
|
44
|
+
from bootstack.widgets._impl.primitives.treeview import TreeView
|
|
45
|
+
from bootstack.i18n import MessageCatalog
|
|
46
|
+
|
|
47
|
+
from .types import (
|
|
48
|
+
parse_selection_mode as _parse_selection_mode,
|
|
49
|
+
build_editing_options as _build_editing_options,
|
|
50
|
+
build_selection_options as _build_selection_options,
|
|
51
|
+
build_filtering_options as _build_filtering_options,
|
|
52
|
+
build_exporting_options as _build_exporting_options,
|
|
53
|
+
build_paging_options as _build_paging_options,
|
|
54
|
+
build_search_options as _build_search_options,
|
|
55
|
+
build_row_alternation_options as _build_row_alternation_options,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
logger = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
# Max characters of the filter summary shown in the status bar before it is
|
|
61
|
+
# truncated (the full text is then revealed via a hover tooltip).
|
|
62
|
+
_FILTER_STATUS_MAXLEN = 48
|
|
63
|
+
|
|
64
|
+
# Read-batch size when streaming an export (a throughput knob, distinct from the
|
|
65
|
+
# on-screen page size).
|
|
66
|
+
_EXPORT_CHUNK_SIZE = 1000
|
|
67
|
+
|
|
68
|
+
# Soft cap for the materializing accessors (to_rows/to_csv); above this they
|
|
69
|
+
# raise and point the caller at the streaming API.
|
|
70
|
+
_EXPORT_MAX_MATERIALIZE = 100_000
|
|
71
|
+
|
|
72
|
+
# Above this row count the built-in "Save to file" runs asynchronously with a
|
|
73
|
+
# progress dialog; below it, a synchronous (instant) write.
|
|
74
|
+
_EXPORT_ASYNC_THRESHOLD = 5000
|
|
75
|
+
|
|
76
|
+
# Stripe strength: fraction of the elevated stripe color blended over the table
|
|
77
|
+
# surface (lower = fainter stripe). Tuned so the stripe stays a faint neutral
|
|
78
|
+
# that contrasts with the subtle accent selection.
|
|
79
|
+
_STRIPE_STRENGTH = 0.5
|
|
80
|
+
|
|
81
|
+
# Fixed pixel size for per-row selection marker icons (checkbox/dot). Rendered
|
|
82
|
+
# 1:1 into the row's icon slot — kept even and unscaled for crisp edges.
|
|
83
|
+
_MARKER_ICON_SIZE = 20
|
|
84
|
+
|
|
85
|
+
# The toolbar and footer are utility chrome around the data, so their widgets are
|
|
86
|
+
# always compact regardless of the table's row density.
|
|
87
|
+
_CHROME_DENSITY: WidgetDensity = 'compact'
|
|
88
|
+
|
|
89
|
+
# Group expand/collapse chevrons shown in the leading slot of group-header rows
|
|
90
|
+
# (the native tree indicator was removed from the item layout).
|
|
91
|
+
_GROUP_OPEN_ICON = 'chevron-down' # expanded
|
|
92
|
+
_GROUP_CLOSED_ICON = 'chevron-right' # collapsed
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _ExportJob:
|
|
96
|
+
"""Drives a streamed export across the Tk event loop (cooperative chunking).
|
|
97
|
+
|
|
98
|
+
Each idle tick writes one chunk and reschedules, so the UI stays responsive
|
|
99
|
+
and the export can be cancelled between chunks. No worker threads — the
|
|
100
|
+
data source's SQLite connection is thread-affine.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, widget, chunks, write_chunk, close, total, *, on_progress, on_complete):
|
|
104
|
+
self._widget = widget # provides after_idle / after_cancel
|
|
105
|
+
self._chunks = chunks # iterator of raw-record chunks
|
|
106
|
+
self._write_chunk = write_chunk
|
|
107
|
+
self._close = close
|
|
108
|
+
self._total = total
|
|
109
|
+
self._on_progress = on_progress
|
|
110
|
+
self._on_complete = on_complete # (status, written, error)
|
|
111
|
+
self._written = 0
|
|
112
|
+
self._cancelled = False
|
|
113
|
+
self._done = False
|
|
114
|
+
self._after_id = None
|
|
115
|
+
|
|
116
|
+
def start(self) -> "_ExportJob":
|
|
117
|
+
self._after_id = self._widget.after_idle(self._step)
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
def cancel(self) -> None:
|
|
121
|
+
"""Request cancellation; takes effect before the next chunk."""
|
|
122
|
+
self._cancelled = True
|
|
123
|
+
|
|
124
|
+
def abort(self) -> None:
|
|
125
|
+
"""Cancel and close the writer NOW (used on widget teardown).
|
|
126
|
+
|
|
127
|
+
Unlike `cancel()`, this doesn't wait for the next idle tick — the
|
|
128
|
+
scheduled step won't run after destroy — so it closes the writer and
|
|
129
|
+
finalizes synchronously.
|
|
130
|
+
"""
|
|
131
|
+
if self._after_id is not None:
|
|
132
|
+
try:
|
|
133
|
+
self._widget.after_cancel(self._after_id)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
self._after_id = None
|
|
137
|
+
self._cancelled = True
|
|
138
|
+
self._finish("cancelled", None)
|
|
139
|
+
|
|
140
|
+
def _step(self) -> None:
|
|
141
|
+
self._after_id = None
|
|
142
|
+
if self._cancelled:
|
|
143
|
+
self._finish("cancelled", None)
|
|
144
|
+
return
|
|
145
|
+
try:
|
|
146
|
+
chunk = next(self._chunks, None)
|
|
147
|
+
except Exception as exc: # data-source read failed
|
|
148
|
+
self._finish("error", exc)
|
|
149
|
+
return
|
|
150
|
+
if chunk is None:
|
|
151
|
+
self._finish("completed", None)
|
|
152
|
+
return
|
|
153
|
+
try:
|
|
154
|
+
self._write_chunk(chunk)
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
self._finish("error", exc)
|
|
157
|
+
return
|
|
158
|
+
self._written += len(chunk)
|
|
159
|
+
if self._on_progress is not None:
|
|
160
|
+
try:
|
|
161
|
+
self._on_progress(self._written, self._total)
|
|
162
|
+
except Exception:
|
|
163
|
+
logger.exception("Export progress callback failed")
|
|
164
|
+
self._after_id = self._widget.after_idle(self._step)
|
|
165
|
+
|
|
166
|
+
def _finish(self, status: str, error) -> None:
|
|
167
|
+
if self._done: # guard against double-finish (e.g. abort after natural end)
|
|
168
|
+
return
|
|
169
|
+
self._done = True
|
|
170
|
+
try:
|
|
171
|
+
self._close()
|
|
172
|
+
except Exception:
|
|
173
|
+
logger.exception("Failed to close export writer")
|
|
174
|
+
if self._on_complete is not None:
|
|
175
|
+
self._on_complete(status, self._written, error)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _has_xlsxwriter() -> bool:
|
|
179
|
+
"""Whether the optional XlsxWriter dependency (bootstack[excel]) is installed."""
|
|
180
|
+
return _module_available("xlsxwriter")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _module_available(module: str) -> bool:
|
|
184
|
+
"""Whether an optional dependency can be imported."""
|
|
185
|
+
import importlib.util
|
|
186
|
+
try:
|
|
187
|
+
return importlib.util.find_spec(module) is not None
|
|
188
|
+
except Exception:
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# Export formats the DataTable can offer. `kind` picks the engine: 'cooperative'
|
|
193
|
+
# uses the column-aware, cancelable, chunk-by-chunk exporter (good for huge CSVs);
|
|
194
|
+
# 'registry' streams through bootstack.data.writers (synchronous). `extra` names
|
|
195
|
+
# the pip extra to install when `available` is False.
|
|
196
|
+
_EXPORT_FORMATS: dict = {
|
|
197
|
+
"csv": {"ext": ".csv", "label": "CSV file", "kind": "cooperative", "extra": None, "available": lambda: True},
|
|
198
|
+
"tsv": {"ext": ".tsv", "label": "TSV file", "kind": "cooperative", "extra": None, "available": lambda: True},
|
|
199
|
+
"xlsx": {"ext": ".xlsx", "label": "Excel file", "kind": "cooperative", "extra": "excel", "available": _has_xlsxwriter},
|
|
200
|
+
"json": {"ext": ".json", "label": "JSON file", "kind": "registry", "extra": None, "available": lambda: True},
|
|
201
|
+
"jsonl": {"ext": ".jsonl", "label": "JSON Lines file", "kind": "registry", "extra": None, "available": lambda: True},
|
|
202
|
+
"xml": {"ext": ".xml", "label": "XML file", "kind": "registry", "extra": None, "available": lambda: True},
|
|
203
|
+
"parquet": {"ext": ".parquet", "label": "Parquet file", "kind": "registry", "extra": "parquet", "available": lambda: _module_available("pyarrow")},
|
|
204
|
+
"feather": {"ext": ".feather", "label": "Feather file", "kind": "registry", "extra": "parquet", "available": lambda: _module_available("pyarrow")},
|
|
205
|
+
"hdf5": {"ext": ".h5", "label": "HDF5 file", "kind": "registry", "extra": "hdf5", "available": lambda: _module_available("tables")},
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# Map a file extension to a canonical export format name (including aliases).
|
|
209
|
+
_EXT_TO_EXPORT_FORMAT: dict = {spec["ext"]: name for name, spec in _EXPORT_FORMATS.items()}
|
|
210
|
+
_EXT_TO_EXPORT_FORMAT.update({
|
|
211
|
+
".ndjson": "jsonl",
|
|
212
|
+
".hdf5": "hdf5",
|
|
213
|
+
".hdf": "hdf5",
|
|
214
|
+
".arrow": "feather",
|
|
215
|
+
".txt": "csv",
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
_TABLE_SEARCH_MODE_OPTIONS = [
|
|
219
|
+
("table.search_mode_equals", "EQUALS"),
|
|
220
|
+
("table.search_mode_contains", "CONTAINS"),
|
|
221
|
+
("table.search_mode_starts_with", "STARTS WITH"),
|
|
222
|
+
("table.search_mode_ends_with", "ENDS WITH"),
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class TableView(Frame):
|
|
236
|
+
"""TableView backed by an in-memory SqliteDataSource.
|
|
237
|
+
|
|
238
|
+
Provides sortable headers, filtering/search, pagination or virtual scrolling,
|
|
239
|
+
optional grouping, column striping, and configurable exporting/editing.
|
|
240
|
+
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
def __init__(
|
|
244
|
+
self,
|
|
245
|
+
master: Master = None,
|
|
246
|
+
# Core data
|
|
247
|
+
columns: list[str | dict] | None = None,
|
|
248
|
+
rows: list | None = None,
|
|
249
|
+
datasource: SqliteDataSource | None = None,
|
|
250
|
+
# Selection & sorting
|
|
251
|
+
selection_mode: Literal['none', 'single', 'multi'] = 'single',
|
|
252
|
+
allow_select_all: bool = True,
|
|
253
|
+
sorting_mode: Literal['single', 'none'] = 'single',
|
|
254
|
+
# Filtering & search
|
|
255
|
+
enable_filtering: bool = True,
|
|
256
|
+
enable_header_filtering: bool = True,
|
|
257
|
+
enable_row_filtering: bool = True,
|
|
258
|
+
enable_search: bool = True,
|
|
259
|
+
broadcast_search: bool = False,
|
|
260
|
+
search_mode: Literal['standard', 'advanced'] = 'standard',
|
|
261
|
+
search_trigger: Literal['enter', 'input'] = 'enter',
|
|
262
|
+
# Paging & scrolling
|
|
263
|
+
paging_mode: Literal['standard', 'virtual'] = 'standard',
|
|
264
|
+
page_size: int = 25,
|
|
265
|
+
page_index: int = 0,
|
|
266
|
+
page_cache_size: int = 3,
|
|
267
|
+
show_vscrollbar: bool = True,
|
|
268
|
+
show_hscrollbar: bool = False,
|
|
269
|
+
# Editing
|
|
270
|
+
enable_adding: bool = False,
|
|
271
|
+
enable_editing: bool = False,
|
|
272
|
+
enable_deleting: bool = False,
|
|
273
|
+
form_options: dict | None = None,
|
|
274
|
+
# Exporting
|
|
275
|
+
enable_exporting: bool = False,
|
|
276
|
+
allow_export_selection: bool = True,
|
|
277
|
+
export_scope: Literal['page', 'all'] = 'page',
|
|
278
|
+
export_formats: tuple[str, ...] | None = None,
|
|
279
|
+
# Appearance & extras
|
|
280
|
+
striped: bool = False,
|
|
281
|
+
striped_background: str = 'background[+0.85]',
|
|
282
|
+
density: WidgetDensity = 'default',
|
|
283
|
+
allow_grouping: bool = False,
|
|
284
|
+
show_table_status: bool = True,
|
|
285
|
+
show_column_chooser: bool = False,
|
|
286
|
+
show_selection_controls: bool = False,
|
|
287
|
+
id_field: str = "id",
|
|
288
|
+
context_menus: Literal['none', 'headers', 'rows', 'all'] = 'all',
|
|
289
|
+
column_min_width: int = 40,
|
|
290
|
+
column_auto_width: bool = False,
|
|
291
|
+
**kwargs: Unpack[FrameKwargs],
|
|
292
|
+
):
|
|
293
|
+
"""
|
|
294
|
+
Create a TableView backed by an in-memory SqliteDataSource.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
master: Parent widget.
|
|
298
|
+
columns: Column definitions (list of strings or dicts with keys like
|
|
299
|
+
"text", "key", "width", "minwidth").
|
|
300
|
+
rows: Initial data to load (list of dicts or row-like sequences).
|
|
301
|
+
datasource: Custom SqliteDataSource; if omitted, an in-memory source is created.
|
|
302
|
+
selection_mode: Selection mode ('none', 'single', 'multi'). Defaults to 'single'.
|
|
303
|
+
allow_select_all: Whether select-all is allowed. Defaults to True.
|
|
304
|
+
sorting_mode: Sorting mode ('single' or 'none'). Defaults to 'single'.
|
|
305
|
+
enable_filtering: Enable filtering features. Defaults to True.
|
|
306
|
+
enable_header_filtering: Show filter option in header context menu. Defaults to True.
|
|
307
|
+
enable_row_filtering: Show filter option in row context menu. Defaults to True.
|
|
308
|
+
enable_search: Show search bar. Defaults to True.
|
|
309
|
+
search_mode: Search mode ('standard' or 'advanced'). Defaults to 'standard'.
|
|
310
|
+
search_trigger: When to trigger search ('enter' or 'input'). Defaults to 'enter'.
|
|
311
|
+
paging_mode: Paging mode ('standard' or 'virtual'). Defaults to 'standard'.
|
|
312
|
+
page_size: Number of rows per page. Defaults to 25.
|
|
313
|
+
page_index: Initial page index. Defaults to 0.
|
|
314
|
+
page_cache_size: Number of pages to cache. Defaults to 3.
|
|
315
|
+
show_vscrollbar: Show vertical scrollbar. Defaults to True.
|
|
316
|
+
show_hscrollbar: Show horizontal scrollbar. Defaults to False.
|
|
317
|
+
enable_adding: Allow adding new rows. Defaults to False.
|
|
318
|
+
enable_editing: Allow editing existing rows. Defaults to False.
|
|
319
|
+
enable_deleting: Allow deleting rows. Defaults to False.
|
|
320
|
+
form_options: Options dict for the edit form dialog.
|
|
321
|
+
enable_exporting: Enable export functionality. Defaults to False.
|
|
322
|
+
allow_export_selection: Allow exporting selected rows. Defaults to True.
|
|
323
|
+
export_scope: Export scope ('page' or 'all'). Defaults to 'page'.
|
|
324
|
+
export_formats: Tuple of export formats (e.g., ('csv', 'xlsx')).
|
|
325
|
+
striped: Show alternating row colors. Defaults to False.
|
|
326
|
+
striped_background: Background color for striped rows. Defaults to 'background[+0.85]'.
|
|
327
|
+
density: Row compactness ('default' or 'compact'). Defaults to 'default'.
|
|
328
|
+
allow_grouping: Allow grouping rows via header context menu. Defaults to False.
|
|
329
|
+
show_table_status: Show filter/sort/group status labels and pager. Defaults to True.
|
|
330
|
+
show_column_chooser: Show column chooser button. Defaults to False.
|
|
331
|
+
show_selection_controls: Show per-row checkboxes in 'multi' selection
|
|
332
|
+
mode (a plain click then toggles rows). No effect in 'single'
|
|
333
|
+
mode or while grouped. Defaults to False.
|
|
334
|
+
id_field: Record field used as the stable row identity for the
|
|
335
|
+
auto-created data source. Ignored when `datasource` is provided.
|
|
336
|
+
Defaults to 'id'.
|
|
337
|
+
context_menus: Context menu visibility ('none', 'headers', 'rows', 'all').
|
|
338
|
+
Defaults to 'all'.
|
|
339
|
+
column_min_width: Global minimum width for columns. Defaults to 40.
|
|
340
|
+
column_auto_width: Automatically size columns to widest visible text.
|
|
341
|
+
Defaults to False.
|
|
342
|
+
**kwargs: Additional arguments passed through to Frame.
|
|
343
|
+
"""
|
|
344
|
+
super().__init__(master, **kwargs)
|
|
345
|
+
|
|
346
|
+
# Build internal configuration dicts from flattened kwargs
|
|
347
|
+
self._editing = _build_editing_options(
|
|
348
|
+
enable_adding=enable_adding,
|
|
349
|
+
enable_editing=enable_editing,
|
|
350
|
+
enable_deleting=enable_deleting,
|
|
351
|
+
form_options=form_options,
|
|
352
|
+
)
|
|
353
|
+
self._paging = _build_paging_options(
|
|
354
|
+
paging_mode=paging_mode,
|
|
355
|
+
page_size=page_size,
|
|
356
|
+
page_index=page_index,
|
|
357
|
+
page_cache_size=page_cache_size,
|
|
358
|
+
show_vscrollbar=show_vscrollbar,
|
|
359
|
+
show_hscrollbar=show_hscrollbar,
|
|
360
|
+
)
|
|
361
|
+
self._exporting = _build_exporting_options(
|
|
362
|
+
enable_exporting=enable_exporting,
|
|
363
|
+
allow_export_selection=allow_export_selection,
|
|
364
|
+
export_scope=export_scope,
|
|
365
|
+
export_formats=export_formats,
|
|
366
|
+
)
|
|
367
|
+
self._warn_unavailable_export_formats()
|
|
368
|
+
self._filtering = _build_filtering_options(
|
|
369
|
+
enable_filtering=enable_filtering,
|
|
370
|
+
enable_header_filtering=enable_header_filtering,
|
|
371
|
+
enable_row_filtering=enable_row_filtering,
|
|
372
|
+
)
|
|
373
|
+
self._selection = _build_selection_options(
|
|
374
|
+
selection_mode=selection_mode,
|
|
375
|
+
allow_select_all=allow_select_all,
|
|
376
|
+
)
|
|
377
|
+
self._searchbar = _build_search_options(
|
|
378
|
+
enable_search=enable_search,
|
|
379
|
+
search_mode=search_mode,
|
|
380
|
+
search_trigger=search_trigger,
|
|
381
|
+
)
|
|
382
|
+
# The active free-text search term (composed with column filters into where()).
|
|
383
|
+
self._search_text: str = ""
|
|
384
|
+
# Tooltip showing the full filter summary when the status label is truncated.
|
|
385
|
+
self._filter_tooltip: ToolTip | None = None
|
|
386
|
+
# In-flight async export jobs, aborted on widget teardown.
|
|
387
|
+
self._export_jobs: set = set()
|
|
388
|
+
# The live add/edit form dialog while it is open (for capture/automation).
|
|
389
|
+
self._active_form_dialog = None
|
|
390
|
+
self._active_chooser_dialog = None
|
|
391
|
+
self._row_alternation = _build_row_alternation_options(
|
|
392
|
+
striped=striped,
|
|
393
|
+
striped_background=striped_background,
|
|
394
|
+
)
|
|
395
|
+
# Treeview row density, forwarded to the TreeView style options.
|
|
396
|
+
self._density: WidgetDensity = density
|
|
397
|
+
# Per-row selection markers (checkbox/dot) in the leading icon slot.
|
|
398
|
+
self._selection_indicators = bool(show_selection_controls)
|
|
399
|
+
# Cache of rendered marker icons keyed by (name, size, color).
|
|
400
|
+
self._marker_icons: dict = {}
|
|
401
|
+
|
|
402
|
+
self._search_mode_map: dict[str, str] = {}
|
|
403
|
+
self._broadcast_search: bool = broadcast_search
|
|
404
|
+
# Suppress the table's own _on_source_change re-load while a broadcast
|
|
405
|
+
# search is being applied (the load is done directly in _apply_where).
|
|
406
|
+
self._suppressing_search_broadcast: bool = False
|
|
407
|
+
self._sorting = sorting_mode
|
|
408
|
+
self._show_table_status = show_table_status
|
|
409
|
+
self._show_column_chooser = show_column_chooser
|
|
410
|
+
self._allow_grouping = allow_grouping
|
|
411
|
+
self._context_menus = (context_menus or 'all').lower()
|
|
412
|
+
self._column_min_width = max(0, column_min_width)
|
|
413
|
+
self._column_auto_width = column_auto_width
|
|
414
|
+
self._datasource = datasource or SqliteDataSource(
|
|
415
|
+
':memory:', page_size=self._paging['page_size'], id_field=id_field
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
self._page_cache: OrderedDict[int, list[dict]] = OrderedDict()
|
|
419
|
+
self._column_defs = columns or []
|
|
420
|
+
self._column_keys: list[str] = []
|
|
421
|
+
self._heading_texts: list[str] = []
|
|
422
|
+
self._sort_state: dict[str, bool] = {} # key -> ascending
|
|
423
|
+
self._current_page = self._paging['page_index']
|
|
424
|
+
self._loading_next = False
|
|
425
|
+
self._heading_fg: str | None = None
|
|
426
|
+
self._icon_sort_up = None
|
|
427
|
+
self._icon_sort_down = None
|
|
428
|
+
self._column_anchors: list[str] = []
|
|
429
|
+
self._column_formats: dict[int, Any] = {} # idx -> resolved display formatter (or None)
|
|
430
|
+
self._column_filters: dict[str, list] = {} # key -> list of allowed values
|
|
431
|
+
self._column_types: dict[str, str] = {}
|
|
432
|
+
self._alignment_sample: list[dict] | None = None
|
|
433
|
+
self._row_map: dict[str, dict] = {}
|
|
434
|
+
self._row_menu: ContextMenu | None = None
|
|
435
|
+
# The row a right-click context menu targets. Right-click never changes
|
|
436
|
+
# the selection (that is a left-click affordance), so the row menu acts
|
|
437
|
+
# on this clicked row instead — see `_context_iids`.
|
|
438
|
+
self._context_iid: str | None = None
|
|
439
|
+
self._display_columns: list[int] = []
|
|
440
|
+
self._header_menu: ContextMenu | None = None
|
|
441
|
+
self._header_menu_col: int | None = None
|
|
442
|
+
self._row_menu_col: int | None = None
|
|
443
|
+
self._cached_total_count: int | None = None
|
|
444
|
+
self._group_by_key: str | None = None
|
|
445
|
+
self._group_parents: dict[str | None, str] = {}
|
|
446
|
+
self._hidden_rows: dict[str, tuple[str, int]] = {}
|
|
447
|
+
|
|
448
|
+
self._resolve_column_keys()
|
|
449
|
+
|
|
450
|
+
seeded_records: list[dict] | None = None
|
|
451
|
+
if rows:
|
|
452
|
+
# Seed the source silently — the table renders this data itself below,
|
|
453
|
+
# and the change subscription is not yet attached.
|
|
454
|
+
with self._silence_source():
|
|
455
|
+
try:
|
|
456
|
+
if self._column_keys:
|
|
457
|
+
# Avoid per-row dict conversion when we already know the column order
|
|
458
|
+
self._datasource.load(rows, column_keys=self._column_keys)
|
|
459
|
+
seeded_records = None
|
|
460
|
+
else:
|
|
461
|
+
seeded_records = self._to_records(rows)
|
|
462
|
+
self._datasource.load(seeded_records)
|
|
463
|
+
except Exception:
|
|
464
|
+
# Last-resort fallback to dict conversion if direct load fails
|
|
465
|
+
seeded_records = self._to_records(rows)
|
|
466
|
+
try:
|
|
467
|
+
self._datasource.load(seeded_records)
|
|
468
|
+
except Exception:
|
|
469
|
+
seeded_records = []
|
|
470
|
+
|
|
471
|
+
self._ensure_column_metadata(seeded_records)
|
|
472
|
+
|
|
473
|
+
# UI
|
|
474
|
+
self._build_toolbar()
|
|
475
|
+
self._build_tree()
|
|
476
|
+
if self._show_table_status or not self._paging['mode'] == 'virtual':
|
|
477
|
+
self._build_footer()
|
|
478
|
+
|
|
479
|
+
# Initial load
|
|
480
|
+
self._load_page(0)
|
|
481
|
+
|
|
482
|
+
# Auto-refresh when the data source changes from the outside (a shared
|
|
483
|
+
# source mutated directly, or a background-thread feed). Every mutation
|
|
484
|
+
# this table makes itself is wrapped in `_silence_source()`, so this
|
|
485
|
+
# handler only fires for genuinely external changes. The hub marshals
|
|
486
|
+
# the callback onto the main thread.
|
|
487
|
+
self._change_sub = None
|
|
488
|
+
on_change = getattr(self._datasource, 'on_change', None)
|
|
489
|
+
if callable(on_change):
|
|
490
|
+
try:
|
|
491
|
+
self._change_sub = on_change(self._on_source_change)
|
|
492
|
+
except Exception:
|
|
493
|
+
self._change_sub = None
|
|
494
|
+
self.bind('<Destroy>', self._on_table_destroy, add='+')
|
|
495
|
+
# The alternating-row stripe + theme-colored marker/chevron/sort-arrow
|
|
496
|
+
# icons are imperative (not refreshed by the ttk style rebuild), so the
|
|
497
|
+
# unified theme walk re-applies them via `_bs_apply_theme` when the table
|
|
498
|
+
# is on screen.
|
|
499
|
+
|
|
500
|
+
def _silence_source(self):
|
|
501
|
+
"""Context manager suppressing source change broadcasts for our own writes."""
|
|
502
|
+
silence = getattr(self._datasource, '_silence', None)
|
|
503
|
+
if callable(silence):
|
|
504
|
+
return silence()
|
|
505
|
+
return contextlib.nullcontext()
|
|
506
|
+
|
|
507
|
+
@contextlib.contextmanager
|
|
508
|
+
def _apply_view_to_source(self):
|
|
509
|
+
"""Temporarily apply this table's local filter/sort to the source (silenced).
|
|
510
|
+
|
|
511
|
+
Saves the source's current where/order state, applies the table's own
|
|
512
|
+
search + column-filter condition and sort order for the duration of the
|
|
513
|
+
block, then restores the source to its original state. This lets each
|
|
514
|
+
DataTable maintain independent view state over a shared source without
|
|
515
|
+
permanently mutating it.
|
|
516
|
+
"""
|
|
517
|
+
ds = self._datasource
|
|
518
|
+
old_filter = getattr(ds, '_filter', None)
|
|
519
|
+
old_sort = list(getattr(ds, '_sort', []))
|
|
520
|
+
# Combine the source's own filter with this table's local search/column
|
|
521
|
+
# filters so source-level constraints (e.g. ds.where(...) called from
|
|
522
|
+
# app code) are respected alongside the table's per-view state.
|
|
523
|
+
# When broadcast_search is on the table already wrote its search condition
|
|
524
|
+
# to the source, so old_filter IS the combined condition — don't add local
|
|
525
|
+
# conditions again or they'd be applied twice.
|
|
526
|
+
if self._broadcast_search:
|
|
527
|
+
combined = old_filter
|
|
528
|
+
else:
|
|
529
|
+
combined = all_of(
|
|
530
|
+
old_filter,
|
|
531
|
+
self._build_search_condition(),
|
|
532
|
+
self._build_column_filter_condition(),
|
|
533
|
+
)
|
|
534
|
+
# Use the table's local sort when set; fall back to the source's sort
|
|
535
|
+
# so a source-level order() is respected when no column header is clicked.
|
|
536
|
+
sort_args = [k if asc else f"-{k}" for k, asc in self._sort_state.items()]
|
|
537
|
+
effective_sort = sort_args if sort_args else old_sort
|
|
538
|
+
with self._silence_source():
|
|
539
|
+
ds.where(combined)
|
|
540
|
+
ds.order(*effective_sort)
|
|
541
|
+
try:
|
|
542
|
+
yield
|
|
543
|
+
finally:
|
|
544
|
+
with self._silence_source():
|
|
545
|
+
try:
|
|
546
|
+
ds.where(old_filter)
|
|
547
|
+
finally:
|
|
548
|
+
ds.order(*old_sort)
|
|
549
|
+
|
|
550
|
+
def _on_source_change(self, event=None) -> None:
|
|
551
|
+
"""Reload the current page after an external data-source change."""
|
|
552
|
+
if self._suppressing_search_broadcast:
|
|
553
|
+
# Consume the suppression — this is the hub flush that the broadcast
|
|
554
|
+
# write in _apply_where() triggered. The table already loaded page 0
|
|
555
|
+
# directly, so skip the redundant reload but allow future changes
|
|
556
|
+
# through by clearing the flag.
|
|
557
|
+
self._suppressing_search_broadcast = False
|
|
558
|
+
return
|
|
559
|
+
try:
|
|
560
|
+
self._clear_cache()
|
|
561
|
+
self._load_page(self._current_page)
|
|
562
|
+
except Exception:
|
|
563
|
+
logger.exception("Failed to reload table after data-source change")
|
|
564
|
+
|
|
565
|
+
def _on_table_destroy(self, event=None) -> None:
|
|
566
|
+
"""Release subscriptions, in-flight exports, and the tooltip on destroy."""
|
|
567
|
+
if event is not None and getattr(event, 'widget', None) is not self:
|
|
568
|
+
return
|
|
569
|
+
# The theme subscription is released by the Frame base hook's own
|
|
570
|
+
# <Destroy> handler (publisher unsubscribe).
|
|
571
|
+
sub = self._change_sub
|
|
572
|
+
self._change_sub = None
|
|
573
|
+
if sub is not None:
|
|
574
|
+
try:
|
|
575
|
+
sub.cancel()
|
|
576
|
+
except Exception:
|
|
577
|
+
pass
|
|
578
|
+
# Abort any in-flight async export: its idle step won't run post-destroy,
|
|
579
|
+
# so close the writer + remove the partial file synchronously now.
|
|
580
|
+
for job in list(self._export_jobs):
|
|
581
|
+
try:
|
|
582
|
+
job.abort()
|
|
583
|
+
except Exception:
|
|
584
|
+
logger.exception("Failed to abort export job on destroy")
|
|
585
|
+
self._export_jobs.clear()
|
|
586
|
+
# Tear down the filter tooltip (unbinds its handlers, cancels its timer).
|
|
587
|
+
tooltip = self._filter_tooltip
|
|
588
|
+
self._filter_tooltip = None
|
|
589
|
+
if tooltip is not None:
|
|
590
|
+
try:
|
|
591
|
+
tooltip.destroy()
|
|
592
|
+
except Exception:
|
|
593
|
+
pass
|
|
594
|
+
|
|
595
|
+
# ------------------------------------------------------------------ Public API
|
|
596
|
+
def set_data(self, rows: list) -> None:
|
|
597
|
+
"""Replace data in the datasource and refresh the grid."""
|
|
598
|
+
with self._silence_source():
|
|
599
|
+
if self._column_keys:
|
|
600
|
+
self._datasource.load(rows, column_keys=self._column_keys)
|
|
601
|
+
seeded_records = None
|
|
602
|
+
else:
|
|
603
|
+
seeded_records = self._to_records(rows)
|
|
604
|
+
self._datasource.load(seeded_records)
|
|
605
|
+
self._ensure_column_metadata(seeded_records)
|
|
606
|
+
self._clear_cache()
|
|
607
|
+
self._alignment_sample = None # re-sample for column alignment on new data
|
|
608
|
+
self._load_page(0)
|
|
609
|
+
|
|
610
|
+
# ------------------------------------------------------------------ Public data/selection API
|
|
611
|
+
def _public_record(self, rec: dict | None) -> dict:
|
|
612
|
+
"""Return a user-facing copy of `rec` — internal columns stripped, `id` surfaced."""
|
|
613
|
+
return self._datasource._public_record(rec)
|
|
614
|
+
|
|
615
|
+
def _record_id(self, rec: dict | None) -> Any:
|
|
616
|
+
"""Stable identity of a raw record, via the datasource's id accessor."""
|
|
617
|
+
return self._datasource._record_id(rec) if rec else None
|
|
618
|
+
|
|
619
|
+
def _internal_fields(self) -> "frozenset[str]":
|
|
620
|
+
"""Raw-record keys the datasource treats as internal (hidden from users)."""
|
|
621
|
+
return self._datasource._internal_fields()
|
|
622
|
+
|
|
623
|
+
def _iid_for_id(self, rid: Any) -> str | None:
|
|
624
|
+
"""Find the row handle of a currently-rendered row by its record `id`."""
|
|
625
|
+
for iid, rec in self._row_map.items():
|
|
626
|
+
if self._record_id(rec) == rid:
|
|
627
|
+
return iid
|
|
628
|
+
return None
|
|
629
|
+
|
|
630
|
+
@property
|
|
631
|
+
def selected_rows(self) -> list[dict]:
|
|
632
|
+
"""List of record dicts for the current selection."""
|
|
633
|
+
rows: list[dict] = []
|
|
634
|
+
for iid in self._tree.selection():
|
|
635
|
+
if iid in self._row_map:
|
|
636
|
+
rows.append(self._public_record(self._row_map[iid]))
|
|
637
|
+
return rows
|
|
638
|
+
|
|
639
|
+
# ------------------------------------------------------------------ Public row/column manipulation
|
|
640
|
+
def insert_rows(self, rows: list) -> None:
|
|
641
|
+
"""Insert new rows via the datasource and refresh."""
|
|
642
|
+
recs = self._to_records(rows)
|
|
643
|
+
inserted: list[dict] = []
|
|
644
|
+
with self._silence_source():
|
|
645
|
+
for rec in recs:
|
|
646
|
+
try:
|
|
647
|
+
# Strip any public 'id' — the datasource assigns the id.
|
|
648
|
+
payload = {k: v for k, v in dict(rec).items() if k != "id"}
|
|
649
|
+
new_id = self._datasource.insert(payload)
|
|
650
|
+
record = self._public_record(payload)
|
|
651
|
+
if new_id is not None:
|
|
652
|
+
record["id"] = new_id
|
|
653
|
+
inserted.append(record)
|
|
654
|
+
except Exception:
|
|
655
|
+
logger.exception("Failed to insert record")
|
|
656
|
+
if inserted:
|
|
657
|
+
self._clear_cache()
|
|
658
|
+
self._load_page(self._current_page)
|
|
659
|
+
self.event_generate("<<RowsInsert>>", data=RowsEvent(records=inserted))
|
|
660
|
+
|
|
661
|
+
def update_rows(self, rows: list[dict]) -> None:
|
|
662
|
+
"""Update rows by record `id`; each dict must include an `id` key."""
|
|
663
|
+
updated: list[dict] = []
|
|
664
|
+
with self._silence_source():
|
|
665
|
+
for rec in rows:
|
|
666
|
+
rec_id = rec.get("id")
|
|
667
|
+
if rec_id is None:
|
|
668
|
+
continue
|
|
669
|
+
_hidden = self._internal_fields()
|
|
670
|
+
updates = {k: v for k, v in rec.items() if k != "id" and k not in _hidden}
|
|
671
|
+
try:
|
|
672
|
+
self._datasource.update(rec_id, updates)
|
|
673
|
+
updated.append(dict(rec))
|
|
674
|
+
except Exception:
|
|
675
|
+
logger.exception("Failed to update record id=%s", rec_id)
|
|
676
|
+
if updated:
|
|
677
|
+
self._clear_cache()
|
|
678
|
+
self._load_page(self._current_page)
|
|
679
|
+
self.event_generate("<<RowsUpdate>>", data=RowsEvent(records=updated))
|
|
680
|
+
|
|
681
|
+
def delete_rows(self, rows_or_ids: list) -> None:
|
|
682
|
+
"""Delete rows by record `id`, or by record dicts containing an `id` key."""
|
|
683
|
+
deleted: list[dict] = []
|
|
684
|
+
with self._silence_source():
|
|
685
|
+
for item in rows_or_ids:
|
|
686
|
+
rec_id = None
|
|
687
|
+
rec = {}
|
|
688
|
+
if isinstance(item, dict):
|
|
689
|
+
rec = dict(item)
|
|
690
|
+
rec_id = item.get("id")
|
|
691
|
+
else:
|
|
692
|
+
rec_id = item
|
|
693
|
+
if rec_id is None:
|
|
694
|
+
continue
|
|
695
|
+
try:
|
|
696
|
+
self._datasource.delete(rec_id)
|
|
697
|
+
if not rec:
|
|
698
|
+
rec = {"id": rec_id}
|
|
699
|
+
deleted.append(rec)
|
|
700
|
+
except Exception:
|
|
701
|
+
logger.exception("Failed to delete record id=%s", rec_id)
|
|
702
|
+
if deleted:
|
|
703
|
+
self._clear_cache()
|
|
704
|
+
self._load_page(self._current_page)
|
|
705
|
+
self.event_generate("<<RowsDelete>>", data=RowsEvent(records=deleted))
|
|
706
|
+
|
|
707
|
+
def insert_columns(self, *_args, **_kwargs) -> None:
|
|
708
|
+
"""Not currently supported; columns are defined at construction time."""
|
|
709
|
+
raise NotImplementedError("Dynamic column insertion is not supported yet")
|
|
710
|
+
|
|
711
|
+
def delete_columns(self, indices: list[int]) -> None:
|
|
712
|
+
"""Hide columns at the given indices."""
|
|
713
|
+
self.hide_columns(indices)
|
|
714
|
+
|
|
715
|
+
def move_rows(self, iids: list[str], to_index: int) -> None:
|
|
716
|
+
"""Move the given rows to a target index in the root list."""
|
|
717
|
+
children = list(self._tree.get_children(""))
|
|
718
|
+
to_index = max(0, min(len(children), to_index))
|
|
719
|
+
for offset, iid in enumerate(iids):
|
|
720
|
+
try:
|
|
721
|
+
self._tree.move(iid, "", to_index + offset)
|
|
722
|
+
except Exception:
|
|
723
|
+
pass
|
|
724
|
+
self._apply_row_alternation()
|
|
725
|
+
moved_recs = [self._row_map.get(i) for i in iids if i in self._row_map]
|
|
726
|
+
if moved_recs:
|
|
727
|
+
records = [self._public_record(r) for r in moved_recs]
|
|
728
|
+
self.event_generate("<<RowsMove>>", data=RowsEvent(records=records))
|
|
729
|
+
|
|
730
|
+
def move_columns(self, from_index: int, to_index: int) -> None:
|
|
731
|
+
"""Reorder a column from one index to another."""
|
|
732
|
+
if from_index < 0 or from_index >= len(self._display_columns):
|
|
733
|
+
return
|
|
734
|
+
to_index = max(0, min(len(self._display_columns) - 1, to_index))
|
|
735
|
+
col_id = self._display_columns.pop(from_index)
|
|
736
|
+
self._display_columns.insert(to_index, col_id)
|
|
737
|
+
self._tree.configure(displaycolumns=self._display_columns)
|
|
738
|
+
|
|
739
|
+
def hide_rows(self, iids: list[str]) -> None:
|
|
740
|
+
"""Hide rows from view (not removed from datasource)."""
|
|
741
|
+
for iid in iids:
|
|
742
|
+
try:
|
|
743
|
+
parent = self._tree.parent(iid)
|
|
744
|
+
children = list(self._tree.get_children(parent))
|
|
745
|
+
idx = children.index(iid)
|
|
746
|
+
self._hidden_rows[iid] = (parent, idx)
|
|
747
|
+
self._tree.detach(iid)
|
|
748
|
+
except Exception:
|
|
749
|
+
pass
|
|
750
|
+
|
|
751
|
+
def unhide_rows(self, iids: list[str] | None = None) -> None:
|
|
752
|
+
"""Restore previously hidden rows."""
|
|
753
|
+
targets = iids or list(self._hidden_rows.keys())
|
|
754
|
+
for iid in targets:
|
|
755
|
+
if iid not in self._hidden_rows:
|
|
756
|
+
continue
|
|
757
|
+
parent, idx = self._hidden_rows.pop(iid)
|
|
758
|
+
try:
|
|
759
|
+
self._tree.move(iid, parent, idx)
|
|
760
|
+
except Exception:
|
|
761
|
+
pass
|
|
762
|
+
self._apply_row_alternation()
|
|
763
|
+
|
|
764
|
+
def hide_columns(self, indices: list[int]) -> None:
|
|
765
|
+
"""Remove columns from the displayed set."""
|
|
766
|
+
for idx in indices:
|
|
767
|
+
if idx in self._display_columns:
|
|
768
|
+
self._display_columns.remove(idx)
|
|
769
|
+
if not self._display_columns and self._heading_texts:
|
|
770
|
+
self._display_columns = list(range(len(self._heading_texts)))
|
|
771
|
+
self._tree.configure(displaycolumns=self._display_columns)
|
|
772
|
+
|
|
773
|
+
def unhide_columns(self, indices: list[int]) -> None:
|
|
774
|
+
"""Add columns back into the displayed set."""
|
|
775
|
+
changed = False
|
|
776
|
+
for idx in indices:
|
|
777
|
+
if idx not in self._display_columns and 0 <= idx < len(self._heading_texts):
|
|
778
|
+
self._display_columns.append(idx)
|
|
779
|
+
changed = True
|
|
780
|
+
if changed:
|
|
781
|
+
self._display_columns = sorted(self._display_columns)
|
|
782
|
+
self._tree.configure(displaycolumns=self._display_columns)
|
|
783
|
+
|
|
784
|
+
def select_rows(self, ids: list) -> None:
|
|
785
|
+
"""Select rows by record `id` (only those currently rendered)."""
|
|
786
|
+
iids = [iid for iid in (self._iid_for_id(rid) for rid in ids) if iid]
|
|
787
|
+
if iids:
|
|
788
|
+
self._tree.selection_set(iids)
|
|
789
|
+
|
|
790
|
+
def deselect_rows(self, ids: list | None = None) -> None:
|
|
791
|
+
"""Clear the selection, or remove specific rows by record `id`."""
|
|
792
|
+
if not ids:
|
|
793
|
+
self._tree.selection_remove(self._tree.selection())
|
|
794
|
+
return
|
|
795
|
+
iids = [iid for iid in (self._iid_for_id(rid) for rid in ids) if iid]
|
|
796
|
+
if iids:
|
|
797
|
+
self._tree.selection_remove(iids)
|
|
798
|
+
|
|
799
|
+
def scroll_to_row(self, rid: Any) -> None:
|
|
800
|
+
"""Ensure the row with the given record `id` is visible."""
|
|
801
|
+
iid = self._iid_for_id(rid)
|
|
802
|
+
if iid is None:
|
|
803
|
+
return
|
|
804
|
+
try:
|
|
805
|
+
self._tree.see(iid)
|
|
806
|
+
except Exception:
|
|
807
|
+
pass
|
|
808
|
+
|
|
809
|
+
# ------------------------------------------------------------------ Pagination helpers
|
|
810
|
+
def next_page(self) -> None:
|
|
811
|
+
self._next_page()
|
|
812
|
+
|
|
813
|
+
def previous_page(self) -> None:
|
|
814
|
+
self._prev_page()
|
|
815
|
+
|
|
816
|
+
def first_page(self) -> None:
|
|
817
|
+
self._first_page()
|
|
818
|
+
|
|
819
|
+
def last_page(self) -> None:
|
|
820
|
+
self._last_page()
|
|
821
|
+
|
|
822
|
+
def go_to_page(self, index: int) -> None:
|
|
823
|
+
self._load_page(max(0, index))
|
|
824
|
+
|
|
825
|
+
@property
|
|
826
|
+
def current_page(self) -> int:
|
|
827
|
+
"""Zero-based index of the page currently shown."""
|
|
828
|
+
return self._current_page
|
|
829
|
+
|
|
830
|
+
@property
|
|
831
|
+
def page_count(self) -> int:
|
|
832
|
+
"""Total number of pages for the current filter/search."""
|
|
833
|
+
return self._total_pages()
|
|
834
|
+
|
|
835
|
+
# ------------------------------------------------------------------ Filter/Sort/Group API
|
|
836
|
+
def get_filters(self) -> dict[str, list]:
|
|
837
|
+
"""Return the active column filters as `{column_key: allowed_values}`."""
|
|
838
|
+
return {k: list(v) for k, v in self._column_filters.items()}
|
|
839
|
+
|
|
840
|
+
def clear_filters(self) -> None:
|
|
841
|
+
"""Remove all active column filters (leaves the search term intact)."""
|
|
842
|
+
self._clear_filter_cmd()
|
|
843
|
+
|
|
844
|
+
def set_filter(self, column: str, values=None) -> None:
|
|
845
|
+
"""Set a column filter (or clear it when `values` is None); composes with search."""
|
|
846
|
+
if values is None:
|
|
847
|
+
self._column_filters.pop(column, None)
|
|
848
|
+
else:
|
|
849
|
+
self._column_filters[column] = list(values)
|
|
850
|
+
self._apply_where()
|
|
851
|
+
|
|
852
|
+
def get_search(self) -> str:
|
|
853
|
+
"""Return the active free-text search term."""
|
|
854
|
+
return self._search_text
|
|
855
|
+
|
|
856
|
+
def set_search(self, text: str) -> None:
|
|
857
|
+
"""Set the free-text search term and re-apply (leaves column filters intact)."""
|
|
858
|
+
text = text or ""
|
|
859
|
+
entry = getattr(self, "_search_entry", None)
|
|
860
|
+
if entry is not None:
|
|
861
|
+
entry.delete(0, "end")
|
|
862
|
+
entry.insert(0, text)
|
|
863
|
+
self._search_text = text
|
|
864
|
+
self._apply_where()
|
|
865
|
+
|
|
866
|
+
def clear_search(self) -> None:
|
|
867
|
+
"""Clear the free-text search term (leaves column filters intact)."""
|
|
868
|
+
self._clear_search()
|
|
869
|
+
|
|
870
|
+
def get_sorting(self) -> dict[str, bool]:
|
|
871
|
+
"""Return a copy of the current sort state {column_key: ascending}."""
|
|
872
|
+
return dict(self._sort_state)
|
|
873
|
+
|
|
874
|
+
def set_sorting(self, key: str, ascending: bool = True) -> None:
|
|
875
|
+
self._sort_state = {key: ascending}
|
|
876
|
+
self._clear_cache()
|
|
877
|
+
self._update_heading_icons()
|
|
878
|
+
self._load_page(0)
|
|
879
|
+
self._update_status_labels()
|
|
880
|
+
|
|
881
|
+
def clear_sorting(self) -> None:
|
|
882
|
+
self._clear_sort()
|
|
883
|
+
|
|
884
|
+
def get_grouping(self) -> str | None:
|
|
885
|
+
return self._group_by_key
|
|
886
|
+
|
|
887
|
+
def set_grouping(self, key: str | None) -> None:
|
|
888
|
+
if not key:
|
|
889
|
+
self._ungroup_all()
|
|
890
|
+
return
|
|
891
|
+
if key not in self._column_keys:
|
|
892
|
+
return
|
|
893
|
+
self._group_by_key = key
|
|
894
|
+
self._group_parents.clear()
|
|
895
|
+
self._sort_state = {key: True}
|
|
896
|
+
self._clear_cache()
|
|
897
|
+
self._update_heading_icons()
|
|
898
|
+
self._load_page(0)
|
|
899
|
+
self._update_status_labels()
|
|
900
|
+
|
|
901
|
+
def clear_grouping(self) -> None:
|
|
902
|
+
self._ungroup_all()
|
|
903
|
+
|
|
904
|
+
# ------------------------------------------------------------------ Group expand/collapse
|
|
905
|
+
def expand_all(self) -> None:
|
|
906
|
+
for iid in self._tree.get_children(""):
|
|
907
|
+
try:
|
|
908
|
+
self._tree.item(iid, open=True)
|
|
909
|
+
except Exception:
|
|
910
|
+
pass
|
|
911
|
+
|
|
912
|
+
def collapse_all(self) -> None:
|
|
913
|
+
for iid in self._tree.get_children(""):
|
|
914
|
+
try:
|
|
915
|
+
self._tree.item(iid, open=False)
|
|
916
|
+
except Exception:
|
|
917
|
+
pass
|
|
918
|
+
|
|
919
|
+
def expand_group(self, group_value) -> None:
|
|
920
|
+
parent = self._group_parents.get(group_value)
|
|
921
|
+
if parent:
|
|
922
|
+
try:
|
|
923
|
+
self._tree.item(parent, open=True)
|
|
924
|
+
except Exception:
|
|
925
|
+
pass
|
|
926
|
+
|
|
927
|
+
def collapse_group(self, group_value) -> None:
|
|
928
|
+
parent = self._group_parents.get(group_value)
|
|
929
|
+
if parent:
|
|
930
|
+
try:
|
|
931
|
+
self._tree.item(parent, open=False)
|
|
932
|
+
except Exception:
|
|
933
|
+
pass
|
|
934
|
+
|
|
935
|
+
def select_all(self) -> None:
|
|
936
|
+
"""Select all visible rows."""
|
|
937
|
+
self._tree.selection_set(self._tree.get_children(""))
|
|
938
|
+
|
|
939
|
+
def deselect_all(self) -> None:
|
|
940
|
+
"""Clear the selection."""
|
|
941
|
+
self._tree.selection_remove(self._tree.selection())
|
|
942
|
+
|
|
943
|
+
# ------------------------------------------------------------------ UI
|
|
944
|
+
|
|
945
|
+
def _resolve_alternating_row_color(self):
|
|
946
|
+
from bootstack.style.utility import mix_colors
|
|
947
|
+
|
|
948
|
+
style = get_style()
|
|
949
|
+
builder = style.style_builder
|
|
950
|
+
color_token = self._row_alternation.get('accent', 'background[+1]')
|
|
951
|
+
|
|
952
|
+
try:
|
|
953
|
+
stripe = builder.color(color_token)
|
|
954
|
+
surface = builder.color('content')
|
|
955
|
+
# Soften the stripe toward the table surface so it stays a faint
|
|
956
|
+
# neutral that contrasts with the subtle accent selection.
|
|
957
|
+
background = mix_colors(stripe, surface, _STRIPE_STRENGTH)
|
|
958
|
+
except Exception:
|
|
959
|
+
background = builder.color('background')
|
|
960
|
+
|
|
961
|
+
try:
|
|
962
|
+
foreground = builder.on_color(background)
|
|
963
|
+
except Exception:
|
|
964
|
+
foreground = builder.color('foreground')
|
|
965
|
+
return background, foreground
|
|
966
|
+
|
|
967
|
+
def _resolve_column_keys(self) -> None:
|
|
968
|
+
if not self._column_defs:
|
|
969
|
+
return
|
|
970
|
+
for idx, col in enumerate(self._column_defs):
|
|
971
|
+
if isinstance(col, str):
|
|
972
|
+
self._column_keys.append(col)
|
|
973
|
+
elif isinstance(col, dict):
|
|
974
|
+
self._column_keys.append(col.get("key") or col.get("text") or str(idx))
|
|
975
|
+
else:
|
|
976
|
+
self._column_keys.append(str(col))
|
|
977
|
+
|
|
978
|
+
def _ensure_column_metadata(self, sample_records: list[dict] | None) -> None:
|
|
979
|
+
"""Guarantee we have column keys/defs before the Treeview is built."""
|
|
980
|
+
if self._column_keys:
|
|
981
|
+
return
|
|
982
|
+
|
|
983
|
+
inferred: list[str] = []
|
|
984
|
+
if sample_records:
|
|
985
|
+
first = sample_records[0]
|
|
986
|
+
if isinstance(first, dict):
|
|
987
|
+
inferred = list(first.keys())
|
|
988
|
+
if not inferred:
|
|
989
|
+
inferred = getattr(self._datasource, "_columns", []) or []
|
|
990
|
+
|
|
991
|
+
_hidden = self._internal_fields()
|
|
992
|
+
inferred = [c for c in inferred if c not in _hidden]
|
|
993
|
+
if not inferred:
|
|
994
|
+
inferred = ["value"]
|
|
995
|
+
|
|
996
|
+
self._column_keys = inferred
|
|
997
|
+
if not self._column_defs:
|
|
998
|
+
self._column_defs = [{"text": c} for c in self._column_keys]
|
|
999
|
+
|
|
1000
|
+
def _build_toolbar(self) -> None:
|
|
1001
|
+
bar = Frame(self, name="toolbar")
|
|
1002
|
+
# Grid in column 0 only so the toolbar's right edge stops at the
|
|
1003
|
+
# tree's right edge instead of extending past the vsb.
|
|
1004
|
+
bar.grid(row=0, column=0, sticky="ew", pady=(0, 4))
|
|
1005
|
+
|
|
1006
|
+
if self._searchbar['enabled']:
|
|
1007
|
+
self._search_entry = TextEntry(bar, density=_CHROME_DENSITY)
|
|
1008
|
+
self._search_entry.insert_addon(Label, 'before', icon="search", icon_only=True)
|
|
1009
|
+
self._search_entry.insert_addon(Button, 'after', icon="x-lg", icon_only=True, command=self._clear_search)
|
|
1010
|
+
# Only reserve a 6 px right gap when the advanced-mode SelectBox
|
|
1011
|
+
# follows the entry; otherwise the entry hugs the toolbar edge.
|
|
1012
|
+
search_padx = (0, 6) if self._searchbar['mode'] == 'advanced' else 0
|
|
1013
|
+
self._search_entry.pack(side="left", fill="x", expand=True, padx=search_padx)
|
|
1014
|
+
trigger = str(self._searchbar.get('event', 'enter')).lower()
|
|
1015
|
+
if trigger == 'input':
|
|
1016
|
+
self._search_entry.on_input(lambda _e: self._run_search())
|
|
1017
|
+
else:
|
|
1018
|
+
self._search_entry.on_enter(lambda _e: self._run_search())
|
|
1019
|
+
# Clear filter when the box is emptied, but do not search on every keystroke
|
|
1020
|
+
self._search_entry.on_input(lambda _e: self._clear_search() if not self._search_entry.get() else None)
|
|
1021
|
+
|
|
1022
|
+
if self._searchbar['mode'] == 'advanced':
|
|
1023
|
+
search_items = []
|
|
1024
|
+
self._search_mode_map = {}
|
|
1025
|
+
for token, code in _TABLE_SEARCH_MODE_OPTIONS:
|
|
1026
|
+
label = MessageCatalog.translate(token)
|
|
1027
|
+
search_items.append(label)
|
|
1028
|
+
self._search_mode_map[label] = code
|
|
1029
|
+
default_value = search_items[0] if search_items else "EQUALS"
|
|
1030
|
+
self._search_mode = SelectBox(
|
|
1031
|
+
bar,
|
|
1032
|
+
items=search_items,
|
|
1033
|
+
value=default_value,
|
|
1034
|
+
width=14,
|
|
1035
|
+
allow_custom_values=False,
|
|
1036
|
+
enable_search=False,
|
|
1037
|
+
density=_CHROME_DENSITY,
|
|
1038
|
+
)
|
|
1039
|
+
self._search_mode.pack(side="left", padx=(0, 6))
|
|
1040
|
+
|
|
1041
|
+
if self._show_column_chooser:
|
|
1042
|
+
self._column_chooser_btn = Button(
|
|
1043
|
+
bar,
|
|
1044
|
+
icon="layout-three-columns",
|
|
1045
|
+
icon_only=True,
|
|
1046
|
+
accent="foreground",
|
|
1047
|
+
variant="ghost",
|
|
1048
|
+
density=_CHROME_DENSITY,
|
|
1049
|
+
command=self._show_column_chooser_dialog,
|
|
1050
|
+
)
|
|
1051
|
+
self._column_chooser_btn.pack(side="right", padx=(4, 0))
|
|
1052
|
+
|
|
1053
|
+
if self._exporting['enabled']:
|
|
1054
|
+
export_items = [
|
|
1055
|
+
{"type": "command", "key": "export_copy", "text": "Copy to clipboard", "command": self._copy_to_clipboard},
|
|
1056
|
+
{"type": "command", "key": "export_save", "text": "Save to file", "command": self._save_to_file},
|
|
1057
|
+
]
|
|
1058
|
+
self._export_btn = DropdownButton(
|
|
1059
|
+
bar,
|
|
1060
|
+
icon="download",
|
|
1061
|
+
icon_only=True,
|
|
1062
|
+
accent="foreground",
|
|
1063
|
+
variant="ghost",
|
|
1064
|
+
compound="image",
|
|
1065
|
+
density=_CHROME_DENSITY,
|
|
1066
|
+
items=export_items,
|
|
1067
|
+
show_dropdown_button=False,
|
|
1068
|
+
)
|
|
1069
|
+
self._export_btn.pack(side="right")
|
|
1070
|
+
|
|
1071
|
+
if self._editing['adding']:
|
|
1072
|
+
Button(
|
|
1073
|
+
bar,
|
|
1074
|
+
icon="plus-circle",
|
|
1075
|
+
text="table.add_record",
|
|
1076
|
+
accent="foreground",
|
|
1077
|
+
variant="ghost",
|
|
1078
|
+
density=_CHROME_DENSITY,
|
|
1079
|
+
command=self._open_new_record,
|
|
1080
|
+
).pack(side="right", padx=(0, 4))
|
|
1081
|
+
|
|
1082
|
+
def _build_tree(self) -> None:
|
|
1083
|
+
cols = [self._col_text(c) for c in self._column_defs] or self._column_keys
|
|
1084
|
+
|
|
1085
|
+
# Grid layout for the TableView body:
|
|
1086
|
+
# row 0: toolbar (col 0)
|
|
1087
|
+
# row 1: tree (col 0) | vsb (col 1, only this row)
|
|
1088
|
+
# row 2: hsb (col 0)
|
|
1089
|
+
# row 3: footer (col 0)
|
|
1090
|
+
# Column 0 expands; column 1 takes the vsb's natural width when present.
|
|
1091
|
+
self.grid_columnconfigure(0, weight=1)
|
|
1092
|
+
self.grid_rowconfigure(1, weight=1)
|
|
1093
|
+
|
|
1094
|
+
self._tree = TreeView(
|
|
1095
|
+
self,
|
|
1096
|
+
columns=list(range(len(cols))),
|
|
1097
|
+
selectmode=_parse_selection_mode(self._selection['mode']),
|
|
1098
|
+
show="headings",
|
|
1099
|
+
density=self._density,
|
|
1100
|
+
)
|
|
1101
|
+
# Inset the tree by the focus-ring affordance baked into sibling
|
|
1102
|
+
# entry images so the tree's content edge lines up with the visible
|
|
1103
|
+
# edge of the toolbar/footer entries (search box, pagination input).
|
|
1104
|
+
from bootstack.style.style_builder_base import StyleBuilderBase
|
|
1105
|
+
affordance = StyleBuilderBase.scale_from_source(8)
|
|
1106
|
+
self._tree.grid(row=1, column=0, sticky="nsew", padx=affordance)
|
|
1107
|
+
self._display_columns = list(range(len(cols)))
|
|
1108
|
+
|
|
1109
|
+
if self._paging['yscroll']:
|
|
1110
|
+
self._vsb = Scrollbar(self, orient="vertical", command=self._tree.yview)
|
|
1111
|
+
self._vsb.grid(row=1, column=1, sticky="ns")
|
|
1112
|
+
if self._paging['mode'] == "virtual":
|
|
1113
|
+
self._tree.configure(yscrollcommand=self._on_scroll)
|
|
1114
|
+
else:
|
|
1115
|
+
self._tree.configure(yscrollcommand=self._vsb.set)
|
|
1116
|
+
else:
|
|
1117
|
+
self._vsb = None
|
|
1118
|
+
|
|
1119
|
+
if self._paging['xscroll']:
|
|
1120
|
+
self._hsb = Scrollbar(self, orient="horizontal", command=self._tree.xview)
|
|
1121
|
+
# Mirror the tree's affordance inset so the hsb aligns with the
|
|
1122
|
+
# tree content and stops at the same right edge.
|
|
1123
|
+
self._hsb.grid(row=2, column=0, sticky="ew", padx=affordance)
|
|
1124
|
+
self._tree.configure(xscrollcommand=self._hsb.set)
|
|
1125
|
+
else:
|
|
1126
|
+
self._hsb = None
|
|
1127
|
+
|
|
1128
|
+
self._heading_texts = []
|
|
1129
|
+
self._column_anchors = []
|
|
1130
|
+
stretch_columns = not self._paging['xscroll'] # allow natural width when xscroll is enabled
|
|
1131
|
+
for idx, text in enumerate(cols):
|
|
1132
|
+
self._heading_texts.append(text)
|
|
1133
|
+
anchor = self._determine_anchor(idx)
|
|
1134
|
+
self._column_anchors.append(anchor)
|
|
1135
|
+
heading_kwargs = {"text": text, "anchor": anchor}
|
|
1136
|
+
# Don't use heading command - we'll handle clicks via Button-1 binding
|
|
1137
|
+
self._tree.heading(idx, **heading_kwargs)
|
|
1138
|
+
# Apply per-column width overrides, fall back to global defaults
|
|
1139
|
+
width = 120
|
|
1140
|
+
minwidth = self._column_min_width
|
|
1141
|
+
if idx < len(self._column_defs):
|
|
1142
|
+
coldef = self._column_defs[idx]
|
|
1143
|
+
if isinstance(coldef, dict):
|
|
1144
|
+
width = coldef.get("width", width)
|
|
1145
|
+
minwidth = coldef.get("minwidth", coldef.get("min_width", minwidth))
|
|
1146
|
+
self._tree.column(idx, anchor=anchor, width=width, minwidth=minwidth, stretch=stretch_columns)
|
|
1147
|
+
self._update_heading_icons()
|
|
1148
|
+
self._tree.bind("<Button-1>", self._on_header_click)
|
|
1149
|
+
self._tree.bind("<<TreeviewSelect>>", self._on_selection_event)
|
|
1150
|
+
# Keep group-header chevrons in sync with their open/closed state.
|
|
1151
|
+
self._tree.bind("<<TreeviewOpen>>", self._refresh_group_chevrons, add="+")
|
|
1152
|
+
self._tree.bind("<<TreeviewClose>>", self._refresh_group_chevrons, add="+")
|
|
1153
|
+
self._tree.bind("<ButtonRelease-1>", self._on_row_click_event)
|
|
1154
|
+
# Escape clears the selection (also reachable in single-select mode, where
|
|
1155
|
+
# clicking can't return to an empty selection). Bound on the tree widget
|
|
1156
|
+
# only (add='+'), so it fires solely when the tree has focus and never
|
|
1157
|
+
# clobbers dialog/menu/search Escape handling, which own their own focus.
|
|
1158
|
+
self._tree.bind("<Escape>", lambda _e: self.deselect_all(), add="+")
|
|
1159
|
+
if self._context_menus != "none":
|
|
1160
|
+
bind_right_click(self._tree, self._on_tree_context)
|
|
1161
|
+
if self._editing['updating']:
|
|
1162
|
+
self._tree.bind("<Double-1>", self._on_row_double_click)
|
|
1163
|
+
# Track resize events to rebalance grouped layouts
|
|
1164
|
+
self._tree.bind("<Configure>", self._on_tree_configure)
|
|
1165
|
+
|
|
1166
|
+
def _build_footer(self) -> None:
|
|
1167
|
+
bar = Frame(self)
|
|
1168
|
+
# Same column 0 as the toolbar so the footer aligns with the table
|
|
1169
|
+
# content and stops at the vsb edge.
|
|
1170
|
+
bar.grid(row=3, column=0, sticky="ew", pady=(6, 0))
|
|
1171
|
+
self._footer_bar = bar
|
|
1172
|
+
|
|
1173
|
+
# A divider separating the footer from the table body. It lives inside
|
|
1174
|
+
# the footer bar, so it shows and hides together with the footer.
|
|
1175
|
+
self._footer_sep = Separator(bar, orient="horizontal")
|
|
1176
|
+
self._footer_sep.pack(side="top", fill="x", pady=(0, 6))
|
|
1177
|
+
|
|
1178
|
+
status_frame = Frame(bar)
|
|
1179
|
+
status_frame.pack(side="left", fill="x", expand=True)
|
|
1180
|
+
self._filter_label = Label(status_frame, text="", anchor="w", accent="muted", font="caption")
|
|
1181
|
+
self._filter_label.pack(side="left", padx=(0, 4))
|
|
1182
|
+
self._sort_label = Label(status_frame, text="", anchor="w", accent="muted", font="caption")
|
|
1183
|
+
self._sort_label.pack(side="left", padx=(8, 4))
|
|
1184
|
+
|
|
1185
|
+
# The pager (page entry + nav) is hidden when there is only one page.
|
|
1186
|
+
pager = Frame(bar)
|
|
1187
|
+
pager.pack(side="right")
|
|
1188
|
+
self._pager_frame = pager
|
|
1189
|
+
info_frame = Frame(pager)
|
|
1190
|
+
info_frame.pack(side='left')
|
|
1191
|
+
Label(info_frame, text="table.page", font="caption").pack(side='left')
|
|
1192
|
+
self._page_entry = Entry(info_frame, width=6, justify="center", density=_CHROME_DENSITY)
|
|
1193
|
+
self._page_entry.bind("<Return>", self._jump_page)
|
|
1194
|
+
self._page_entry.pack(side="left", padx=8)
|
|
1195
|
+
self._page_label = Label(info_frame, text="", font="caption")
|
|
1196
|
+
self._page_label.pack(side="left", padx=(0, 8))
|
|
1197
|
+
|
|
1198
|
+
Separator(pager, orient="vertical").pack(side="left", fill="y", padx=8)
|
|
1199
|
+
|
|
1200
|
+
btn_frame = Frame(pager)
|
|
1201
|
+
btn_frame.pack(side="left")
|
|
1202
|
+
Button(btn_frame, icon="chevron-double-left", accent="foreground", variant="ghost", icon_only=True, density=_CHROME_DENSITY, command=self._first_page).pack(
|
|
1203
|
+
side="left")
|
|
1204
|
+
Button(btn_frame, icon="chevron-left", icon_only=True, accent="foreground", variant="ghost", density=_CHROME_DENSITY, command=self._prev_page).pack(
|
|
1205
|
+
side="left")
|
|
1206
|
+
Button(btn_frame, icon="chevron-right", icon_only=True, accent="foreground", variant="ghost", density=_CHROME_DENSITY, command=self._next_page).pack(
|
|
1207
|
+
side="left")
|
|
1208
|
+
Button(btn_frame, icon="chevron-double-right", icon_only=True, accent="foreground", variant="ghost", density=_CHROME_DENSITY, command=self._last_page).pack(
|
|
1209
|
+
side="left")
|
|
1210
|
+
|
|
1211
|
+
self._update_footer_visibility()
|
|
1212
|
+
|
|
1213
|
+
def _update_footer_visibility(self) -> None:
|
|
1214
|
+
"""Hide the pager on a single page and collapse the footer when empty.
|
|
1215
|
+
|
|
1216
|
+
`show_status_bar=False` hides the whole footer.
|
|
1217
|
+
"""
|
|
1218
|
+
bar = getattr(self, "_footer_bar", None)
|
|
1219
|
+
if bar is None:
|
|
1220
|
+
return
|
|
1221
|
+
if not self._show_table_status:
|
|
1222
|
+
bar.grid_remove()
|
|
1223
|
+
return
|
|
1224
|
+
multipage = self._total_pages() > 1
|
|
1225
|
+
has_status = bool(self._filter_label.cget("text")) or bool(self._sort_label.cget("text"))
|
|
1226
|
+
if multipage:
|
|
1227
|
+
if not self._pager_frame.winfo_manager():
|
|
1228
|
+
self._pager_frame.pack(side="right")
|
|
1229
|
+
else:
|
|
1230
|
+
self._pager_frame.pack_forget()
|
|
1231
|
+
if multipage or has_status:
|
|
1232
|
+
bar.grid()
|
|
1233
|
+
else:
|
|
1234
|
+
bar.grid_remove()
|
|
1235
|
+
|
|
1236
|
+
# ------------------------------------------------------------------ Helpers
|
|
1237
|
+
def _col_text(self, col) -> str:
|
|
1238
|
+
if isinstance(col, str):
|
|
1239
|
+
return col
|
|
1240
|
+
if isinstance(col, dict):
|
|
1241
|
+
return col.get("text") or col.get("key") or ""
|
|
1242
|
+
return str(col)
|
|
1243
|
+
|
|
1244
|
+
def _header_context_enabled(self) -> bool:
|
|
1245
|
+
return self._context_menus in ("all", "headers")
|
|
1246
|
+
|
|
1247
|
+
def _row_context_enabled(self) -> bool:
|
|
1248
|
+
return self._context_menus in ("all", "rows")
|
|
1249
|
+
|
|
1250
|
+
def _determine_anchor(self, idx: int) -> str:
|
|
1251
|
+
"""Pick an anchor for the given column index.
|
|
1252
|
+
|
|
1253
|
+
Priority:
|
|
1254
|
+
1) Explicit anchor/align in column definition
|
|
1255
|
+
2) Explicit dtype/type hint in column definition (numeric -> right)
|
|
1256
|
+
3) Numeric columns -> right
|
|
1257
|
+
4) Default -> left
|
|
1258
|
+
"""
|
|
1259
|
+
if idx < len(self._column_defs):
|
|
1260
|
+
coldef = self._column_defs[idx]
|
|
1261
|
+
if isinstance(coldef, dict):
|
|
1262
|
+
anchor = coldef.get("anchor") or coldef.get("align")
|
|
1263
|
+
if anchor:
|
|
1264
|
+
return anchor
|
|
1265
|
+
# Allow a dtype/type hint on the column definition
|
|
1266
|
+
dtype = coldef.get("dtype") or coldef.get("type")
|
|
1267
|
+
if dtype:
|
|
1268
|
+
dtype_upper = str(dtype).upper()
|
|
1269
|
+
if any(t in dtype_upper for t in ("INT", "REAL", "NUM", "DECIMAL", "DOUBLE", "FLOAT")):
|
|
1270
|
+
return "e"
|
|
1271
|
+
if "TEXT" in dtype_upper or "STR" in dtype_upper or "CHAR" in dtype_upper:
|
|
1272
|
+
return "w"
|
|
1273
|
+
# Infer from type
|
|
1274
|
+
key = self._column_keys[idx] if idx < len(self._column_keys) else None
|
|
1275
|
+
ctype = self._get_column_type(key) if key else ""
|
|
1276
|
+
if ctype and any(t in ctype.upper() for t in ("INT", "REAL", "NUM", "DECIMAL", "DOUBLE", "FLOAT")):
|
|
1277
|
+
return "e"
|
|
1278
|
+
# Fallback: sample values to detect numeric strings
|
|
1279
|
+
if self._is_numeric_sample(idx):
|
|
1280
|
+
return "e"
|
|
1281
|
+
return "w"
|
|
1282
|
+
|
|
1283
|
+
def _get_column_type(self, key: str | None) -> str:
|
|
1284
|
+
if not key:
|
|
1285
|
+
return ""
|
|
1286
|
+
if key in self._column_types:
|
|
1287
|
+
return self._column_types[key]
|
|
1288
|
+
# Try PRAGMA table_info
|
|
1289
|
+
try:
|
|
1290
|
+
cur = self._datasource.conn.execute(f"PRAGMA table_info({self._datasource._table})")
|
|
1291
|
+
for cid, name, ctype, *_rest in cur.fetchall():
|
|
1292
|
+
if name == key:
|
|
1293
|
+
self._column_types[key] = ctype or ""
|
|
1294
|
+
return self._column_types[key]
|
|
1295
|
+
except Exception:
|
|
1296
|
+
pass
|
|
1297
|
+
return ""
|
|
1298
|
+
|
|
1299
|
+
def _load_alignment_sample(self) -> list[dict]:
|
|
1300
|
+
if self._alignment_sample is not None:
|
|
1301
|
+
return self._alignment_sample
|
|
1302
|
+
try:
|
|
1303
|
+
sample = self._datasource.page(0)
|
|
1304
|
+
except Exception:
|
|
1305
|
+
sample = []
|
|
1306
|
+
self._alignment_sample = sample or []
|
|
1307
|
+
return self._alignment_sample
|
|
1308
|
+
|
|
1309
|
+
def _is_numeric_sample(self, idx: int) -> bool:
|
|
1310
|
+
"""Check sample values to decide if a column with text storage is numeric-like."""
|
|
1311
|
+
key = self._column_keys[idx] if idx < len(self._column_keys) else None
|
|
1312
|
+
if not key:
|
|
1313
|
+
return False
|
|
1314
|
+
sample = self._load_alignment_sample()
|
|
1315
|
+
if not sample:
|
|
1316
|
+
return False
|
|
1317
|
+
|
|
1318
|
+
def is_num(val) -> bool:
|
|
1319
|
+
if val is None or val == "":
|
|
1320
|
+
return True
|
|
1321
|
+
try:
|
|
1322
|
+
float(val)
|
|
1323
|
+
return True
|
|
1324
|
+
except Exception:
|
|
1325
|
+
return False
|
|
1326
|
+
|
|
1327
|
+
seen = 0
|
|
1328
|
+
for rec in sample[: min(20, len(sample))]:
|
|
1329
|
+
if key not in rec:
|
|
1330
|
+
continue
|
|
1331
|
+
seen += 1
|
|
1332
|
+
if not is_num(rec.get(key)):
|
|
1333
|
+
return False
|
|
1334
|
+
return seen > 0
|
|
1335
|
+
|
|
1336
|
+
def _to_records(self, rows: list) -> list[dict]:
|
|
1337
|
+
records: list[dict] = []
|
|
1338
|
+
if not rows:
|
|
1339
|
+
return records
|
|
1340
|
+
keys = self._column_keys or [str(i) for i in range(len(rows[0]))]
|
|
1341
|
+
for rec in rows:
|
|
1342
|
+
if isinstance(rec, dict):
|
|
1343
|
+
records.append(rec)
|
|
1344
|
+
else:
|
|
1345
|
+
records.append({k: rec[i] if i < len(rec) else "" for i, k in enumerate(keys)})
|
|
1346
|
+
return records
|
|
1347
|
+
|
|
1348
|
+
def _refresh_tree(self, records: list[dict]) -> None:
|
|
1349
|
+
# Preserve the selection across the rebuild for rows that remain visible.
|
|
1350
|
+
# Selection is view/page-scoped: a sort keeps every row (selection fully
|
|
1351
|
+
# preserved), a search keeps the still-matching rows, and rows no longer
|
|
1352
|
+
# shown drop out. Captured by record id since the row handles are rebuilt.
|
|
1353
|
+
prev_selected_ids = {
|
|
1354
|
+
self._record_id(self._row_map[iid])
|
|
1355
|
+
for iid in self._tree.selection() if iid in self._row_map
|
|
1356
|
+
}
|
|
1357
|
+
self._tree.delete(*self._tree.get_children())
|
|
1358
|
+
self._row_map.clear()
|
|
1359
|
+
if not self._column_keys and records:
|
|
1360
|
+
self._column_keys = list(records[0].keys())
|
|
1361
|
+
grouped = bool(self._group_by_key) and self._group_by_key in self._column_keys
|
|
1362
|
+
self._apply_group_show_state(grouped)
|
|
1363
|
+
if grouped:
|
|
1364
|
+
self._render_grouped(records)
|
|
1365
|
+
else:
|
|
1366
|
+
self._render_flat(records)
|
|
1367
|
+
self._apply_row_alternation()
|
|
1368
|
+
if prev_selected_ids:
|
|
1369
|
+
restore = [iid for iid, rec in self._row_map.items()
|
|
1370
|
+
if self._record_id(rec) in prev_selected_ids]
|
|
1371
|
+
if restore:
|
|
1372
|
+
self._tree.selection_set(restore)
|
|
1373
|
+
self._update_selection_markers()
|
|
1374
|
+
|
|
1375
|
+
def _append_tree(self, records: list[dict]) -> None:
|
|
1376
|
+
# Grouped mode rebuilds the view instead of appending to keep hierarchy consistent
|
|
1377
|
+
if self._group_by_key:
|
|
1378
|
+
self._refresh_tree(records)
|
|
1379
|
+
return
|
|
1380
|
+
stripe = self._row_alternation.get('enabled', False) and not self._group_by_key
|
|
1381
|
+
start_idx = len(self._tree.get_children(""))
|
|
1382
|
+
for offset, rec in enumerate(records):
|
|
1383
|
+
values = self._display_values(rec)
|
|
1384
|
+
tags = ("altrow",) if stripe and (start_idx + offset) % 2 == 1 else ()
|
|
1385
|
+
iid = self._tree.insert("", "end", values=values, tags=tags)
|
|
1386
|
+
self._row_map[iid] = rec
|
|
1387
|
+
self._apply_row_alternation()
|
|
1388
|
+
self._update_selection_markers()
|
|
1389
|
+
|
|
1390
|
+
def _total_pages(self) -> int:
|
|
1391
|
+
try:
|
|
1392
|
+
if self._cached_total_count is None:
|
|
1393
|
+
with self._apply_view_to_source():
|
|
1394
|
+
self._cached_total_count = self._datasource.count
|
|
1395
|
+
total = self._cached_total_count
|
|
1396
|
+
size = getattr(self._datasource, "page_size", self._paging['page_size']) or 1
|
|
1397
|
+
return max(1, (total + size - 1) // size)
|
|
1398
|
+
except Exception:
|
|
1399
|
+
return 1
|
|
1400
|
+
|
|
1401
|
+
# ------------------------------------------------------------------ Paging
|
|
1402
|
+
def _load_page(self, page: int, append: bool = False) -> None:
|
|
1403
|
+
if not append and page in self._page_cache:
|
|
1404
|
+
records = self._page_cache[page]
|
|
1405
|
+
else:
|
|
1406
|
+
with self._apply_view_to_source():
|
|
1407
|
+
try:
|
|
1408
|
+
records = self._datasource.page(page)
|
|
1409
|
+
if self._cached_total_count is None:
|
|
1410
|
+
self._cached_total_count = self._datasource.count
|
|
1411
|
+
except Exception:
|
|
1412
|
+
records = []
|
|
1413
|
+
if not append:
|
|
1414
|
+
self._remember_page(page, records)
|
|
1415
|
+
self._current_page = max(0, page)
|
|
1416
|
+
try:
|
|
1417
|
+
if append:
|
|
1418
|
+
self._append_tree(records)
|
|
1419
|
+
else:
|
|
1420
|
+
self._refresh_tree(records)
|
|
1421
|
+
if self._column_auto_width:
|
|
1422
|
+
self._auto_size_columns(records if not append else None)
|
|
1423
|
+
self._update_page_label()
|
|
1424
|
+
finally:
|
|
1425
|
+
self._loading_next = False
|
|
1426
|
+
|
|
1427
|
+
def _update_page_label(self) -> None:
|
|
1428
|
+
if hasattr(self, "_page_entry"):
|
|
1429
|
+
self._page_entry.delete(0, 'end')
|
|
1430
|
+
self._page_entry.insert(0, str(self._current_page + 1))
|
|
1431
|
+
if hasattr(self, "_page_label"):
|
|
1432
|
+
of_text = MessageCatalog.translate("table.of")
|
|
1433
|
+
self._page_label.configure(text=f"{of_text} {self._total_pages()}")
|
|
1434
|
+
if self._show_table_status:
|
|
1435
|
+
self._update_status_labels()
|
|
1436
|
+
|
|
1437
|
+
def _first_page(self) -> None:
|
|
1438
|
+
self._load_page(0)
|
|
1439
|
+
|
|
1440
|
+
def _prev_page(self) -> None:
|
|
1441
|
+
self._load_page(max(0, self._current_page - 1))
|
|
1442
|
+
|
|
1443
|
+
def _next_page(self) -> None:
|
|
1444
|
+
self._load_page(min(self._total_pages() - 1, self._current_page + 1))
|
|
1445
|
+
|
|
1446
|
+
def _last_page(self) -> None:
|
|
1447
|
+
self._load_page(self._total_pages() - 1)
|
|
1448
|
+
|
|
1449
|
+
def _jump_page(self, _event=None) -> None:
|
|
1450
|
+
try:
|
|
1451
|
+
target = int(self._page_entry.get()) - 1
|
|
1452
|
+
except Exception:
|
|
1453
|
+
return
|
|
1454
|
+
target = max(0, min(self._total_pages() - 1, target))
|
|
1455
|
+
self._load_page(target)
|
|
1456
|
+
|
|
1457
|
+
def _on_scroll(self, first: float, last: float) -> None:
|
|
1458
|
+
"""Drive scrollbar and trigger lazy loading when near the bottom."""
|
|
1459
|
+
# Grouped mode disables virtual scroll append to avoid breaking hierarchy
|
|
1460
|
+
if self._group_by_key:
|
|
1461
|
+
self._vsb.set(first, last)
|
|
1462
|
+
return
|
|
1463
|
+
try:
|
|
1464
|
+
first_f = float(first)
|
|
1465
|
+
last_f = float(last)
|
|
1466
|
+
except Exception:
|
|
1467
|
+
self._vsb.set(first, last)
|
|
1468
|
+
return
|
|
1469
|
+
|
|
1470
|
+
self._vsb.set(first_f, last_f)
|
|
1471
|
+
if (
|
|
1472
|
+
self._paging['mode'] == "virtual"
|
|
1473
|
+
and last_f >= 0.85 # prefetch a bit earlier for smoother scrolling
|
|
1474
|
+
and not self._loading_next
|
|
1475
|
+
and hasattr(self._datasource, "has_next_page")
|
|
1476
|
+
and self._datasource.has_next_page()
|
|
1477
|
+
):
|
|
1478
|
+
# Load next page and keep appending rows
|
|
1479
|
+
self._loading_next = True
|
|
1480
|
+
self._load_page(self._current_page + 1, append=True)
|
|
1481
|
+
|
|
1482
|
+
# ------------------------------------------------------------------ Search & sort
|
|
1483
|
+
def _build_search_condition(self):
|
|
1484
|
+
"""Build the search condition from the active search term (or None)."""
|
|
1485
|
+
text = self._search_text
|
|
1486
|
+
if not text or not self._column_keys:
|
|
1487
|
+
return None
|
|
1488
|
+
if hasattr(self, "_search_mode") and self._search_mode_map:
|
|
1489
|
+
display_mode = self._search_mode.get()
|
|
1490
|
+
mode = self._search_mode_map.get(display_mode, "CONTAINS")
|
|
1491
|
+
else:
|
|
1492
|
+
mode = "CONTAINS"
|
|
1493
|
+
mode_upper = mode.upper().replace(" ", "_")
|
|
1494
|
+
if mode_upper == "STARTS_WITH":
|
|
1495
|
+
make = lambda c: col(c).startswith(text)
|
|
1496
|
+
elif mode_upper == "ENDS_WITH":
|
|
1497
|
+
make = lambda c: col(c).endswith(text)
|
|
1498
|
+
elif mode_upper == "EQUALS":
|
|
1499
|
+
make = lambda c: col(c) == text
|
|
1500
|
+
else: # CONTAINS (default)
|
|
1501
|
+
make = lambda c: col(c).contains(text)
|
|
1502
|
+
return any_of(*(make(c) for c in self._column_keys))
|
|
1503
|
+
|
|
1504
|
+
def _apply_where(self) -> None:
|
|
1505
|
+
"""Recompute and apply the table's local filter state.
|
|
1506
|
+
|
|
1507
|
+
When `broadcast_search` is enabled, the combined condition is written to
|
|
1508
|
+
the source un-silenced so that all other views (charts, etc.) re-render.
|
|
1509
|
+
The table suppresses its own `_on_source_change` callback for this write
|
|
1510
|
+
to avoid a double page-load.
|
|
1511
|
+
"""
|
|
1512
|
+
if self._broadcast_search:
|
|
1513
|
+
condition = all_of(
|
|
1514
|
+
self._build_search_condition(),
|
|
1515
|
+
self._build_column_filter_condition(),
|
|
1516
|
+
)
|
|
1517
|
+
try:
|
|
1518
|
+
# Set the flag BEFORE the write; clear it in _on_source_change
|
|
1519
|
+
# (not here in finally) so it is still True when the hub's
|
|
1520
|
+
# after_idle flush fires, preventing a redundant second reload.
|
|
1521
|
+
self._suppressing_search_broadcast = True
|
|
1522
|
+
self._datasource.where(condition)
|
|
1523
|
+
except Exception:
|
|
1524
|
+
logger.exception("Failed to broadcast search filter")
|
|
1525
|
+
self._suppressing_search_broadcast = False # clear on error
|
|
1526
|
+
self._clear_cache()
|
|
1527
|
+
self._load_page(0)
|
|
1528
|
+
self._update_status_labels()
|
|
1529
|
+
|
|
1530
|
+
def _run_search(self) -> None:
|
|
1531
|
+
self._search_text = self._search_entry.get()
|
|
1532
|
+
self._apply_where()
|
|
1533
|
+
|
|
1534
|
+
def _clear_search(self) -> None:
|
|
1535
|
+
entry = getattr(self, "_search_entry", None)
|
|
1536
|
+
if entry is not None:
|
|
1537
|
+
entry.delete(0, 'end')
|
|
1538
|
+
self._search_text = ""
|
|
1539
|
+
self._apply_where()
|
|
1540
|
+
|
|
1541
|
+
def _on_sort(self, column_index: int) -> None:
|
|
1542
|
+
if column_index >= len(self._column_keys):
|
|
1543
|
+
return
|
|
1544
|
+
key = self._column_keys[column_index]
|
|
1545
|
+
asc = not self._sort_state.get(key, True)
|
|
1546
|
+
# Clear other sort states to keep single-column sort
|
|
1547
|
+
self._sort_state = {key: asc}
|
|
1548
|
+
self._clear_cache()
|
|
1549
|
+
self._update_heading_icons()
|
|
1550
|
+
self._load_page(0)
|
|
1551
|
+
self._update_status_labels()
|
|
1552
|
+
|
|
1553
|
+
def _column_heading(self, key: str) -> str:
|
|
1554
|
+
"""Display heading for a column key (falls back to the key)."""
|
|
1555
|
+
try:
|
|
1556
|
+
idx = self._column_keys.index(key)
|
|
1557
|
+
return self._heading_texts[idx] if idx < len(self._heading_texts) else key
|
|
1558
|
+
except Exception:
|
|
1559
|
+
return key
|
|
1560
|
+
|
|
1561
|
+
def _filter_description(self) -> str:
|
|
1562
|
+
"""Human-readable summary of the active search term and column filters.
|
|
1563
|
+
|
|
1564
|
+
The search term spans all columns (`'term' in any column`); each column
|
|
1565
|
+
filter reads like `Heading='value'` (or `Heading in (...)` for several).
|
|
1566
|
+
"""
|
|
1567
|
+
def fmt(v) -> str:
|
|
1568
|
+
return "(blank)" if v is None else f"'{v}'"
|
|
1569
|
+
|
|
1570
|
+
parts = []
|
|
1571
|
+
if self._search_text:
|
|
1572
|
+
parts.append(f"{fmt(self._search_text)} in any column")
|
|
1573
|
+
for key, values in self._column_filters.items():
|
|
1574
|
+
heading = self._column_heading(key)
|
|
1575
|
+
vals = list(values)
|
|
1576
|
+
if len(vals) == 1:
|
|
1577
|
+
parts.append(f"{heading}={fmt(vals[0])}")
|
|
1578
|
+
else:
|
|
1579
|
+
parts.append(f"{heading} in ({', '.join(fmt(v) for v in vals)})")
|
|
1580
|
+
return ", ".join(parts)
|
|
1581
|
+
|
|
1582
|
+
def _update_filter_tooltip(self, text: str) -> None:
|
|
1583
|
+
"""Show a hover tooltip with the full filter text, or remove it."""
|
|
1584
|
+
if not hasattr(self, "_filter_label"):
|
|
1585
|
+
return
|
|
1586
|
+
if text:
|
|
1587
|
+
if self._filter_tooltip is None:
|
|
1588
|
+
self._filter_tooltip = ToolTip(self._filter_label, text=text)
|
|
1589
|
+
else:
|
|
1590
|
+
self._filter_tooltip._text = text
|
|
1591
|
+
elif self._filter_tooltip is not None:
|
|
1592
|
+
self._filter_tooltip.destroy()
|
|
1593
|
+
self._filter_tooltip = None
|
|
1594
|
+
|
|
1595
|
+
def _update_status_labels(self) -> None:
|
|
1596
|
+
# Filter — summarize the active search term and any column filters,
|
|
1597
|
+
# truncating the label and revealing the full text via tooltip on overflow.
|
|
1598
|
+
filter_txt = ""
|
|
1599
|
+
tooltip_txt = ""
|
|
1600
|
+
try:
|
|
1601
|
+
description = self._filter_description()
|
|
1602
|
+
if description:
|
|
1603
|
+
shown = description
|
|
1604
|
+
if len(description) > _FILTER_STATUS_MAXLEN:
|
|
1605
|
+
shown = description[: _FILTER_STATUS_MAXLEN - 1].rstrip() + "…"
|
|
1606
|
+
filter_txt = MessageCatalog.translate("table.filter_status", shown)
|
|
1607
|
+
if shown != description:
|
|
1608
|
+
tooltip_txt = MessageCatalog.translate("table.filter_status", description)
|
|
1609
|
+
except Exception:
|
|
1610
|
+
pass
|
|
1611
|
+
self._update_filter_tooltip(tooltip_txt)
|
|
1612
|
+
# Sort
|
|
1613
|
+
sort_txt = ""
|
|
1614
|
+
try:
|
|
1615
|
+
if self._sort_state and not self._group_by_key:
|
|
1616
|
+
key, ascending = next(iter(self._sort_state.items()))
|
|
1617
|
+
heading = self._column_heading(key)
|
|
1618
|
+
direction = "↑" if ascending else "↓"
|
|
1619
|
+
sort_txt = MessageCatalog.translate("table.sort_status", f"{heading} {direction}")
|
|
1620
|
+
except Exception:
|
|
1621
|
+
pass
|
|
1622
|
+
group_txt = ""
|
|
1623
|
+
if self._group_by_key:
|
|
1624
|
+
try:
|
|
1625
|
+
col_idx = self._column_keys.index(self._group_by_key)
|
|
1626
|
+
heading_text = self._heading_texts[col_idx] if col_idx < len(
|
|
1627
|
+
self._heading_texts) else self._group_by_key
|
|
1628
|
+
except Exception:
|
|
1629
|
+
heading_text = self._group_by_key
|
|
1630
|
+
group_txt = MessageCatalog.translate("table.group_status", heading_text)
|
|
1631
|
+
|
|
1632
|
+
if hasattr(self, "_filter_label"):
|
|
1633
|
+
self._filter_label.configure(text=filter_txt)
|
|
1634
|
+
if hasattr(self, "_sort_label"):
|
|
1635
|
+
joined = " | ".join([t for t in (sort_txt, group_txt) if t])
|
|
1636
|
+
self._sort_label.configure(text=joined)
|
|
1637
|
+
self._update_footer_visibility()
|
|
1638
|
+
|
|
1639
|
+
# ------------------------------------------------------------------ Row context menu
|
|
1640
|
+
def _dismiss_context_menus(self) -> None:
|
|
1641
|
+
"""Hide any open built-in row/header context menu (idempotent)."""
|
|
1642
|
+
for menu in (self._row_menu, self._header_menu):
|
|
1643
|
+
if menu is not None:
|
|
1644
|
+
try:
|
|
1645
|
+
menu.hide()
|
|
1646
|
+
except Exception:
|
|
1647
|
+
pass
|
|
1648
|
+
|
|
1649
|
+
def _ensure_row_menu(self) -> None:
|
|
1650
|
+
if not self._row_context_enabled():
|
|
1651
|
+
return
|
|
1652
|
+
if self._row_menu:
|
|
1653
|
+
return
|
|
1654
|
+
# Activation is wired upstream by bind_right_click on the tree so the
|
|
1655
|
+
# row vs header dispatch can run before the (lazily built) menu
|
|
1656
|
+
# decides which one to show.
|
|
1657
|
+
menu = ContextMenu(master=self, target=self._tree, attach='sw', trigger=None)
|
|
1658
|
+
if not self._sorting == 'none':
|
|
1659
|
+
menu.add_command(text="table.sort_asc", command=lambda: self._sort_selection(True))
|
|
1660
|
+
menu.add_command(text="table.sort_desc", command=lambda: self._sort_selection(False))
|
|
1661
|
+
|
|
1662
|
+
if self._filtering['row_menu_filtering']:
|
|
1663
|
+
menu.add_separator()
|
|
1664
|
+
menu.add_command(text="table.filter_by_value", command=self._filter_by_value)
|
|
1665
|
+
menu.add_command(text="table.hide_select", command=self._hide_selection)
|
|
1666
|
+
menu.add_command(text="table.clear_filters", command=self._clear_filter_cmd)
|
|
1667
|
+
|
|
1668
|
+
menu.add_separator()
|
|
1669
|
+
menu.add_command(text="table.move_up", command=self._move_row_up)
|
|
1670
|
+
menu.add_command(text="table.move_down", command=self._move_row_down)
|
|
1671
|
+
menu.add_command(text="table.move_top", command=self._move_row_top)
|
|
1672
|
+
menu.add_command(text="table.move_bottom", command=self._move_row_bottom)
|
|
1673
|
+
|
|
1674
|
+
if self._editing['updating'] or self._editing['deleting']:
|
|
1675
|
+
menu.add_separator()
|
|
1676
|
+
if self._editing['updating']:
|
|
1677
|
+
menu.add_command(text="table.edit", command=self._edit_selected_row)
|
|
1678
|
+
if self._editing['deleting']:
|
|
1679
|
+
menu.add_command(text="table.delete_row", command=self._delete_selected_row)
|
|
1680
|
+
self._row_menu = menu
|
|
1681
|
+
|
|
1682
|
+
def _on_row_context(self, event) -> None:
|
|
1683
|
+
if not self._row_context_enabled():
|
|
1684
|
+
return
|
|
1685
|
+
iid = self._tree.identify_row(event.y)
|
|
1686
|
+
col_id = self._tree.identify_column(event.x)
|
|
1687
|
+
try:
|
|
1688
|
+
col_idx = int(col_id.strip("#")) - 1
|
|
1689
|
+
except Exception:
|
|
1690
|
+
col_idx = 0
|
|
1691
|
+
# Right-click does not alter the selection (left-click owns that); it
|
|
1692
|
+
# only records which row the menu targets and opens the menu there.
|
|
1693
|
+
self._context_iid = iid or None
|
|
1694
|
+
if not iid:
|
|
1695
|
+
return # empty space or a group-header row — no row menu
|
|
1696
|
+
rec = self._row_map.get(iid, {})
|
|
1697
|
+
self.event_generate("<<RowRightClick>>", data=RowEvent(record=self._public_record(rec), id=self._record_id(rec)))
|
|
1698
|
+
self._row_menu_col = col_idx
|
|
1699
|
+
self._ensure_row_menu()
|
|
1700
|
+
self._row_menu.show(position=(event.x_root, event.y_root))
|
|
1701
|
+
|
|
1702
|
+
def _on_row_double_click(self, event) -> None:
|
|
1703
|
+
region = self._tree.identify_region(event.x, event.y)
|
|
1704
|
+
if region == "heading":
|
|
1705
|
+
return
|
|
1706
|
+
iid = self._tree.identify_row(event.y)
|
|
1707
|
+
if not iid:
|
|
1708
|
+
return
|
|
1709
|
+
rec = self._row_map.get(iid, {})
|
|
1710
|
+
self.event_generate("<<RowDoubleClick>>", data=RowEvent(record=self._public_record(rec), id=self._record_id(rec)))
|
|
1711
|
+
if self._editing['updating']:
|
|
1712
|
+
self._open_form_dialog(rec)
|
|
1713
|
+
|
|
1714
|
+
def _open_new_record(self) -> None:
|
|
1715
|
+
if not self._editing['adding']:
|
|
1716
|
+
return
|
|
1717
|
+
self._open_form_dialog(None)
|
|
1718
|
+
|
|
1719
|
+
def new_row(self, defaults: dict | None = None) -> dict | None:
|
|
1720
|
+
"""Open the built-in New Record dialog; return the new record or None."""
|
|
1721
|
+
return self._open_form_dialog(None, defaults=defaults)
|
|
1722
|
+
|
|
1723
|
+
def edit_row(self, record_id) -> dict | None:
|
|
1724
|
+
"""Open the built-in Edit Record dialog for `record_id`; return it or None."""
|
|
1725
|
+
record = self._datasource.get(record_id)
|
|
1726
|
+
if record is None:
|
|
1727
|
+
return None
|
|
1728
|
+
return self._open_form_dialog(record)
|
|
1729
|
+
|
|
1730
|
+
def _open_form_dialog(self, record: dict | None, *, defaults: dict | None = None) -> dict | None:
|
|
1731
|
+
from bootstack.dialogs._impl.formdialog import FormDialog
|
|
1732
|
+
|
|
1733
|
+
try:
|
|
1734
|
+
# Ensure geometry info is current so centering uses real widget bounds
|
|
1735
|
+
self.update_idletasks()
|
|
1736
|
+
except Exception:
|
|
1737
|
+
pass
|
|
1738
|
+
dialog_master = self.winfo_toplevel() if hasattr(self, "winfo_toplevel") else self
|
|
1739
|
+
|
|
1740
|
+
form_items = self._build_form_items()
|
|
1741
|
+
initial_data = dict(record) if record else dict(defaults or {})
|
|
1742
|
+
|
|
1743
|
+
form_options = dict(self._editing['form'])
|
|
1744
|
+
form_options.setdefault('col_count', 2)
|
|
1745
|
+
form_options.setdefault('min_col_width', 260)
|
|
1746
|
+
form_options.setdefault('scrollable', True)
|
|
1747
|
+
form_options.setdefault('resizable', True)
|
|
1748
|
+
|
|
1749
|
+
# Build buttons: Cancel, Delete (only for existing records), Save
|
|
1750
|
+
record_id = self._record_id(record)
|
|
1751
|
+
if record and record_id is not None:
|
|
1752
|
+
buttons: list[str | dict] = ['Cancel']
|
|
1753
|
+
if self._editing['deleting']:
|
|
1754
|
+
buttons.append({"text": "Delete", "role": "secondary", "result": "delete"})
|
|
1755
|
+
buttons.append("Save")
|
|
1756
|
+
else:
|
|
1757
|
+
buttons = ["Cancel", "Save"]
|
|
1758
|
+
|
|
1759
|
+
dialog = FormDialog(
|
|
1760
|
+
parent=dialog_master,
|
|
1761
|
+
title="Edit Record" if record else "New Record",
|
|
1762
|
+
data=initial_data,
|
|
1763
|
+
items=form_items,
|
|
1764
|
+
col_count=form_options.get('col_count', 2),
|
|
1765
|
+
min_col_width=form_options.get('min_col_width', 260),
|
|
1766
|
+
scrollable=form_options.get('scrollable', True),
|
|
1767
|
+
buttons=buttons,
|
|
1768
|
+
resizable=(True, True) if form_options.get('resizable', True) else (False, False),
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
self._active_form_dialog = dialog
|
|
1772
|
+
try:
|
|
1773
|
+
dialog.show() # centers over the parent window (the table)
|
|
1774
|
+
finally:
|
|
1775
|
+
self._active_form_dialog = None
|
|
1776
|
+
result = dialog.result
|
|
1777
|
+
|
|
1778
|
+
if result is None:
|
|
1779
|
+
return None
|
|
1780
|
+
|
|
1781
|
+
# Handle delete action
|
|
1782
|
+
if result == "delete" and record and record_id is not None:
|
|
1783
|
+
deleted = self._public_record(record)
|
|
1784
|
+
try:
|
|
1785
|
+
with self._silence_source():
|
|
1786
|
+
self._datasource.delete(record_id)
|
|
1787
|
+
self._clear_cache()
|
|
1788
|
+
self._load_page(self._current_page)
|
|
1789
|
+
self.event_generate("<<RowsDelete>>", data=RowsEvent(records=[deleted]))
|
|
1790
|
+
except Exception:
|
|
1791
|
+
logger.exception("Failed to delete record id=%s", record_id)
|
|
1792
|
+
return None
|
|
1793
|
+
|
|
1794
|
+
data = result
|
|
1795
|
+
new_id = None
|
|
1796
|
+
if record and record_id is not None:
|
|
1797
|
+
rec_id = record_id
|
|
1798
|
+
updates = dict(data)
|
|
1799
|
+
for _f in self._internal_fields():
|
|
1800
|
+
updates.pop(_f, None)
|
|
1801
|
+
try:
|
|
1802
|
+
with self._silence_source():
|
|
1803
|
+
self._datasource.update(rec_id, updates)
|
|
1804
|
+
except Exception:
|
|
1805
|
+
logger.exception("Failed to update record id=%s", rec_id)
|
|
1806
|
+
return None
|
|
1807
|
+
saved_id, change_event = rec_id, "<<RowsUpdate>>"
|
|
1808
|
+
else:
|
|
1809
|
+
try:
|
|
1810
|
+
with self._silence_source():
|
|
1811
|
+
new_id = self._datasource.insert(dict(data))
|
|
1812
|
+
except Exception:
|
|
1813
|
+
logger.exception("Failed to create record from %s", data)
|
|
1814
|
+
return None
|
|
1815
|
+
saved_id, change_event = new_id, "<<RowsInsert>>"
|
|
1816
|
+
self._clear_cache()
|
|
1817
|
+
target_page = self._current_page
|
|
1818
|
+
if not record:
|
|
1819
|
+
# After creating, compute last page using fresh count so the new row is visible
|
|
1820
|
+
located_page = self._find_record_page(new_id) if new_id is not None else None
|
|
1821
|
+
target_page = located_page if located_page is not None else max(0, self._total_pages() - 1)
|
|
1822
|
+
self._load_page(target_page)
|
|
1823
|
+
if new_id is not None:
|
|
1824
|
+
self._focus_record(new_id)
|
|
1825
|
+
saved = None
|
|
1826
|
+
if saved_id is not None:
|
|
1827
|
+
fresh = self._datasource.get(saved_id)
|
|
1828
|
+
if fresh is not None:
|
|
1829
|
+
saved = self._public_record(fresh)
|
|
1830
|
+
if saved is not None:
|
|
1831
|
+
self.event_generate(change_event, data=RowsEvent(records=[saved]))
|
|
1832
|
+
return saved
|
|
1833
|
+
|
|
1834
|
+
def _build_form_items(self) -> list[dict]:
|
|
1835
|
+
items: list[dict] = []
|
|
1836
|
+
for idx, key in enumerate(self._column_keys):
|
|
1837
|
+
coldef = self._column_defs[idx] if idx < len(self._column_defs) else key
|
|
1838
|
+
label = self._col_text(coldef)
|
|
1839
|
+
editor_opts = {}
|
|
1840
|
+
editor = None
|
|
1841
|
+
dtype = None
|
|
1842
|
+
readonly = False
|
|
1843
|
+
if isinstance(coldef, dict):
|
|
1844
|
+
editor_opts = dict(coldef.get("editor_options", {}))
|
|
1845
|
+
editor = coldef.get("editor")
|
|
1846
|
+
dtype = coldef.get("dtype") or coldef.get("type")
|
|
1847
|
+
readonly = bool(coldef.get("readonly", False))
|
|
1848
|
+
if coldef.get("required"):
|
|
1849
|
+
editor_opts.setdefault("required", True)
|
|
1850
|
+
# Show validation messages to avoid layout jump on first error
|
|
1851
|
+
editor_opts.setdefault("show_message", True)
|
|
1852
|
+
items.append(
|
|
1853
|
+
{
|
|
1854
|
+
"key": key,
|
|
1855
|
+
"label": label,
|
|
1856
|
+
"dtype": dtype,
|
|
1857
|
+
"editor": editor,
|
|
1858
|
+
"editor_options": {**editor_opts},
|
|
1859
|
+
"readonly": readonly,
|
|
1860
|
+
"type": "field",
|
|
1861
|
+
}
|
|
1862
|
+
)
|
|
1863
|
+
return items
|
|
1864
|
+
|
|
1865
|
+
def _context_iids(self) -> tuple[str, ...]:
|
|
1866
|
+
"""Row id(s) a row-menu command acts on.
|
|
1867
|
+
|
|
1868
|
+
The menu targets the right-clicked row. When that row is part of the
|
|
1869
|
+
current (left-click) selection, the whole selection is the target — the
|
|
1870
|
+
intuitive multi-select behavior — otherwise just the clicked row. Either
|
|
1871
|
+
way the selection itself is left unchanged.
|
|
1872
|
+
"""
|
|
1873
|
+
clicked = self._context_iid
|
|
1874
|
+
selection = tuple(self._tree.selection())
|
|
1875
|
+
if clicked and clicked in selection:
|
|
1876
|
+
return selection
|
|
1877
|
+
if clicked:
|
|
1878
|
+
return (clicked,)
|
|
1879
|
+
return selection
|
|
1880
|
+
|
|
1881
|
+
def _filter_by_value(self) -> None:
|
|
1882
|
+
selection = self._context_iids()
|
|
1883
|
+
if not selection:
|
|
1884
|
+
return
|
|
1885
|
+
iid = selection[0]
|
|
1886
|
+
col_idx = max(0, min(self._row_menu_col or 0, len(self._column_keys) - 1))
|
|
1887
|
+
key = self._column_keys[col_idx]
|
|
1888
|
+
rec = self._row_map.get(iid)
|
|
1889
|
+
if rec is None:
|
|
1890
|
+
return
|
|
1891
|
+
# Use the stored record value (real type, handles None) rather than the
|
|
1892
|
+
# Tk display string, so the filter and status read correctly.
|
|
1893
|
+
self._column_filters[key] = [rec.get(key)]
|
|
1894
|
+
self._apply_where()
|
|
1895
|
+
|
|
1896
|
+
def _sort_selection(self, ascending: bool) -> None:
|
|
1897
|
+
selection = self._context_iids()
|
|
1898
|
+
if not selection:
|
|
1899
|
+
return
|
|
1900
|
+
iid = selection[0]
|
|
1901
|
+
col_idx = max(0, min(self._row_menu_col or 0, len(self._column_keys) - 1))
|
|
1902
|
+
key = self._column_keys[col_idx]
|
|
1903
|
+
self._sort_state = {key: ascending}
|
|
1904
|
+
self._clear_cache()
|
|
1905
|
+
self._update_heading_icons()
|
|
1906
|
+
self._load_page(0)
|
|
1907
|
+
|
|
1908
|
+
def _clear_filter_cmd(self) -> None:
|
|
1909
|
+
self._column_filters.clear()
|
|
1910
|
+
self._apply_where()
|
|
1911
|
+
|
|
1912
|
+
def _move_row_up(self) -> None:
|
|
1913
|
+
self._move_row_relative(-1)
|
|
1914
|
+
|
|
1915
|
+
def _move_row_down(self) -> None:
|
|
1916
|
+
self._move_row_relative(1)
|
|
1917
|
+
|
|
1918
|
+
def _move_row_top(self) -> None:
|
|
1919
|
+
self._move_row_absolute(0)
|
|
1920
|
+
|
|
1921
|
+
def _move_row_bottom(self) -> None:
|
|
1922
|
+
children = list(self._tree.get_children())
|
|
1923
|
+
if children:
|
|
1924
|
+
self._move_row_absolute(len(children) - 1)
|
|
1925
|
+
|
|
1926
|
+
def _move_row_relative(self, delta: int) -> None:
|
|
1927
|
+
sel = list(self._context_iids())
|
|
1928
|
+
if not sel:
|
|
1929
|
+
return
|
|
1930
|
+
target_iid = sel[0]
|
|
1931
|
+
children = list(self._tree.get_children())
|
|
1932
|
+
try:
|
|
1933
|
+
idx = children.index(target_iid)
|
|
1934
|
+
except ValueError:
|
|
1935
|
+
return
|
|
1936
|
+
new_idx = max(0, min(len(children) - 1, idx + delta))
|
|
1937
|
+
if new_idx == idx:
|
|
1938
|
+
return
|
|
1939
|
+
self._tree.move(target_iid, "", new_idx)
|
|
1940
|
+
self._apply_row_alternation()
|
|
1941
|
+
rec = self._row_map.get(target_iid)
|
|
1942
|
+
if rec:
|
|
1943
|
+
self.event_generate("<<RowsMove>>", data=RowsEvent(records=[self._public_record(rec)]))
|
|
1944
|
+
|
|
1945
|
+
def _move_row_absolute(self, new_idx: int) -> None:
|
|
1946
|
+
sel = list(self._context_iids())
|
|
1947
|
+
if not sel:
|
|
1948
|
+
return
|
|
1949
|
+
target_iid = sel[0]
|
|
1950
|
+
children = list(self._tree.get_children())
|
|
1951
|
+
new_idx = max(0, min(len(children) - 1, new_idx))
|
|
1952
|
+
self._tree.move(target_iid, "", new_idx)
|
|
1953
|
+
self._apply_row_alternation()
|
|
1954
|
+
rec = self._row_map.get(target_iid)
|
|
1955
|
+
if rec:
|
|
1956
|
+
self.event_generate("<<RowsMove>>", data=RowsEvent(records=[self._public_record(rec)]))
|
|
1957
|
+
|
|
1958
|
+
def _hide_selection(self) -> None:
|
|
1959
|
+
sel = list(self._context_iids())
|
|
1960
|
+
for iid in sel:
|
|
1961
|
+
self._tree.delete(iid)
|
|
1962
|
+
self._row_map.pop(iid, None)
|
|
1963
|
+
|
|
1964
|
+
def _edit_selected_row(self) -> None:
|
|
1965
|
+
"""Open the form dialog for the right-clicked row."""
|
|
1966
|
+
sel = list(self._context_iids())
|
|
1967
|
+
if not sel:
|
|
1968
|
+
return
|
|
1969
|
+
iid = sel[0]
|
|
1970
|
+
rec = self._row_map.get(iid, {})
|
|
1971
|
+
self._open_form_dialog(rec)
|
|
1972
|
+
|
|
1973
|
+
def _delete_selected_row(self) -> None:
|
|
1974
|
+
"""Delete the right-clicked row from the datasource."""
|
|
1975
|
+
sel = list(self._context_iids())
|
|
1976
|
+
if not sel:
|
|
1977
|
+
return
|
|
1978
|
+
iid = sel[0]
|
|
1979
|
+
rec = self._row_map.get(iid, {})
|
|
1980
|
+
rec_id = self._record_id(rec)
|
|
1981
|
+
if rec_id is not None:
|
|
1982
|
+
try:
|
|
1983
|
+
with self._silence_source():
|
|
1984
|
+
self._datasource.delete(rec_id)
|
|
1985
|
+
self._clear_cache()
|
|
1986
|
+
self._load_page(self._current_page)
|
|
1987
|
+
self.event_generate("<<RowsDelete>>", data=RowsEvent(records=[self._public_record(rec)]))
|
|
1988
|
+
except Exception:
|
|
1989
|
+
logger.exception("Failed to delete record id=%s", rec_id)
|
|
1990
|
+
|
|
1991
|
+
def _delete_selection(self) -> None:
|
|
1992
|
+
sel = list(self._tree.selection())
|
|
1993
|
+
deleted_records: list[dict] = []
|
|
1994
|
+
changed = False
|
|
1995
|
+
with self._silence_source():
|
|
1996
|
+
for iid in sel:
|
|
1997
|
+
rec = dict(self._row_map.get(iid) or {})
|
|
1998
|
+
if rec:
|
|
1999
|
+
deleted_records.append(rec)
|
|
2000
|
+
rec_id = self._record_id(rec)
|
|
2001
|
+
if rec_id is not None:
|
|
2002
|
+
try:
|
|
2003
|
+
self._datasource.delete(rec_id)
|
|
2004
|
+
changed = True
|
|
2005
|
+
except Exception:
|
|
2006
|
+
pass
|
|
2007
|
+
self._row_map.pop(iid, None)
|
|
2008
|
+
if changed:
|
|
2009
|
+
self._clear_cache()
|
|
2010
|
+
self._load_page(self._current_page)
|
|
2011
|
+
if deleted_records:
|
|
2012
|
+
records = [self._public_record(r) for r in deleted_records]
|
|
2013
|
+
self.event_generate("<<RowsDelete>>", data=RowsEvent(records=records))
|
|
2014
|
+
|
|
2015
|
+
# ------------------------------------------------------------------ Cache helpers
|
|
2016
|
+
def _clear_cache(self) -> None:
|
|
2017
|
+
if self._page_cache:
|
|
2018
|
+
self._page_cache.clear()
|
|
2019
|
+
# Invalidate total count cache when data/filter/sort changes
|
|
2020
|
+
self._cached_total_count = None
|
|
2021
|
+
|
|
2022
|
+
def _load_heading_icons(self) -> None:
|
|
2023
|
+
"""Load and cache heading icons (sort arrows) sized to match the heading color."""
|
|
2024
|
+
try:
|
|
2025
|
+
fg = self._get_heading_fg()
|
|
2026
|
+
if fg == self._heading_fg and self._icon_sort_up:
|
|
2027
|
+
return
|
|
2028
|
+
self._heading_fg = fg
|
|
2029
|
+
self._icon_sort_up = _ImageService.get_icon("sort-up", 16, fg)
|
|
2030
|
+
self._icon_sort_down = _ImageService.get_icon("sort-down", 16, fg)
|
|
2031
|
+
except Exception:
|
|
2032
|
+
self._icon_sort_up = None
|
|
2033
|
+
self._icon_sort_down = None
|
|
2034
|
+
|
|
2035
|
+
def _get_heading_fg(self) -> str:
|
|
2036
|
+
"""Resolve a heading foreground color with light-biased fallbacks."""
|
|
2037
|
+
style = get_style()
|
|
2038
|
+
ttk_style = self._tree.cget('style')
|
|
2039
|
+
# Try configured value first
|
|
2040
|
+
return style.configure(f"{ttk_style}.Heading", 'foreground')
|
|
2041
|
+
|
|
2042
|
+
def _update_heading_icons(self) -> None:
|
|
2043
|
+
"""Apply sort direction icons to headings."""
|
|
2044
|
+
if not self._heading_texts:
|
|
2045
|
+
return
|
|
2046
|
+
self._load_heading_icons()
|
|
2047
|
+
for idx, text in enumerate(self._heading_texts):
|
|
2048
|
+
image = ""
|
|
2049
|
+
if idx < len(self._column_keys):
|
|
2050
|
+
key = self._column_keys[idx]
|
|
2051
|
+
state = self._sort_state.get(key)
|
|
2052
|
+
if state is True:
|
|
2053
|
+
image = self._icon_sort_up if self._icon_sort_up else ""
|
|
2054
|
+
elif state is False:
|
|
2055
|
+
image = self._icon_sort_down if self._icon_sort_down else ""
|
|
2056
|
+
self._tree.heading(idx, text=text, image=image)
|
|
2057
|
+
|
|
2058
|
+
def _remember_page(self, page: int, records: list[dict]) -> None:
|
|
2059
|
+
if self._paging['cache_size'] <= 0:
|
|
2060
|
+
return
|
|
2061
|
+
# Move/update LRU cache
|
|
2062
|
+
if page in self._page_cache:
|
|
2063
|
+
self._page_cache.pop(page)
|
|
2064
|
+
self._page_cache[page] = records
|
|
2065
|
+
if len(self._page_cache) > self._paging['cache_size']:
|
|
2066
|
+
self._page_cache.popitem(last=False)
|
|
2067
|
+
|
|
2068
|
+
def _focus_record(self, record_id) -> None:
|
|
2069
|
+
"""Select and scroll to a record by id if it's on the current page."""
|
|
2070
|
+
try:
|
|
2071
|
+
rid = str(record_id)
|
|
2072
|
+
for iid, rec in self._row_map.items():
|
|
2073
|
+
if str(self._record_id(rec)) == rid:
|
|
2074
|
+
self._tree.selection_set(iid)
|
|
2075
|
+
self._tree.see(iid)
|
|
2076
|
+
break
|
|
2077
|
+
except Exception:
|
|
2078
|
+
pass
|
|
2079
|
+
|
|
2080
|
+
def _find_record_page(self, record_id) -> int | None:
|
|
2081
|
+
"""Locate the page index containing the given record id, if available."""
|
|
2082
|
+
try:
|
|
2083
|
+
rid = str(record_id)
|
|
2084
|
+
total_pages = self._total_pages()
|
|
2085
|
+
for page_idx in range(total_pages):
|
|
2086
|
+
try:
|
|
2087
|
+
with self._apply_view_to_source():
|
|
2088
|
+
rows = self._datasource.page(page_idx)
|
|
2089
|
+
except Exception:
|
|
2090
|
+
break
|
|
2091
|
+
if any(str(self._record_id(rec)) == rid for rec in rows):
|
|
2092
|
+
return page_idx
|
|
2093
|
+
except Exception:
|
|
2094
|
+
pass
|
|
2095
|
+
return None
|
|
2096
|
+
|
|
2097
|
+
def _auto_size_columns(self, records: list[dict] | None = None) -> None:
|
|
2098
|
+
"""Auto-size columns to the widest value among current rows/headings."""
|
|
2099
|
+
if not self._column_keys:
|
|
2100
|
+
return
|
|
2101
|
+
try:
|
|
2102
|
+
style = get_style()
|
|
2103
|
+
# Prefer the Treeview body font; fall back to TLabel/body or default
|
|
2104
|
+
tv_style = self._tree.cget("style") or "Treeview"
|
|
2105
|
+
body_font = (
|
|
2106
|
+
style.lookup(tv_style, "font")
|
|
2107
|
+
or style.lookup("TLabel", "font")
|
|
2108
|
+
or getattr(style, "fonts", {}).get("body")
|
|
2109
|
+
or "TkDefaultFont"
|
|
2110
|
+
)
|
|
2111
|
+
content_font = tkfont.nametofont(body_font)
|
|
2112
|
+
except Exception:
|
|
2113
|
+
content_font = None
|
|
2114
|
+
|
|
2115
|
+
pad_px = 20
|
|
2116
|
+
|
|
2117
|
+
# Gather samples from headings, provided records, and current tree values
|
|
2118
|
+
tree_samples = []
|
|
2119
|
+
for iid in self._tree.get_children(""):
|
|
2120
|
+
tree_samples.append(self._tree.item(iid, "values"))
|
|
2121
|
+
for ciid in self._tree.get_children(iid):
|
|
2122
|
+
tree_samples.append(self._tree.item(ciid, "values"))
|
|
2123
|
+
|
|
2124
|
+
for idx, key in enumerate(self._column_keys):
|
|
2125
|
+
samples = []
|
|
2126
|
+
if idx < len(self._heading_texts):
|
|
2127
|
+
samples.append(str(self._heading_texts[idx]))
|
|
2128
|
+
if records:
|
|
2129
|
+
for rec in records:
|
|
2130
|
+
samples.append(str(rec.get(key, "")))
|
|
2131
|
+
for vals in tree_samples:
|
|
2132
|
+
if idx < len(vals):
|
|
2133
|
+
samples.append(str(vals[idx]))
|
|
2134
|
+
|
|
2135
|
+
# Honor explicit column width if provided
|
|
2136
|
+
explicit_width = None
|
|
2137
|
+
if idx < len(self._column_defs):
|
|
2138
|
+
coldef = self._column_defs[idx]
|
|
2139
|
+
if isinstance(coldef, dict):
|
|
2140
|
+
explicit_width = coldef.get("width")
|
|
2141
|
+
|
|
2142
|
+
if explicit_width is not None:
|
|
2143
|
+
try:
|
|
2144
|
+
self._tree.column(idx, width=explicit_width, minwidth=self._column_min_width)
|
|
2145
|
+
except Exception:
|
|
2146
|
+
pass
|
|
2147
|
+
continue
|
|
2148
|
+
|
|
2149
|
+
text = max(samples, key=len) if samples else ""
|
|
2150
|
+
if content_font:
|
|
2151
|
+
width = content_font.measure(text) + pad_px
|
|
2152
|
+
else:
|
|
2153
|
+
width = 0
|
|
2154
|
+
# Fallback to simple char-based estimate to avoid under-measuring
|
|
2155
|
+
char_estimate = len(text) * 10 + pad_px
|
|
2156
|
+
width = max(width, char_estimate, self._column_min_width)
|
|
2157
|
+
# Cap width to available viewport so we don't force the tree wider than its frame
|
|
2158
|
+
try:
|
|
2159
|
+
avail = max(0, int(self._tree.winfo_width()) - pad_px)
|
|
2160
|
+
if avail > 0:
|
|
2161
|
+
width = min(width, avail)
|
|
2162
|
+
except Exception:
|
|
2163
|
+
pass
|
|
2164
|
+
try:
|
|
2165
|
+
self._tree.column(idx, width=width, minwidth=self._column_min_width)
|
|
2166
|
+
except Exception:
|
|
2167
|
+
pass
|
|
2168
|
+
|
|
2169
|
+
def _bs_apply_theme(self, _event: Any = None) -> None:
|
|
2170
|
+
"""Re-resolve imperatively-colored visuals on a theme change.
|
|
2171
|
+
|
|
2172
|
+
The ttk style rebuild refreshes neither the imperative stripe tag colors
|
|
2173
|
+
nor the theme-colored marker / group-chevron / sort-arrow PhotoImages —
|
|
2174
|
+
the latter are cached by color and held on rows, so without this they
|
|
2175
|
+
keep their old-theme tint after a light/dark toggle.
|
|
2176
|
+
"""
|
|
2177
|
+
try:
|
|
2178
|
+
self._apply_row_alternation()
|
|
2179
|
+
except Exception:
|
|
2180
|
+
pass
|
|
2181
|
+
# Drop the by-color icon cache and re-apply every theme-colored glyph so
|
|
2182
|
+
# it re-renders against the new theme. Each call no-ops when its glyph
|
|
2183
|
+
# isn't in play (e.g. markers only in multi-select, chevrons only in
|
|
2184
|
+
# group mode), so this is safe regardless of the current configuration.
|
|
2185
|
+
try:
|
|
2186
|
+
self._marker_icons.clear()
|
|
2187
|
+
self._update_selection_markers()
|
|
2188
|
+
self._refresh_group_chevrons()
|
|
2189
|
+
self._update_heading_icons()
|
|
2190
|
+
except Exception:
|
|
2191
|
+
pass
|
|
2192
|
+
|
|
2193
|
+
def _apply_row_alternation(self) -> None:
|
|
2194
|
+
"""Apply alternating row colors via a tag."""
|
|
2195
|
+
enabled = self._row_alternation.get('enabled', False)
|
|
2196
|
+
if not enabled or self._group_by_key:
|
|
2197
|
+
return
|
|
2198
|
+
bg, fg = self._resolve_alternating_row_color()
|
|
2199
|
+
try:
|
|
2200
|
+
self._tree.tag_configure("altrow", background=bg, foreground=fg)
|
|
2201
|
+
# Some themes honor the "striped" tag name; configure it too
|
|
2202
|
+
self._tree.tag_configure("striped", background=bg, foreground=fg)
|
|
2203
|
+
except Exception:
|
|
2204
|
+
return
|
|
2205
|
+
|
|
2206
|
+
queue = list(self._tree.get_children(""))
|
|
2207
|
+
idx = 0
|
|
2208
|
+
while queue:
|
|
2209
|
+
iid = queue.pop(0)
|
|
2210
|
+
try:
|
|
2211
|
+
tags = list(self._tree.item(iid, "tags") or [])
|
|
2212
|
+
if idx % 2 == 1:
|
|
2213
|
+
if "altrow" not in tags:
|
|
2214
|
+
tags.append("altrow")
|
|
2215
|
+
if "striped" not in tags:
|
|
2216
|
+
tags.append("striped")
|
|
2217
|
+
else:
|
|
2218
|
+
tags = [t for t in tags if t not in ("altrow", "striped")]
|
|
2219
|
+
self._tree.item(iid, tags=tags)
|
|
2220
|
+
except Exception:
|
|
2221
|
+
pass
|
|
2222
|
+
queue.extend(list(self._tree.get_children(iid)))
|
|
2223
|
+
idx += 1
|
|
2224
|
+
|
|
2225
|
+
# ------------------------------------------------------------------ Selection markers
|
|
2226
|
+
def _selection_markers_active(self) -> bool:
|
|
2227
|
+
"""Whether per-row selection checkboxes should be shown right now.
|
|
2228
|
+
|
|
2229
|
+
Multi-select only — single-select relies on the row highlight alone, and
|
|
2230
|
+
grouped mode reserves the leading slot for the expand/collapse control.
|
|
2231
|
+
"""
|
|
2232
|
+
return (
|
|
2233
|
+
self._selection_indicators
|
|
2234
|
+
and not self._group_by_key
|
|
2235
|
+
and self._selection.get('mode', 'single') == 'multi'
|
|
2236
|
+
)
|
|
2237
|
+
|
|
2238
|
+
def _marker_icon_specs(self) -> tuple[int, str]:
|
|
2239
|
+
"""Resolve (size, color) for marker icons from the active theme.
|
|
2240
|
+
|
|
2241
|
+
Rendered at a fixed even pixel size: the glyph is blitted 1:1 into the
|
|
2242
|
+
row, so a clean target size keeps it crisp and avoids the resampling
|
|
2243
|
+
softness that DPI-scaling a small icon introduces.
|
|
2244
|
+
"""
|
|
2245
|
+
builder = get_style().style_builder
|
|
2246
|
+
try:
|
|
2247
|
+
color = builder.on_color(builder.color('content'))
|
|
2248
|
+
except Exception:
|
|
2249
|
+
color = '#000000'
|
|
2250
|
+
return _MARKER_ICON_SIZE, color
|
|
2251
|
+
|
|
2252
|
+
def _marker_column_width(self) -> int:
|
|
2253
|
+
"""Width for the tree column (#0) when it hosts a selection marker.
|
|
2254
|
+
|
|
2255
|
+
Accounts for the icon plus the indicator/spacer/padding the shared item
|
|
2256
|
+
layout reserves ahead of the image slot.
|
|
2257
|
+
"""
|
|
2258
|
+
size, _ = self._marker_icon_specs()
|
|
2259
|
+
try:
|
|
2260
|
+
pad = get_style().style_builder.scale(24)
|
|
2261
|
+
except Exception:
|
|
2262
|
+
pad = 24
|
|
2263
|
+
return int(size + pad)
|
|
2264
|
+
|
|
2265
|
+
def _marker_accent_color(self) -> str:
|
|
2266
|
+
"""Solid accent color used to fill the checked/selected marker glyph."""
|
|
2267
|
+
builder = get_style().style_builder
|
|
2268
|
+
token = self._selection.get('accent', 'primary')
|
|
2269
|
+
try:
|
|
2270
|
+
return builder.color(token)
|
|
2271
|
+
except Exception:
|
|
2272
|
+
try:
|
|
2273
|
+
return builder.color('primary')
|
|
2274
|
+
except Exception:
|
|
2275
|
+
return self._marker_icon_specs()[1]
|
|
2276
|
+
|
|
2277
|
+
def _marker_unchecked_color(self) -> str:
|
|
2278
|
+
"""Color for the empty (unchecked) box outline — a muted neutral."""
|
|
2279
|
+
builder = get_style().style_builder
|
|
2280
|
+
try:
|
|
2281
|
+
return builder.color('muted')
|
|
2282
|
+
except Exception:
|
|
2283
|
+
return self._marker_icon_specs()[1]
|
|
2284
|
+
|
|
2285
|
+
def _marker_icon(self, name: str | None, color: str | None = None):
|
|
2286
|
+
"""Return a cached marker icon for the current theme.
|
|
2287
|
+
|
|
2288
|
+
`color` overrides the default neutral foreground (used to fill the
|
|
2289
|
+
checked/selected glyph with the accent). A `name` of None yields a
|
|
2290
|
+
transparent placeholder so unmarked rows keep their text alignment.
|
|
2291
|
+
"""
|
|
2292
|
+
size, default_color = self._marker_icon_specs()
|
|
2293
|
+
use_color = color or default_color
|
|
2294
|
+
key = (name, size, use_color)
|
|
2295
|
+
cached = self._marker_icons.get(key)
|
|
2296
|
+
if cached is not None:
|
|
2297
|
+
return cached
|
|
2298
|
+
try:
|
|
2299
|
+
if name is None:
|
|
2300
|
+
from bootstack.style.utility import create_transparent_image
|
|
2301
|
+
img = create_transparent_image(size, size)
|
|
2302
|
+
else:
|
|
2303
|
+
img = _ImageService.get_icon(name, size, use_color)
|
|
2304
|
+
except Exception:
|
|
2305
|
+
return None
|
|
2306
|
+
self._marker_icons[key] = img
|
|
2307
|
+
return img
|
|
2308
|
+
|
|
2309
|
+
def _update_selection_markers(self) -> None:
|
|
2310
|
+
"""Mirror the current selection as a per-row checkbox (multi-select).
|
|
2311
|
+
|
|
2312
|
+
Visual only — selection is still driven by clicks/keyboard. The checked
|
|
2313
|
+
box is filled with the accent; the unchecked box is a muted outline.
|
|
2314
|
+
"""
|
|
2315
|
+
if not self._selection_markers_active():
|
|
2316
|
+
return
|
|
2317
|
+
selected = set(self._tree.selection())
|
|
2318
|
+
on_icon = self._marker_icon('check-square-fill', self._marker_accent_color())
|
|
2319
|
+
off_icon = self._marker_icon('square', self._marker_unchecked_color())
|
|
2320
|
+
for iid in self._tree.get_children(""):
|
|
2321
|
+
img = on_icon if iid in selected else off_icon
|
|
2322
|
+
try:
|
|
2323
|
+
self._tree.item(iid, image=img if img is not None else "")
|
|
2324
|
+
except Exception:
|
|
2325
|
+
pass
|
|
2326
|
+
|
|
2327
|
+
# ------------------------------------------------------------------ Group chevrons
|
|
2328
|
+
def _chevron_icon(self, opened: bool):
|
|
2329
|
+
"""Cached expand/collapse chevron for a group-header row, neutral-toned."""
|
|
2330
|
+
name = _GROUP_OPEN_ICON if opened else _GROUP_CLOSED_ICON
|
|
2331
|
+
return self._marker_icon(name)
|
|
2332
|
+
|
|
2333
|
+
def _toggle_group_open(self, iid) -> None:
|
|
2334
|
+
"""Flip a group header's open state and swap its chevron to match.
|
|
2335
|
+
|
|
2336
|
+
Setting `open` programmatically does not fire `<<TreeviewOpen/Close>>`,
|
|
2337
|
+
so the chevron is updated here directly (the event bindings still cover
|
|
2338
|
+
keyboard-driven expand/collapse).
|
|
2339
|
+
"""
|
|
2340
|
+
try:
|
|
2341
|
+
new_state = not bool(int(self._tree.item(iid, "open") or 0))
|
|
2342
|
+
self._tree.item(iid, open=new_state, image=self._chevron_icon(new_state))
|
|
2343
|
+
except Exception:
|
|
2344
|
+
pass
|
|
2345
|
+
|
|
2346
|
+
def _refresh_group_chevrons(self, _event=None) -> None:
|
|
2347
|
+
"""Sync every group header's chevron to its current open/closed state."""
|
|
2348
|
+
if not self._group_by_key:
|
|
2349
|
+
return
|
|
2350
|
+
for iid in self._group_parents.values():
|
|
2351
|
+
try:
|
|
2352
|
+
opened = bool(int(self._tree.item(iid, "open") or 0))
|
|
2353
|
+
img = self._chevron_icon(opened)
|
|
2354
|
+
if img is not None:
|
|
2355
|
+
self._tree.item(iid, image=img)
|
|
2356
|
+
except Exception:
|
|
2357
|
+
pass
|
|
2358
|
+
|
|
2359
|
+
def _rebalance_grouped_widths(self) -> None:
|
|
2360
|
+
"""Distribute available width across data columns when grouped so the left tree column is included."""
|
|
2361
|
+
# Only rebalance when grouping is active and xscroll is off (otherwise user can scroll)
|
|
2362
|
+
if not self._group_by_key or self._paging['xscroll']:
|
|
2363
|
+
return
|
|
2364
|
+
try:
|
|
2365
|
+
tree_width = max(0, int(self._tree.winfo_width()))
|
|
2366
|
+
group_width = max(0, int(self._tree.column("#0", option="width") or 0))
|
|
2367
|
+
vsb_width = 0
|
|
2368
|
+
if getattr(self, "_vsb", None):
|
|
2369
|
+
try:
|
|
2370
|
+
self._vsb.update_idletasks()
|
|
2371
|
+
if self._vsb.winfo_ismapped():
|
|
2372
|
+
vsb_width = int(self._vsb.winfo_width())
|
|
2373
|
+
except Exception:
|
|
2374
|
+
vsb_width = 0
|
|
2375
|
+
# Leave a small cushion to avoid oscillating scrollbar
|
|
2376
|
+
available = tree_width - group_width - vsb_width - 8
|
|
2377
|
+
if available <= 0:
|
|
2378
|
+
return
|
|
2379
|
+
cols = [c for c in self._display_columns if c < len(self._heading_texts)]
|
|
2380
|
+
if not cols:
|
|
2381
|
+
return
|
|
2382
|
+
width = max(self._column_min_width, available // len(cols))
|
|
2383
|
+
for c in cols:
|
|
2384
|
+
self._tree.column(c, width=width, stretch=True)
|
|
2385
|
+
# Keep the group column fixed so only data columns flex
|
|
2386
|
+
self._tree.column("#0", stretch=False)
|
|
2387
|
+
except Exception:
|
|
2388
|
+
pass
|
|
2389
|
+
|
|
2390
|
+
def _on_tree_configure(self, _event=None) -> None:
|
|
2391
|
+
"""Handle resize events to keep grouped layouts sized to the available width."""
|
|
2392
|
+
self._rebalance_grouped_widths()
|
|
2393
|
+
|
|
2394
|
+
# ------------------------------------------------------------------ Export helpers
|
|
2395
|
+
# ------------------------------------------------------------------ Export — data access
|
|
2396
|
+
def _export_columns(self) -> tuple[list[str], list[str]]:
|
|
2397
|
+
"""Return `(header_texts, keys)` for the displayed columns (no internal columns)."""
|
|
2398
|
+
_hidden = self._internal_fields()
|
|
2399
|
+
keys = [k for k in self._column_keys if k not in _hidden]
|
|
2400
|
+
headers = [self._column_heading(k) for k in keys]
|
|
2401
|
+
return headers, keys
|
|
2402
|
+
|
|
2403
|
+
def _ds_page_size(self) -> int:
|
|
2404
|
+
"""Page size used by the data source (matches what `_load_page` renders)."""
|
|
2405
|
+
return getattr(self._datasource, "page_size", self._paging['page_size'])
|
|
2406
|
+
|
|
2407
|
+
def _scope_count(self, scope: str) -> int:
|
|
2408
|
+
"""Number of rows the given scope would export."""
|
|
2409
|
+
if scope == "selection":
|
|
2410
|
+
return len(self._tree.selection())
|
|
2411
|
+
with self._apply_view_to_source():
|
|
2412
|
+
if scope == "page":
|
|
2413
|
+
psize = self._ds_page_size()
|
|
2414
|
+
start = self._current_page * psize
|
|
2415
|
+
return len(self._datasource.page_slice(start, psize))
|
|
2416
|
+
return self._datasource.count
|
|
2417
|
+
|
|
2418
|
+
def _iter_raw_chunks(self, scope: str, chunk_size: int):
|
|
2419
|
+
"""Yield lists of raw records for `scope`, paging through large 'all' scopes."""
|
|
2420
|
+
if scope == "selection":
|
|
2421
|
+
rows = [self._row_map[iid] for iid in self._tree.selection() if iid in self._row_map]
|
|
2422
|
+
if rows:
|
|
2423
|
+
yield rows
|
|
2424
|
+
return
|
|
2425
|
+
# The view (this table's filter/sort) is applied to the SHARED source only
|
|
2426
|
+
# for the duration of each read, never across a `yield` — otherwise a
|
|
2427
|
+
# caller that stops iterating early (e.g. `break`s out of `iter_rows`)
|
|
2428
|
+
# would suspend the generator inside the context manager and leave the
|
|
2429
|
+
# shared source clobbered until GC, defeating per-view isolation.
|
|
2430
|
+
if scope == "page":
|
|
2431
|
+
with self._apply_view_to_source():
|
|
2432
|
+
psize = self._ds_page_size()
|
|
2433
|
+
start = self._current_page * psize
|
|
2434
|
+
rows = self._datasource.page_slice(start, psize)
|
|
2435
|
+
if rows:
|
|
2436
|
+
yield rows
|
|
2437
|
+
return
|
|
2438
|
+
# 'all' (the filtered/sorted set) — page through so memory stays flat.
|
|
2439
|
+
with self._apply_view_to_source():
|
|
2440
|
+
total = self._datasource.count
|
|
2441
|
+
offset = 0
|
|
2442
|
+
while offset < total:
|
|
2443
|
+
with self._apply_view_to_source():
|
|
2444
|
+
chunk = self._datasource.page_slice(offset, chunk_size)
|
|
2445
|
+
if not chunk:
|
|
2446
|
+
break
|
|
2447
|
+
yield chunk
|
|
2448
|
+
offset += len(chunk)
|
|
2449
|
+
|
|
2450
|
+
def _to_delimited(self, rows: list, delimiter: str) -> str:
|
|
2451
|
+
"""Serialize `rows` (raw records) as delimited text over the displayed columns."""
|
|
2452
|
+
import csv
|
|
2453
|
+
import io
|
|
2454
|
+
|
|
2455
|
+
headers, keys = self._export_columns()
|
|
2456
|
+
buf = io.StringIO()
|
|
2457
|
+
writer = csv.writer(buf, delimiter=delimiter)
|
|
2458
|
+
writer.writerow(headers)
|
|
2459
|
+
writer.writerows([[rec.get(k, "") for k in keys] for rec in rows])
|
|
2460
|
+
return buf.getvalue()
|
|
2461
|
+
|
|
2462
|
+
def _guard_materialize(self, scope: str, max_rows: int | None) -> None:
|
|
2463
|
+
if max_rows is None:
|
|
2464
|
+
return
|
|
2465
|
+
n = self._scope_count(scope)
|
|
2466
|
+
if n > max_rows:
|
|
2467
|
+
raise ValueError(
|
|
2468
|
+
f"{n} rows exceeds max_rows={max_rows}; use iter_rows() or "
|
|
2469
|
+
f"export_file() to stream large exports."
|
|
2470
|
+
)
|
|
2471
|
+
|
|
2472
|
+
def to_rows(self, scope: Literal["all", "page", "selection"] = "all", *, max_rows: int | None = _EXPORT_MAX_MATERIALIZE) -> list[dict]:
|
|
2473
|
+
"""Return the scope's records as a list of dicts (materialized — small data).
|
|
2474
|
+
|
|
2475
|
+
Raises if the row count exceeds `max_rows`; use `iter_rows()` for large data.
|
|
2476
|
+
"""
|
|
2477
|
+
self._guard_materialize(scope, max_rows)
|
|
2478
|
+
return [self._public_record(r) for chunk in self._iter_raw_chunks(scope, _EXPORT_CHUNK_SIZE) for r in chunk]
|
|
2479
|
+
|
|
2480
|
+
def to_csv(self, scope: Literal["all", "page", "selection"] = "all", *, max_rows: int | None = _EXPORT_MAX_MATERIALIZE) -> str:
|
|
2481
|
+
"""Return the scope's data as a CSV string (materialized — small data).
|
|
2482
|
+
|
|
2483
|
+
Raises if the row count exceeds `max_rows`; use `export_file()` for large data.
|
|
2484
|
+
"""
|
|
2485
|
+
self._guard_materialize(scope, max_rows)
|
|
2486
|
+
rows = [r for chunk in self._iter_raw_chunks(scope, _EXPORT_CHUNK_SIZE) for r in chunk]
|
|
2487
|
+
return self._to_delimited(rows, ",")
|
|
2488
|
+
|
|
2489
|
+
def iter_rows(self, scope: Literal["all", "page", "selection"] = "all", chunk_size: int = _EXPORT_CHUNK_SIZE):
|
|
2490
|
+
"""Lazily yield the scope's records one at a time, paging the data source."""
|
|
2491
|
+
for chunk in self._iter_raw_chunks(scope, chunk_size):
|
|
2492
|
+
for rec in chunk:
|
|
2493
|
+
yield self._public_record(rec)
|
|
2494
|
+
|
|
2495
|
+
# ------------------------------------------------------------------ Export — file/clipboard
|
|
2496
|
+
def _configured_export_formats(self) -> list[str]:
|
|
2497
|
+
"""The export formats this table offers (the `export_formats` config)."""
|
|
2498
|
+
configured = self._exporting.get('formats') or ('csv',)
|
|
2499
|
+
return [f for f in configured if f in _EXPORT_FORMATS]
|
|
2500
|
+
|
|
2501
|
+
def _available_export_formats(self) -> list[str]:
|
|
2502
|
+
"""Configured formats whose optional dependency (if any) is installed."""
|
|
2503
|
+
return [f for f in self._configured_export_formats() if _EXPORT_FORMATS[f]["available"]()]
|
|
2504
|
+
|
|
2505
|
+
def _warn_unavailable_export_formats(self) -> None:
|
|
2506
|
+
"""Warn (developer-facing) about configured formats missing their dependency."""
|
|
2507
|
+
for fmt in self._configured_export_formats():
|
|
2508
|
+
spec = _EXPORT_FORMATS[fmt]
|
|
2509
|
+
if not spec["available"]():
|
|
2510
|
+
logger.warning(
|
|
2511
|
+
"DataTable export_formats includes %r, but its dependency is not "
|
|
2512
|
+
"installed (pip install bootstack[%s]); it is hidden from the export "
|
|
2513
|
+
"menu until then.", fmt, spec["extra"],
|
|
2514
|
+
)
|
|
2515
|
+
|
|
2516
|
+
def _resolve_format(self, path: str, fmt: str | None) -> str:
|
|
2517
|
+
"""Resolve the export format from an explicit `fmt` or the path extension.
|
|
2518
|
+
|
|
2519
|
+
Validates against the configured `export_formats` and that the format's
|
|
2520
|
+
optional dependency (if any) is installed.
|
|
2521
|
+
"""
|
|
2522
|
+
if fmt:
|
|
2523
|
+
name = fmt.lower()
|
|
2524
|
+
else:
|
|
2525
|
+
ext = os.path.splitext(path)[1].lower()
|
|
2526
|
+
name = _EXT_TO_EXPORT_FORMAT.get(ext)
|
|
2527
|
+
configured = self._configured_export_formats()
|
|
2528
|
+
if name not in _EXPORT_FORMATS or name not in configured:
|
|
2529
|
+
raise ValueError(
|
|
2530
|
+
f"Export format {name!r} is not enabled. Configured formats: "
|
|
2531
|
+
f"{', '.join(configured)}. Add it via export_formats=."
|
|
2532
|
+
)
|
|
2533
|
+
spec = _EXPORT_FORMATS[name]
|
|
2534
|
+
if not spec["available"]():
|
|
2535
|
+
raise RuntimeError(
|
|
2536
|
+
f"{name} export requires an optional dependency: "
|
|
2537
|
+
f"pip install bootstack[{spec['extra']}]."
|
|
2538
|
+
)
|
|
2539
|
+
return name
|
|
2540
|
+
|
|
2541
|
+
def _make_writer(self, path: str, fmt: str, headers: list[str], keys: list[str]):
|
|
2542
|
+
"""Open a streaming writer for `fmt`; return `(write_chunk, close)`.
|
|
2543
|
+
|
|
2544
|
+
Both writers append incrementally (csv row-by-row, xlsx in
|
|
2545
|
+
constant-memory mode), so the full dataset is never materialized.
|
|
2546
|
+
"""
|
|
2547
|
+
if fmt == "xlsx":
|
|
2548
|
+
import xlsxwriter
|
|
2549
|
+
|
|
2550
|
+
workbook = xlsxwriter.Workbook(path, {"constant_memory": True})
|
|
2551
|
+
worksheet = workbook.add_worksheet()
|
|
2552
|
+
bold = workbook.add_format({"bold": True})
|
|
2553
|
+
for c, header in enumerate(headers):
|
|
2554
|
+
worksheet.write(0, c, header, bold)
|
|
2555
|
+
state = {"row": 1}
|
|
2556
|
+
|
|
2557
|
+
def write_chunk(chunk):
|
|
2558
|
+
row_idx = state["row"]
|
|
2559
|
+
for rec in chunk:
|
|
2560
|
+
for c, key in enumerate(keys):
|
|
2561
|
+
worksheet.write(row_idx, c, rec.get(key))
|
|
2562
|
+
row_idx += 1
|
|
2563
|
+
state["row"] = row_idx
|
|
2564
|
+
|
|
2565
|
+
return write_chunk, workbook.close
|
|
2566
|
+
|
|
2567
|
+
import csv
|
|
2568
|
+
|
|
2569
|
+
delimiter = "\t" if fmt == "tsv" else ","
|
|
2570
|
+
handle = open(path, "w", newline="", encoding="utf-8")
|
|
2571
|
+
writer = csv.writer(handle, delimiter=delimiter)
|
|
2572
|
+
writer.writerow(headers)
|
|
2573
|
+
|
|
2574
|
+
def write_chunk(chunk):
|
|
2575
|
+
writer.writerows([[rec.get(k, "") for k in keys] for rec in chunk])
|
|
2576
|
+
|
|
2577
|
+
return write_chunk, handle.close
|
|
2578
|
+
|
|
2579
|
+
def export_file(
|
|
2580
|
+
self,
|
|
2581
|
+
path: str,
|
|
2582
|
+
scope: Literal["all", "page", "selection"] = "all",
|
|
2583
|
+
*,
|
|
2584
|
+
format: str | None = None,
|
|
2585
|
+
chunk_size: int = _EXPORT_CHUNK_SIZE,
|
|
2586
|
+
on_progress=None,
|
|
2587
|
+
) -> int:
|
|
2588
|
+
"""Stream the scope's data to `path`, paging so memory stays flat.
|
|
2589
|
+
|
|
2590
|
+
Synchronous. Format is inferred from the path extension unless `format`
|
|
2591
|
+
is given. `on_progress(written, total)` is called after each chunk.
|
|
2592
|
+
Returns the number of rows written.
|
|
2593
|
+
"""
|
|
2594
|
+
fmt = self._resolve_format(path, format)
|
|
2595
|
+
if _EXPORT_FORMATS[fmt]["kind"] == "registry":
|
|
2596
|
+
return self._export_file_registry(path, scope, fmt, chunk_size, on_progress)
|
|
2597
|
+
headers, keys = self._export_columns()
|
|
2598
|
+
total = self._scope_count(scope)
|
|
2599
|
+
write_chunk, close = self._make_writer(path, fmt, headers, keys)
|
|
2600
|
+
written = 0
|
|
2601
|
+
try:
|
|
2602
|
+
for chunk in self._iter_raw_chunks(scope, chunk_size):
|
|
2603
|
+
write_chunk(chunk)
|
|
2604
|
+
written += len(chunk)
|
|
2605
|
+
if on_progress is not None:
|
|
2606
|
+
on_progress(written, total)
|
|
2607
|
+
finally:
|
|
2608
|
+
close()
|
|
2609
|
+
self._emit_export(target="file", fmt=fmt, path=path, count=written)
|
|
2610
|
+
return written
|
|
2611
|
+
|
|
2612
|
+
def _export_file_registry(self, path, scope, fmt, chunk_size, on_progress) -> int:
|
|
2613
|
+
"""Synchronously stream a registry-format export over the displayed columns.
|
|
2614
|
+
|
|
2615
|
+
Records are projected to the exported columns (what the table shows) and
|
|
2616
|
+
streamed through `bootstack.data.writers`, so the export carries the same
|
|
2617
|
+
columns as CSV/XLSX. (For the full record set, use
|
|
2618
|
+
`table.data_source.save(path)`.)
|
|
2619
|
+
"""
|
|
2620
|
+
from bootstack.data.writers import write_records
|
|
2621
|
+
|
|
2622
|
+
_headers, keys = self._export_columns()
|
|
2623
|
+
total = self._scope_count(scope)
|
|
2624
|
+
counter = {"n": 0}
|
|
2625
|
+
|
|
2626
|
+
def _records():
|
|
2627
|
+
for chunk in self._iter_raw_chunks(scope, chunk_size):
|
|
2628
|
+
for raw in chunk:
|
|
2629
|
+
pub = self._public_record(raw)
|
|
2630
|
+
yield {k: pub.get(k) for k in keys}
|
|
2631
|
+
counter["n"] += len(chunk)
|
|
2632
|
+
if on_progress is not None:
|
|
2633
|
+
on_progress(counter["n"], total)
|
|
2634
|
+
|
|
2635
|
+
write_records(path, _records(), format=fmt)
|
|
2636
|
+
self._emit_export(target="file", fmt=fmt, path=path, count=counter["n"])
|
|
2637
|
+
return counter["n"]
|
|
2638
|
+
|
|
2639
|
+
def export_file_async(
|
|
2640
|
+
self,
|
|
2641
|
+
path: str,
|
|
2642
|
+
scope: Literal["all", "page", "selection"] = "all",
|
|
2643
|
+
*,
|
|
2644
|
+
format: Literal["csv", "tsv", "xlsx"] | None = None,
|
|
2645
|
+
chunk_size: int = _EXPORT_CHUNK_SIZE,
|
|
2646
|
+
on_progress=None,
|
|
2647
|
+
on_done=None,
|
|
2648
|
+
) -> _ExportJob:
|
|
2649
|
+
"""Stream to `path` without blocking the UI; return a cancelable job.
|
|
2650
|
+
|
|
2651
|
+
Writes one chunk per idle tick. `on_progress(written, total)` fires per
|
|
2652
|
+
chunk; `on_done(status, written, error)` fires once at the end with
|
|
2653
|
+
`status` in `'completed'` / `'cancelled'` / `'error'`. Cancel or error
|
|
2654
|
+
removes the partial file. Call `job.cancel()` to stop.
|
|
2655
|
+
"""
|
|
2656
|
+
fmt = self._resolve_format(path, format)
|
|
2657
|
+
if _EXPORT_FORMATS[fmt]["kind"] == "registry":
|
|
2658
|
+
raise ValueError(
|
|
2659
|
+
f"Async export supports csv/tsv/xlsx; {fmt!r} writes synchronously — "
|
|
2660
|
+
f"use export_file()."
|
|
2661
|
+
)
|
|
2662
|
+
headers, keys = self._export_columns()
|
|
2663
|
+
total = self._scope_count(scope)
|
|
2664
|
+
write_chunk, close = self._make_writer(path, fmt, headers, keys)
|
|
2665
|
+
chunks = self._iter_raw_chunks(scope, chunk_size)
|
|
2666
|
+
|
|
2667
|
+
def _complete(status, written, error):
|
|
2668
|
+
self._export_jobs.discard(job)
|
|
2669
|
+
if status == "completed":
|
|
2670
|
+
self._emit_export(target="file", fmt=fmt, path=path, count=written)
|
|
2671
|
+
else:
|
|
2672
|
+
try:
|
|
2673
|
+
os.remove(path)
|
|
2674
|
+
except OSError:
|
|
2675
|
+
pass
|
|
2676
|
+
if status == "error":
|
|
2677
|
+
logger.error("Async export to %s failed", path, exc_info=error)
|
|
2678
|
+
if on_done is not None:
|
|
2679
|
+
on_done(status, written, error)
|
|
2680
|
+
|
|
2681
|
+
job = _ExportJob(
|
|
2682
|
+
self, chunks, write_chunk, close, total,
|
|
2683
|
+
on_progress=on_progress, on_complete=_complete,
|
|
2684
|
+
)
|
|
2685
|
+
self._export_jobs.add(job)
|
|
2686
|
+
return job.start()
|
|
2687
|
+
|
|
2688
|
+
def _emit_export(self, *, target: str, fmt: str, path: str | None, count: int, records=None) -> None:
|
|
2689
|
+
self.event_generate(
|
|
2690
|
+
"<<Export>>",
|
|
2691
|
+
data=ExportEvent(count=count, target=target, format=fmt, path=path, records=records or []),
|
|
2692
|
+
)
|
|
2693
|
+
|
|
2694
|
+
def _default_scope(self) -> str:
|
|
2695
|
+
"""Scope for the built-in export actions: the selection if any, else all."""
|
|
2696
|
+
if self._exporting.get('allow_export_selection', True) and self._tree.selection():
|
|
2697
|
+
return "selection"
|
|
2698
|
+
return "all"
|
|
2699
|
+
|
|
2700
|
+
def _update_export_labels(self) -> None:
|
|
2701
|
+
"""Reflect the implicit export scope (selection vs all) in the menu labels."""
|
|
2702
|
+
btn = getattr(self, "_export_btn", None)
|
|
2703
|
+
tree = getattr(self, "_tree", None)
|
|
2704
|
+
if btn is None or tree is None:
|
|
2705
|
+
return
|
|
2706
|
+
count = len(tree.selection())
|
|
2707
|
+
if self._exporting.get('allow_export_selection', True) and count > 0:
|
|
2708
|
+
copy_text = f"Copy selection ({count:,})"
|
|
2709
|
+
save_text = f"Save selection ({count:,})"
|
|
2710
|
+
else:
|
|
2711
|
+
copy_text = "Copy to clipboard"
|
|
2712
|
+
save_text = "Save to file"
|
|
2713
|
+
try:
|
|
2714
|
+
btn.configure_item("export_copy", text=copy_text)
|
|
2715
|
+
btn.configure_item("export_save", text=save_text)
|
|
2716
|
+
except Exception:
|
|
2717
|
+
logger.exception("Failed to update export menu labels")
|
|
2718
|
+
|
|
2719
|
+
def _copy_to_clipboard(self) -> None:
|
|
2720
|
+
"""Copy the default scope to the clipboard as tab-separated text."""
|
|
2721
|
+
scope = self._default_scope()
|
|
2722
|
+
rows = [r for chunk in self._iter_raw_chunks(scope, _EXPORT_CHUNK_SIZE) for r in chunk]
|
|
2723
|
+
if not rows:
|
|
2724
|
+
return
|
|
2725
|
+
try:
|
|
2726
|
+
self.clipboard_clear()
|
|
2727
|
+
self.clipboard_append(self._to_delimited(rows, "\t"))
|
|
2728
|
+
except Exception:
|
|
2729
|
+
logger.exception("Failed to copy table to clipboard")
|
|
2730
|
+
return
|
|
2731
|
+
self._emit_export(
|
|
2732
|
+
target="clipboard", fmt="tsv", path=None, count=len(rows),
|
|
2733
|
+
records=[self._public_record(r) for r in rows],
|
|
2734
|
+
)
|
|
2735
|
+
|
|
2736
|
+
def _save_to_file(self) -> None:
|
|
2737
|
+
"""Prompt for a destination and stream the default scope to it.
|
|
2738
|
+
|
|
2739
|
+
Small exports run synchronously (instant); large ones run on the event
|
|
2740
|
+
loop with a progress dialog so the UI stays responsive and cancelable.
|
|
2741
|
+
"""
|
|
2742
|
+
from tkinter import filedialog
|
|
2743
|
+
|
|
2744
|
+
available = self._available_export_formats() or ["csv"]
|
|
2745
|
+
filetypes = [(_EXPORT_FORMATS[f]["label"], "*" + _EXPORT_FORMATS[f]["ext"]) for f in available]
|
|
2746
|
+
filetypes.append(("All files", "*.*"))
|
|
2747
|
+
default_fmt = available[0]
|
|
2748
|
+
default_ext = _EXPORT_FORMATS[default_fmt]["ext"]
|
|
2749
|
+
|
|
2750
|
+
scope = self._default_scope()
|
|
2751
|
+
path = filedialog.asksaveasfilename(
|
|
2752
|
+
parent=self.winfo_toplevel(),
|
|
2753
|
+
title=MessageCatalog.translate("table.export"),
|
|
2754
|
+
defaultextension=default_ext,
|
|
2755
|
+
initialfile="table_export" + default_ext,
|
|
2756
|
+
filetypes=filetypes,
|
|
2757
|
+
)
|
|
2758
|
+
if not path:
|
|
2759
|
+
return
|
|
2760
|
+
|
|
2761
|
+
# Registry formats write synchronously; only the cooperative formats
|
|
2762
|
+
# (csv/tsv/xlsx) use the async progress path for very large exports.
|
|
2763
|
+
try:
|
|
2764
|
+
fmt = self._resolve_format(path, None)
|
|
2765
|
+
except Exception:
|
|
2766
|
+
logger.exception("Failed to resolve export format for %s", path)
|
|
2767
|
+
return
|
|
2768
|
+
cooperative = _EXPORT_FORMATS[fmt]["kind"] == "cooperative"
|
|
2769
|
+
if not cooperative or self._scope_count(scope) <= _EXPORT_ASYNC_THRESHOLD:
|
|
2770
|
+
try:
|
|
2771
|
+
self.export_file(path, scope=scope)
|
|
2772
|
+
except Exception:
|
|
2773
|
+
logger.exception("Failed to export table to %s", path)
|
|
2774
|
+
return
|
|
2775
|
+
self._save_to_file_with_progress(path, scope)
|
|
2776
|
+
|
|
2777
|
+
def _save_to_file_with_progress(self, path: str, scope: str) -> None:
|
|
2778
|
+
"""Run a large export on the event loop behind a non-blocking progress window.
|
|
2779
|
+
|
|
2780
|
+
The window does not block (no nested loop), so the export's chunk steps
|
|
2781
|
+
run on the live main loop and the bar updates as it goes.
|
|
2782
|
+
"""
|
|
2783
|
+
from bootstack._runtime.toplevel import Toplevel
|
|
2784
|
+
|
|
2785
|
+
total = self._scope_count(scope)
|
|
2786
|
+
parent = self.winfo_toplevel()
|
|
2787
|
+
state: dict = {"job": None, "destroyed": False}
|
|
2788
|
+
|
|
2789
|
+
top = Toplevel(
|
|
2790
|
+
title=MessageCatalog.translate("table.export"),
|
|
2791
|
+
master=parent,
|
|
2792
|
+
transient=parent,
|
|
2793
|
+
minsize=(440, 120),
|
|
2794
|
+
resizable=(False, False),
|
|
2795
|
+
center_on_parent=True,
|
|
2796
|
+
)
|
|
2797
|
+
frame = Frame(top, padding=14)
|
|
2798
|
+
frame.pack(fill="both", expand=True)
|
|
2799
|
+
label = Label(frame, text=f"Exporting 0 of {total:,}…")
|
|
2800
|
+
label.pack(anchor="w", pady=(0, 8))
|
|
2801
|
+
bar = Progressbar(frame, mode="determinate", maximum=max(total, 1), value=0)
|
|
2802
|
+
bar.pack(fill="x", pady=(0, 8))
|
|
2803
|
+
Label(frame, text="Close this window to cancel.", accent="muted").pack(anchor="w")
|
|
2804
|
+
|
|
2805
|
+
def cancel_export():
|
|
2806
|
+
"""Cancel the running job (its partial file is removed on cancel)."""
|
|
2807
|
+
if state["job"] is not None:
|
|
2808
|
+
state["job"].cancel()
|
|
2809
|
+
|
|
2810
|
+
def destroy_window():
|
|
2811
|
+
if state["destroyed"]:
|
|
2812
|
+
return
|
|
2813
|
+
state["destroyed"] = True
|
|
2814
|
+
try:
|
|
2815
|
+
top.destroy()
|
|
2816
|
+
except Exception:
|
|
2817
|
+
pass
|
|
2818
|
+
|
|
2819
|
+
def on_progress(written, count):
|
|
2820
|
+
if not state["destroyed"]:
|
|
2821
|
+
bar.set(written)
|
|
2822
|
+
label.configure(text=f"Exporting {written:,} of {count:,}…")
|
|
2823
|
+
|
|
2824
|
+
def on_done(status, written, error):
|
|
2825
|
+
destroy_window()
|
|
2826
|
+
if status == "error":
|
|
2827
|
+
logger.error("Export to %s failed", path, exc_info=error)
|
|
2828
|
+
|
|
2829
|
+
def request_close():
|
|
2830
|
+
# Closing the window cancels the export and removes the partial file.
|
|
2831
|
+
state["destroyed"] = True
|
|
2832
|
+
cancel_export()
|
|
2833
|
+
return None
|
|
2834
|
+
|
|
2835
|
+
top.add_close_handler(request_close)
|
|
2836
|
+
top.show() # Toplevel starts withdrawn; reveal it before the export runs.
|
|
2837
|
+
state["job"] = self.export_file_async(path, scope, on_progress=on_progress, on_done=on_done)
|
|
2838
|
+
|
|
2839
|
+
# ------------------------------------------------------------------ Header click handling
|
|
2840
|
+
def _toggle_select_active(self) -> bool:
|
|
2841
|
+
"""Whether a plain click should toggle a row (checklist behavior).
|
|
2842
|
+
|
|
2843
|
+
Active whenever the multi-select checkboxes are visible, where users
|
|
2844
|
+
expect a click to add/remove a row without holding Ctrl/Shift.
|
|
2845
|
+
"""
|
|
2846
|
+
return self._selection_markers_active()
|
|
2847
|
+
|
|
2848
|
+
def _on_header_click(self, event):
|
|
2849
|
+
"""Handle left-click: header sorting, or toggle-select with checkboxes."""
|
|
2850
|
+
# A left-click on the tree dismisses an open context menu. Do it
|
|
2851
|
+
# explicitly: the toggle-select and group-row branches below return
|
|
2852
|
+
# 'break', which stops the event before it reaches the window-level
|
|
2853
|
+
# outside-click handler that would otherwise close the menu.
|
|
2854
|
+
self._dismiss_context_menus()
|
|
2855
|
+
region = self._tree.identify_region(event.x, event.y)
|
|
2856
|
+
if region == "heading":
|
|
2857
|
+
if self._sorting == 'none':
|
|
2858
|
+
return None
|
|
2859
|
+
col_id = self._tree.identify_column(event.x) # e.g. "#1"
|
|
2860
|
+
try:
|
|
2861
|
+
display_idx = int(col_id.strip("#")) - 1
|
|
2862
|
+
except Exception:
|
|
2863
|
+
return None
|
|
2864
|
+
if display_idx < 0 or display_idx >= len(self._display_columns):
|
|
2865
|
+
return None
|
|
2866
|
+
column_idx = self._display_columns[display_idx]
|
|
2867
|
+
self._on_sort(column_idx)
|
|
2868
|
+
return None
|
|
2869
|
+
|
|
2870
|
+
# A row with children is expandable — clicking anywhere on it toggles
|
|
2871
|
+
# open/closed (the native indicator that used to own this click was
|
|
2872
|
+
# removed from the item layout). Leaf rows have no children, so they
|
|
2873
|
+
# fall through to normal selection below.
|
|
2874
|
+
iid = self._tree.identify_row(event.y)
|
|
2875
|
+
if iid and self._tree.get_children(iid):
|
|
2876
|
+
self._toggle_group_open(iid)
|
|
2877
|
+
return "break"
|
|
2878
|
+
|
|
2879
|
+
# Body click with checkboxes showing: treat the list like a checklist —
|
|
2880
|
+
# a plain click toggles the row in/out of the selection (no modifier),
|
|
2881
|
+
# and "break" suppresses ttk's default replace-the-selection behavior.
|
|
2882
|
+
if self._toggle_select_active():
|
|
2883
|
+
iid = self._tree.identify_row(event.y)
|
|
2884
|
+
if iid:
|
|
2885
|
+
if iid in self._tree.selection():
|
|
2886
|
+
self._tree.selection_remove(iid)
|
|
2887
|
+
else:
|
|
2888
|
+
self._tree.selection_add(iid)
|
|
2889
|
+
return "break"
|
|
2890
|
+
return None
|
|
2891
|
+
|
|
2892
|
+
def _filter_header_column(self) -> None:
|
|
2893
|
+
"""Show filter dialog for the currently selected header column."""
|
|
2894
|
+
col = self._header_menu_col
|
|
2895
|
+
if col is None or col >= len(self._column_keys):
|
|
2896
|
+
return
|
|
2897
|
+
self._show_column_filter_dialog(col)
|
|
2898
|
+
|
|
2899
|
+
def _show_column_filter_dialog(self, column_idx: int) -> None:
|
|
2900
|
+
"""Show FilterDialog with distinct values for the column."""
|
|
2901
|
+
from bootstack.dialogs._impl.filterdialog import FilterDialog
|
|
2902
|
+
|
|
2903
|
+
if column_idx >= len(self._column_keys):
|
|
2904
|
+
return
|
|
2905
|
+
|
|
2906
|
+
key = self._column_keys[column_idx]
|
|
2907
|
+
heading_text = self._heading_texts[column_idx] if column_idx < len(self._heading_texts) else key
|
|
2908
|
+
|
|
2909
|
+
# Get distinct values from datasource
|
|
2910
|
+
try:
|
|
2911
|
+
distinct_values = self._datasource.get_distinct_values(key)
|
|
2912
|
+
except Exception:
|
|
2913
|
+
distinct_values = []
|
|
2914
|
+
|
|
2915
|
+
if not distinct_values:
|
|
2916
|
+
return
|
|
2917
|
+
|
|
2918
|
+
empty_text = MessageCatalog.translate("table.empty")
|
|
2919
|
+
# Build items for the filter dialog
|
|
2920
|
+
current_filter = self._column_filters.get(key)
|
|
2921
|
+
items = []
|
|
2922
|
+
for val in distinct_values:
|
|
2923
|
+
display_text = str(val) if val is not None else empty_text
|
|
2924
|
+
selected = current_filter is None or val in current_filter
|
|
2925
|
+
items.append(
|
|
2926
|
+
{
|
|
2927
|
+
"text": display_text,
|
|
2928
|
+
"value": val,
|
|
2929
|
+
"selected": selected
|
|
2930
|
+
})
|
|
2931
|
+
|
|
2932
|
+
# Position dialog below the header
|
|
2933
|
+
col_id = f"#{self._display_columns.index(column_idx) + 1}" if column_idx in self._display_columns else "#1"
|
|
2934
|
+
pos_x = self._tree.winfo_rootx()
|
|
2935
|
+
pos_y = self._tree.winfo_rooty()
|
|
2936
|
+
|
|
2937
|
+
tree_items = self._tree.get_children()
|
|
2938
|
+
if tree_items:
|
|
2939
|
+
bbox = self._tree.bbox(tree_items[0], col_id)
|
|
2940
|
+
if bbox:
|
|
2941
|
+
pos_x = self._tree.winfo_rootx() + bbox[0]
|
|
2942
|
+
pos_y = self._tree.winfo_rooty() + bbox[1] + 2
|
|
2943
|
+
|
|
2944
|
+
dialog = FilterDialog(
|
|
2945
|
+
master=self.winfo_toplevel(),
|
|
2946
|
+
title=MessageCatalog.translate("table.filter_column", heading_text),
|
|
2947
|
+
items=items,
|
|
2948
|
+
enable_search=True,
|
|
2949
|
+
enable_select_all=True,
|
|
2950
|
+
undecorated=True
|
|
2951
|
+
)
|
|
2952
|
+
|
|
2953
|
+
result = dialog.show(position=(pos_x, pos_y))
|
|
2954
|
+
|
|
2955
|
+
if result is not None:
|
|
2956
|
+
self._apply_column_filter(key, result, distinct_values)
|
|
2957
|
+
|
|
2958
|
+
def _apply_column_filter(self, key: str, selected_values: list, all_values: list) -> None:
|
|
2959
|
+
"""Apply column filter based on selected values."""
|
|
2960
|
+
# If all values selected, clear the filter for this column
|
|
2961
|
+
if set(selected_values) == set(all_values):
|
|
2962
|
+
self._column_filters.pop(key, None)
|
|
2963
|
+
else:
|
|
2964
|
+
self._column_filters[key] = selected_values
|
|
2965
|
+
|
|
2966
|
+
# Build combined WHERE clause from all column filters
|
|
2967
|
+
self._rebuild_filter_where()
|
|
2968
|
+
|
|
2969
|
+
def _build_column_filter_condition(self):
|
|
2970
|
+
"""Build the combined condition from all active column filters (or None)."""
|
|
2971
|
+
clauses = []
|
|
2972
|
+
for key, values in self._column_filters.items():
|
|
2973
|
+
if not values:
|
|
2974
|
+
# No values selected = match nothing for this column
|
|
2975
|
+
clauses.append(col(key).is_in([]))
|
|
2976
|
+
continue
|
|
2977
|
+
non_null = [v for v in values if v is not None]
|
|
2978
|
+
parts = []
|
|
2979
|
+
if non_null:
|
|
2980
|
+
parts.append(col(key).is_in(non_null))
|
|
2981
|
+
if None in values:
|
|
2982
|
+
parts.append(col(key).is_null())
|
|
2983
|
+
clause = any_of(*parts)
|
|
2984
|
+
if clause is not None:
|
|
2985
|
+
clauses.append(clause)
|
|
2986
|
+
return all_of(*clauses)
|
|
2987
|
+
|
|
2988
|
+
def _rebuild_filter_where(self) -> None:
|
|
2989
|
+
"""Re-apply the combined where() after a column filter change."""
|
|
2990
|
+
self._apply_where()
|
|
2991
|
+
|
|
2992
|
+
# ------------------------------------------------------------------ Context dispatch
|
|
2993
|
+
def _on_tree_context(self, event) -> None:
|
|
2994
|
+
if self._context_menus == "none":
|
|
2995
|
+
return
|
|
2996
|
+
region = self._tree.identify_region(event.x, event.y)
|
|
2997
|
+
if region == "heading":
|
|
2998
|
+
if not self._header_context_enabled():
|
|
2999
|
+
return
|
|
3000
|
+
self._on_header_context(event)
|
|
3001
|
+
else:
|
|
3002
|
+
if not self._row_context_enabled():
|
|
3003
|
+
return
|
|
3004
|
+
self._on_row_context(event)
|
|
3005
|
+
|
|
3006
|
+
def _on_selection_event(self, _event=None) -> None:
|
|
3007
|
+
"""Forward selection changes to subscribers."""
|
|
3008
|
+
rows = self.selected_rows
|
|
3009
|
+
ids = [r.get("id") for r in rows]
|
|
3010
|
+
self._update_export_labels()
|
|
3011
|
+
self._update_selection_markers()
|
|
3012
|
+
self.event_generate("<<SelectionChange>>", data=SelectionEvent(records=rows, ids=ids))
|
|
3013
|
+
|
|
3014
|
+
def _on_row_click_event(self, event) -> None:
|
|
3015
|
+
region = self._tree.identify_region(event.x, event.y)
|
|
3016
|
+
if region == "heading":
|
|
3017
|
+
return
|
|
3018
|
+
iid = self._tree.identify_row(event.y)
|
|
3019
|
+
if not iid or iid not in self._row_map:
|
|
3020
|
+
return # empty space or a group-header row (no record)
|
|
3021
|
+
rec = self._row_map.get(iid, {})
|
|
3022
|
+
self.event_generate("<<RowClick>>", data=RowEvent(record=self._public_record(rec), id=self._record_id(rec)))
|
|
3023
|
+
|
|
3024
|
+
# ------------------------------------------------------------------ Header context menu
|
|
3025
|
+
def _ensure_header_menu(self) -> None:
|
|
3026
|
+
if not self._header_context_enabled():
|
|
3027
|
+
return
|
|
3028
|
+
if self._header_menu:
|
|
3029
|
+
return
|
|
3030
|
+
menu = ContextMenu(master=self, target=self._tree, trigger=None)
|
|
3031
|
+
menu.add_command(text="table.align_left", icon="align-start", command=self._align_header_left)
|
|
3032
|
+
menu.add_command(text="table.align_center", icon="align-center", command=self._align_header_center)
|
|
3033
|
+
menu.add_command(text="table.align_right", icon="align-end", command=self._align_header_right)
|
|
3034
|
+
menu.add_separator()
|
|
3035
|
+
menu.add_command(text="table.move_left", icon="arrow-left", command=self._move_header_left)
|
|
3036
|
+
menu.add_command(text="table.move_right", icon="arrow-right", command=self._move_header_right)
|
|
3037
|
+
menu.add_command(text="table.move_first", icon="arrow-bar-left", command=self._move_header_first)
|
|
3038
|
+
menu.add_command(text="table.move_last", icon="arrow-bar-right", command=self._move_header_last)
|
|
3039
|
+
menu.add_separator()
|
|
3040
|
+
menu.add_command(text="table.hide_column", icon="eye-slash", command=self._hide_header_column)
|
|
3041
|
+
menu.add_command(text="table.show_all", icon="eye", command=self._show_all_columns)
|
|
3042
|
+
if self._allow_grouping:
|
|
3043
|
+
menu.add_separator()
|
|
3044
|
+
menu.add_command(text="table.group_by_column", command=self._group_header_column)
|
|
3045
|
+
menu.add_command(text="table.ungroup_all", command=self._ungroup_all)
|
|
3046
|
+
menu.add_separator()
|
|
3047
|
+
menu.add_command(text="table.reset", icon="arrow-counterclockwise", command=self._reset_table)
|
|
3048
|
+
menu.add_separator()
|
|
3049
|
+
if not self._sorting == 'none':
|
|
3050
|
+
menu.add_command(text="table.clear_sort", icon="x-lg", command=self._clear_sort)
|
|
3051
|
+
self._header_menu = menu
|
|
3052
|
+
|
|
3053
|
+
def _on_header_context(self, event) -> None:
|
|
3054
|
+
if not self._header_context_enabled():
|
|
3055
|
+
return
|
|
3056
|
+
# Only handle header clicks
|
|
3057
|
+
if self._tree.identify_region(event.x, event.y) != "heading":
|
|
3058
|
+
return
|
|
3059
|
+
col_id = self._tree.identify_column(event.x) # e.g. "#1"
|
|
3060
|
+
try:
|
|
3061
|
+
idx = int(col_id.strip("#")) - 1
|
|
3062
|
+
except Exception:
|
|
3063
|
+
return
|
|
3064
|
+
if idx < 0 or idx >= len(self._display_columns):
|
|
3065
|
+
return
|
|
3066
|
+
self._header_menu_col = self._display_columns[idx]
|
|
3067
|
+
self._ensure_header_menu()
|
|
3068
|
+
|
|
3069
|
+
# Try to position at bottom-left of the clicked header
|
|
3070
|
+
pos_x, pos_y = event.x_root, event.y_root
|
|
3071
|
+
items = self._tree.get_children()
|
|
3072
|
+
if items:
|
|
3073
|
+
bbox = self._tree.bbox(items[0], col_id)
|
|
3074
|
+
if bbox:
|
|
3075
|
+
# bbox is relative to the widget; bbox[1] is header height offset
|
|
3076
|
+
pos_x = self._tree.winfo_rootx() + bbox[0]
|
|
3077
|
+
pos_y = self._tree.winfo_rooty() + bbox[1] + 2
|
|
3078
|
+
self._header_menu.show(position=(pos_x, pos_y))
|
|
3079
|
+
|
|
3080
|
+
def _align_header_left(self) -> None:
|
|
3081
|
+
self._set_heading_anchor("w")
|
|
3082
|
+
|
|
3083
|
+
def _align_header_center(self) -> None:
|
|
3084
|
+
self._set_heading_anchor("center")
|
|
3085
|
+
|
|
3086
|
+
def _align_header_right(self) -> None:
|
|
3087
|
+
self._set_heading_anchor("e")
|
|
3088
|
+
|
|
3089
|
+
def _set_heading_anchor(self, anchor: str) -> None:
|
|
3090
|
+
"""Align only the header text for the selected column."""
|
|
3091
|
+
col = self._header_menu_col
|
|
3092
|
+
if col is None:
|
|
3093
|
+
return
|
|
3094
|
+
self._tree.heading(col, anchor=anchor)
|
|
3095
|
+
self._tree.column(col, anchor=anchor)
|
|
3096
|
+
|
|
3097
|
+
def _move_header_left(self) -> None:
|
|
3098
|
+
self._move_column(-1)
|
|
3099
|
+
|
|
3100
|
+
def _move_header_right(self) -> None:
|
|
3101
|
+
self._move_column(1)
|
|
3102
|
+
|
|
3103
|
+
def _move_header_first(self) -> None:
|
|
3104
|
+
self._move_column(to_index=0)
|
|
3105
|
+
|
|
3106
|
+
def _move_header_last(self) -> None:
|
|
3107
|
+
self._move_column(to_index=len(self._display_columns) - 1)
|
|
3108
|
+
|
|
3109
|
+
def _move_column(self, delta: int | None = None, to_index: int | None = None) -> None:
|
|
3110
|
+
col = self._header_menu_col
|
|
3111
|
+
if col is None or col not in self._display_columns:
|
|
3112
|
+
return
|
|
3113
|
+
current_pos = self._display_columns.index(col)
|
|
3114
|
+
if to_index is not None:
|
|
3115
|
+
new_pos = max(0, min(len(self._display_columns) - 1, to_index))
|
|
3116
|
+
else:
|
|
3117
|
+
new_pos = current_pos + (delta or 0)
|
|
3118
|
+
new_pos = max(0, min(len(self._display_columns) - 1, new_pos))
|
|
3119
|
+
if new_pos == current_pos:
|
|
3120
|
+
return
|
|
3121
|
+
self._display_columns.pop(current_pos)
|
|
3122
|
+
self._display_columns.insert(new_pos, col)
|
|
3123
|
+
self._tree.configure(displaycolumns=self._display_columns)
|
|
3124
|
+
|
|
3125
|
+
def _hide_header_column(self) -> None:
|
|
3126
|
+
col = self._header_menu_col
|
|
3127
|
+
if col is None or col not in self._display_columns:
|
|
3128
|
+
return
|
|
3129
|
+
self._display_columns.remove(col)
|
|
3130
|
+
if not self._display_columns:
|
|
3131
|
+
self._display_columns = list(range(len(self._heading_texts)))
|
|
3132
|
+
self._tree.configure(displaycolumns=self._display_columns)
|
|
3133
|
+
|
|
3134
|
+
def _show_all_columns(self) -> None:
|
|
3135
|
+
if not self._heading_texts:
|
|
3136
|
+
return
|
|
3137
|
+
self._display_columns = list(range(len(self._heading_texts)))
|
|
3138
|
+
self._tree.configure(displaycolumns=self._display_columns)
|
|
3139
|
+
|
|
3140
|
+
def _show_column_chooser_dialog(self) -> None:
|
|
3141
|
+
"""Show a dialog to select which columns are visible."""
|
|
3142
|
+
from bootstack.dialogs._impl.filterdialog import FilterDialog
|
|
3143
|
+
|
|
3144
|
+
if not self._heading_texts:
|
|
3145
|
+
return
|
|
3146
|
+
|
|
3147
|
+
# Build items for the filter dialog
|
|
3148
|
+
items = []
|
|
3149
|
+
for idx, text in enumerate(self._heading_texts):
|
|
3150
|
+
items.append(
|
|
3151
|
+
{
|
|
3152
|
+
"text": text,
|
|
3153
|
+
"value": idx,
|
|
3154
|
+
"selected": idx in self._display_columns
|
|
3155
|
+
})
|
|
3156
|
+
|
|
3157
|
+
# Calculate position: align dialog's top-right to button's bottom-right
|
|
3158
|
+
btn = self._column_chooser_btn
|
|
3159
|
+
btn.update_idletasks()
|
|
3160
|
+
btn_right = btn.winfo_rootx() + btn.winfo_width()
|
|
3161
|
+
btn_bottom = btn.winfo_rooty() + btn.winfo_height()
|
|
3162
|
+
dialog_width = 250 # FilterDialog has fixed width of 250
|
|
3163
|
+
pos_x = btn_right - dialog_width - 2 # 2px west
|
|
3164
|
+
pos_y = btn_bottom + 2 # 2px south
|
|
3165
|
+
|
|
3166
|
+
dialog = FilterDialog(
|
|
3167
|
+
master=self.winfo_toplevel(),
|
|
3168
|
+
title="Columns",
|
|
3169
|
+
items=items,
|
|
3170
|
+
enable_search=False,
|
|
3171
|
+
enable_select_all=True,
|
|
3172
|
+
undecorated=True
|
|
3173
|
+
)
|
|
3174
|
+
|
|
3175
|
+
self._active_chooser_dialog = dialog
|
|
3176
|
+
try:
|
|
3177
|
+
result = dialog.show(position=(pos_x, pos_y))
|
|
3178
|
+
finally:
|
|
3179
|
+
self._active_chooser_dialog = None
|
|
3180
|
+
|
|
3181
|
+
if result is not None:
|
|
3182
|
+
# Update display columns based on selection
|
|
3183
|
+
self._display_columns = [idx for idx in result if isinstance(idx, int)]
|
|
3184
|
+
if not self._display_columns:
|
|
3185
|
+
# Ensure at least one column is visible
|
|
3186
|
+
self._display_columns = list(range(len(self._heading_texts)))
|
|
3187
|
+
self._tree.configure(displaycolumns=self._display_columns)
|
|
3188
|
+
|
|
3189
|
+
def _reset_table(self) -> None:
|
|
3190
|
+
# Reset sort, columns visibility/order, and reload first page
|
|
3191
|
+
self._display_columns = list(range(len(self._heading_texts)))
|
|
3192
|
+
self._tree.configure(displaycolumns=self._display_columns)
|
|
3193
|
+
self._clear_sort()
|
|
3194
|
+
|
|
3195
|
+
# ------------------------------------------------------------------ Grouping
|
|
3196
|
+
def _group_header_column(self) -> None:
|
|
3197
|
+
"""Group current view by the selected header column."""
|
|
3198
|
+
col = self._header_menu_col
|
|
3199
|
+
if col is None or col >= len(self._column_keys):
|
|
3200
|
+
return
|
|
3201
|
+
key = self._column_keys[col]
|
|
3202
|
+
self._group_by_key = key
|
|
3203
|
+
self._group_parents.clear()
|
|
3204
|
+
self._sort_state = {key: True}
|
|
3205
|
+
self._clear_cache()
|
|
3206
|
+
self._update_heading_icons()
|
|
3207
|
+
self._load_page(0)
|
|
3208
|
+
self._update_status_labels()
|
|
3209
|
+
|
|
3210
|
+
def _ungroup_all(self) -> None:
|
|
3211
|
+
"""Return to flat view."""
|
|
3212
|
+
if not self._group_by_key:
|
|
3213
|
+
return
|
|
3214
|
+
self._group_by_key = None
|
|
3215
|
+
self._group_parents.clear()
|
|
3216
|
+
self._apply_group_show_state(False)
|
|
3217
|
+
self._load_page(self._current_page)
|
|
3218
|
+
self._update_status_labels()
|
|
3219
|
+
|
|
3220
|
+
def _grouping_primary_index(self) -> int | None:
|
|
3221
|
+
"""Display column promoted into the tree column (#0) while grouped.
|
|
3222
|
+
|
|
3223
|
+
The first visible column that isn't the group-by column: its values move
|
|
3224
|
+
into #0 (indented under the group headers), so #0 becomes a real data
|
|
3225
|
+
column rather than a reserved group-only column.
|
|
3226
|
+
"""
|
|
3227
|
+
if not self._group_by_key or self._group_by_key not in self._column_keys:
|
|
3228
|
+
return None
|
|
3229
|
+
group_idx = self._column_keys.index(self._group_by_key)
|
|
3230
|
+
for c in self._display_columns:
|
|
3231
|
+
if c != group_idx:
|
|
3232
|
+
return c
|
|
3233
|
+
return None
|
|
3234
|
+
|
|
3235
|
+
def _apply_group_show_state(self, grouped: bool) -> None:
|
|
3236
|
+
"""Toggle tree column visibility when grouping."""
|
|
3237
|
+
if grouped:
|
|
3238
|
+
self._tree.configure(show="tree headings")
|
|
3239
|
+
group_idx = self._column_keys.index(self._group_by_key)
|
|
3240
|
+
primary = self._grouping_primary_index()
|
|
3241
|
+
# #0 becomes the first non-group column: take its heading + width, and
|
|
3242
|
+
# drop both it and the group column out of the value columns (the group
|
|
3243
|
+
# appears as the header rows; the primary appears in #0).
|
|
3244
|
+
if primary is not None:
|
|
3245
|
+
heading_text = self._heading_texts[primary] if primary < len(self._heading_texts) else ""
|
|
3246
|
+
try:
|
|
3247
|
+
width = int(self._tree.column(primary, option="width")) or 200
|
|
3248
|
+
except Exception:
|
|
3249
|
+
width = 200
|
|
3250
|
+
self._tree.heading("#0", text=heading_text, anchor="w")
|
|
3251
|
+
self._tree.column("#0", width=max(width, 160), minwidth=120, anchor="w", stretch=False)
|
|
3252
|
+
else:
|
|
3253
|
+
self._tree.heading("#0", text="", anchor="w")
|
|
3254
|
+
self._tree.column("#0", width=200, minwidth=120, anchor="w", stretch=False)
|
|
3255
|
+
try:
|
|
3256
|
+
hidden = {group_idx, primary}
|
|
3257
|
+
visible = [c for c in self._display_columns if c not in hidden]
|
|
3258
|
+
self._tree.configure(displaycolumns=visible or self._display_columns)
|
|
3259
|
+
except Exception:
|
|
3260
|
+
pass
|
|
3261
|
+
try:
|
|
3262
|
+
# Reset horizontal view so the group column is not scrolled out
|
|
3263
|
+
self._tree.xview_moveto(0)
|
|
3264
|
+
except Exception:
|
|
3265
|
+
pass
|
|
3266
|
+
self._rebalance_grouped_widths()
|
|
3267
|
+
elif self._selection_markers_active():
|
|
3268
|
+
# Reveal a narrow tree column to host the per-row selection marker.
|
|
3269
|
+
self._tree.configure(show="tree headings")
|
|
3270
|
+
self._tree.heading("#0", text="")
|
|
3271
|
+
marker_w = self._marker_column_width()
|
|
3272
|
+
self._tree.column("#0", width=marker_w, minwidth=marker_w, anchor="center", stretch=False)
|
|
3273
|
+
self._restore_data_columns()
|
|
3274
|
+
else:
|
|
3275
|
+
self._tree.configure(show="headings")
|
|
3276
|
+
# Keep the tree column narrow/inert when unused
|
|
3277
|
+
self._tree.heading("#0", text="")
|
|
3278
|
+
self._tree.column("#0", width=0, minwidth=0, stretch=False)
|
|
3279
|
+
self._restore_data_columns()
|
|
3280
|
+
|
|
3281
|
+
def _restore_data_columns(self) -> None:
|
|
3282
|
+
"""Show the full set of data columns (used when not grouped)."""
|
|
3283
|
+
try:
|
|
3284
|
+
self._tree.configure(displaycolumns=self._display_columns)
|
|
3285
|
+
except Exception:
|
|
3286
|
+
pass
|
|
3287
|
+
try:
|
|
3288
|
+
stretch_cols = not self._paging['xscroll']
|
|
3289
|
+
for idx in range(len(self._heading_texts)):
|
|
3290
|
+
self._tree.column(idx, stretch=stretch_cols)
|
|
3291
|
+
except Exception:
|
|
3292
|
+
pass
|
|
3293
|
+
|
|
3294
|
+
# ------------------------------------------------------------------ Cell formatting
|
|
3295
|
+
def _column_formatter(self, idx: int):
|
|
3296
|
+
"""Resolve (and cache) a column's display formatter callable, or None.
|
|
3297
|
+
|
|
3298
|
+
A column's `format` may be a format-spec string (applied as
|
|
3299
|
+
`spec.format(value)`) or a callable `(value) -> str`.
|
|
3300
|
+
"""
|
|
3301
|
+
if idx in self._column_formats:
|
|
3302
|
+
return self._column_formats[idx]
|
|
3303
|
+
formatter = None
|
|
3304
|
+
if 0 <= idx < len(self._column_defs):
|
|
3305
|
+
coldef = self._column_defs[idx]
|
|
3306
|
+
if isinstance(coldef, dict):
|
|
3307
|
+
spec = coldef.get("format")
|
|
3308
|
+
if callable(spec):
|
|
3309
|
+
formatter = spec
|
|
3310
|
+
elif isinstance(spec, str) and spec:
|
|
3311
|
+
formatter = (lambda s: (lambda v: s.format(v)))(spec)
|
|
3312
|
+
self._column_formats[idx] = formatter
|
|
3313
|
+
return formatter
|
|
3314
|
+
|
|
3315
|
+
def _format_cell(self, idx: int, value):
|
|
3316
|
+
"""Apply a column's display formatter to a value (raw value on failure)."""
|
|
3317
|
+
if value is None or value == "":
|
|
3318
|
+
return value
|
|
3319
|
+
formatter = self._column_formatter(idx)
|
|
3320
|
+
if formatter is None:
|
|
3321
|
+
return value
|
|
3322
|
+
try:
|
|
3323
|
+
return formatter(value)
|
|
3324
|
+
except Exception:
|
|
3325
|
+
return value
|
|
3326
|
+
|
|
3327
|
+
def _display_values(self, rec: dict) -> list:
|
|
3328
|
+
"""Build the formatted value row for display (record stays raw in _row_map)."""
|
|
3329
|
+
return [self._format_cell(i, rec.get(k, "")) for i, k in enumerate(self._column_keys)]
|
|
3330
|
+
|
|
3331
|
+
def _render_flat(self, records: list[dict]) -> None:
|
|
3332
|
+
"""Insert records as flat rows."""
|
|
3333
|
+
stripe = self._row_alternation.get('enabled', False) and not self._group_by_key
|
|
3334
|
+
for idx, rec in enumerate(records):
|
|
3335
|
+
values = self._display_values(rec)
|
|
3336
|
+
tags = ("altrow",) if stripe and idx % 2 == 1 else ()
|
|
3337
|
+
iid = self._tree.insert("", "end", values=values, tags=tags)
|
|
3338
|
+
self._row_map[iid] = rec
|
|
3339
|
+
|
|
3340
|
+
def _render_grouped(self, records: list[dict]) -> None:
|
|
3341
|
+
"""Insert records under group-header nodes.
|
|
3342
|
+
|
|
3343
|
+
Group headers are root-level nodes whose label sits in the tree column
|
|
3344
|
+
(#0). Each child carries its primary field (the first non-group column)
|
|
3345
|
+
as #0 text — indented under its group — with the full record kept in the
|
|
3346
|
+
value columns (the primary + group columns are hidden from those).
|
|
3347
|
+
"""
|
|
3348
|
+
key = self._group_by_key
|
|
3349
|
+
if not key or key not in self._column_keys:
|
|
3350
|
+
self._render_flat(records)
|
|
3351
|
+
return
|
|
3352
|
+
primary_idx = self._grouping_primary_index()
|
|
3353
|
+
primary_key = self._column_keys[primary_idx] if primary_idx is not None else None
|
|
3354
|
+
groups: OrderedDict[str | None, list[dict]] = OrderedDict()
|
|
3355
|
+
for rec in records:
|
|
3356
|
+
groups.setdefault(rec.get(key), []).append(rec)
|
|
3357
|
+
self._group_parents.clear()
|
|
3358
|
+
for val, items in groups.items():
|
|
3359
|
+
label_val = "(None)" if val is None else str(val)
|
|
3360
|
+
label = f"{label_val} ({len(items)})"
|
|
3361
|
+
parent_iid = self._tree.insert(
|
|
3362
|
+
"", "end", text=label, open=True, image=self._chevron_icon(True)
|
|
3363
|
+
)
|
|
3364
|
+
self._group_parents[val] = parent_iid
|
|
3365
|
+
# Transparent stand-in the same size as the chevron, so a child's
|
|
3366
|
+
# depth-indent isn't cancelled out by the parent's chevron width
|
|
3367
|
+
# (keeps the child names visibly nested under their group).
|
|
3368
|
+
leaf_image = self._marker_icon(None)
|
|
3369
|
+
for rec in items:
|
|
3370
|
+
values = self._display_values(rec)
|
|
3371
|
+
primary_text = "" if primary_idx is None else str(
|
|
3372
|
+
self._format_cell(primary_idx, rec.get(primary_key, ""))
|
|
3373
|
+
)
|
|
3374
|
+
iid = self._tree.insert(
|
|
3375
|
+
parent_iid, "end", text=primary_text, values=values, image=leaf_image
|
|
3376
|
+
)
|
|
3377
|
+
self._row_map[iid] = rec
|
|
3378
|
+
|
|
3379
|
+
def _clear_sort(self) -> None:
|
|
3380
|
+
self._sort_state.clear()
|
|
3381
|
+
self._clear_cache()
|
|
3382
|
+
self._update_heading_icons()
|
|
3383
|
+
self._load_page(0)
|
|
3384
|
+
self._update_status_labels()
|
|
3385
|
+
|
|
3386
|
+
|
|
3387
|
+
# Backwards-compatible alias for the legacy Tableview name
|
|
3388
|
+
Tableview = TableView
|