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,882 @@
|
|
|
1
|
+
"""Internal Chart composite — embeds a matplotlib figure in a themed frame.
|
|
2
|
+
|
|
3
|
+
Two modes, both hosting a matplotlib ``FigureCanvasTkAgg`` inside a bootstack
|
|
4
|
+
`Frame`:
|
|
5
|
+
|
|
6
|
+
- **Figure host** (``figure=``): the caller owns a `Figure`; the composite embeds
|
|
7
|
+
it and recolors its chrome (faces, spines, text) to the active theme — a
|
|
8
|
+
best-effort pass over the existing artists.
|
|
9
|
+
- **Managed render** (``render=``): the composite owns the figure and redraw
|
|
10
|
+
loop. Each redraw clears the axes, applies the theme as matplotlib rcParams
|
|
11
|
+
(including a semantic accent color cycle), calls the caller's ``render(ax,
|
|
12
|
+
data)``, and draws. It re-renders on theme change and whenever a bound
|
|
13
|
+
`Signal` changes (optionally debounced).
|
|
14
|
+
|
|
15
|
+
The public `bootstack.Chart` wrapper drives this; nothing here is public.
|
|
16
|
+
matplotlib is an optional dependency (the ``viz`` extra), imported lazily inside
|
|
17
|
+
the methods so importing bootstack never pulls it in.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import inspect
|
|
23
|
+
from typing import Any, Callable, Sequence
|
|
24
|
+
|
|
25
|
+
from bootstack.widgets._impl.primitives.frame import Frame
|
|
26
|
+
from bootstack.widgets.types import Master
|
|
27
|
+
|
|
28
|
+
# Accent roles used, in order, for the categorical series color cycle.
|
|
29
|
+
_CYCLE_ROLES = ("primary", "success", "info", "warning", "danger", "secondary")
|
|
30
|
+
|
|
31
|
+
# Standard navigation tools: name -> (icon glyph, group). The group index drives
|
|
32
|
+
# the auto-inserted divider between adjacent tools of different groups (history /
|
|
33
|
+
# navigate / io). `toolbar=True` renders them all, in this order.
|
|
34
|
+
_TOOL_ICONS = {
|
|
35
|
+
"home": "house",
|
|
36
|
+
"back": "arrow-left",
|
|
37
|
+
"forward": "arrow-right",
|
|
38
|
+
"pan": "arrows-move",
|
|
39
|
+
"zoom": "zoom-in",
|
|
40
|
+
"save": "download",
|
|
41
|
+
}
|
|
42
|
+
_TOOL_GROUP = {"home": 0, "back": 0, "forward": 0, "pan": 1, "zoom": 1, "save": 2}
|
|
43
|
+
_DEFAULT_TOOLS = ("home", "back", "forward", "pan", "zoom", "save")
|
|
44
|
+
|
|
45
|
+
# family name -> resolved family (or None if matplotlib can't find it). Cached so
|
|
46
|
+
# the theme-rc build does not probe the font manager on every redraw.
|
|
47
|
+
_FONT_CACHE: dict[str, str | None] = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _color(token: str, fallback: str) -> str:
|
|
51
|
+
"""Resolve a theme color token to a hex string, falling back on failure."""
|
|
52
|
+
from bootstack.style import get_theme_color
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
return get_theme_color(token)
|
|
56
|
+
except Exception:
|
|
57
|
+
return fallback
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _resolve_font_family(name: str) -> str | None:
|
|
61
|
+
"""Return `name` if matplotlib can resolve that font family, else None."""
|
|
62
|
+
if name in _FONT_CACHE:
|
|
63
|
+
return _FONT_CACHE[name]
|
|
64
|
+
resolved: str | None = None
|
|
65
|
+
try:
|
|
66
|
+
from matplotlib.font_manager import FontProperties, fontManager
|
|
67
|
+
|
|
68
|
+
if fontManager.findfont(FontProperties(family=name), fallback_to_default=False):
|
|
69
|
+
resolved = name
|
|
70
|
+
except Exception:
|
|
71
|
+
resolved = None
|
|
72
|
+
_FONT_CACHE[name] = resolved
|
|
73
|
+
return resolved
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _border_color(surface: str) -> str:
|
|
77
|
+
"""The themed border color appropriate for `surface`.
|
|
78
|
+
|
|
79
|
+
Uses the style builder's ``border()`` (a soft stroke derived from the surface
|
|
80
|
+
by blending toward its on-color), so the chart's axis lines adapt to whatever
|
|
81
|
+
surface it sits on — slightly stronger on a tinted `card` than on `content`,
|
|
82
|
+
matching how every other widget borders itself.
|
|
83
|
+
"""
|
|
84
|
+
base = _color(surface, _color("background", "#ffffff"))
|
|
85
|
+
try:
|
|
86
|
+
from bootstack.style.style import get_theme_provider
|
|
87
|
+
from bootstack.style.style_builder_ttk import StyleBuilderTtk
|
|
88
|
+
|
|
89
|
+
builder = StyleBuilderTtk(theme_provider=get_theme_provider(), style_instance=None)
|
|
90
|
+
return builder.border(base)
|
|
91
|
+
except Exception:
|
|
92
|
+
return _color("stroke", base)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _theme_palette(surface: str = "content") -> dict[str, str]:
|
|
96
|
+
"""The chart's chrome colors from the active theme (best-effort).
|
|
97
|
+
|
|
98
|
+
`surface` is the chart's inherited surface token, so the figure background
|
|
99
|
+
matches whatever the chart sits on (e.g. a `card`) instead of a fixed token —
|
|
100
|
+
no seam between the chart and its container.
|
|
101
|
+
"""
|
|
102
|
+
fg = _color("foreground", "#212529")
|
|
103
|
+
bg = _color(surface, _color("background", "#ffffff"))
|
|
104
|
+
return {
|
|
105
|
+
"figure_bg": bg,
|
|
106
|
+
"axes_bg": bg,
|
|
107
|
+
"fg": fg,
|
|
108
|
+
"stroke": _border_color(surface),
|
|
109
|
+
"grid": _color("stroke_subtle", fg),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _accent_cycle() -> list[str]:
|
|
114
|
+
"""The categorical series colors from the active theme's accent roles."""
|
|
115
|
+
cycle: list[str] = []
|
|
116
|
+
for role in _CYCLE_ROLES:
|
|
117
|
+
hexval = _color(f"{role}[500]", "") or _color(role, "")
|
|
118
|
+
if hexval:
|
|
119
|
+
cycle.append(hexval)
|
|
120
|
+
return cycle
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# seaborn runs lower-saturation than matplotlib (its plots are usually
|
|
124
|
+
# area-filled — bars, violins, KDE — where vivid blocks read heavy). We keep the
|
|
125
|
+
# brand hues but soften them to match the seaborn aesthetic, via seaborn's own
|
|
126
|
+
# `desat`. matplotlib line plots keep the full-saturation `[500]` cycle.
|
|
127
|
+
_SEABORN_DESAT = 0.75
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _seed_seaborn(cycle: list[str], desat: float = _SEABORN_DESAT) -> None:
|
|
131
|
+
"""If seaborn is already imported, point its palette at the accent cycle.
|
|
132
|
+
|
|
133
|
+
A no-op when seaborn is not in use — the composite never imports it (it stays
|
|
134
|
+
in the optional ``viz-seaborn`` extra, off the import path). When the user has
|
|
135
|
+
imported seaborn, this is what makes a `sns.barplot(..., ax=ax)` (or any
|
|
136
|
+
categorical seaborn plot) pick up the theme's accent colors instead of
|
|
137
|
+
seaborn's own default palette — softened by `desat` to suit seaborn's muted,
|
|
138
|
+
fill-heavy look. Called inside the render `rc_context`, so it governs that
|
|
139
|
+
render and reverts with the rest of the themed rcParams.
|
|
140
|
+
"""
|
|
141
|
+
import sys
|
|
142
|
+
|
|
143
|
+
sns = sys.modules.get("seaborn")
|
|
144
|
+
if sns is None or not cycle:
|
|
145
|
+
return
|
|
146
|
+
try:
|
|
147
|
+
sns.set_palette(sns.color_palette(cycle, desat=desat))
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _theme_rc(surface: str = "content", with_cycle: bool = True) -> dict[str, Any]:
|
|
153
|
+
"""Translate the active theme into a matplotlib rcParams dict.
|
|
154
|
+
|
|
155
|
+
Governs artists created within a ``matplotlib.rc_context(rc)`` block: figure
|
|
156
|
+
and axes faces, spine/tick/label/title colors, grid color, the theme body
|
|
157
|
+
font, and — when `with_cycle` is true — a semantic accent ``prop_cycle`` for
|
|
158
|
+
series. The chrome keys are always present; only the series color cycle is
|
|
159
|
+
optional, so ``themed=False`` keeps the chart fitting the app while leaving
|
|
160
|
+
data colors to the caller. `surface` is the chart's inherited surface token,
|
|
161
|
+
so the figure background matches its container.
|
|
162
|
+
"""
|
|
163
|
+
pal = _theme_palette(surface)
|
|
164
|
+
fg = pal["fg"]
|
|
165
|
+
stroke = pal["stroke"]
|
|
166
|
+
rc: dict[str, Any] = {
|
|
167
|
+
"figure.facecolor": pal["figure_bg"],
|
|
168
|
+
"savefig.facecolor": pal["figure_bg"],
|
|
169
|
+
"axes.facecolor": pal["axes_bg"],
|
|
170
|
+
# Drop the heavy box: hide the top/right spines and soften the rest to the
|
|
171
|
+
# theme's border color instead of the near-black foreground.
|
|
172
|
+
"axes.edgecolor": stroke,
|
|
173
|
+
"axes.spines.top": False,
|
|
174
|
+
"axes.spines.right": False,
|
|
175
|
+
"axes.labelcolor": fg,
|
|
176
|
+
"axes.titlecolor": fg,
|
|
177
|
+
"text.color": fg,
|
|
178
|
+
"xtick.color": stroke, # tick marks soft
|
|
179
|
+
"ytick.color": stroke,
|
|
180
|
+
"xtick.labelcolor": fg, # tick labels stay readable
|
|
181
|
+
"ytick.labelcolor": fg,
|
|
182
|
+
"grid.color": pal["grid"],
|
|
183
|
+
"legend.facecolor": pal["axes_bg"],
|
|
184
|
+
"legend.edgecolor": stroke,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
cycle = _accent_cycle() if with_cycle else []
|
|
188
|
+
if cycle:
|
|
189
|
+
try:
|
|
190
|
+
from cycler import cycler
|
|
191
|
+
|
|
192
|
+
rc["axes.prop_cycle"] = cycler(color=cycle)
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
from bootstack.style.style import get_theme_provider
|
|
198
|
+
|
|
199
|
+
spec = get_theme_provider().typography.body
|
|
200
|
+
rc["font.size"] = spec.size
|
|
201
|
+
family = _resolve_font_family(spec.font)
|
|
202
|
+
if family:
|
|
203
|
+
rc["font.family"] = family
|
|
204
|
+
except Exception:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
return rc
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _positional_arity(fn: Callable) -> int:
|
|
211
|
+
"""Count the positional parameters of `fn` (``*args`` counts as 2)."""
|
|
212
|
+
try:
|
|
213
|
+
sig = inspect.signature(fn)
|
|
214
|
+
except (TypeError, ValueError):
|
|
215
|
+
return 2
|
|
216
|
+
params = sig.parameters.values()
|
|
217
|
+
if any(p.kind == p.VAR_POSITIONAL for p in params):
|
|
218
|
+
return 2
|
|
219
|
+
return sum(
|
|
220
|
+
1 for p in params if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _as_artist_list(result: Any) -> list[Any]:
|
|
225
|
+
"""Normalize a setup() return (one artist or several) to a list."""
|
|
226
|
+
if result is None:
|
|
227
|
+
return []
|
|
228
|
+
if isinstance(result, (list, tuple)):
|
|
229
|
+
return list(result)
|
|
230
|
+
return [result]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class Chart(Frame):
|
|
234
|
+
"""Frame hosting a matplotlib figure — themed, optionally managed/reactive."""
|
|
235
|
+
|
|
236
|
+
def __init__(
|
|
237
|
+
self,
|
|
238
|
+
master: Master = None,
|
|
239
|
+
*,
|
|
240
|
+
figure: Any = None,
|
|
241
|
+
render: Callable | None = None,
|
|
242
|
+
signals: Sequence[Any] | None = None,
|
|
243
|
+
data_source: Any = None,
|
|
244
|
+
debounce: int = 0,
|
|
245
|
+
toolbar: bool | Sequence[str] = False,
|
|
246
|
+
themed: bool = True,
|
|
247
|
+
seaborn_desat: float = _SEABORN_DESAT,
|
|
248
|
+
**kwargs: Any,
|
|
249
|
+
) -> None:
|
|
250
|
+
super().__init__(master, **kwargs)
|
|
251
|
+
|
|
252
|
+
from matplotlib.figure import Figure
|
|
253
|
+
|
|
254
|
+
from bootstack.scheduling import Schedule
|
|
255
|
+
|
|
256
|
+
self._themed = bool(themed)
|
|
257
|
+
self._seaborn_desat = float(seaborn_desat)
|
|
258
|
+
self._render = render
|
|
259
|
+
self._render_arity = _positional_arity(render) if render else 0
|
|
260
|
+
self._signals = list(signals) if signals else []
|
|
261
|
+
self._data_source = data_source
|
|
262
|
+
self._rows: list[Any] = []
|
|
263
|
+
self._source_handle: Any = None
|
|
264
|
+
self._debounce = max(0, int(debounce))
|
|
265
|
+
self._debounce_job: Any = None
|
|
266
|
+
self._render_pending = False
|
|
267
|
+
self._signal_handles: list[Any] = []
|
|
268
|
+
self._schedule = Schedule(self)
|
|
269
|
+
self._anim: Any = None # matplotlib FuncAnimation when animate() is used
|
|
270
|
+
self._anim_artists: list[Any] = []
|
|
271
|
+
self._anim_wanted = False # user intent: should it be playing
|
|
272
|
+
self._anim_running_now = False # actual timer state we last applied
|
|
273
|
+
self._anim_obscured = False # fully covered by another window
|
|
274
|
+
self._anim_first_draw_cid: Any = None
|
|
275
|
+
|
|
276
|
+
# Navigation toolbar. `_nav` is matplotlib's logic-only
|
|
277
|
+
# NavigationToolbar2Tk; `_navbar` is the themed bootstack bar that drives
|
|
278
|
+
# it. Built per-canvas in `_build_canvas` so a figure swap rebuilds both.
|
|
279
|
+
# `toolbar` selects the standard tools: True = all, a sequence = that
|
|
280
|
+
# subset (in order), False = no bar. An empty sequence builds an empty bar
|
|
281
|
+
# for author-only buttons via `chart.toolbar`.
|
|
282
|
+
if toolbar is False or toolbar is None:
|
|
283
|
+
self._toolbar_tools: list[str] | None = None
|
|
284
|
+
elif toolbar is True:
|
|
285
|
+
self._toolbar_tools = list(_DEFAULT_TOOLS)
|
|
286
|
+
else:
|
|
287
|
+
self._toolbar_tools = list(toolbar)
|
|
288
|
+
if self._toolbar_tools:
|
|
289
|
+
from bootstack.errors import BootstackError
|
|
290
|
+
|
|
291
|
+
unknown = [t for t in self._toolbar_tools if t not in _TOOL_ICONS]
|
|
292
|
+
if unknown:
|
|
293
|
+
raise BootstackError(
|
|
294
|
+
f"Chart(toolbar=) has unknown tool(s) {unknown}; valid tools "
|
|
295
|
+
f"are {list(_TOOL_ICONS)}."
|
|
296
|
+
)
|
|
297
|
+
self._show_toolbar = self._toolbar_tools is not None
|
|
298
|
+
self._canvas: Any = None
|
|
299
|
+
self._widget: Any = None
|
|
300
|
+
self._nav: Any = None
|
|
301
|
+
self._navbar: Any = None
|
|
302
|
+
self._navdiv: Any = None
|
|
303
|
+
self._pan_btn: Any = None
|
|
304
|
+
self._zoom_btn: Any = None
|
|
305
|
+
self._msg_label: Any = None
|
|
306
|
+
|
|
307
|
+
self._figure = figure if figure is not None else Figure()
|
|
308
|
+
# When the chart owns the figure (managed render / imperative drawing),
|
|
309
|
+
# use matplotlib's constrained layout so the axes fill the figure instead
|
|
310
|
+
# of leaving matplotlib's wide default subplot margins. A caller-supplied
|
|
311
|
+
# figure owns its own layout — leave it untouched.
|
|
312
|
+
self._owns_figure = figure is None
|
|
313
|
+
self._apply_owned_layout()
|
|
314
|
+
self._build_canvas()
|
|
315
|
+
|
|
316
|
+
# Bind a data source before the first render so its rows are available.
|
|
317
|
+
if self._data_source is not None:
|
|
318
|
+
self._rows = self._read_rows()
|
|
319
|
+
try:
|
|
320
|
+
self._source_handle = self._data_source.on_change(self._on_source_change)
|
|
321
|
+
except Exception:
|
|
322
|
+
self._source_handle = None
|
|
323
|
+
|
|
324
|
+
if self._render is not None:
|
|
325
|
+
self._render_managed()
|
|
326
|
+
else:
|
|
327
|
+
self._style_figure()
|
|
328
|
+
self._canvas.draw_idle()
|
|
329
|
+
|
|
330
|
+
self._first_paint_done = False
|
|
331
|
+
self._bind_signals()
|
|
332
|
+
# First-paint + visibility gating. A chart is usually built while its
|
|
333
|
+
# window is hidden (inside the `with App()/Workbench()` block), so the
|
|
334
|
+
# initial draw lands on an unmapped, default-sized canvas. If the realized
|
|
335
|
+
# size matches what was laid out while hidden, no <Configure> fires on
|
|
336
|
+
# show and matplotlib never repaints — the chart looks stale or wrong-
|
|
337
|
+
# sized until a manual resize or theme change forces one. Repaint once on
|
|
338
|
+
# first <Map> at the realized size to close that gap. <Map>/<Unmap> also
|
|
339
|
+
# gate animations (paused while hidden); <Visibility> catches a full cover
|
|
340
|
+
# (best-effort, mainly X11).
|
|
341
|
+
self.bind("<Map>", self._on_map, add="+")
|
|
342
|
+
self.bind("<Unmap>", self._on_anim_visibility, add="+")
|
|
343
|
+
self.bind("<Visibility>", self._on_anim_obscured, add="+")
|
|
344
|
+
self.bind("<Destroy>", self._on_destroy, add="+")
|
|
345
|
+
|
|
346
|
+
def _surface_token(self) -> str:
|
|
347
|
+
"""The chart's inherited surface token (drives the figure background)."""
|
|
348
|
+
return getattr(self, "_surface", None) or "content"
|
|
349
|
+
|
|
350
|
+
def _apply_owned_layout(self) -> None:
|
|
351
|
+
"""Use constrained layout on a figure we own, so the axes fill it."""
|
|
352
|
+
if not self._owns_figure:
|
|
353
|
+
return
|
|
354
|
+
try:
|
|
355
|
+
self._figure.set_layout_engine("constrained")
|
|
356
|
+
except Exception:
|
|
357
|
+
pass
|
|
358
|
+
|
|
359
|
+
# ----- canvas + navigation toolbar --------------------------------------
|
|
360
|
+
|
|
361
|
+
def _build_canvas(self) -> None:
|
|
362
|
+
"""Embed the current figure, with the themed nav toolbar when enabled."""
|
|
363
|
+
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
|
364
|
+
|
|
365
|
+
self._canvas = FigureCanvasTkAgg(self._figure, master=self)
|
|
366
|
+
self._widget = self._canvas.get_tk_widget()
|
|
367
|
+
self._widget.configure(highlightthickness=0, bd=0)
|
|
368
|
+
if self._show_toolbar:
|
|
369
|
+
self._build_navbar() # packs at the top, above the canvas
|
|
370
|
+
self._widget.pack(fill="both", expand=True)
|
|
371
|
+
|
|
372
|
+
def _teardown_canvas(self) -> None:
|
|
373
|
+
"""Destroy the canvas and toolbar (for a figure swap)."""
|
|
374
|
+
self._pan_btn = self._zoom_btn = self._msg_label = None
|
|
375
|
+
for attr in ("_navbar", "_navdiv", "_nav", "_widget"):
|
|
376
|
+
widget = getattr(self, attr, None)
|
|
377
|
+
if widget is not None:
|
|
378
|
+
try:
|
|
379
|
+
widget.destroy()
|
|
380
|
+
except Exception:
|
|
381
|
+
pass
|
|
382
|
+
setattr(self, attr, None)
|
|
383
|
+
self._canvas = None
|
|
384
|
+
|
|
385
|
+
def _build_navbar(self) -> None:
|
|
386
|
+
"""Build a themed bootstack toolbar driving matplotlib's navigation.
|
|
387
|
+
|
|
388
|
+
matplotlib splits navigation *logic* (history, pan, zoom-rectangle, the
|
|
389
|
+
canvas-drawn rubber band) from its raw-Tk button strip. We instantiate
|
|
390
|
+
`NavigationToolbar2Tk` with ``pack_toolbar=False`` so its unstyled tk
|
|
391
|
+
frame is created but never mapped, then wire a compact bootstack bar to
|
|
392
|
+
its methods — reusing the pan/zoom math and rubber band with zero
|
|
393
|
+
reimplementation, and none of the off-theme tk chrome.
|
|
394
|
+
"""
|
|
395
|
+
from matplotlib.backends.backend_tkagg import NavigationToolbar2Tk
|
|
396
|
+
|
|
397
|
+
from bootstack.widgets._impl.composites.toolbar import Toolbar as _Toolbar
|
|
398
|
+
|
|
399
|
+
from bootstack.widgets._impl.primitives.label import Label
|
|
400
|
+
from bootstack.widgets._impl.primitives.separator import Separator
|
|
401
|
+
|
|
402
|
+
self._nav = NavigationToolbar2Tk(self._canvas, self, pack_toolbar=False)
|
|
403
|
+
# Route matplotlib's coordinate readout to our themed label.
|
|
404
|
+
self._nav.set_message = self._set_nav_message
|
|
405
|
+
|
|
406
|
+
# The bar blends into the chart surface; a soft hairline below it (the
|
|
407
|
+
# shell chrome-divider strength) separates it from the canvas, instead of
|
|
408
|
+
# a tinted band.
|
|
409
|
+
commands = {
|
|
410
|
+
"home": self._nav.home,
|
|
411
|
+
"back": self._nav.back,
|
|
412
|
+
"forward": self._nav.forward,
|
|
413
|
+
"pan": self._toggle_pan,
|
|
414
|
+
"zoom": self._toggle_zoom,
|
|
415
|
+
"save": self._save_figure,
|
|
416
|
+
}
|
|
417
|
+
bar = _Toolbar(self, density="compact")
|
|
418
|
+
prev_group: int | None = None
|
|
419
|
+
for name in self._toolbar_tools or ():
|
|
420
|
+
# A divider falls between adjacent tools of different groups.
|
|
421
|
+
if prev_group is not None and _TOOL_GROUP[name] != prev_group:
|
|
422
|
+
bar.add_separator()
|
|
423
|
+
btn = bar.add_button(icon=_TOOL_ICONS[name], command=commands[name])
|
|
424
|
+
if name == "pan":
|
|
425
|
+
self._pan_btn = btn
|
|
426
|
+
elif name == "zoom":
|
|
427
|
+
self._zoom_btn = btn
|
|
428
|
+
prev_group = _TOOL_GROUP[name]
|
|
429
|
+
# The coordinate readout anchors to the right edge; author-added buttons
|
|
430
|
+
# pack to the left (after the built-in tools), so the two never collide.
|
|
431
|
+
self._msg_label = Label(bar.content, text="", font="caption")
|
|
432
|
+
self._msg_label.pack(side="right", padx=6)
|
|
433
|
+
bar.pack(side="top", fill="x")
|
|
434
|
+
divider = Separator(self, orient="horizontal", border_strength=0.90)
|
|
435
|
+
divider.pack(side="top", fill="x")
|
|
436
|
+
self._navbar = bar
|
|
437
|
+
self._navdiv = divider
|
|
438
|
+
|
|
439
|
+
def _toggle_pan(self) -> None:
|
|
440
|
+
if self._nav is not None:
|
|
441
|
+
self._nav.pan()
|
|
442
|
+
self._sync_nav_modes()
|
|
443
|
+
|
|
444
|
+
def _toggle_zoom(self) -> None:
|
|
445
|
+
if self._nav is not None:
|
|
446
|
+
self._nav.zoom()
|
|
447
|
+
self._sync_nav_modes()
|
|
448
|
+
|
|
449
|
+
def _sync_nav_modes(self) -> None:
|
|
450
|
+
"""Reflect the active pan/zoom mode in the toolbar buttons."""
|
|
451
|
+
mode = str(getattr(self._nav, "mode", ""))
|
|
452
|
+
self._set_button_active(self._pan_btn, mode == "pan/zoom")
|
|
453
|
+
self._set_button_active(self._zoom_btn, mode == "zoom rect")
|
|
454
|
+
|
|
455
|
+
@staticmethod
|
|
456
|
+
def _set_button_active(btn: Any, active: bool) -> None:
|
|
457
|
+
if btn is None:
|
|
458
|
+
return
|
|
459
|
+
try:
|
|
460
|
+
if active:
|
|
461
|
+
btn.configure(variant="solid", accent="primary")
|
|
462
|
+
else:
|
|
463
|
+
btn.configure(variant="ghost", accent="")
|
|
464
|
+
except Exception:
|
|
465
|
+
pass
|
|
466
|
+
|
|
467
|
+
def _set_nav_message(self, text: str) -> None:
|
|
468
|
+
if self._msg_label is not None:
|
|
469
|
+
try:
|
|
470
|
+
self._msg_label.configure(text=text or "")
|
|
471
|
+
except Exception:
|
|
472
|
+
pass
|
|
473
|
+
|
|
474
|
+
def _save_figure(self) -> None:
|
|
475
|
+
"""Save the figure through bootstack's themed file dialog."""
|
|
476
|
+
from bootstack.dialogs import ask_save_file
|
|
477
|
+
|
|
478
|
+
path = ask_save_file(
|
|
479
|
+
title="Save chart",
|
|
480
|
+
initial_file="chart.png",
|
|
481
|
+
file_types=[
|
|
482
|
+
("PNG image", "*.png"),
|
|
483
|
+
("PDF document", "*.pdf"),
|
|
484
|
+
("SVG image", "*.svg"),
|
|
485
|
+
("All files", "*.*"),
|
|
486
|
+
],
|
|
487
|
+
default_extension=".png",
|
|
488
|
+
parent=self,
|
|
489
|
+
)
|
|
490
|
+
if path:
|
|
491
|
+
try:
|
|
492
|
+
self._figure.savefig(path)
|
|
493
|
+
except Exception:
|
|
494
|
+
pass
|
|
495
|
+
|
|
496
|
+
# ----- reactive wiring --------------------------------------------------
|
|
497
|
+
|
|
498
|
+
def _bind_signals(self) -> None:
|
|
499
|
+
for sig in self._signals:
|
|
500
|
+
try:
|
|
501
|
+
self._signal_handles.append(sig.subscribe(self._on_signal_change))
|
|
502
|
+
except Exception:
|
|
503
|
+
pass
|
|
504
|
+
|
|
505
|
+
def _on_signal_change(self, *_: Any) -> None:
|
|
506
|
+
if self._debounce > 0:
|
|
507
|
+
if self._debounce_job is not None:
|
|
508
|
+
try:
|
|
509
|
+
self._debounce_job.cancel()
|
|
510
|
+
except Exception:
|
|
511
|
+
pass
|
|
512
|
+
self._debounce_job = self._schedule.delay(self._debounce, self._request_render)
|
|
513
|
+
else:
|
|
514
|
+
self._request_render()
|
|
515
|
+
|
|
516
|
+
def _request_render(self) -> None:
|
|
517
|
+
"""Coalesce a burst of changes into one render at the next idle frame.
|
|
518
|
+
|
|
519
|
+
A timer or stream that updates a bound signal faster than matplotlib can
|
|
520
|
+
draw would otherwise queue an unbounded backlog of synchronous rebuilds
|
|
521
|
+
and starve the event loop. Collapsing to one render per idle keeps the UI
|
|
522
|
+
responsive and the redraw rate bounded by what the figure can sustain.
|
|
523
|
+
"""
|
|
524
|
+
if self._render_pending or not self.winfo_exists():
|
|
525
|
+
return
|
|
526
|
+
self._render_pending = True
|
|
527
|
+
self._schedule.idle(self._flush_render)
|
|
528
|
+
|
|
529
|
+
def _flush_render(self) -> None:
|
|
530
|
+
self._render_pending = False
|
|
531
|
+
self._render_managed()
|
|
532
|
+
|
|
533
|
+
def _current_data(self) -> Any:
|
|
534
|
+
"""The data passed to a managed render.
|
|
535
|
+
|
|
536
|
+
The bound data source's rows when one is set; otherwise a signal value
|
|
537
|
+
(or a tuple of values for multiple signals); or None.
|
|
538
|
+
"""
|
|
539
|
+
if self._data_source is not None:
|
|
540
|
+
return self._rows
|
|
541
|
+
if not self._signals:
|
|
542
|
+
return None
|
|
543
|
+
values = [sig() for sig in self._signals]
|
|
544
|
+
return values[0] if len(values) == 1 else tuple(values)
|
|
545
|
+
|
|
546
|
+
# ----- data source ------------------------------------------------------
|
|
547
|
+
|
|
548
|
+
def _read_rows(self) -> list[Any]:
|
|
549
|
+
"""Read all current records from the source (respecting its filter/sort)."""
|
|
550
|
+
ds = self._data_source
|
|
551
|
+
if ds is None:
|
|
552
|
+
return []
|
|
553
|
+
try:
|
|
554
|
+
n = ds.count
|
|
555
|
+
return list(ds.page_slice(0, n)) if n else []
|
|
556
|
+
except Exception:
|
|
557
|
+
return []
|
|
558
|
+
|
|
559
|
+
def _on_source_change(self, _event: Any = None) -> None:
|
|
560
|
+
self._rows = self._read_rows()
|
|
561
|
+
self._request_render()
|
|
562
|
+
|
|
563
|
+
# ----- managed render ---------------------------------------------------
|
|
564
|
+
|
|
565
|
+
def _invoke_render(self, ax: Any, data: Any) -> None:
|
|
566
|
+
if self._render_arity >= 2:
|
|
567
|
+
self._render(ax, data)
|
|
568
|
+
else:
|
|
569
|
+
self._render(ax)
|
|
570
|
+
|
|
571
|
+
def _render_managed(self) -> None:
|
|
572
|
+
"""Clear, apply the theme rc, call render, and draw.
|
|
573
|
+
|
|
574
|
+
The chrome (backgrounds, text, axes) is always themed so the chart fits
|
|
575
|
+
the app. ``themed=False`` only drops the accent series cycle and seaborn
|
|
576
|
+
palette, leaving data colors to the caller; explicit per-series colors win
|
|
577
|
+
either way. Reactivity is unchanged.
|
|
578
|
+
"""
|
|
579
|
+
if self._render is None or not self.winfo_exists():
|
|
580
|
+
return
|
|
581
|
+
import matplotlib
|
|
582
|
+
|
|
583
|
+
rc = _theme_rc(self._surface_token(), with_cycle=self._themed)
|
|
584
|
+
fig = self._figure
|
|
585
|
+
fig.clear()
|
|
586
|
+
fig.set_facecolor(rc["figure.facecolor"])
|
|
587
|
+
data = self._current_data()
|
|
588
|
+
with matplotlib.rc_context(rc):
|
|
589
|
+
if self._themed:
|
|
590
|
+
_seed_seaborn(_accent_cycle(), self._seaborn_desat)
|
|
591
|
+
self._invoke_render(fig.add_subplot(111), data)
|
|
592
|
+
self._canvas.draw_idle()
|
|
593
|
+
|
|
594
|
+
# ----- figure-host theming ----------------------------------------------
|
|
595
|
+
|
|
596
|
+
def _style_figure(self) -> None:
|
|
597
|
+
"""Recolor an externally-built figure's chrome to the theme.
|
|
598
|
+
|
|
599
|
+
Best-effort pass over the *existing* artists — it does not impose a grid
|
|
600
|
+
or restyle data series, which the figure's author owns. Never raises. The
|
|
601
|
+
chrome always tracks the theme (independent of ``themed=``, which only
|
|
602
|
+
governs whether the managed path imposes the accent series colors).
|
|
603
|
+
"""
|
|
604
|
+
pal = _theme_palette(self._surface_token())
|
|
605
|
+
fig = self._figure
|
|
606
|
+
try:
|
|
607
|
+
fig.set_facecolor(pal["figure_bg"])
|
|
608
|
+
for ax in fig.axes:
|
|
609
|
+
self._recolor_axes(ax, pal)
|
|
610
|
+
except Exception:
|
|
611
|
+
pass
|
|
612
|
+
|
|
613
|
+
@staticmethod
|
|
614
|
+
def _recolor_axes(ax: Any, pal: dict[str, str]) -> None:
|
|
615
|
+
"""Recolor one axes' chrome (faces, spines, ticks, labels) to the theme."""
|
|
616
|
+
ax.set_facecolor(pal["axes_bg"])
|
|
617
|
+
for spine in ax.spines.values():
|
|
618
|
+
spine.set_color(pal["stroke"])
|
|
619
|
+
ax.tick_params(color=pal["stroke"], labelcolor=pal["fg"], which="both")
|
|
620
|
+
ax.xaxis.label.set_color(pal["fg"])
|
|
621
|
+
ax.yaxis.label.set_color(pal["fg"])
|
|
622
|
+
ax.title.set_color(pal["fg"])
|
|
623
|
+
for label in (*ax.get_xticklabels(), *ax.get_yticklabels()):
|
|
624
|
+
label.set_color(pal["fg"])
|
|
625
|
+
for line in (*ax.get_xgridlines(), *ax.get_ygridlines()):
|
|
626
|
+
line.set_color(pal["grid"])
|
|
627
|
+
legend = ax.get_legend()
|
|
628
|
+
if legend is not None:
|
|
629
|
+
frame = legend.get_frame()
|
|
630
|
+
frame.set_facecolor(pal["axes_bg"])
|
|
631
|
+
frame.set_edgecolor(pal["fg"])
|
|
632
|
+
for text in legend.get_texts():
|
|
633
|
+
text.set_color(pal["fg"])
|
|
634
|
+
|
|
635
|
+
def _bs_apply_theme(self) -> None:
|
|
636
|
+
"""Re-resolve theme colors and redraw (called after a theme rebuild).
|
|
637
|
+
|
|
638
|
+
Runs regardless of ``themed=`` — the chrome always follows the theme so
|
|
639
|
+
the chart keeps fitting the app; ``themed=False`` only drops the accent
|
|
640
|
+
series colors, which a re-render naturally honors.
|
|
641
|
+
"""
|
|
642
|
+
if self._anim is not None:
|
|
643
|
+
# Recolor the axes chrome, then force the animation to recapture its
|
|
644
|
+
# blit background in the new theme. The blit cache only re-grabs when
|
|
645
|
+
# the axes *view* changes, so a recolor alone would keep restoring the
|
|
646
|
+
# stale background over the new colors — clearing the cache forces a
|
|
647
|
+
# fresh grab on the next frame. Animated artists keep their setup
|
|
648
|
+
# colors. (Falls back to a plain draw if matplotlib internals shift.)
|
|
649
|
+
pal = _theme_palette(self._surface_token())
|
|
650
|
+
try:
|
|
651
|
+
self._figure.set_facecolor(pal["figure_bg"])
|
|
652
|
+
for ax in self._figure.axes:
|
|
653
|
+
self._recolor_axes(ax, pal)
|
|
654
|
+
self._canvas.draw() # render the new-colored background to the buffer
|
|
655
|
+
cache = getattr(self._anim, "_blit_cache", None)
|
|
656
|
+
if cache is not None:
|
|
657
|
+
cache.clear() # next frame re-grabs the background
|
|
658
|
+
except Exception:
|
|
659
|
+
try:
|
|
660
|
+
self._canvas.draw()
|
|
661
|
+
except Exception:
|
|
662
|
+
pass
|
|
663
|
+
elif self._render is not None:
|
|
664
|
+
self._render_managed() # full re-render picks up the new rc + cycle
|
|
665
|
+
else:
|
|
666
|
+
self._style_figure()
|
|
667
|
+
try:
|
|
668
|
+
self._canvas.draw_idle()
|
|
669
|
+
except Exception:
|
|
670
|
+
pass
|
|
671
|
+
|
|
672
|
+
# ----- figure / lifecycle -----------------------------------------------
|
|
673
|
+
|
|
674
|
+
def set_figure(self, figure: Any) -> None:
|
|
675
|
+
"""Replace the embedded figure, tearing down the old canvas widget."""
|
|
676
|
+
from matplotlib.figure import Figure
|
|
677
|
+
|
|
678
|
+
owns = figure is None
|
|
679
|
+
if figure is None:
|
|
680
|
+
figure = Figure()
|
|
681
|
+
self._teardown_canvas()
|
|
682
|
+
self._figure = figure
|
|
683
|
+
self._owns_figure = owns
|
|
684
|
+
self._apply_owned_layout()
|
|
685
|
+
self._build_canvas()
|
|
686
|
+
if self._render is not None:
|
|
687
|
+
self._render_managed()
|
|
688
|
+
else:
|
|
689
|
+
self._style_figure()
|
|
690
|
+
self._canvas.draw_idle()
|
|
691
|
+
|
|
692
|
+
def draw(self) -> None:
|
|
693
|
+
"""Redraw — re-run the managed render, or refresh the hosted figure."""
|
|
694
|
+
if self._render is not None:
|
|
695
|
+
self._render_managed()
|
|
696
|
+
else:
|
|
697
|
+
try:
|
|
698
|
+
self._canvas.draw_idle()
|
|
699
|
+
except Exception:
|
|
700
|
+
pass
|
|
701
|
+
|
|
702
|
+
# ----- animation (blitting) ---------------------------------------------
|
|
703
|
+
|
|
704
|
+
def animate(
|
|
705
|
+
self,
|
|
706
|
+
setup: Callable,
|
|
707
|
+
update: Callable,
|
|
708
|
+
*,
|
|
709
|
+
interval: int = 30,
|
|
710
|
+
frames: Any = None,
|
|
711
|
+
) -> Any:
|
|
712
|
+
"""Drive a blitting animation, returning the matplotlib animation object.
|
|
713
|
+
|
|
714
|
+
Owns the figure: clears it, builds a themed axes, calls ``setup(ax)``
|
|
715
|
+
once to create the artists, then each frame calls ``update(value,
|
|
716
|
+
artists)`` and redraws only those artists over a cached background.
|
|
717
|
+
`value` is elapsed seconds for a continuous animation (so motion is
|
|
718
|
+
time-based and jitter-free), or the frame value when `frames` is given.
|
|
719
|
+
"""
|
|
720
|
+
import itertools
|
|
721
|
+
import time
|
|
722
|
+
|
|
723
|
+
import matplotlib
|
|
724
|
+
from matplotlib.animation import FuncAnimation
|
|
725
|
+
|
|
726
|
+
self._stop_anim()
|
|
727
|
+
update_arity = _positional_arity(update)
|
|
728
|
+
# Continuous animation (no explicit `frames`) is driven by elapsed
|
|
729
|
+
# wall-clock time, not the frame index — so uneven timer delivery does
|
|
730
|
+
# not change the apparent velocity (no speed-up / slow-down jitter).
|
|
731
|
+
timed = frames is None
|
|
732
|
+
start = {"t": None}
|
|
733
|
+
|
|
734
|
+
rc = _theme_rc(self._surface_token(), with_cycle=self._themed)
|
|
735
|
+
fig = self._figure
|
|
736
|
+
fig.clear()
|
|
737
|
+
fig.set_facecolor(rc["figure.facecolor"])
|
|
738
|
+
# Create the artists ONCE. FuncAnimation re-invokes init_func on every
|
|
739
|
+
# full redraw (resize/theme), so init must only RETURN the artists —
|
|
740
|
+
# re-running setup there would accumulate a new artist each redraw.
|
|
741
|
+
with matplotlib.rc_context(rc):
|
|
742
|
+
if self._themed:
|
|
743
|
+
_seed_seaborn(_accent_cycle(), self._seaborn_desat)
|
|
744
|
+
self._anim_artists = _as_artist_list(setup(fig.add_subplot(111)))
|
|
745
|
+
|
|
746
|
+
def init() -> list[Any]:
|
|
747
|
+
return self._anim_artists
|
|
748
|
+
|
|
749
|
+
def func(frame: Any) -> list[Any]:
|
|
750
|
+
artists = self._anim_artists
|
|
751
|
+
arg = artists[0] if len(artists) == 1 else artists
|
|
752
|
+
if timed:
|
|
753
|
+
if start["t"] is None:
|
|
754
|
+
start["t"] = time.perf_counter()
|
|
755
|
+
value: Any = time.perf_counter() - start["t"]
|
|
756
|
+
else:
|
|
757
|
+
value = frame
|
|
758
|
+
if update_arity >= 2:
|
|
759
|
+
update(value, arg)
|
|
760
|
+
else:
|
|
761
|
+
update(value)
|
|
762
|
+
return self._anim_artists
|
|
763
|
+
|
|
764
|
+
self._anim = FuncAnimation(
|
|
765
|
+
fig,
|
|
766
|
+
func,
|
|
767
|
+
init_func=init,
|
|
768
|
+
frames=frames if frames is not None else itertools.count(),
|
|
769
|
+
interval=max(1, int(interval)),
|
|
770
|
+
blit=True,
|
|
771
|
+
cache_frame_data=False,
|
|
772
|
+
)
|
|
773
|
+
self._anim_wanted = True
|
|
774
|
+
self._anim_obscured = False
|
|
775
|
+
# FuncAnimation auto-starts on the first draw_event; assume running, then
|
|
776
|
+
# reconcile against actual visibility once that first draw has happened
|
|
777
|
+
# (covers a chart created on a hidden tab).
|
|
778
|
+
self._anim_running_now = True
|
|
779
|
+
|
|
780
|
+
def _after_first_draw(_event: Any) -> None:
|
|
781
|
+
if self._anim_first_draw_cid is not None:
|
|
782
|
+
try:
|
|
783
|
+
self._canvas.mpl_disconnect(self._anim_first_draw_cid)
|
|
784
|
+
except Exception:
|
|
785
|
+
pass
|
|
786
|
+
self._anim_first_draw_cid = None
|
|
787
|
+
self._apply_anim_state()
|
|
788
|
+
|
|
789
|
+
self._anim_first_draw_cid = self._canvas.mpl_connect("draw_event", _after_first_draw)
|
|
790
|
+
self._canvas.draw_idle()
|
|
791
|
+
return self._anim
|
|
792
|
+
|
|
793
|
+
def _anim_visible(self) -> bool:
|
|
794
|
+
"""Whether the chart is actually visible — mapped and not fully covered."""
|
|
795
|
+
try:
|
|
796
|
+
return bool(self.winfo_viewable()) and not self._anim_obscured
|
|
797
|
+
except Exception:
|
|
798
|
+
return not self._anim_obscured
|
|
799
|
+
|
|
800
|
+
def _apply_anim_state(self) -> None:
|
|
801
|
+
"""Run the animation only when wanted AND actually visible."""
|
|
802
|
+
anim = self._anim
|
|
803
|
+
if anim is None:
|
|
804
|
+
return
|
|
805
|
+
should = self._anim_wanted and self._anim_visible()
|
|
806
|
+
if should == self._anim_running_now:
|
|
807
|
+
return
|
|
808
|
+
try:
|
|
809
|
+
anim.resume() if should else anim.pause()
|
|
810
|
+
except Exception:
|
|
811
|
+
return
|
|
812
|
+
self._anim_running_now = should
|
|
813
|
+
|
|
814
|
+
def _set_anim_wanted(self, wanted: bool) -> None:
|
|
815
|
+
self._anim_wanted = bool(wanted)
|
|
816
|
+
self._apply_anim_state()
|
|
817
|
+
|
|
818
|
+
def _on_map(self, event: Any = None) -> None:
|
|
819
|
+
if event is not None and getattr(event, "widget", None) is not self:
|
|
820
|
+
return
|
|
821
|
+
# On first show, repaint at the realized size — the construction-time
|
|
822
|
+
# draw happened while hidden and may not have reached the screen.
|
|
823
|
+
if not self._first_paint_done:
|
|
824
|
+
self._first_paint_done = True
|
|
825
|
+
try:
|
|
826
|
+
self.draw()
|
|
827
|
+
except Exception:
|
|
828
|
+
pass
|
|
829
|
+
self._on_anim_visibility(event)
|
|
830
|
+
|
|
831
|
+
def _on_anim_visibility(self, event: Any = None) -> None:
|
|
832
|
+
if event is not None and getattr(event, "widget", None) is not self:
|
|
833
|
+
return
|
|
834
|
+
if self._anim is not None:
|
|
835
|
+
self._apply_anim_state()
|
|
836
|
+
|
|
837
|
+
def _on_anim_obscured(self, event: Any = None) -> None:
|
|
838
|
+
# `<Visibility>` is best-effort (mainly X11); only a FULL cover pauses.
|
|
839
|
+
state = getattr(event, "state", "") if event is not None else ""
|
|
840
|
+
self._anim_obscured = state == "VisibilityFullyObscured"
|
|
841
|
+
if self._anim is not None:
|
|
842
|
+
self._apply_anim_state()
|
|
843
|
+
|
|
844
|
+
def _stop_anim(self) -> None:
|
|
845
|
+
anim = self._anim
|
|
846
|
+
self._anim = None
|
|
847
|
+
self._anim_wanted = False
|
|
848
|
+
self._anim_running_now = False
|
|
849
|
+
if self._anim_first_draw_cid is not None:
|
|
850
|
+
try:
|
|
851
|
+
self._canvas.mpl_disconnect(self._anim_first_draw_cid)
|
|
852
|
+
except Exception:
|
|
853
|
+
pass
|
|
854
|
+
self._anim_first_draw_cid = None
|
|
855
|
+
if anim is not None:
|
|
856
|
+
try:
|
|
857
|
+
anim.event_source.stop()
|
|
858
|
+
except Exception:
|
|
859
|
+
pass
|
|
860
|
+
|
|
861
|
+
def _on_destroy(self, event: Any = None) -> None:
|
|
862
|
+
if event is not None and getattr(event, "widget", None) is not self:
|
|
863
|
+
return
|
|
864
|
+
self._stop_anim()
|
|
865
|
+
for handle in self._signal_handles:
|
|
866
|
+
try:
|
|
867
|
+
handle.cancel()
|
|
868
|
+
except Exception:
|
|
869
|
+
pass
|
|
870
|
+
self._signal_handles = []
|
|
871
|
+
if self._source_handle is not None:
|
|
872
|
+
try:
|
|
873
|
+
self._source_handle.cancel()
|
|
874
|
+
except Exception:
|
|
875
|
+
pass
|
|
876
|
+
self._source_handle = None
|
|
877
|
+
if self._debounce_job is not None:
|
|
878
|
+
try:
|
|
879
|
+
self._debounce_job.cancel()
|
|
880
|
+
except Exception:
|
|
881
|
+
pass
|
|
882
|
+
self._debounce_job = None
|