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