bootstack 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bootstack/__init__.py +249 -0
- bootstack/__main__.py +5 -0
- bootstack/api/__init__.py +127 -0
- bootstack/api/app.py +30 -0
- bootstack/api/constants.py +3 -0
- bootstack/api/data.py +23 -0
- bootstack/api/dialogs.py +44 -0
- bootstack/api/i18n.py +17 -0
- bootstack/api/localization.py +16 -0
- bootstack/api/menu.py +7 -0
- bootstack/api/style.py +23 -0
- bootstack/api/utils.py +24 -0
- bootstack/api/widgets.py +137 -0
- bootstack/assets/__init__.py +24 -0
- bootstack/assets/bootstack-transparent.png +0 -0
- bootstack/assets/bootstack.ico +0 -0
- bootstack/assets/bootstack.png +0 -0
- bootstack/assets/elements/__init__.py +0 -0
- bootstack/assets/elements/badge-pill.png +0 -0
- bootstack/assets/elements/badge-square.png +0 -0
- bootstack/assets/elements/border.png +0 -0
- bootstack/assets/elements/button-compact.png +0 -0
- bootstack/assets/elements/button-default.png +0 -0
- bootstack/assets/elements/button-group-horizontal-after-compact.png +0 -0
- bootstack/assets/elements/button-group-horizontal-after-default.png +0 -0
- bootstack/assets/elements/button-group-horizontal-before-compact.png +0 -0
- bootstack/assets/elements/button-group-horizontal-before-default.png +0 -0
- bootstack/assets/elements/button-group-horizontal-center-compact.png +0 -0
- bootstack/assets/elements/button-group-horizontal-center-default.png +0 -0
- bootstack/assets/elements/button-group-vertical-after-compact.png +0 -0
- bootstack/assets/elements/button-group-vertical-after-default.png +0 -0
- bootstack/assets/elements/button-group-vertical-before-compact.png +0 -0
- bootstack/assets/elements/button-group-vertical-before-default.png +0 -0
- bootstack/assets/elements/button-group-vertical-center-compact.png +0 -0
- bootstack/assets/elements/button-group-vertical-center-default.png +0 -0
- bootstack/assets/elements/checkbox-checked.png +0 -0
- bootstack/assets/elements/checkbox-indeterminate.png +0 -0
- bootstack/assets/elements/checkbox-unchecked.png +0 -0
- bootstack/assets/elements/field.png +0 -0
- bootstack/assets/elements/input-after-compact.png +0 -0
- bootstack/assets/elements/input-after-default.png +0 -0
- bootstack/assets/elements/input-before-compact.png +0 -0
- bootstack/assets/elements/input-before-default.png +0 -0
- bootstack/assets/elements/input-compact.png +0 -0
- bootstack/assets/elements/input-default.png +0 -0
- bootstack/assets/elements/list-item-separated.png +0 -0
- bootstack/assets/elements/list-item.png +0 -0
- bootstack/assets/elements/manifest.toml +480 -0
- bootstack/assets/elements/menu-item.png +0 -0
- bootstack/assets/elements/nav-button-compact.png +0 -0
- bootstack/assets/elements/nav-button-default.png +0 -0
- bootstack/assets/elements/nav-icon-button-compact.png +0 -0
- bootstack/assets/elements/nav-icon-button-default.png +0 -0
- bootstack/assets/elements/notebook-client-border.png +0 -0
- bootstack/assets/elements/notebook-tab-active.png +0 -0
- bootstack/assets/elements/notebook-tab-bar.png +0 -0
- bootstack/assets/elements/notebook-tab-normal.png +0 -0
- bootstack/assets/elements/notebook-tab-pill.png +0 -0
- bootstack/assets/elements/progress-bar-horizontal-striped.png +0 -0
- bootstack/assets/elements/progress-bar-solid.png +0 -0
- bootstack/assets/elements/progress-bar-thin.png +0 -0
- bootstack/assets/elements/progress-bar-vertical-striped.png +0 -0
- bootstack/assets/elements/radio-selected.png +0 -0
- bootstack/assets/elements/radio-unselected.png +0 -0
- bootstack/assets/elements/scrollbar-horizontal.png +0 -0
- bootstack/assets/elements/scrollbar-vertical.png +0 -0
- bootstack/assets/elements/slider-handle-focus.png +0 -0
- bootstack/assets/elements/slider-handle.png +0 -0
- bootstack/assets/elements/slider-track-horizontal.png +0 -0
- bootstack/assets/elements/slider-track-vertical.png +0 -0
- bootstack/assets/elements/switch-off.png +0 -0
- bootstack/assets/elements/switch-on.png +0 -0
- bootstack/assets/elements/tabs-bar-horizontal.png +0 -0
- bootstack/assets/elements/tabs-bar-vertical.png +0 -0
- bootstack/assets/elements/tabs-pill.png +0 -0
- bootstack/assets/locales/ar/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/ar/LC_MESSAGES/bootstack.po +853 -0
- bootstack/assets/locales/bg/LC_MESSAGES/bootstack.po +875 -0
- bootstack/assets/locales/cs/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/cs/LC_MESSAGES/bootstack.po +853 -0
- bootstack/assets/locales/da/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/da/LC_MESSAGES/bootstack.po +853 -0
- bootstack/assets/locales/de/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/de/LC_MESSAGES/bootstack.po +853 -0
- bootstack/assets/locales/en/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/en/LC_MESSAGES/bootstack.po +875 -0
- bootstack/assets/locales/es/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/es/LC_MESSAGES/bootstack.po +853 -0
- bootstack/assets/locales/fr/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/fr/LC_MESSAGES/bootstack.po +853 -0
- bootstack/assets/locales/he/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/he/LC_MESSAGES/bootstack.po +851 -0
- bootstack/assets/locales/hi/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/hi/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/it/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/it/LC_MESSAGES/bootstack.po +841 -0
- bootstack/assets/locales/ja/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/ja/LC_MESSAGES/bootstack.po +914 -0
- bootstack/assets/locales/ko/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/ko/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/nb/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/nb/LC_MESSAGES/bootstack.po +841 -0
- bootstack/assets/locales/nl/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/nl/LC_MESSAGES/bootstack.po +841 -0
- bootstack/assets/locales/pl/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/pl/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/pt/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/pt/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/sl/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/sl/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/sv/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/sv/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/tr/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/tr/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.po +842 -0
- bootstack/assets/themes/__init__.py +0 -0
- bootstack/assets/themes/amber-dark.json +32 -0
- bootstack/assets/themes/amber-light.json +32 -0
- bootstack/assets/themes/aurora-dark.json +32 -0
- bootstack/assets/themes/aurora-light.json +32 -0
- bootstack/assets/themes/bootstrap-dark.json +32 -0
- bootstack/assets/themes/bootstrap-light.json +32 -0
- bootstack/assets/themes/classic-dark.json +32 -0
- bootstack/assets/themes/classic-light.json +32 -0
- bootstack/assets/themes/docs-dark.json +32 -0
- bootstack/assets/themes/docs-light.json +32 -0
- bootstack/assets/themes/forest-dark.json +32 -0
- bootstack/assets/themes/forest-light.json +32 -0
- bootstack/assets/themes/ocean-dark.json +32 -0
- bootstack/assets/themes/ocean-light.json +32 -0
- bootstack/assets/themes/rose-dark.json +32 -0
- bootstack/assets/themes/rose-light.json +32 -0
- bootstack/assets/widgets/__init__.py +0 -0
- bootstack/assets/widgets/badge-default.png +0 -0
- bootstack/assets/widgets/badge-pill.png +0 -0
- bootstack/assets/widgets/border.png +0 -0
- bootstack/assets/widgets/button-group-horizontal-after.png +0 -0
- bootstack/assets/widgets/button-group-horizontal-before.png +0 -0
- bootstack/assets/widgets/button-group-horizontal-center.png +0 -0
- bootstack/assets/widgets/button-group-vertical-after.png +0 -0
- bootstack/assets/widgets/button-group-vertical-before.png +0 -0
- bootstack/assets/widgets/button-group-vertical-center.png +0 -0
- bootstack/assets/widgets/button.png +0 -0
- bootstack/assets/widgets/checkbox-checked.png +0 -0
- bootstack/assets/widgets/checkbox-indeterminate.png +0 -0
- bootstack/assets/widgets/checkbox-unchecked.png +0 -0
- bootstack/assets/widgets/field.png +0 -0
- bootstack/assets/widgets/icon-button.png +0 -0
- bootstack/assets/widgets/input-inner.png +0 -0
- bootstack/assets/widgets/input-prefix.png +0 -0
- bootstack/assets/widgets/input-suffix.png +0 -0
- bootstack/assets/widgets/input.png +0 -0
- bootstack/assets/widgets/list-item-focus.png +0 -0
- bootstack/assets/widgets/list-item-separated.png +0 -0
- bootstack/assets/widgets/menu-item-separated.png +0 -0
- bootstack/assets/widgets/notebook-client-border.png +0 -0
- bootstack/assets/widgets/notebook-pill-active.png +0 -0
- bootstack/assets/widgets/notebook-pill-inactive.png +0 -0
- bootstack/assets/widgets/notebook-tab-active.png +0 -0
- bootstack/assets/widgets/notebook-tab-border.png +0 -0
- bootstack/assets/widgets/notebook-tab-normal.png +0 -0
- bootstack/assets/widgets/notebook-underline.png +0 -0
- bootstack/assets/widgets/progress-bar-horizontal-default.png +0 -0
- bootstack/assets/widgets/progress-bar-horizontal-striped.png +0 -0
- bootstack/assets/widgets/progress-bar-vertical-default.png +0 -0
- bootstack/assets/widgets/progress-bar-vertical-striped.png +0 -0
- bootstack/assets/widgets/progress-trough-horizontal.png +0 -0
- bootstack/assets/widgets/progress-trough-vertical.png +0 -0
- bootstack/assets/widgets/radio-selected.png +0 -0
- bootstack/assets/widgets/radio-unselected.png +0 -0
- bootstack/assets/widgets/scrollbar-horizontal-rounded.png +0 -0
- bootstack/assets/widgets/scrollbar-vertical-rounded.png +0 -0
- bootstack/assets/widgets/separator-horizontal.png +0 -0
- bootstack/assets/widgets/separator-vertical.png +0 -0
- bootstack/assets/widgets/slider-handle-focus.png +0 -0
- bootstack/assets/widgets/slider-handle.png +0 -0
- bootstack/assets/widgets/slider-track-horizontal.png +0 -0
- bootstack/assets/widgets/slider-track-vertical.png +0 -0
- bootstack/assets/widgets/switch-off.png +0 -0
- bootstack/assets/widgets/switch-on.png +0 -0
- bootstack/assets/widgets/tabs-bar-horizontal.png +0 -0
- bootstack/assets/widgets/tabs-bar-vertical.png +0 -0
- bootstack/assets/widgets/tabs-pill.png +0 -0
- bootstack/cli/__init__.py +124 -0
- bootstack/cli/__main__.py +6 -0
- bootstack/cli/add.py +439 -0
- bootstack/cli/build.py +115 -0
- bootstack/cli/config.py +287 -0
- bootstack/cli/demo.py +1267 -0
- bootstack/cli/doctor.py +195 -0
- bootstack/cli/list_cmd.py +71 -0
- bootstack/cli/promote.py +120 -0
- bootstack/cli/pyinstaller.py +246 -0
- bootstack/cli/run.py +99 -0
- bootstack/cli/start.py +105 -0
- bootstack/cli/templates/__init__.py +861 -0
- bootstack/constants.py +325 -0
- bootstack/core/__init__.py +34 -0
- bootstack/core/capabilities/__init__.py +45 -0
- bootstack/core/capabilities/after.py +103 -0
- bootstack/core/capabilities/bind.py +154 -0
- bootstack/core/capabilities/bindtags.py +112 -0
- bootstack/core/capabilities/busy.py +61 -0
- bootstack/core/capabilities/clipboard.py +88 -0
- bootstack/core/capabilities/focus.py +118 -0
- bootstack/core/capabilities/grab.py +65 -0
- bootstack/core/capabilities/grid.py +188 -0
- bootstack/core/capabilities/localization.py +231 -0
- bootstack/core/capabilities/pack.py +119 -0
- bootstack/core/capabilities/place.py +92 -0
- bootstack/core/capabilities/selection.py +136 -0
- bootstack/core/capabilities/signals.py +242 -0
- bootstack/core/capabilities/winfo.py +315 -0
- bootstack/core/colorutils.py +234 -0
- bootstack/core/exceptions.py +95 -0
- bootstack/core/images.py +283 -0
- bootstack/core/localization/README.md +90 -0
- bootstack/core/localization/__init__.py +13 -0
- bootstack/core/localization/intl_format.py +580 -0
- bootstack/core/localization/msgcat.py +425 -0
- bootstack/core/localization/specs.py +143 -0
- bootstack/core/mixins/__init__.py +1 -0
- bootstack/core/mixins/ttk_state.py +35 -0
- bootstack/core/mixins/widget.py +132 -0
- bootstack/core/publisher.py +147 -0
- bootstack/core/signals/README.md +112 -0
- bootstack/core/signals/__init__.py +8 -0
- bootstack/core/signals/integration.py +100 -0
- bootstack/core/signals/signal.py +317 -0
- bootstack/core/signals/types.py +4 -0
- bootstack/core/validation/__init__.py +5 -0
- bootstack/core/validation/types.py +13 -0
- bootstack/core/validation/validation_result.py +17 -0
- bootstack/core/validation/validation_rules.py +112 -0
- bootstack/core/variables.py +62 -0
- bootstack/datasource/README.md +607 -0
- bootstack/datasource/__init__.py +51 -0
- bootstack/datasource/base.py +474 -0
- bootstack/datasource/file_source.py +541 -0
- bootstack/datasource/memory_source.py +482 -0
- bootstack/datasource/sqlite_source.py +453 -0
- bootstack/datasource/types.py +259 -0
- bootstack/dialogs/__init__.py +56 -0
- bootstack/dialogs/colorchooser.py +674 -0
- bootstack/dialogs/colordropper.py +257 -0
- bootstack/dialogs/datedialog.py +404 -0
- bootstack/dialogs/dialog.py +514 -0
- bootstack/dialogs/filterdialog.py +358 -0
- bootstack/dialogs/fontdialog.py +339 -0
- bootstack/dialogs/formdialog.py +541 -0
- bootstack/dialogs/message.py +489 -0
- bootstack/dialogs/query.py +561 -0
- bootstack/py.typed +1 -0
- bootstack/runtime/__init__.py +3 -0
- bootstack/runtime/app.py +879 -0
- bootstack/runtime/base_window.py +786 -0
- bootstack/runtime/events.py +399 -0
- bootstack/runtime/menu.py +510 -0
- bootstack/runtime/shortcuts.py +423 -0
- bootstack/runtime/tk_patch.py +31 -0
- bootstack/runtime/toplevel.py +131 -0
- bootstack/runtime/utility.py +371 -0
- bootstack/runtime/visual_focus.py +228 -0
- bootstack/runtime/window_utilities.py +1043 -0
- bootstack/style/__init__.py +5498 -0
- bootstack/style/bootstyle.py +507 -0
- bootstack/style/bootstyle_builder_base.py +752 -0
- bootstack/style/bootstyle_builder_mixed.py +93 -0
- bootstack/style/bootstyle_builder_tk.py +109 -0
- bootstack/style/bootstyle_builder_ttk.py +354 -0
- bootstack/style/builders/__init__.py +51 -0
- bootstack/style/builders/badge.py +44 -0
- bootstack/style/builders/button.py +453 -0
- bootstack/style/builders/buttongroup.py +344 -0
- bootstack/style/builders/calendar.py +271 -0
- bootstack/style/builders/checkbutton.py +95 -0
- bootstack/style/builders/combobox.py +112 -0
- bootstack/style/builders/contextmenu.py +268 -0
- bootstack/style/builders/entry.py +83 -0
- bootstack/style/builders/expander.py +171 -0
- bootstack/style/builders/field.py +312 -0
- bootstack/style/builders/frame.py +27 -0
- bootstack/style/builders/label.py +28 -0
- bootstack/style/builders/labelframe.py +41 -0
- bootstack/style/builders/listview.py +267 -0
- bootstack/style/builders/menubar.py +74 -0
- bootstack/style/builders/menubutton.py +408 -0
- bootstack/style/builders/notebook.py +316 -0
- bootstack/style/builders/panedwindow.py +25 -0
- bootstack/style/builders/progressbar.py +71 -0
- bootstack/style/builders/radiobutton.py +68 -0
- bootstack/style/builders/scale.py +66 -0
- bootstack/style/builders/scrollbar.py +360 -0
- bootstack/style/builders/separator.py +45 -0
- bootstack/style/builders/sidenav.py +313 -0
- bootstack/style/builders/sizegrip.py +15 -0
- bootstack/style/builders/spinbox.py +119 -0
- bootstack/style/builders/switch.py +67 -0
- bootstack/style/builders/tabitem.py +205 -0
- bootstack/style/builders/toolbutton.py +260 -0
- bootstack/style/builders/tooltip.py +26 -0
- bootstack/style/builders/treeview.py +269 -0
- bootstack/style/builders/utils.py +404 -0
- bootstack/style/builders_tk/__init__.py +16 -0
- bootstack/style/builders_tk/defaults.py +229 -0
- bootstack/style/element.py +173 -0
- bootstack/style/style.py +499 -0
- bootstack/style/theme_provider.py +449 -0
- bootstack/style/tk_patch.py +5 -0
- bootstack/style/token_maps.py +42 -0
- bootstack/style/types.py +32 -0
- bootstack/style/typography.py +527 -0
- bootstack/style/utility.py +696 -0
- bootstack/themes/__init__.py +12 -0
- bootstack/themes/standard.py +415 -0
- bootstack/themes/user.py +45 -0
- bootstack/widgets/__init__.py +53 -0
- bootstack/widgets/composites/__init__.py +38 -0
- bootstack/widgets/composites/accordion.py +385 -0
- bootstack/widgets/composites/appshell.py +445 -0
- bootstack/widgets/composites/buttongroup.py +391 -0
- bootstack/widgets/composites/calendar.py +914 -0
- bootstack/widgets/composites/compositeframe.py +282 -0
- bootstack/widgets/composites/contextmenu.py +1754 -0
- bootstack/widgets/composites/dateentry.py +261 -0
- bootstack/widgets/composites/dropdownbutton.py +190 -0
- bootstack/widgets/composites/expander.py +508 -0
- bootstack/widgets/composites/field.py +448 -0
- bootstack/widgets/composites/floodgauge.py +434 -0
- bootstack/widgets/composites/form.py +983 -0
- bootstack/widgets/composites/labeledscale.py +209 -0
- bootstack/widgets/composites/list/__init__.py +15 -0
- bootstack/widgets/composites/list/listitem.py +733 -0
- bootstack/widgets/composites/list/listview.py +1507 -0
- bootstack/widgets/composites/menubar.py +303 -0
- bootstack/widgets/composites/meter.py +882 -0
- bootstack/widgets/composites/numericentry.py +183 -0
- bootstack/widgets/composites/pagestack.py +330 -0
- bootstack/widgets/composites/passwordentry.py +149 -0
- bootstack/widgets/composites/pathentry.py +223 -0
- bootstack/widgets/composites/radiogroup.py +466 -0
- bootstack/widgets/composites/scrolledtext.py +388 -0
- bootstack/widgets/composites/scrolledtext.pyi +186 -0
- bootstack/widgets/composites/scrollview.py +675 -0
- bootstack/widgets/composites/selectbox.py +544 -0
- bootstack/widgets/composites/sidenav/__init__.py +24 -0
- bootstack/widgets/composites/sidenav/group.py +485 -0
- bootstack/widgets/composites/sidenav/header.py +83 -0
- bootstack/widgets/composites/sidenav/item.py +413 -0
- bootstack/widgets/composites/sidenav/separator.py +51 -0
- bootstack/widgets/composites/sidenav/view.py +919 -0
- bootstack/widgets/composites/spinnerentry.py +232 -0
- bootstack/widgets/composites/tableview/__init__.py +5 -0
- bootstack/widgets/composites/tableview/tableview.py +2254 -0
- bootstack/widgets/composites/tableview/types.py +169 -0
- bootstack/widgets/composites/tabs/__init__.py +6 -0
- bootstack/widgets/composites/tabs/tabitem.py +372 -0
- bootstack/widgets/composites/tabs/tabs.py +478 -0
- bootstack/widgets/composites/tabs/tabview.py +352 -0
- bootstack/widgets/composites/textentry.py +90 -0
- bootstack/widgets/composites/timeentry.py +189 -0
- bootstack/widgets/composites/toast.py +364 -0
- bootstack/widgets/composites/togglegroup.py +382 -0
- bootstack/widgets/composites/toolbar.py +393 -0
- bootstack/widgets/composites/tooltip.py +404 -0
- bootstack/widgets/internal/__init__.py +0 -0
- bootstack/widgets/internal/wrapper_base.py +304 -0
- bootstack/widgets/mixins/__init__.py +25 -0
- bootstack/widgets/mixins/configure_mixin.py +186 -0
- bootstack/widgets/mixins/entry_mixin.py +70 -0
- bootstack/widgets/mixins/font_mixin.py +346 -0
- bootstack/widgets/mixins/icon_mixin.py +38 -0
- bootstack/widgets/mixins/localization_mixin.py +255 -0
- bootstack/widgets/mixins/signal_mixin.py +272 -0
- bootstack/widgets/mixins/validation_mixin.py +204 -0
- bootstack/widgets/parts/__init__.py +11 -0
- bootstack/widgets/parts/numberentry_part.py +345 -0
- bootstack/widgets/parts/spinnerentry_part.py +394 -0
- bootstack/widgets/parts/textentry_part.py +344 -0
- bootstack/widgets/primitives/__init__.py +55 -0
- bootstack/widgets/primitives/badge.py +44 -0
- bootstack/widgets/primitives/button.py +89 -0
- bootstack/widgets/primitives/card.py +66 -0
- bootstack/widgets/primitives/checkbutton.py +124 -0
- bootstack/widgets/primitives/checktoggle.py +53 -0
- bootstack/widgets/primitives/combobox.py +165 -0
- bootstack/widgets/primitives/entry.py +98 -0
- bootstack/widgets/primitives/frame.py +206 -0
- bootstack/widgets/primitives/gridframe.py +479 -0
- bootstack/widgets/primitives/label.py +95 -0
- bootstack/widgets/primitives/labelframe.py +63 -0
- bootstack/widgets/primitives/menubutton.py +118 -0
- bootstack/widgets/primitives/notebook.py +551 -0
- bootstack/widgets/primitives/optionmenu.py +248 -0
- bootstack/widgets/primitives/packframe.py +228 -0
- bootstack/widgets/primitives/panedwindow.py +58 -0
- bootstack/widgets/primitives/progressbar.py +95 -0
- bootstack/widgets/primitives/radiobutton.py +115 -0
- bootstack/widgets/primitives/radiotoggle.py +50 -0
- bootstack/widgets/primitives/scale.py +85 -0
- bootstack/widgets/primitives/scrollbar.py +56 -0
- bootstack/widgets/primitives/separator.py +56 -0
- bootstack/widgets/primitives/sizegrip.py +47 -0
- bootstack/widgets/primitives/spinbox.py +91 -0
- bootstack/widgets/primitives/switch.py +41 -0
- bootstack/widgets/primitives/treeview.py +77 -0
- bootstack/widgets/types.py +20 -0
- bootstack-0.1.0a1.dist-info/METADATA +196 -0
- bootstack-0.1.0a1.dist-info/RECORD +419 -0
- bootstack-0.1.0a1.dist-info/WHEEL +5 -0
- bootstack-0.1.0a1.dist-info/entry_points.txt +2 -0
- bootstack-0.1.0a1.dist-info/licenses/LICENSE +22 -0
- bootstack-0.1.0a1.dist-info/licenses/NOTICE +10 -0
- bootstack-0.1.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1754 @@
|
|
|
1
|
+
"""Context menu widget for displaying popup menus.
|
|
2
|
+
|
|
3
|
+
Provides a customizable context menu with support for commands, checkbuttons,
|
|
4
|
+
radiobuttons, and separators.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from tkinter import BooleanVar, IntVar, Misc, StringVar, TclError, Toplevel, Widget
|
|
8
|
+
from typing import Any, Callable, Union
|
|
9
|
+
|
|
10
|
+
from bootstack.runtime.shortcuts import get_shortcuts
|
|
11
|
+
from bootstack.style.bootstyle_builder_base import BootstyleBuilderBase
|
|
12
|
+
from bootstack.widgets.primitives import RadioToggle, CheckToggle, Frame, Label, Separator
|
|
13
|
+
from bootstack.widgets.primitives.button import Button
|
|
14
|
+
from bootstack.widgets.types import Master
|
|
15
|
+
from bootstack.widgets.primitives.checkbutton import CheckButton
|
|
16
|
+
from bootstack.widgets.composites.compositeframe import CompositeFrame
|
|
17
|
+
from bootstack.widgets.mixins import CustomConfigMixin, configure_delegate
|
|
18
|
+
from bootstack.widgets.primitives.radiobutton import RadioButton
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Sentinel for "argument not provided" so we can distinguish between
|
|
22
|
+
# "caller omitted target" (default to master) and "caller passed target=None
|
|
23
|
+
# explicitly" (no target — no positioning, no auto-trigger).
|
|
24
|
+
_TARGET_DEFAULT: Any = object()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _CommandItemFrame(CompositeFrame):
|
|
28
|
+
"""Container frame for command items that delegates to the inner button.
|
|
29
|
+
|
|
30
|
+
Uses CompositeFrame for automatic state propagation across children.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, master, **kwargs):
|
|
34
|
+
"""Create a command item container frame."""
|
|
35
|
+
self._button: Button | None = None # Must be set before super().__init__
|
|
36
|
+
super().__init__(master, **kwargs)
|
|
37
|
+
|
|
38
|
+
def invoke(self):
|
|
39
|
+
"""Delegate invoke to the button."""
|
|
40
|
+
if self._button:
|
|
41
|
+
return self._button.invoke()
|
|
42
|
+
|
|
43
|
+
def state(self, statespec=None):
|
|
44
|
+
"""Get or set state, propagating to button when setting."""
|
|
45
|
+
if statespec is None:
|
|
46
|
+
# Getter: return button state if available
|
|
47
|
+
if self._button:
|
|
48
|
+
return self._button.state()
|
|
49
|
+
return super().state()
|
|
50
|
+
else:
|
|
51
|
+
# Setter: set state on self (for Composite propagation)
|
|
52
|
+
# Button and label get state from Composite directly since they're registered
|
|
53
|
+
return super().state(statespec)
|
|
54
|
+
|
|
55
|
+
def configure(self, cnf=None, **kwargs):
|
|
56
|
+
"""Delegate configure to the button for common options."""
|
|
57
|
+
if self._button:
|
|
58
|
+
return self._button.configure(cnf, **kwargs)
|
|
59
|
+
return super().configure(cnf, **kwargs)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ContextMenuItem:
|
|
63
|
+
"""Data class for context menu items.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
type (str): Type of menu item ('command', 'checkbutton', 'radiobutton', 'separator').
|
|
67
|
+
kwargs (dict): Additional keyword arguments for the item.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, type: str, **kwargs) -> None:
|
|
71
|
+
"""Initialize a context menu item.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
type (str): Type of menu item ('command', 'checkbutton', 'radiobutton', 'separator').
|
|
75
|
+
**kwargs: Additional arguments passed to the widget.
|
|
76
|
+
"""
|
|
77
|
+
self.type: str = type
|
|
78
|
+
self.kwargs: dict[str, Any] = kwargs
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _ToplevelContextMenu(CustomConfigMixin):
|
|
82
|
+
"""Themed Toplevel-backed context menu (Win/Linux backend).
|
|
83
|
+
|
|
84
|
+
Internal backend used by `ContextMenu` on Windows and Linux. Renders
|
|
85
|
+
items as bootstack-styled widgets inside an overrideredirect Toplevel
|
|
86
|
+
so theme tokens, density, and rich item types apply consistently.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
master: Master = None,
|
|
92
|
+
minwidth: int = 150,
|
|
93
|
+
width: int = None,
|
|
94
|
+
minheight: int = None,
|
|
95
|
+
height: int = None,
|
|
96
|
+
target: Misc = None,
|
|
97
|
+
anchor: str = 'nw',
|
|
98
|
+
attach: str = 'se',
|
|
99
|
+
offset: tuple[int, int] = None,
|
|
100
|
+
hide_on_outside_click: bool = True,
|
|
101
|
+
items: list[ContextMenuItem] = None,
|
|
102
|
+
density: str = 'default',
|
|
103
|
+
):
|
|
104
|
+
"""Initialize the themed Toplevel backend.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
master: Parent widget. If None, uses the default root window.
|
|
108
|
+
minwidth: Minimum width for the menu in pixels. Default is 150.
|
|
109
|
+
width: Fixed width for the menu in pixels. If None, uses minwidth.
|
|
110
|
+
minheight: Minimum height for the menu in pixels. If None, auto-sizes.
|
|
111
|
+
height: Fixed height for the menu in pixels. If None, auto-sizes to content.
|
|
112
|
+
target: Target widget to attach the menu to. Used for relative positioning.
|
|
113
|
+
anchor: Anchor point on the menu to align (e.g., 'nw', 'ne', 'sw', 'se', 'center').
|
|
114
|
+
attach: Anchor point on the target to align to (same options as anchor).
|
|
115
|
+
offset: Tuple (dx, dy) applied after alignment. Defaults to
|
|
116
|
+
`(scale_from_source(10), 0)` to account for the focus-ring
|
|
117
|
+
affordance baked into trigger button images, so attached menus
|
|
118
|
+
align with the visible button border out of the box. Pass
|
|
119
|
+
`(0, 0)` explicitly to position the menu at the exact anchor
|
|
120
|
+
point with no offset.
|
|
121
|
+
hide_on_outside_click: If True, menu hides when clicking outside.
|
|
122
|
+
Default is True.
|
|
123
|
+
items: List of ContextMenuItem objects to add initially.
|
|
124
|
+
density: Item typography density ('default' or 'compact'). Items
|
|
125
|
+
inherit this so they match the trigger widget's font size.
|
|
126
|
+
"""
|
|
127
|
+
super().__init__()
|
|
128
|
+
self._master = master
|
|
129
|
+
self._target = target
|
|
130
|
+
self._minwidth = minwidth
|
|
131
|
+
self._width = width
|
|
132
|
+
self._minheight = minheight
|
|
133
|
+
self._height = height
|
|
134
|
+
self._anchor = (anchor or 'nw').lower()
|
|
135
|
+
self._attach = (attach or 'nw').lower()
|
|
136
|
+
self._offset = offset if offset is not None else (BootstyleBuilderBase.scale_from_source(10), 0)
|
|
137
|
+
self._hide_on_outside_click = hide_on_outside_click
|
|
138
|
+
self._density = density
|
|
139
|
+
self._on_item_click_callback = None
|
|
140
|
+
self._click_handler_id = None
|
|
141
|
+
self._click_binding_root = None
|
|
142
|
+
self._click_bind_after_id = None
|
|
143
|
+
|
|
144
|
+
# Create toplevel window. This backend is selected on Win/Linux only;
|
|
145
|
+
# Aqua dispatches to `_NativeContextMenu` to avoid the key-window
|
|
146
|
+
# activation issues that affect a reused overrideredirect Toplevel
|
|
147
|
+
# on macOS.
|
|
148
|
+
self._toplevel = Toplevel(master)
|
|
149
|
+
self._toplevel.withdraw()
|
|
150
|
+
self._toplevel.overrideredirect(True)
|
|
151
|
+
|
|
152
|
+
# Create frame with border and padding
|
|
153
|
+
self._frame = Frame(
|
|
154
|
+
self._toplevel,
|
|
155
|
+
show_border=True,
|
|
156
|
+
padding=4,
|
|
157
|
+
surface='overlay'
|
|
158
|
+
)
|
|
159
|
+
self._frame.pack(fill='both', expand=True)
|
|
160
|
+
|
|
161
|
+
# Configure size constraints
|
|
162
|
+
if width:
|
|
163
|
+
self._frame.configure(width=width)
|
|
164
|
+
if height:
|
|
165
|
+
self._frame.configure(height=height)
|
|
166
|
+
|
|
167
|
+
# Set minimum size on toplevel
|
|
168
|
+
if minwidth or minheight:
|
|
169
|
+
self._toplevel.minsize(minwidth or 0, minheight or 0)
|
|
170
|
+
|
|
171
|
+
# Track menu items by key with insertion order
|
|
172
|
+
self._items: dict[str, Widget] = {}
|
|
173
|
+
self._item_order: list[str] = []
|
|
174
|
+
self._counter = 0 # For auto-generating keys
|
|
175
|
+
self._highlighted_index = -1
|
|
176
|
+
|
|
177
|
+
# Add initial items if provided
|
|
178
|
+
if items:
|
|
179
|
+
self.add_items(items)
|
|
180
|
+
|
|
181
|
+
# Setup keyboard bindings
|
|
182
|
+
self._setup_keyboard_bindings()
|
|
183
|
+
|
|
184
|
+
def _generate_key(self) -> str:
|
|
185
|
+
"""Generate an auto key for an item."""
|
|
186
|
+
key = f"item_{self._counter}"
|
|
187
|
+
self._counter += 1
|
|
188
|
+
return key
|
|
189
|
+
|
|
190
|
+
def _resolve_key(self, key_or_index: str | int) -> str:
|
|
191
|
+
"""Resolve a key or index to a key.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
key_or_index: Either a string key or integer index.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
The string key.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
KeyError: If key not found.
|
|
201
|
+
IndexError: If index out of range.
|
|
202
|
+
"""
|
|
203
|
+
if isinstance(key_or_index, int):
|
|
204
|
+
try:
|
|
205
|
+
return self._item_order[key_or_index]
|
|
206
|
+
except IndexError as exc:
|
|
207
|
+
raise IndexError(f"ContextMenu item index {key_or_index} out of range") from exc
|
|
208
|
+
else:
|
|
209
|
+
if key_or_index not in self._items:
|
|
210
|
+
raise KeyError(f"No item with key '{key_or_index}'")
|
|
211
|
+
return key_or_index
|
|
212
|
+
|
|
213
|
+
def _register_item(self, key: str | None, widget: Widget) -> str:
|
|
214
|
+
"""Register an item with optional key, auto-generating if needed.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
key: Optional key. Auto-generated if None.
|
|
218
|
+
widget: The widget to register.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
The key used (either provided or auto-generated).
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
ValueError: If key already exists.
|
|
225
|
+
"""
|
|
226
|
+
if key is None:
|
|
227
|
+
key = self._generate_key()
|
|
228
|
+
|
|
229
|
+
if key in self._items:
|
|
230
|
+
raise ValueError(f"Item with key '{key}' already exists")
|
|
231
|
+
|
|
232
|
+
self._items[key] = widget
|
|
233
|
+
self._item_order.append(key)
|
|
234
|
+
return key
|
|
235
|
+
|
|
236
|
+
def on_item_click(self, callback: Callable) -> None:
|
|
237
|
+
"""Set item click callback. Callback receives `item_info = {'type': str, 'text': str, 'value': Any}`."""
|
|
238
|
+
self._on_item_click_callback = callback
|
|
239
|
+
|
|
240
|
+
def off_item_click(self) -> None:
|
|
241
|
+
"""Remove the item click callback."""
|
|
242
|
+
self._on_item_click_callback = None
|
|
243
|
+
|
|
244
|
+
def add_command(
|
|
245
|
+
self,
|
|
246
|
+
text: str = None,
|
|
247
|
+
icon: str = None,
|
|
248
|
+
command: Callable = None,
|
|
249
|
+
disabled: bool = False,
|
|
250
|
+
shortcut: str = None,
|
|
251
|
+
key: str = None
|
|
252
|
+
) -> Button:
|
|
253
|
+
"""Add a command button to the menu.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
text (str): Button text label.
|
|
257
|
+
icon (str): Optional icon name. Uses 'empty' placeholder if None
|
|
258
|
+
to maintain text alignment with items that have icons.
|
|
259
|
+
command (Callable): Function to call when clicked.
|
|
260
|
+
disabled (bool): If True, the item is disabled and cannot be clicked.
|
|
261
|
+
shortcut (str): Optional keyboard shortcut. Can be either:
|
|
262
|
+
- A key registered with the Shortcuts service (e.g., "save")
|
|
263
|
+
- A literal display string (e.g., "Ctrl+S")
|
|
264
|
+
If a registered key is provided, the platform-appropriate
|
|
265
|
+
display string is shown automatically.
|
|
266
|
+
key (str): Optional unique identifier. Auto-generated if not provided.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Button: The created Button widget.
|
|
270
|
+
"""
|
|
271
|
+
# Resolve shortcut display text from the Shortcuts service
|
|
272
|
+
shortcut_display = None
|
|
273
|
+
if shortcut:
|
|
274
|
+
# Try to look up as a registered shortcut key first
|
|
275
|
+
shortcuts = get_shortcuts()
|
|
276
|
+
display = shortcuts.display(shortcut)
|
|
277
|
+
shortcut_display = display if display else shortcut
|
|
278
|
+
|
|
279
|
+
if shortcut_display:
|
|
280
|
+
# Use CompositeFrame container for items with shortcuts
|
|
281
|
+
# This handles state propagation (hover, pressed, focus) across children
|
|
282
|
+
container = _CommandItemFrame(self._frame, variant='context-frame')
|
|
283
|
+
container.pack(fill='x', padx=0, pady=0)
|
|
284
|
+
|
|
285
|
+
btn = Button(
|
|
286
|
+
container,
|
|
287
|
+
text=text,
|
|
288
|
+
icon=icon or 'empty',
|
|
289
|
+
compound='left',
|
|
290
|
+
variant='context-item',
|
|
291
|
+
density=self._density,
|
|
292
|
+
command=lambda: self._handle_item_click('command', text, command)
|
|
293
|
+
)
|
|
294
|
+
btn.pack(side='left', fill='x', expand=True)
|
|
295
|
+
|
|
296
|
+
shortcut_label = Label(
|
|
297
|
+
container,
|
|
298
|
+
text=shortcut_display,
|
|
299
|
+
variant='context-label',
|
|
300
|
+
density=self._density,
|
|
301
|
+
padding=(0, 0, 4, 0)
|
|
302
|
+
)
|
|
303
|
+
shortcut_label.pack(side='right')
|
|
304
|
+
|
|
305
|
+
# Register children with CompositeFrame for state propagation
|
|
306
|
+
container.register_composite(btn)
|
|
307
|
+
container.register_composite(shortcut_label)
|
|
308
|
+
|
|
309
|
+
container._button = btn
|
|
310
|
+
if disabled:
|
|
311
|
+
container.set_disabled(True)
|
|
312
|
+
|
|
313
|
+
self._register_item(key, container)
|
|
314
|
+
return btn
|
|
315
|
+
else:
|
|
316
|
+
# Simple button without shortcut
|
|
317
|
+
btn = Button(
|
|
318
|
+
self._frame,
|
|
319
|
+
text=text,
|
|
320
|
+
icon=icon or 'empty',
|
|
321
|
+
compound='left',
|
|
322
|
+
variant='context-item',
|
|
323
|
+
density=self._density,
|
|
324
|
+
command=lambda: self._handle_item_click('command', text, command)
|
|
325
|
+
)
|
|
326
|
+
if disabled:
|
|
327
|
+
btn.state(['disabled'])
|
|
328
|
+
btn.pack(fill='x', padx=0, pady=0)
|
|
329
|
+
self._register_item(key, btn)
|
|
330
|
+
return btn
|
|
331
|
+
|
|
332
|
+
def add_checkbutton(
|
|
333
|
+
self,
|
|
334
|
+
text: str = None,
|
|
335
|
+
value: bool = False,
|
|
336
|
+
command: Callable = None,
|
|
337
|
+
key: str = None
|
|
338
|
+
) -> CheckButton:
|
|
339
|
+
"""Add a checkbutton to the menu.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
text (str): Checkbutton text label.
|
|
343
|
+
value (bool): Initial checked state.
|
|
344
|
+
command (Callable): Function to call when toggled.
|
|
345
|
+
key (str): Optional unique identifier. Auto-generated if not provided.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
CheckButton: The created CheckButton widget.
|
|
349
|
+
"""
|
|
350
|
+
var = BooleanVar(value=value)
|
|
351
|
+
|
|
352
|
+
def on_toggle():
|
|
353
|
+
self._handle_item_click('checkbutton', text, command, var.get())
|
|
354
|
+
|
|
355
|
+
cb = CheckToggle(
|
|
356
|
+
self._frame,
|
|
357
|
+
text=text,
|
|
358
|
+
variable=var,
|
|
359
|
+
variant='context-check',
|
|
360
|
+
density=self._density,
|
|
361
|
+
command=on_toggle
|
|
362
|
+
)
|
|
363
|
+
cb.pack(fill='x', padx=0, pady=0)
|
|
364
|
+
cb._variable = var # Store reference to prevent garbage collection
|
|
365
|
+
self._register_item(key, cb)
|
|
366
|
+
return cb
|
|
367
|
+
|
|
368
|
+
def add_radiobutton(
|
|
369
|
+
self,
|
|
370
|
+
text: str = None,
|
|
371
|
+
value: Any = None,
|
|
372
|
+
variable: Union[StringVar, IntVar] = None,
|
|
373
|
+
command: Callable = None,
|
|
374
|
+
key: str = None
|
|
375
|
+
) -> RadioButton:
|
|
376
|
+
"""Add a radiobutton to the menu.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
text (str): Radiobutton text label.
|
|
380
|
+
value (Any): Value to set when selected.
|
|
381
|
+
variable (StringVar | IntVar): Tkinter Variable to link with.
|
|
382
|
+
command (Callable): Function to call when selected.
|
|
383
|
+
key (str): Optional unique identifier. Auto-generated if not provided.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
RadioButton: The created RadioButton widget.
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
def on_select():
|
|
390
|
+
self._handle_item_click('radiobutton', text, command, value)
|
|
391
|
+
|
|
392
|
+
rb = RadioToggle(
|
|
393
|
+
self._frame,
|
|
394
|
+
text=text,
|
|
395
|
+
value=value,
|
|
396
|
+
variable=variable,
|
|
397
|
+
variant='context-radio',
|
|
398
|
+
density=self._density,
|
|
399
|
+
command=on_select
|
|
400
|
+
)
|
|
401
|
+
rb.pack(fill='x', padx=0, pady=0)
|
|
402
|
+
self._register_item(key, rb)
|
|
403
|
+
return rb
|
|
404
|
+
|
|
405
|
+
def add_separator(self, key: str = None) -> Separator:
|
|
406
|
+
"""Add a horizontal separator to the menu.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
key (str): Optional unique identifier. Auto-generated if not provided.
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
Separator: The created Separator widget.
|
|
413
|
+
"""
|
|
414
|
+
sep = Separator(self._frame, orient='horizontal')
|
|
415
|
+
sep.pack(fill='x', padx=0, pady=3)
|
|
416
|
+
self._register_item(key, sep)
|
|
417
|
+
return sep
|
|
418
|
+
|
|
419
|
+
def add_item(self, type: str, **kwargs: Any) -> Widget:
|
|
420
|
+
"""Add a menu item based on type.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
type (str): Type of item ('command', 'checkbutton', 'radiobutton', 'separator').
|
|
424
|
+
**kwargs: Arguments passed to the appropriate add_* method.
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
Widget: The created widget.
|
|
428
|
+
"""
|
|
429
|
+
if type == 'command':
|
|
430
|
+
return self.add_command(**kwargs)
|
|
431
|
+
elif type == 'checkbutton':
|
|
432
|
+
return self.add_checkbutton(**kwargs)
|
|
433
|
+
elif type == 'radiobutton':
|
|
434
|
+
return self.add_radiobutton(**kwargs)
|
|
435
|
+
elif type == 'separator':
|
|
436
|
+
return self.add_separator(**kwargs)
|
|
437
|
+
else:
|
|
438
|
+
raise ValueError(f"Unknown item type: {type}")
|
|
439
|
+
|
|
440
|
+
def add_items(self, items: list) -> None:
|
|
441
|
+
"""Add multiple items at once.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
items (list): List of ContextMenuItem objects or dictionaries with 'type' and 'kwargs'.
|
|
445
|
+
"""
|
|
446
|
+
for item in items:
|
|
447
|
+
if isinstance(item, ContextMenuItem):
|
|
448
|
+
self.add_item(item.type, **item.kwargs)
|
|
449
|
+
elif isinstance(item, dict):
|
|
450
|
+
item_type = item.get('type')
|
|
451
|
+
kwargs = {k: v for k, v in item.items() if k != 'type'}
|
|
452
|
+
self.add_item(item_type, **kwargs)
|
|
453
|
+
|
|
454
|
+
def items(self, value=None):
|
|
455
|
+
"""Get or set the current menu items."""
|
|
456
|
+
if value is None:
|
|
457
|
+
return self._delegate_items(None)
|
|
458
|
+
self._delegate_items(value)
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
def keys(self) -> tuple[str, ...]:
|
|
462
|
+
"""Get all item keys in order.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
A tuple of all item keys in the order they were added.
|
|
466
|
+
"""
|
|
467
|
+
return tuple(self._item_order)
|
|
468
|
+
|
|
469
|
+
def insert_item(self, index: int, type: str, **kwargs: Any) -> Widget:
|
|
470
|
+
"""Insert a new item at the given index.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
index (int): Position to insert the item at.
|
|
474
|
+
type (str): Type of item ('command', 'checkbutton', 'radiobutton', 'separator').
|
|
475
|
+
**kwargs: Arguments passed to the appropriate add_* method.
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
Widget: The created widget.
|
|
479
|
+
"""
|
|
480
|
+
before_key = self._item_order[index] if 0 <= index < len(self._item_order) else None
|
|
481
|
+
before_widget = self._items[before_key] if before_key else None
|
|
482
|
+
|
|
483
|
+
widget = self.add_item(type, **kwargs)
|
|
484
|
+
|
|
485
|
+
if before_widget is None:
|
|
486
|
+
return widget
|
|
487
|
+
|
|
488
|
+
# Get the key of the just-added widget (last in order)
|
|
489
|
+
new_key = self._item_order.pop()
|
|
490
|
+
|
|
491
|
+
pack_info = widget.pack_info()
|
|
492
|
+
widget.pack_forget()
|
|
493
|
+
pack_info.pop('in', None)
|
|
494
|
+
pack_info['before'] = before_widget
|
|
495
|
+
widget.pack(**pack_info)
|
|
496
|
+
|
|
497
|
+
# Insert key at correct position
|
|
498
|
+
self._item_order.insert(index, new_key)
|
|
499
|
+
return widget
|
|
500
|
+
|
|
501
|
+
def item(self, key_or_index: str | int) -> Widget:
|
|
502
|
+
"""Get a menu item by key or index.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
key_or_index: The key (str) or index (int) of the item to retrieve.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
The menu item widget.
|
|
509
|
+
|
|
510
|
+
Raises:
|
|
511
|
+
KeyError: If no item with the given key exists.
|
|
512
|
+
IndexError: If the index is out of range.
|
|
513
|
+
"""
|
|
514
|
+
key = self._resolve_key(key_or_index)
|
|
515
|
+
return self._items[key]
|
|
516
|
+
|
|
517
|
+
def remove_item(self, key_or_index: str | int) -> None:
|
|
518
|
+
"""Remove and destroy the item by key or index.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
key_or_index: Key (str) or index (int) of the item to remove.
|
|
522
|
+
"""
|
|
523
|
+
key = self._resolve_key(key_or_index)
|
|
524
|
+
widget = self._items.pop(key)
|
|
525
|
+
self._item_order.remove(key)
|
|
526
|
+
|
|
527
|
+
try:
|
|
528
|
+
widget.destroy()
|
|
529
|
+
except TclError:
|
|
530
|
+
pass
|
|
531
|
+
return None
|
|
532
|
+
|
|
533
|
+
def move_item(self, from_key_or_index: str | int, to_index: int) -> Widget:
|
|
534
|
+
"""Reorder an existing item to a new index.
|
|
535
|
+
|
|
536
|
+
Args:
|
|
537
|
+
from_key_or_index: Key (str) or index (int) of the item to move.
|
|
538
|
+
to_index (int): New index for the item.
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
Widget: The moved widget.
|
|
542
|
+
"""
|
|
543
|
+
key = self._resolve_key(from_key_or_index)
|
|
544
|
+
widget = self._items[key]
|
|
545
|
+
|
|
546
|
+
# Remove from current position in order
|
|
547
|
+
self._item_order.remove(key)
|
|
548
|
+
|
|
549
|
+
pack_info = widget.pack_info()
|
|
550
|
+
widget.pack_forget()
|
|
551
|
+
|
|
552
|
+
# Clamp destination to valid bounds
|
|
553
|
+
if to_index < 0:
|
|
554
|
+
to_index = 0
|
|
555
|
+
if to_index > len(self._item_order):
|
|
556
|
+
to_index = len(self._item_order)
|
|
557
|
+
|
|
558
|
+
# Insert at new position
|
|
559
|
+
self._item_order.insert(to_index, key)
|
|
560
|
+
before_key = self._item_order[to_index + 1] if to_index + 1 < len(self._item_order) else None
|
|
561
|
+
before_widget = self._items[before_key] if before_key else None
|
|
562
|
+
|
|
563
|
+
pack_info.pop('in', None)
|
|
564
|
+
pack_info.pop('in_', None)
|
|
565
|
+
pack_info.pop('before', None)
|
|
566
|
+
pack_info.pop('after', None)
|
|
567
|
+
if before_widget:
|
|
568
|
+
pack_info['before'] = before_widget
|
|
569
|
+
widget.pack(in_=self._frame, **pack_info)
|
|
570
|
+
return widget
|
|
571
|
+
|
|
572
|
+
def configure_item(self, key_or_index: str | int, option: str | None = None, **kwargs: Any) -> Any:
|
|
573
|
+
"""Configure an individual menu item by key or index.
|
|
574
|
+
|
|
575
|
+
Args:
|
|
576
|
+
key_or_index: Key (str) or index (int) of the item to configure.
|
|
577
|
+
option: Optional option name to query (getter path).
|
|
578
|
+
**kwargs: Option values to set (setter path).
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
- When called with no kwargs and no option: full option map for the item.
|
|
582
|
+
- When called with option only: a 5-tuple matching tkinter's configure.
|
|
583
|
+
- When called with kwargs: the result of the underlying widget's configure.
|
|
584
|
+
"""
|
|
585
|
+
key = self._resolve_key(key_or_index)
|
|
586
|
+
widget = self._items[key]
|
|
587
|
+
|
|
588
|
+
# Getter: all options
|
|
589
|
+
if option is None and not kwargs:
|
|
590
|
+
return widget.configure()
|
|
591
|
+
|
|
592
|
+
# Getter: single option
|
|
593
|
+
if option is not None and not kwargs:
|
|
594
|
+
return widget.configure(option)
|
|
595
|
+
|
|
596
|
+
# Setter path
|
|
597
|
+
return widget.configure(**kwargs)
|
|
598
|
+
|
|
599
|
+
def show(self, position: tuple[int, int] = None) -> None:
|
|
600
|
+
"""Show the context menu.
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
position (tuple): Optional screen coordinate (x, y) to align to. If provided,
|
|
604
|
+
the menu's anchor will align to this point. Negative x/y are
|
|
605
|
+
treated as offsets from the screen's right/bottom.
|
|
606
|
+
"""
|
|
607
|
+
# Update geometry before showing
|
|
608
|
+
self._toplevel.update_idletasks()
|
|
609
|
+
|
|
610
|
+
# Determine position
|
|
611
|
+
pos = self._compute_position(position)
|
|
612
|
+
if pos:
|
|
613
|
+
self._toplevel.geometry(f"+{pos[0]}+{pos[1]}")
|
|
614
|
+
|
|
615
|
+
# Show the menu
|
|
616
|
+
self._toplevel.deiconify()
|
|
617
|
+
self._toplevel.lift()
|
|
618
|
+
self._toplevel.focus_force()
|
|
619
|
+
|
|
620
|
+
# Start with no item highlighted (keyboard nav will highlight on first arrow key)
|
|
621
|
+
self._highlighted_index = -1
|
|
622
|
+
|
|
623
|
+
# Setup click outside handler if enabled
|
|
624
|
+
if self._hide_on_outside_click:
|
|
625
|
+
self._setup_click_outside_handler()
|
|
626
|
+
|
|
627
|
+
def _setup_keyboard_bindings(self) -> None:
|
|
628
|
+
"""Setup keyboard navigation bindings on the toplevel."""
|
|
629
|
+
self._toplevel.bind('<Escape>', lambda e: self.hide())
|
|
630
|
+
self._toplevel.bind('<Down>', self._on_arrow_down)
|
|
631
|
+
self._toplevel.bind('<Up>', self._on_arrow_up)
|
|
632
|
+
self._toplevel.bind('<Return>', self._on_enter)
|
|
633
|
+
self._toplevel.bind('<KP_Enter>', self._on_enter)
|
|
634
|
+
|
|
635
|
+
def _get_actionable_items(self) -> list:
|
|
636
|
+
"""Return list of items that can be navigated to (excludes separators)."""
|
|
637
|
+
return [self._items[key] for key in self._item_order if not isinstance(self._items[key], Separator)]
|
|
638
|
+
|
|
639
|
+
def _on_arrow_down(self, event) -> str:
|
|
640
|
+
"""Handle arrow down key."""
|
|
641
|
+
actionable = self._get_actionable_items()
|
|
642
|
+
if not actionable:
|
|
643
|
+
return 'break'
|
|
644
|
+
|
|
645
|
+
# Find next actionable item
|
|
646
|
+
current = self._highlighted_index
|
|
647
|
+
next_idx = current + 1 if current < len(actionable) - 1 else 0
|
|
648
|
+
self._update_highlight(next_idx)
|
|
649
|
+
return 'break'
|
|
650
|
+
|
|
651
|
+
def _on_arrow_up(self, event) -> str:
|
|
652
|
+
"""Handle arrow up key."""
|
|
653
|
+
actionable = self._get_actionable_items()
|
|
654
|
+
if not actionable:
|
|
655
|
+
return 'break'
|
|
656
|
+
|
|
657
|
+
# Find previous actionable item
|
|
658
|
+
current = self._highlighted_index
|
|
659
|
+
prev_idx = current - 1 if current > 0 else len(actionable) - 1
|
|
660
|
+
self._update_highlight(prev_idx)
|
|
661
|
+
return 'break'
|
|
662
|
+
|
|
663
|
+
def _on_enter(self, event) -> str:
|
|
664
|
+
"""Handle enter key to activate highlighted item."""
|
|
665
|
+
actionable = self._get_actionable_items()
|
|
666
|
+
if not actionable or self._highlighted_index < 0:
|
|
667
|
+
return 'break'
|
|
668
|
+
|
|
669
|
+
if 0 <= self._highlighted_index < len(actionable):
|
|
670
|
+
item = actionable[self._highlighted_index]
|
|
671
|
+
# Simulate a click by invoking the button
|
|
672
|
+
item.invoke()
|
|
673
|
+
return 'break'
|
|
674
|
+
|
|
675
|
+
def _update_highlight(self, new_index: int) -> None:
|
|
676
|
+
"""Update the highlighted item."""
|
|
677
|
+
actionable = self._get_actionable_items()
|
|
678
|
+
if not actionable:
|
|
679
|
+
self._highlighted_index = -1
|
|
680
|
+
return
|
|
681
|
+
|
|
682
|
+
# Clamp index
|
|
683
|
+
new_index = max(0, min(new_index, len(actionable) - 1))
|
|
684
|
+
|
|
685
|
+
# Remove highlight from old item
|
|
686
|
+
if 0 <= self._highlighted_index < len(actionable):
|
|
687
|
+
actionable[self._highlighted_index].state(['!focus'])
|
|
688
|
+
|
|
689
|
+
# Add highlight to new item
|
|
690
|
+
actionable[new_index].state(['focus'])
|
|
691
|
+
self._highlighted_index = new_index
|
|
692
|
+
|
|
693
|
+
def hide(self) -> None:
|
|
694
|
+
"""Hide the context menu."""
|
|
695
|
+
# Unbind click handler first
|
|
696
|
+
self._cancel_click_outside_after()
|
|
697
|
+
self._unbind_click_outside_handler()
|
|
698
|
+
|
|
699
|
+
# Clear highlight state
|
|
700
|
+
self._clear_highlight()
|
|
701
|
+
|
|
702
|
+
if self._toplevel.winfo_exists():
|
|
703
|
+
self._toplevel.withdraw()
|
|
704
|
+
|
|
705
|
+
def _clear_highlight(self) -> None:
|
|
706
|
+
"""Clear the highlight from the current item."""
|
|
707
|
+
actionable = self._get_actionable_items()
|
|
708
|
+
if 0 <= self._highlighted_index < len(actionable):
|
|
709
|
+
actionable[self._highlighted_index].state(['!focus'])
|
|
710
|
+
self._highlighted_index = -1
|
|
711
|
+
|
|
712
|
+
def destroy(self) -> None:
|
|
713
|
+
"""Destroy the context menu and cleanup resources."""
|
|
714
|
+
# Unbind click handler
|
|
715
|
+
self._cancel_click_outside_after()
|
|
716
|
+
self._unbind_click_outside_handler()
|
|
717
|
+
|
|
718
|
+
# Destroy toplevel
|
|
719
|
+
if self._toplevel.winfo_exists():
|
|
720
|
+
self._toplevel.destroy()
|
|
721
|
+
|
|
722
|
+
def _handle_item_click(self, type: str, text: str, command: Callable = None, value: Any = None) -> None:
|
|
723
|
+
"""Handle item click events.
|
|
724
|
+
|
|
725
|
+
Args:
|
|
726
|
+
type (str): Type of item clicked.
|
|
727
|
+
text (str): Text of the item.
|
|
728
|
+
command (Callable): Command to execute.
|
|
729
|
+
value (Any): Value associated with the item.
|
|
730
|
+
"""
|
|
731
|
+
# Prepare event data
|
|
732
|
+
data = {
|
|
733
|
+
'type': type,
|
|
734
|
+
'text': text,
|
|
735
|
+
'value': value
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
# Call registered callback
|
|
739
|
+
if self._on_item_click_callback:
|
|
740
|
+
self._on_item_click_callback(data)
|
|
741
|
+
|
|
742
|
+
# Execute item command
|
|
743
|
+
if command:
|
|
744
|
+
command()
|
|
745
|
+
|
|
746
|
+
# Hide menu after item click
|
|
747
|
+
self.hide()
|
|
748
|
+
|
|
749
|
+
def _compute_position(self, position: tuple[int, int] | None) -> tuple[int, int] | None:
|
|
750
|
+
"""Compute screen coordinates for the menu based on anchor/attach/offset."""
|
|
751
|
+
|
|
752
|
+
def anchor_offsets(key: str, width: int, height: int) -> tuple[float, float]:
|
|
753
|
+
table = {
|
|
754
|
+
'nw': (0, 0),
|
|
755
|
+
'n': (width / 2, 0),
|
|
756
|
+
'ne': (width, 0),
|
|
757
|
+
'w': (0, height / 2),
|
|
758
|
+
'center': (width / 2, height / 2),
|
|
759
|
+
'e': (width, height / 2),
|
|
760
|
+
'sw': (0, height),
|
|
761
|
+
's': (width / 2, height),
|
|
762
|
+
'se': (width, height),
|
|
763
|
+
}
|
|
764
|
+
if key not in table:
|
|
765
|
+
raise ValueError(f"Invalid anchor '{key}'. Use one of: {', '.join(table.keys())}")
|
|
766
|
+
return table[key]
|
|
767
|
+
|
|
768
|
+
# Ensure geometry is up to date for accurate size
|
|
769
|
+
self._toplevel.update_idletasks()
|
|
770
|
+
|
|
771
|
+
menu_w = self._toplevel.winfo_reqwidth()
|
|
772
|
+
menu_h = self._toplevel.winfo_reqheight()
|
|
773
|
+
|
|
774
|
+
# Base point: from provided position or target attach
|
|
775
|
+
base_x = base_y = None
|
|
776
|
+
|
|
777
|
+
if position is not None:
|
|
778
|
+
base_x, base_y = position
|
|
779
|
+
elif self._target and self._target.winfo_exists():
|
|
780
|
+
self._target.update_idletasks()
|
|
781
|
+
target_w = self._target.winfo_width()
|
|
782
|
+
target_h = self._target.winfo_height()
|
|
783
|
+
base_x = self._target.winfo_rootx()
|
|
784
|
+
base_y = self._target.winfo_rooty()
|
|
785
|
+
attach_dx, attach_dy = anchor_offsets(self._attach, target_w, target_h)
|
|
786
|
+
base_x += attach_dx
|
|
787
|
+
base_y += attach_dy
|
|
788
|
+
else:
|
|
789
|
+
return None
|
|
790
|
+
|
|
791
|
+
menu_dx, menu_dy = anchor_offsets(self._anchor, menu_w, menu_h)
|
|
792
|
+
final_x = int(base_x - menu_dx + self._offset[0])
|
|
793
|
+
final_y = int(base_y - menu_dy + self._offset[1])
|
|
794
|
+
|
|
795
|
+
# Flip vertically when the menu would overflow the screen bottom and
|
|
796
|
+
# there's room above the target. Matches Tk combobox PlacePopdown.
|
|
797
|
+
if self._target is not None and self._target.winfo_exists():
|
|
798
|
+
screen_h = self._toplevel.winfo_screenheight()
|
|
799
|
+
if final_y + menu_h > screen_h:
|
|
800
|
+
target_top = self._target.winfo_rooty()
|
|
801
|
+
alt_y = target_top - menu_h - self._offset[1]
|
|
802
|
+
if alt_y >= 0:
|
|
803
|
+
final_y = alt_y
|
|
804
|
+
return final_x, final_y
|
|
805
|
+
|
|
806
|
+
def _setup_click_outside_handler(self) -> None:
|
|
807
|
+
"""Setup handler to hide menu when clicking outside."""
|
|
808
|
+
|
|
809
|
+
def on_click(event):
|
|
810
|
+
# Don't process if menu is not visible
|
|
811
|
+
if not self._toplevel.winfo_viewable():
|
|
812
|
+
return
|
|
813
|
+
|
|
814
|
+
# Check if click is inside the menu
|
|
815
|
+
try:
|
|
816
|
+
x, y = event.x_root, event.y_root
|
|
817
|
+
tx = self._toplevel.winfo_rootx()
|
|
818
|
+
ty = self._toplevel.winfo_rooty()
|
|
819
|
+
tw = self._toplevel.winfo_width()
|
|
820
|
+
th = self._toplevel.winfo_height()
|
|
821
|
+
|
|
822
|
+
# Click is outside if coordinates are not within bounds
|
|
823
|
+
if not (tx <= x <= tx + tw and ty <= y <= ty + th):
|
|
824
|
+
self.hide()
|
|
825
|
+
except TclError:
|
|
826
|
+
# If the menu has been torn down, ensure it is hidden
|
|
827
|
+
self.hide()
|
|
828
|
+
|
|
829
|
+
def bind_click():
|
|
830
|
+
# Clear the pending after id once we run
|
|
831
|
+
self._click_bind_after_id = None
|
|
832
|
+
|
|
833
|
+
# Skip binding if the menu is already hidden
|
|
834
|
+
if not (self._toplevel.winfo_exists() and self._toplevel.winfo_viewable()):
|
|
835
|
+
return
|
|
836
|
+
|
|
837
|
+
if self._toplevel.winfo_exists():
|
|
838
|
+
self._unbind_click_outside_handler()
|
|
839
|
+
root = self._get_binding_root()
|
|
840
|
+
if root and root.winfo_exists():
|
|
841
|
+
self._click_binding_root = root
|
|
842
|
+
self._click_handler_id = root.bind('<Button-1>', on_click, add='+')
|
|
843
|
+
|
|
844
|
+
# Delay binding to avoid capturing the click that shows the menu
|
|
845
|
+
self._cancel_click_outside_after()
|
|
846
|
+
self._click_bind_after_id = self._toplevel.after(100, bind_click)
|
|
847
|
+
|
|
848
|
+
def _get_binding_root(self) -> Widget | None:
|
|
849
|
+
"""Return the widget to bind click-outside events to."""
|
|
850
|
+
candidate = self._target or self._master or self._toplevel.master
|
|
851
|
+
if candidate:
|
|
852
|
+
try:
|
|
853
|
+
return candidate.winfo_toplevel()
|
|
854
|
+
except TclError:
|
|
855
|
+
return None
|
|
856
|
+
return None
|
|
857
|
+
|
|
858
|
+
def _unbind_click_outside_handler(self) -> None:
|
|
859
|
+
"""Remove the click-outside binding if present."""
|
|
860
|
+
if not self._click_handler_id or not self._click_binding_root:
|
|
861
|
+
return
|
|
862
|
+
|
|
863
|
+
try:
|
|
864
|
+
if self._click_binding_root.winfo_exists():
|
|
865
|
+
self._click_binding_root.unbind('<Button-1>', self._click_handler_id)
|
|
866
|
+
except TclError:
|
|
867
|
+
pass
|
|
868
|
+
finally:
|
|
869
|
+
self._click_handler_id = None
|
|
870
|
+
self._click_binding_root = None
|
|
871
|
+
|
|
872
|
+
def _cancel_click_outside_after(self) -> None:
|
|
873
|
+
"""Cancel any scheduled click-outside binding."""
|
|
874
|
+
if self._click_bind_after_id and self._toplevel.winfo_exists():
|
|
875
|
+
try:
|
|
876
|
+
self._toplevel.after_cancel(self._click_bind_after_id)
|
|
877
|
+
except TclError:
|
|
878
|
+
pass
|
|
879
|
+
self._click_bind_after_id = None
|
|
880
|
+
|
|
881
|
+
# ----- Configuration delegates -------------------------------------------------
|
|
882
|
+
|
|
883
|
+
@configure_delegate('minwidth')
|
|
884
|
+
def _delegate_minwidth(self, value: int | None):
|
|
885
|
+
"""Get or set the minimum width."""
|
|
886
|
+
if value is None:
|
|
887
|
+
return self._minwidth
|
|
888
|
+
self._minwidth = value
|
|
889
|
+
return self._toplevel.minsize(value or 0, self._minheight or 0)
|
|
890
|
+
|
|
891
|
+
@configure_delegate('minheight')
|
|
892
|
+
def _delegate_minheight(self, value: int | None):
|
|
893
|
+
"""Get or set the minimum height."""
|
|
894
|
+
if value is None:
|
|
895
|
+
return self._minheight
|
|
896
|
+
self._minheight = value
|
|
897
|
+
return self._toplevel.minsize(self._minwidth or 0, value or 0)
|
|
898
|
+
|
|
899
|
+
@configure_delegate('width')
|
|
900
|
+
def _delegate_width(self, value: int | None):
|
|
901
|
+
"""Get or set the fixed width."""
|
|
902
|
+
if value is None:
|
|
903
|
+
return self._width
|
|
904
|
+
self._width = value
|
|
905
|
+
return self._frame.configure(width=value if value is not None else '')
|
|
906
|
+
|
|
907
|
+
@configure_delegate('height')
|
|
908
|
+
def _delegate_height(self, value: int | None):
|
|
909
|
+
"""Get or set the fixed height."""
|
|
910
|
+
if value is None:
|
|
911
|
+
return self._height
|
|
912
|
+
self._height = value
|
|
913
|
+
return self._frame.configure(height=value if value is not None else '')
|
|
914
|
+
|
|
915
|
+
@configure_delegate('anchor')
|
|
916
|
+
def _delegate_anchor(self, value: str | None):
|
|
917
|
+
"""Get or set the menu anchor."""
|
|
918
|
+
if value is None:
|
|
919
|
+
return self._anchor
|
|
920
|
+
self._anchor = (value or 'nw').lower()
|
|
921
|
+
return None
|
|
922
|
+
|
|
923
|
+
@configure_delegate('attach')
|
|
924
|
+
def _delegate_attach(self, value: str | None):
|
|
925
|
+
"""Get or set the target attach anchor."""
|
|
926
|
+
if value is None:
|
|
927
|
+
return self._attach
|
|
928
|
+
self._attach = (value or 'nw').lower()
|
|
929
|
+
return None
|
|
930
|
+
|
|
931
|
+
@configure_delegate('offset')
|
|
932
|
+
def _delegate_offset(self, value: tuple[int, int] | None):
|
|
933
|
+
"""Get or set the positional offset."""
|
|
934
|
+
if value is None:
|
|
935
|
+
return self._offset
|
|
936
|
+
try:
|
|
937
|
+
dx, dy = value # type: ignore[misc]
|
|
938
|
+
except Exception:
|
|
939
|
+
dx, dy = (0, 0)
|
|
940
|
+
self._offset = (dx, dy)
|
|
941
|
+
return None
|
|
942
|
+
|
|
943
|
+
@configure_delegate('hide_on_outside_click')
|
|
944
|
+
def _delegate_hide_on_outside_click(self, value: bool | None):
|
|
945
|
+
"""Get or set outside-click hide behavior."""
|
|
946
|
+
if value is None:
|
|
947
|
+
return self._hide_on_outside_click
|
|
948
|
+
self._hide_on_outside_click = bool(value)
|
|
949
|
+
return None
|
|
950
|
+
|
|
951
|
+
@configure_delegate('target')
|
|
952
|
+
def _delegate_target(self, value: Misc | None):
|
|
953
|
+
"""Get or set the target widget used for positioning."""
|
|
954
|
+
if value is None:
|
|
955
|
+
return self._target
|
|
956
|
+
self._target = value
|
|
957
|
+
return None
|
|
958
|
+
|
|
959
|
+
@configure_delegate('items')
|
|
960
|
+
def _delegate_items(self, value: list[ContextMenuItem] | None):
|
|
961
|
+
"""Get or replace the menu items."""
|
|
962
|
+
if value is None:
|
|
963
|
+
# Return items in order
|
|
964
|
+
return [self._items[key] for key in self._item_order]
|
|
965
|
+
|
|
966
|
+
# Destroy existing widgets before replacing
|
|
967
|
+
for widget in self._items.values():
|
|
968
|
+
try:
|
|
969
|
+
widget.destroy()
|
|
970
|
+
except TclError:
|
|
971
|
+
pass
|
|
972
|
+
self._items = {}
|
|
973
|
+
self._item_order = []
|
|
974
|
+
self._counter = 0
|
|
975
|
+
self.add_items(value)
|
|
976
|
+
return None
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
class _NativeContextMenu(CustomConfigMixin):
|
|
980
|
+
"""Native `tk.Menu`-backed context menu (Aqua/Windows backend).
|
|
981
|
+
|
|
982
|
+
Internal backend used by `ContextMenu` on macOS so the popup is a real
|
|
983
|
+
NSMenu — sidesteps the key-window/activation issues that affect a
|
|
984
|
+
reused overrideredirect Toplevel. Theming follows the system menu look;
|
|
985
|
+
icons resolve through `BootstrapIcon` and re-render on theme change.
|
|
986
|
+
"""
|
|
987
|
+
|
|
988
|
+
def __init__(
|
|
989
|
+
self,
|
|
990
|
+
master: Master = None,
|
|
991
|
+
minwidth: int = 150,
|
|
992
|
+
width: int = None,
|
|
993
|
+
minheight: int = None,
|
|
994
|
+
height: int = None,
|
|
995
|
+
target: Misc = None,
|
|
996
|
+
anchor: str = 'nw',
|
|
997
|
+
attach: str = 'se',
|
|
998
|
+
offset: tuple[int, int] = None,
|
|
999
|
+
hide_on_outside_click: bool = True,
|
|
1000
|
+
items: list[ContextMenuItem] = None,
|
|
1001
|
+
density: str = 'default',
|
|
1002
|
+
):
|
|
1003
|
+
"""Initialize the native tk.Menu backend.
|
|
1004
|
+
|
|
1005
|
+
Args mirror the themed backend so the public `ContextMenu` API is
|
|
1006
|
+
identical across platforms. Several options (`minwidth`, `width`,
|
|
1007
|
+
`height`, `hide_on_outside_click`, `density`) are stored for
|
|
1008
|
+
`cget` parity but have no effect — the system menu controls
|
|
1009
|
+
sizing, dismissal, and typography on the host platform.
|
|
1010
|
+
"""
|
|
1011
|
+
import tkinter as tk
|
|
1012
|
+
from bootstack.runtime.menu import MenuManager
|
|
1013
|
+
|
|
1014
|
+
super().__init__()
|
|
1015
|
+
self._master = master
|
|
1016
|
+
self._target = target
|
|
1017
|
+
self._minwidth = minwidth
|
|
1018
|
+
self._width = width
|
|
1019
|
+
self._minheight = minheight
|
|
1020
|
+
self._height = height
|
|
1021
|
+
self._anchor = (anchor or 'nw').lower()
|
|
1022
|
+
self._attach = (attach or 'nw').lower()
|
|
1023
|
+
# Default offset matches the themed backend so consumers that pass
|
|
1024
|
+
# an explicit offset for chrome alignment don't need a Mac-specific
|
|
1025
|
+
# branch. The native menu still clamps to screen edges itself.
|
|
1026
|
+
self._offset = offset if offset is not None else (BootstyleBuilderBase.scale_from_source(10), 0)
|
|
1027
|
+
self._hide_on_outside_click = hide_on_outside_click
|
|
1028
|
+
self._density = density
|
|
1029
|
+
self._on_item_click_callback = None
|
|
1030
|
+
|
|
1031
|
+
# Create the native menu and look up the per-root MenuManager so
|
|
1032
|
+
# icon resolution, label translation, and <<ThemeChanged>> tracking
|
|
1033
|
+
# are shared with the rest of the app's tk.Menu surfaces (menubars,
|
|
1034
|
+
# other context menus). Avoids duplicating those concerns here.
|
|
1035
|
+
self._menu = tk.Menu(master, tearoff=0)
|
|
1036
|
+
self._mgr = MenuManager.for_widget(master) if master is not None else None
|
|
1037
|
+
|
|
1038
|
+
# Item tracking by key with insertion order; specs are kept so we
|
|
1039
|
+
# can rebuild the menu on insert/move (tk.Menu has no atomic move).
|
|
1040
|
+
self._item_specs: dict[str, dict] = {}
|
|
1041
|
+
self._item_order: list[str] = []
|
|
1042
|
+
self._counter = 0
|
|
1043
|
+
|
|
1044
|
+
# Strong ref to Tk variables so they aren't GC'd while the menu
|
|
1045
|
+
# holds them. PhotoImage refs live in MenuManager.menu_items so
|
|
1046
|
+
# we don't need to track them locally.
|
|
1047
|
+
self._var_refs: dict[str, Any] = {}
|
|
1048
|
+
|
|
1049
|
+
if items:
|
|
1050
|
+
self.add_items(items)
|
|
1051
|
+
|
|
1052
|
+
# ----- Internal helpers -------------------------------------------------
|
|
1053
|
+
|
|
1054
|
+
def _generate_key(self) -> str:
|
|
1055
|
+
key = f"item_{self._counter}"
|
|
1056
|
+
self._counter += 1
|
|
1057
|
+
return key
|
|
1058
|
+
|
|
1059
|
+
def _resolve_key(self, key_or_index: str | int) -> str:
|
|
1060
|
+
if isinstance(key_or_index, int):
|
|
1061
|
+
try:
|
|
1062
|
+
return self._item_order[key_or_index]
|
|
1063
|
+
except IndexError as exc:
|
|
1064
|
+
raise IndexError(
|
|
1065
|
+
f"ContextMenu item index {key_or_index} out of range"
|
|
1066
|
+
) from exc
|
|
1067
|
+
if key_or_index not in self._item_specs:
|
|
1068
|
+
raise KeyError(f"No item with key '{key_or_index}'")
|
|
1069
|
+
return key_or_index
|
|
1070
|
+
|
|
1071
|
+
def _key_to_index(self, key: str) -> int:
|
|
1072
|
+
return self._item_order.index(key)
|
|
1073
|
+
|
|
1074
|
+
def _resolve_label(self, text: str | None) -> str:
|
|
1075
|
+
"""Translate a label via MenuManager (or pass-through if no manager)."""
|
|
1076
|
+
if self._mgr is None:
|
|
1077
|
+
return text or ''
|
|
1078
|
+
return self._mgr.translate_label(text) or ''
|
|
1079
|
+
|
|
1080
|
+
def _resolve_icon(self, icon_spec: Any) -> tuple[Any, str | None, int]:
|
|
1081
|
+
"""Resolve an icon spec via MenuManager.
|
|
1082
|
+
|
|
1083
|
+
Returns `(None, None, 0)` when no manager is available or the
|
|
1084
|
+
spec doesn't produce an icon, mirroring MenuManager's contract.
|
|
1085
|
+
"""
|
|
1086
|
+
if self._mgr is None:
|
|
1087
|
+
return None, None, 0
|
|
1088
|
+
return self._mgr.resolve_icon(icon_spec)
|
|
1089
|
+
|
|
1090
|
+
def _wrap_command(self, type_: str, text: str | None,
|
|
1091
|
+
command: Callable | None, value: Any = None) -> Callable:
|
|
1092
|
+
def fire():
|
|
1093
|
+
if self._on_item_click_callback:
|
|
1094
|
+
self._on_item_click_callback({
|
|
1095
|
+
'type': type_,
|
|
1096
|
+
'text': text,
|
|
1097
|
+
'value': value,
|
|
1098
|
+
})
|
|
1099
|
+
if command:
|
|
1100
|
+
command()
|
|
1101
|
+
return fire
|
|
1102
|
+
|
|
1103
|
+
def _resolve_shortcut(self, shortcut: str | None) -> str | None:
|
|
1104
|
+
"""Platform-correct accelerator display via the Shortcuts service.
|
|
1105
|
+
|
|
1106
|
+
Accepts a registered key, a modifier pattern (`'Mod+S'`,
|
|
1107
|
+
`'F5'`), or a literal display string. See
|
|
1108
|
+
`bootstack.runtime.shortcuts.format_shortcut` for details.
|
|
1109
|
+
"""
|
|
1110
|
+
if not shortcut:
|
|
1111
|
+
return None
|
|
1112
|
+
from bootstack.runtime.shortcuts import format_shortcut
|
|
1113
|
+
display = format_shortcut(shortcut)
|
|
1114
|
+
return display or None
|
|
1115
|
+
|
|
1116
|
+
# ----- Public API mirroring the themed backend ---------------------------
|
|
1117
|
+
|
|
1118
|
+
def on_item_click(self, callback: Callable) -> None:
|
|
1119
|
+
self._on_item_click_callback = callback
|
|
1120
|
+
|
|
1121
|
+
def off_item_click(self) -> None:
|
|
1122
|
+
self._on_item_click_callback = None
|
|
1123
|
+
|
|
1124
|
+
def add_command(
|
|
1125
|
+
self,
|
|
1126
|
+
text: str = None,
|
|
1127
|
+
icon: str = None,
|
|
1128
|
+
command: Callable = None,
|
|
1129
|
+
disabled: bool = False,
|
|
1130
|
+
shortcut: str = None,
|
|
1131
|
+
key: str = None,
|
|
1132
|
+
) -> str:
|
|
1133
|
+
"""Add a command. Returns the item key (no widget on this backend)."""
|
|
1134
|
+
key = key or self._generate_key()
|
|
1135
|
+
if key in self._item_specs:
|
|
1136
|
+
raise ValueError(f"Item with key '{key}' already exists")
|
|
1137
|
+
|
|
1138
|
+
photo, icon_name, icon_size = self._resolve_icon(icon)
|
|
1139
|
+
accelerator = self._resolve_shortcut(shortcut)
|
|
1140
|
+
|
|
1141
|
+
opts: dict[str, Any] = {
|
|
1142
|
+
'label': self._resolve_label(text),
|
|
1143
|
+
'command': self._wrap_command('command', text, command),
|
|
1144
|
+
}
|
|
1145
|
+
if photo is not None:
|
|
1146
|
+
opts['image'] = photo
|
|
1147
|
+
opts['compound'] = 'left'
|
|
1148
|
+
if accelerator:
|
|
1149
|
+
opts['accelerator'] = accelerator
|
|
1150
|
+
if disabled:
|
|
1151
|
+
opts['state'] = 'disabled'
|
|
1152
|
+
|
|
1153
|
+
self._menu.add_command(**opts)
|
|
1154
|
+
|
|
1155
|
+
if icon_name and self._mgr is not None:
|
|
1156
|
+
self._mgr.register_icon(self._menu, self._menu.index('end'), icon_name, icon_size)
|
|
1157
|
+
|
|
1158
|
+
self._item_specs[key] = {
|
|
1159
|
+
'type': 'command',
|
|
1160
|
+
'text': text,
|
|
1161
|
+
'icon': icon,
|
|
1162
|
+
'command': command,
|
|
1163
|
+
'disabled': disabled,
|
|
1164
|
+
'shortcut': shortcut,
|
|
1165
|
+
}
|
|
1166
|
+
self._item_order.append(key)
|
|
1167
|
+
return key
|
|
1168
|
+
|
|
1169
|
+
def add_checkbutton(
|
|
1170
|
+
self,
|
|
1171
|
+
text: str = None,
|
|
1172
|
+
value: bool = False,
|
|
1173
|
+
command: Callable = None,
|
|
1174
|
+
key: str = None,
|
|
1175
|
+
) -> str:
|
|
1176
|
+
key = key or self._generate_key()
|
|
1177
|
+
if key in self._item_specs:
|
|
1178
|
+
raise ValueError(f"Item with key '{key}' already exists")
|
|
1179
|
+
|
|
1180
|
+
var = BooleanVar(value=value)
|
|
1181
|
+
self._var_refs[key] = var
|
|
1182
|
+
|
|
1183
|
+
def on_toggle():
|
|
1184
|
+
if self._on_item_click_callback:
|
|
1185
|
+
self._on_item_click_callback({
|
|
1186
|
+
'type': 'checkbutton',
|
|
1187
|
+
'text': text,
|
|
1188
|
+
'value': var.get(),
|
|
1189
|
+
})
|
|
1190
|
+
if command:
|
|
1191
|
+
command()
|
|
1192
|
+
|
|
1193
|
+
self._menu.add_checkbutton(
|
|
1194
|
+
label=self._resolve_label(text), variable=var, command=on_toggle,
|
|
1195
|
+
)
|
|
1196
|
+
self._item_specs[key] = {
|
|
1197
|
+
'type': 'checkbutton',
|
|
1198
|
+
'text': text,
|
|
1199
|
+
'value': value,
|
|
1200
|
+
'command': command,
|
|
1201
|
+
}
|
|
1202
|
+
self._item_order.append(key)
|
|
1203
|
+
return key
|
|
1204
|
+
|
|
1205
|
+
def add_radiobutton(
|
|
1206
|
+
self,
|
|
1207
|
+
text: str = None,
|
|
1208
|
+
value: Any = None,
|
|
1209
|
+
variable: Union[StringVar, IntVar] = None,
|
|
1210
|
+
command: Callable = None,
|
|
1211
|
+
key: str = None,
|
|
1212
|
+
) -> str:
|
|
1213
|
+
key = key or self._generate_key()
|
|
1214
|
+
if key in self._item_specs:
|
|
1215
|
+
raise ValueError(f"Item with key '{key}' already exists")
|
|
1216
|
+
|
|
1217
|
+
if variable is None:
|
|
1218
|
+
variable = StringVar()
|
|
1219
|
+
# Always retain a strong ref; if the caller owns the variable, this
|
|
1220
|
+
# is a harmless extra reference.
|
|
1221
|
+
self._var_refs[key] = variable
|
|
1222
|
+
|
|
1223
|
+
def on_select():
|
|
1224
|
+
if self._on_item_click_callback:
|
|
1225
|
+
self._on_item_click_callback({
|
|
1226
|
+
'type': 'radiobutton',
|
|
1227
|
+
'text': text,
|
|
1228
|
+
'value': value,
|
|
1229
|
+
})
|
|
1230
|
+
if command:
|
|
1231
|
+
command()
|
|
1232
|
+
|
|
1233
|
+
self._menu.add_radiobutton(
|
|
1234
|
+
label=self._resolve_label(text),
|
|
1235
|
+
variable=variable,
|
|
1236
|
+
value=value,
|
|
1237
|
+
command=on_select,
|
|
1238
|
+
)
|
|
1239
|
+
self._item_specs[key] = {
|
|
1240
|
+
'type': 'radiobutton',
|
|
1241
|
+
'text': text,
|
|
1242
|
+
'value': value,
|
|
1243
|
+
'variable': variable,
|
|
1244
|
+
'command': command,
|
|
1245
|
+
}
|
|
1246
|
+
self._item_order.append(key)
|
|
1247
|
+
return key
|
|
1248
|
+
|
|
1249
|
+
def add_separator(self, key: str = None) -> str:
|
|
1250
|
+
key = key or self._generate_key()
|
|
1251
|
+
if key in self._item_specs:
|
|
1252
|
+
raise ValueError(f"Item with key '{key}' already exists")
|
|
1253
|
+
self._menu.add_separator()
|
|
1254
|
+
self._item_specs[key] = {'type': 'separator'}
|
|
1255
|
+
self._item_order.append(key)
|
|
1256
|
+
return key
|
|
1257
|
+
|
|
1258
|
+
def add_item(self, type: str, **kwargs: Any) -> str:
|
|
1259
|
+
if type == 'command':
|
|
1260
|
+
return self.add_command(**kwargs)
|
|
1261
|
+
if type == 'checkbutton':
|
|
1262
|
+
return self.add_checkbutton(**kwargs)
|
|
1263
|
+
if type == 'radiobutton':
|
|
1264
|
+
return self.add_radiobutton(**kwargs)
|
|
1265
|
+
if type == 'separator':
|
|
1266
|
+
return self.add_separator(**kwargs)
|
|
1267
|
+
raise ValueError(f"Unknown item type: {type}")
|
|
1268
|
+
|
|
1269
|
+
def add_items(self, items: list) -> None:
|
|
1270
|
+
for item in items:
|
|
1271
|
+
if isinstance(item, ContextMenuItem):
|
|
1272
|
+
self.add_item(item.type, **item.kwargs)
|
|
1273
|
+
elif isinstance(item, dict):
|
|
1274
|
+
item_type = item.get('type')
|
|
1275
|
+
kwargs = {k: v for k, v in item.items() if k != 'type'}
|
|
1276
|
+
self.add_item(item_type, **kwargs)
|
|
1277
|
+
|
|
1278
|
+
def items(self, value=None):
|
|
1279
|
+
if value is None:
|
|
1280
|
+
return self._delegate_items(None)
|
|
1281
|
+
self._delegate_items(value)
|
|
1282
|
+
return None
|
|
1283
|
+
|
|
1284
|
+
def keys(self) -> tuple[str, ...]:
|
|
1285
|
+
return tuple(self._item_order)
|
|
1286
|
+
|
|
1287
|
+
def insert_item(self, index: int, type: str, **kwargs: Any) -> str:
|
|
1288
|
+
# Append, then reorder + rebuild — tk.Menu has no atomic move op
|
|
1289
|
+
# that preserves command bindings cleanly across insert points.
|
|
1290
|
+
new_key = self.add_item(type, **kwargs)
|
|
1291
|
+
self._item_order.remove(new_key)
|
|
1292
|
+
if index < 0:
|
|
1293
|
+
index = 0
|
|
1294
|
+
if index > len(self._item_order):
|
|
1295
|
+
index = len(self._item_order)
|
|
1296
|
+
self._item_order.insert(index, new_key)
|
|
1297
|
+
self._rebuild_menu()
|
|
1298
|
+
return new_key
|
|
1299
|
+
|
|
1300
|
+
def item(self, key_or_index: str | int) -> dict:
|
|
1301
|
+
"""Return the spec dict for an item.
|
|
1302
|
+
|
|
1303
|
+
Note: native backend has no per-item widget. The returned dict is
|
|
1304
|
+
the original spec passed to `add_*` — useful for inspection but
|
|
1305
|
+
not a Tk widget. Mutating it does not affect the rendered menu.
|
|
1306
|
+
"""
|
|
1307
|
+
key = self._resolve_key(key_or_index)
|
|
1308
|
+
return self._item_specs[key]
|
|
1309
|
+
|
|
1310
|
+
def remove_item(self, key_or_index: str | int) -> None:
|
|
1311
|
+
key = self._resolve_key(key_or_index)
|
|
1312
|
+
idx = self._key_to_index(key)
|
|
1313
|
+
try:
|
|
1314
|
+
self._menu.delete(idx)
|
|
1315
|
+
except TclError:
|
|
1316
|
+
pass
|
|
1317
|
+
self._item_order.remove(key)
|
|
1318
|
+
self._item_specs.pop(key, None)
|
|
1319
|
+
self._var_refs.pop(key, None)
|
|
1320
|
+
# Drop all icon-tracking entries for this menu and re-register
|
|
1321
|
+
# remaining items so MenuManager's index map stays correct after
|
|
1322
|
+
# the deletion shifted later items down by one.
|
|
1323
|
+
if self._mgr is not None:
|
|
1324
|
+
self._mgr.unregister_menu(self._menu)
|
|
1325
|
+
self._reregister_icons()
|
|
1326
|
+
return None
|
|
1327
|
+
|
|
1328
|
+
def _reregister_icons(self) -> None:
|
|
1329
|
+
"""Re-register tracked icons with MenuManager from current spec state.
|
|
1330
|
+
|
|
1331
|
+
Used after remove/move/rebuild operations so theme-change updates
|
|
1332
|
+
target the correct entry indices.
|
|
1333
|
+
"""
|
|
1334
|
+
if self._mgr is None:
|
|
1335
|
+
return
|
|
1336
|
+
for i, key in enumerate(self._item_order):
|
|
1337
|
+
spec = self._item_specs.get(key, {})
|
|
1338
|
+
icon_spec = spec.get('icon')
|
|
1339
|
+
if not icon_spec or icon_spec == 'empty':
|
|
1340
|
+
continue
|
|
1341
|
+
_, name, size = self._mgr.resolve_icon(icon_spec)
|
|
1342
|
+
if name:
|
|
1343
|
+
self._mgr.register_icon(self._menu, i, name, size)
|
|
1344
|
+
|
|
1345
|
+
def move_item(self, from_key_or_index: str | int, to_index: int):
|
|
1346
|
+
key = self._resolve_key(from_key_or_index)
|
|
1347
|
+
self._item_order.remove(key)
|
|
1348
|
+
if to_index < 0:
|
|
1349
|
+
to_index = 0
|
|
1350
|
+
if to_index > len(self._item_order):
|
|
1351
|
+
to_index = len(self._item_order)
|
|
1352
|
+
self._item_order.insert(to_index, key)
|
|
1353
|
+
self._rebuild_menu()
|
|
1354
|
+
return self._item_specs[key]
|
|
1355
|
+
|
|
1356
|
+
def configure_item(self, key_or_index: str | int,
|
|
1357
|
+
option: str | None = None, **kwargs: Any) -> Any:
|
|
1358
|
+
key = self._resolve_key(key_or_index)
|
|
1359
|
+
idx = self._key_to_index(key)
|
|
1360
|
+
if option is None and not kwargs:
|
|
1361
|
+
return self._menu.entryconfigure(idx)
|
|
1362
|
+
if option is not None and not kwargs:
|
|
1363
|
+
return self._menu.entryconfigure(idx, option)
|
|
1364
|
+
return self._menu.entryconfigure(idx, **kwargs)
|
|
1365
|
+
|
|
1366
|
+
def show(self, position: tuple[int, int] = None) -> None:
|
|
1367
|
+
x, y = self._compute_position(position)
|
|
1368
|
+
try:
|
|
1369
|
+
self._menu.tk_popup(x, y)
|
|
1370
|
+
finally:
|
|
1371
|
+
try:
|
|
1372
|
+
self._menu.grab_release()
|
|
1373
|
+
except TclError:
|
|
1374
|
+
pass
|
|
1375
|
+
|
|
1376
|
+
def hide(self) -> None:
|
|
1377
|
+
try:
|
|
1378
|
+
self._menu.unpost()
|
|
1379
|
+
except TclError:
|
|
1380
|
+
pass
|
|
1381
|
+
|
|
1382
|
+
def destroy(self) -> None:
|
|
1383
|
+
# Drop tracking entries with the shared MenuManager so its
|
|
1384
|
+
# <<ThemeChanged>> handler doesn't try to reconfigure a deleted
|
|
1385
|
+
# menu's entries on the next theme change.
|
|
1386
|
+
if self._mgr is not None:
|
|
1387
|
+
try:
|
|
1388
|
+
self._mgr.unregister_menu(self._menu)
|
|
1389
|
+
except Exception:
|
|
1390
|
+
pass
|
|
1391
|
+
try:
|
|
1392
|
+
self._menu.destroy()
|
|
1393
|
+
except TclError:
|
|
1394
|
+
pass
|
|
1395
|
+
|
|
1396
|
+
# ----- Internal: full menu rebuild ---------------------------------------
|
|
1397
|
+
|
|
1398
|
+
def _rebuild_menu(self) -> None:
|
|
1399
|
+
"""Tear down and re-add all entries from stored specs.
|
|
1400
|
+
|
|
1401
|
+
Used by `insert_item` and `move_item` since tk.Menu offers no
|
|
1402
|
+
atomic reorder. Icon tracking is unregistered then re-registered
|
|
1403
|
+
with MenuManager so theme-change updates target the right indices.
|
|
1404
|
+
"""
|
|
1405
|
+
try:
|
|
1406
|
+
last = self._menu.index('end')
|
|
1407
|
+
except TclError:
|
|
1408
|
+
last = None
|
|
1409
|
+
if last is not None:
|
|
1410
|
+
try:
|
|
1411
|
+
self._menu.delete(0, last)
|
|
1412
|
+
except TclError:
|
|
1413
|
+
pass
|
|
1414
|
+
|
|
1415
|
+
if self._mgr is not None:
|
|
1416
|
+
self._mgr.unregister_menu(self._menu)
|
|
1417
|
+
|
|
1418
|
+
for key in self._item_order:
|
|
1419
|
+
spec = self._item_specs[key]
|
|
1420
|
+
type_ = spec['type']
|
|
1421
|
+
if type_ == 'separator':
|
|
1422
|
+
self._menu.add_separator()
|
|
1423
|
+
continue
|
|
1424
|
+
|
|
1425
|
+
label = self._resolve_label(spec.get('text'))
|
|
1426
|
+
text = spec.get('text')
|
|
1427
|
+
|
|
1428
|
+
if type_ == 'command':
|
|
1429
|
+
opts: dict[str, Any] = {
|
|
1430
|
+
'label': label,
|
|
1431
|
+
'command': self._wrap_command(
|
|
1432
|
+
'command', text, spec.get('command'),
|
|
1433
|
+
),
|
|
1434
|
+
}
|
|
1435
|
+
photo, icon_name, icon_size = self._resolve_icon(spec.get('icon'))
|
|
1436
|
+
if photo is not None:
|
|
1437
|
+
opts['image'] = photo
|
|
1438
|
+
opts['compound'] = 'left'
|
|
1439
|
+
accelerator = self._resolve_shortcut(spec.get('shortcut'))
|
|
1440
|
+
if accelerator:
|
|
1441
|
+
opts['accelerator'] = accelerator
|
|
1442
|
+
if spec.get('disabled'):
|
|
1443
|
+
opts['state'] = 'disabled'
|
|
1444
|
+
self._menu.add_command(**opts)
|
|
1445
|
+
if icon_name and self._mgr is not None:
|
|
1446
|
+
self._mgr.register_icon(
|
|
1447
|
+
self._menu, self._menu.index('end'), icon_name, icon_size,
|
|
1448
|
+
)
|
|
1449
|
+
elif type_ == 'checkbutton':
|
|
1450
|
+
var = self._var_refs[key]
|
|
1451
|
+
command = spec.get('command')
|
|
1452
|
+
|
|
1453
|
+
def on_toggle(_var=var, _text=text, _cmd=command):
|
|
1454
|
+
if self._on_item_click_callback:
|
|
1455
|
+
self._on_item_click_callback({
|
|
1456
|
+
'type': 'checkbutton',
|
|
1457
|
+
'text': _text,
|
|
1458
|
+
'value': _var.get(),
|
|
1459
|
+
})
|
|
1460
|
+
if _cmd:
|
|
1461
|
+
_cmd()
|
|
1462
|
+
|
|
1463
|
+
self._menu.add_checkbutton(
|
|
1464
|
+
label=label, variable=var, command=on_toggle,
|
|
1465
|
+
)
|
|
1466
|
+
elif type_ == 'radiobutton':
|
|
1467
|
+
var = spec.get('variable') or self._var_refs.get(key)
|
|
1468
|
+
value = spec.get('value')
|
|
1469
|
+
command = spec.get('command')
|
|
1470
|
+
|
|
1471
|
+
def on_select(_text=text, _value=value, _cmd=command):
|
|
1472
|
+
if self._on_item_click_callback:
|
|
1473
|
+
self._on_item_click_callback({
|
|
1474
|
+
'type': 'radiobutton',
|
|
1475
|
+
'text': _text,
|
|
1476
|
+
'value': _value,
|
|
1477
|
+
})
|
|
1478
|
+
if _cmd:
|
|
1479
|
+
_cmd()
|
|
1480
|
+
|
|
1481
|
+
self._menu.add_radiobutton(
|
|
1482
|
+
label=label,
|
|
1483
|
+
variable=var,
|
|
1484
|
+
value=value,
|
|
1485
|
+
command=on_select,
|
|
1486
|
+
)
|
|
1487
|
+
|
|
1488
|
+
def _compute_position(self, position: tuple[int, int] | None) -> tuple[int, int]:
|
|
1489
|
+
"""Resolve the screen-coordinate target for `tk_popup`.
|
|
1490
|
+
|
|
1491
|
+
Mirrors the themed backend's anchor/attach/offset semantics, but
|
|
1492
|
+
without the menu-size step (the native menu auto-positions). When
|
|
1493
|
+
`position` is given, anchor/attach are ignored and only `offset`
|
|
1494
|
+
applies, matching the themed backend behavior.
|
|
1495
|
+
"""
|
|
1496
|
+
if position is not None:
|
|
1497
|
+
return int(position[0] + self._offset[0]), int(position[1] + self._offset[1])
|
|
1498
|
+
|
|
1499
|
+
if self._target and self._target.winfo_exists():
|
|
1500
|
+
self._target.update_idletasks()
|
|
1501
|
+
target_w = self._target.winfo_width()
|
|
1502
|
+
target_h = self._target.winfo_height()
|
|
1503
|
+
base_x = self._target.winfo_rootx()
|
|
1504
|
+
base_y = self._target.winfo_rooty()
|
|
1505
|
+
attach_table = {
|
|
1506
|
+
'nw': (0, 0),
|
|
1507
|
+
'n': (target_w / 2, 0),
|
|
1508
|
+
'ne': (target_w, 0),
|
|
1509
|
+
'w': (0, target_h / 2),
|
|
1510
|
+
'center': (target_w / 2, target_h / 2),
|
|
1511
|
+
'e': (target_w, target_h / 2),
|
|
1512
|
+
'sw': (0, target_h),
|
|
1513
|
+
's': (target_w / 2, target_h),
|
|
1514
|
+
'se': (target_w, target_h),
|
|
1515
|
+
}
|
|
1516
|
+
dx, dy = attach_table.get(self._attach, (0, 0))
|
|
1517
|
+
return (
|
|
1518
|
+
int(base_x + dx + self._offset[0]),
|
|
1519
|
+
int(base_y + dy + self._offset[1]),
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
return 0, 0
|
|
1523
|
+
|
|
1524
|
+
# ----- Configuration delegates -------------------------------------------
|
|
1525
|
+
|
|
1526
|
+
@configure_delegate('minwidth')
|
|
1527
|
+
def _delegate_minwidth(self, value: int | None):
|
|
1528
|
+
if value is None:
|
|
1529
|
+
return self._minwidth
|
|
1530
|
+
self._minwidth = value
|
|
1531
|
+
return None
|
|
1532
|
+
|
|
1533
|
+
@configure_delegate('minheight')
|
|
1534
|
+
def _delegate_minheight(self, value: int | None):
|
|
1535
|
+
if value is None:
|
|
1536
|
+
return self._minheight
|
|
1537
|
+
self._minheight = value
|
|
1538
|
+
return None
|
|
1539
|
+
|
|
1540
|
+
@configure_delegate('width')
|
|
1541
|
+
def _delegate_width(self, value: int | None):
|
|
1542
|
+
if value is None:
|
|
1543
|
+
return self._width
|
|
1544
|
+
self._width = value
|
|
1545
|
+
return None
|
|
1546
|
+
|
|
1547
|
+
@configure_delegate('height')
|
|
1548
|
+
def _delegate_height(self, value: int | None):
|
|
1549
|
+
if value is None:
|
|
1550
|
+
return self._height
|
|
1551
|
+
self._height = value
|
|
1552
|
+
return None
|
|
1553
|
+
|
|
1554
|
+
@configure_delegate('anchor')
|
|
1555
|
+
def _delegate_anchor(self, value: str | None):
|
|
1556
|
+
if value is None:
|
|
1557
|
+
return self._anchor
|
|
1558
|
+
self._anchor = (value or 'nw').lower()
|
|
1559
|
+
return None
|
|
1560
|
+
|
|
1561
|
+
@configure_delegate('attach')
|
|
1562
|
+
def _delegate_attach(self, value: str | None):
|
|
1563
|
+
if value is None:
|
|
1564
|
+
return self._attach
|
|
1565
|
+
self._attach = (value or 'nw').lower()
|
|
1566
|
+
return None
|
|
1567
|
+
|
|
1568
|
+
@configure_delegate('offset')
|
|
1569
|
+
def _delegate_offset(self, value: tuple[int, int] | None):
|
|
1570
|
+
if value is None:
|
|
1571
|
+
return self._offset
|
|
1572
|
+
try:
|
|
1573
|
+
dx, dy = value # type: ignore[misc]
|
|
1574
|
+
except Exception:
|
|
1575
|
+
dx, dy = (0, 0)
|
|
1576
|
+
self._offset = (dx, dy)
|
|
1577
|
+
return None
|
|
1578
|
+
|
|
1579
|
+
@configure_delegate('hide_on_outside_click')
|
|
1580
|
+
def _delegate_hide_on_outside_click(self, value: bool | None):
|
|
1581
|
+
if value is None:
|
|
1582
|
+
return self._hide_on_outside_click
|
|
1583
|
+
self._hide_on_outside_click = bool(value)
|
|
1584
|
+
return None
|
|
1585
|
+
|
|
1586
|
+
@configure_delegate('target')
|
|
1587
|
+
def _delegate_target(self, value: Misc | None):
|
|
1588
|
+
if value is None:
|
|
1589
|
+
return self._target
|
|
1590
|
+
self._target = value
|
|
1591
|
+
return None
|
|
1592
|
+
|
|
1593
|
+
@configure_delegate('items')
|
|
1594
|
+
def _delegate_items(self, value: list | None):
|
|
1595
|
+
if value is None:
|
|
1596
|
+
# Return spec dicts in order (no widgets exist on this backend)
|
|
1597
|
+
return [self._item_specs[key] for key in self._item_order]
|
|
1598
|
+
|
|
1599
|
+
# Replace all items
|
|
1600
|
+
try:
|
|
1601
|
+
last = self._menu.index('end')
|
|
1602
|
+
if last is not None:
|
|
1603
|
+
self._menu.delete(0, last)
|
|
1604
|
+
except TclError:
|
|
1605
|
+
pass
|
|
1606
|
+
if self._mgr is not None:
|
|
1607
|
+
self._mgr.unregister_menu(self._menu)
|
|
1608
|
+
self._item_specs = {}
|
|
1609
|
+
self._item_order = []
|
|
1610
|
+
self._counter = 0
|
|
1611
|
+
self._var_refs = {}
|
|
1612
|
+
self.add_items(value)
|
|
1613
|
+
return None
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
class ContextMenu:
|
|
1617
|
+
"""Public ContextMenu — dispatches to a platform-appropriate backend.
|
|
1618
|
+
|
|
1619
|
+
On macOS this materializes as a native `tk.Menu` (NSMenu) so popups
|
|
1620
|
+
integrate with the system, dodging the key-window/activation issues
|
|
1621
|
+
that affect a reused overrideredirect Toplevel on Aqua. On Windows
|
|
1622
|
+
and Linux it uses the themed Toplevel-backed implementation so
|
|
1623
|
+
bootstyle, density, and rich item types apply consistently.
|
|
1624
|
+
|
|
1625
|
+
The public API is identical across backends. Consumers should not
|
|
1626
|
+
rely on `item()` returning a Tk widget — on the native backend it
|
|
1627
|
+
returns the original spec dict, since no per-item widget exists.
|
|
1628
|
+
"""
|
|
1629
|
+
|
|
1630
|
+
def __init__(
|
|
1631
|
+
self,
|
|
1632
|
+
master: Master = None,
|
|
1633
|
+
minwidth: int = 150,
|
|
1634
|
+
width: int = None,
|
|
1635
|
+
minheight: int = None,
|
|
1636
|
+
height: int = None,
|
|
1637
|
+
target: Misc = _TARGET_DEFAULT,
|
|
1638
|
+
anchor: str = 'nw',
|
|
1639
|
+
attach: str = 'se',
|
|
1640
|
+
offset: tuple[int, int] = None,
|
|
1641
|
+
hide_on_outside_click: bool = True,
|
|
1642
|
+
items: list[ContextMenuItem] = None,
|
|
1643
|
+
density: str = 'default',
|
|
1644
|
+
trigger: str | None = 'right-click',
|
|
1645
|
+
):
|
|
1646
|
+
"""Create a ContextMenu.
|
|
1647
|
+
|
|
1648
|
+
Args mirror the underlying backend; `trigger` is a dispatcher-level
|
|
1649
|
+
convenience that auto-binds the menu to `target`'s click event so
|
|
1650
|
+
callers don't have to wire a `bind('<Button-3>', show_at)` handler
|
|
1651
|
+
themselves. Set `trigger=None` (or `'manual'`) to opt out and
|
|
1652
|
+
manage activation in caller code (e.g. when the menu is built lazily).
|
|
1653
|
+
|
|
1654
|
+
`target` defaults to `master` when omitted, since the menu is
|
|
1655
|
+
usually attached to the same widget that owns it. Pass `target=None`
|
|
1656
|
+
explicitly to opt out of positioning/auto-binding (e.g. when calling
|
|
1657
|
+
`show(position=(x, y))` with cursor-driven coordinates instead).
|
|
1658
|
+
|
|
1659
|
+
Trigger values:
|
|
1660
|
+
- `'right-click'` (default): portable right-click via
|
|
1661
|
+
`bootstack.runtime.utility.bind_right_click` —
|
|
1662
|
+
`<Button-3>` on Win/Linux plus `<Button-2>` and
|
|
1663
|
+
`<Control-Button-1>` on Aqua.
|
|
1664
|
+
- `'click'` / `'left-click'`: `<Button-1>`.
|
|
1665
|
+
- `'double-click'`: `<Double-Button-1>`.
|
|
1666
|
+
- `'shift-click'`: `<Shift-Button-1>`.
|
|
1667
|
+
- `'ctrl-click'` / `'control-click'`: `<Control-Button-1>`
|
|
1668
|
+
(note that on Aqua this is the same as Ctrl+click for context
|
|
1669
|
+
menus, since macOS uses Ctrl+click as a context-menu gesture).
|
|
1670
|
+
- `None` or `'manual'`: no auto-binding.
|
|
1671
|
+
"""
|
|
1672
|
+
# Default target to master when omitted; explicit `None` opts out.
|
|
1673
|
+
if target is _TARGET_DEFAULT:
|
|
1674
|
+
target = master
|
|
1675
|
+
|
|
1676
|
+
winsys = None
|
|
1677
|
+
probe = master if master is not None else target
|
|
1678
|
+
if probe is not None:
|
|
1679
|
+
try:
|
|
1680
|
+
winsys = probe.tk.call('tk', 'windowingsystem')
|
|
1681
|
+
except (TclError, AttributeError):
|
|
1682
|
+
winsys = None
|
|
1683
|
+
if winsys is None:
|
|
1684
|
+
try:
|
|
1685
|
+
import tkinter as _tk
|
|
1686
|
+
root = _tk._get_default_root()
|
|
1687
|
+
if root is not None:
|
|
1688
|
+
winsys = root.tk.call('tk', 'windowingsystem')
|
|
1689
|
+
except (TclError, AttributeError):
|
|
1690
|
+
winsys = None
|
|
1691
|
+
|
|
1692
|
+
backend_cls = _NativeContextMenu if winsys == 'aqua' else _ToplevelContextMenu
|
|
1693
|
+
self._impl = backend_cls(
|
|
1694
|
+
master=master,
|
|
1695
|
+
minwidth=minwidth,
|
|
1696
|
+
width=width,
|
|
1697
|
+
minheight=minheight,
|
|
1698
|
+
height=height,
|
|
1699
|
+
target=target,
|
|
1700
|
+
anchor=anchor,
|
|
1701
|
+
attach=attach,
|
|
1702
|
+
offset=offset,
|
|
1703
|
+
hide_on_outside_click=hide_on_outside_click,
|
|
1704
|
+
items=items,
|
|
1705
|
+
density=density,
|
|
1706
|
+
)
|
|
1707
|
+
|
|
1708
|
+
# Auto-bind the activation gesture to the target widget. Skip when
|
|
1709
|
+
# there's no target (no widget to bind on) or the caller explicitly
|
|
1710
|
+
# opted out so existing widgets that manage their own triggers
|
|
1711
|
+
# (OptionMenu, DropdownButton, Tableview, SideNav) keep working.
|
|
1712
|
+
if target is not None and trigger not in (None, 'manual', 'none'):
|
|
1713
|
+
self._bind_trigger(target, trigger)
|
|
1714
|
+
|
|
1715
|
+
def _bind_trigger(self, target: Misc, trigger: str) -> None:
|
|
1716
|
+
"""Bind `target`'s activation event to show this menu at the click."""
|
|
1717
|
+
from bootstack.runtime.utility import bind_right_click
|
|
1718
|
+
|
|
1719
|
+
def show_at(event):
|
|
1720
|
+
self.show(position=(event.x_root, event.y_root))
|
|
1721
|
+
|
|
1722
|
+
normalized = trigger.lower().replace('_', '-')
|
|
1723
|
+
if normalized in ('right-click', 'right'):
|
|
1724
|
+
bind_right_click(target, show_at)
|
|
1725
|
+
elif normalized in ('click', 'left-click', 'left'):
|
|
1726
|
+
target.bind('<Button-1>', show_at, add='+')
|
|
1727
|
+
elif normalized in ('double-click', 'double'):
|
|
1728
|
+
target.bind('<Double-Button-1>', show_at, add='+')
|
|
1729
|
+
elif normalized in ('shift-click', 'shift'):
|
|
1730
|
+
target.bind('<Shift-Button-1>', show_at, add='+')
|
|
1731
|
+
elif normalized in ('ctrl-click', 'control-click', 'ctrl', 'control'):
|
|
1732
|
+
target.bind('<Control-Button-1>', show_at, add='+')
|
|
1733
|
+
else:
|
|
1734
|
+
raise ValueError(
|
|
1735
|
+
f"Unknown trigger {trigger!r}. Use 'right-click', 'click', "
|
|
1736
|
+
f"'double-click', 'shift-click', 'ctrl-click', or 'manual'."
|
|
1737
|
+
)
|
|
1738
|
+
|
|
1739
|
+
# Forward every other attribute (methods, configure delegates, etc.)
|
|
1740
|
+
# to the active backend. `_impl` itself is a real instance attribute
|
|
1741
|
+
# so it's resolved by normal attribute lookup before __getattr__ runs.
|
|
1742
|
+
def __getattr__(self, name: str):
|
|
1743
|
+
# __getattr__ is only consulted when normal lookup fails, so we
|
|
1744
|
+
# won't recurse on '_impl' here unless backend init raised.
|
|
1745
|
+
impl = self.__dict__.get('_impl')
|
|
1746
|
+
if impl is None:
|
|
1747
|
+
raise AttributeError(name)
|
|
1748
|
+
return getattr(impl, name)
|
|
1749
|
+
|
|
1750
|
+
def __getitem__(self, key):
|
|
1751
|
+
return self._impl[key]
|
|
1752
|
+
|
|
1753
|
+
def __setitem__(self, key, value):
|
|
1754
|
+
self._impl[key] = value
|