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.
Files changed (471) hide show
  1. bootstack/__init__.py +157 -0
  2. bootstack/__main__.py +5 -0
  3. bootstack/_core/__init__.py +21 -0
  4. bootstack/_core/capabilities/__init__.py +45 -0
  5. bootstack/_core/capabilities/after.py +103 -0
  6. bootstack/_core/capabilities/bind.py +192 -0
  7. bootstack/_core/capabilities/bindtags.py +112 -0
  8. bootstack/_core/capabilities/busy.py +72 -0
  9. bootstack/_core/capabilities/clipboard.py +89 -0
  10. bootstack/_core/capabilities/focus.py +118 -0
  11. bootstack/_core/capabilities/grab.py +65 -0
  12. bootstack/_core/capabilities/grid.py +211 -0
  13. bootstack/_core/capabilities/localization.py +231 -0
  14. bootstack/_core/capabilities/pack.py +140 -0
  15. bootstack/_core/capabilities/place.py +113 -0
  16. bootstack/_core/capabilities/selection.py +136 -0
  17. bootstack/_core/capabilities/signals.py +244 -0
  18. bootstack/_core/capabilities/winfo.py +315 -0
  19. bootstack/_core/colorutils.py +234 -0
  20. bootstack/_core/exceptions.py +25 -0
  21. bootstack/_core/images.py +463 -0
  22. bootstack/_core/mixins/__init__.py +1 -0
  23. bootstack/_core/mixins/ttk_state.py +35 -0
  24. bootstack/_core/mixins/widget.py +132 -0
  25. bootstack/_core/paths.py +49 -0
  26. bootstack/_core/publisher.py +149 -0
  27. bootstack/_core/variables.py +62 -0
  28. bootstack/_runtime/__init__.py +3 -0
  29. bootstack/_runtime/app.py +930 -0
  30. bootstack/_runtime/base_window.py +945 -0
  31. bootstack/_runtime/events.py +399 -0
  32. bootstack/_runtime/shortcuts.py +496 -0
  33. bootstack/_runtime/tk_patch.py +43 -0
  34. bootstack/_runtime/toplevel.py +276 -0
  35. bootstack/_runtime/utility.py +457 -0
  36. bootstack/_runtime/visual_focus.py +240 -0
  37. bootstack/_runtime/window_utilities.py +1123 -0
  38. bootstack/assets/__init__.py +21 -0
  39. bootstack/assets/bootstack.ico +0 -0
  40. bootstack/assets/bootstack.png +0 -0
  41. bootstack/assets/elements/__init__.py +0 -0
  42. bootstack/assets/elements/badge-pill.png +0 -0
  43. bootstack/assets/elements/badge-square.png +0 -0
  44. bootstack/assets/elements/button-compact.png +0 -0
  45. bootstack/assets/elements/button-default.png +0 -0
  46. bootstack/assets/elements/buttongroup-after-h-compact.png +0 -0
  47. bootstack/assets/elements/buttongroup-after-h-default.png +0 -0
  48. bootstack/assets/elements/buttongroup-after-v-compact.png +0 -0
  49. bootstack/assets/elements/buttongroup-after-v-default.png +0 -0
  50. bootstack/assets/elements/buttongroup-before-h-compact.png +0 -0
  51. bootstack/assets/elements/buttongroup-before-h-default.png +0 -0
  52. bootstack/assets/elements/buttongroup-before-v-compact.png +0 -0
  53. bootstack/assets/elements/buttongroup-before-v-default.png +0 -0
  54. bootstack/assets/elements/buttongroup-center-h-compact.png +0 -0
  55. bootstack/assets/elements/buttongroup-center-h-default.png +0 -0
  56. bootstack/assets/elements/buttongroup-center-v-compact.png +0 -0
  57. bootstack/assets/elements/buttongroup-center-v-default.png +0 -0
  58. bootstack/assets/elements/card.png +0 -0
  59. bootstack/assets/elements/checkbox-checked.png +0 -0
  60. bootstack/assets/elements/checkbox-indeterminate.png +0 -0
  61. bootstack/assets/elements/checkbox-unchecked.png +0 -0
  62. bootstack/assets/elements/field.png +0 -0
  63. bootstack/assets/elements/input-addon-compact.png +0 -0
  64. bootstack/assets/elements/input-addon-default.png +0 -0
  65. bootstack/assets/elements/input-compact.png +0 -0
  66. bootstack/assets/elements/input-default.png +0 -0
  67. bootstack/assets/elements/list-item-separated.png +0 -0
  68. bootstack/assets/elements/list-item.png +0 -0
  69. bootstack/assets/elements/listrow-compact.png +0 -0
  70. bootstack/assets/elements/listrow-default.png +0 -0
  71. bootstack/assets/elements/manifest.toml +361 -0
  72. bootstack/assets/elements/menu-item.png +0 -0
  73. bootstack/assets/elements/navitem-compact.png +0 -0
  74. bootstack/assets/elements/navitem-default.png +0 -0
  75. bootstack/assets/elements/progressbar-h-compact.png +0 -0
  76. bootstack/assets/elements/progressbar-h-default.png +0 -0
  77. bootstack/assets/elements/progressbar-v-compact.png +0 -0
  78. bootstack/assets/elements/progressbar-v-default.png +0 -0
  79. bootstack/assets/elements/radiobutton.png +0 -0
  80. bootstack/assets/elements/scrollbar-horizontal.png +0 -0
  81. bootstack/assets/elements/scrollbar-vertical.png +0 -0
  82. bootstack/assets/elements/slider-handle.png +0 -0
  83. bootstack/assets/elements/slider-track-h.png +0 -0
  84. bootstack/assets/elements/slider-track-v.png +0 -0
  85. bootstack/assets/elements/switch-off.png +0 -0
  86. bootstack/assets/elements/switch-on.png +0 -0
  87. bootstack/assets/elements/tab-h.png +0 -0
  88. bootstack/assets/elements/tab-v.png +0 -0
  89. bootstack/assets/icons/bootstrap.ttf +0 -0
  90. bootstack/assets/icons/glyphmap.json +2080 -0
  91. bootstack/assets/icons/icon_metrics.json +12470 -0
  92. bootstack/assets/locales/ar/LC_MESSAGES/bootstack.mo +0 -0
  93. bootstack/assets/locales/ar/LC_MESSAGES/bootstack.po +856 -0
  94. bootstack/assets/locales/bg/LC_MESSAGES/bootstack.mo +0 -0
  95. bootstack/assets/locales/bg/LC_MESSAGES/bootstack.po +878 -0
  96. bootstack/assets/locales/cs/LC_MESSAGES/bootstack.mo +0 -0
  97. bootstack/assets/locales/cs/LC_MESSAGES/bootstack.po +856 -0
  98. bootstack/assets/locales/da/LC_MESSAGES/bootstack.mo +0 -0
  99. bootstack/assets/locales/da/LC_MESSAGES/bootstack.po +856 -0
  100. bootstack/assets/locales/de/LC_MESSAGES/bootstack.mo +0 -0
  101. bootstack/assets/locales/de/LC_MESSAGES/bootstack.po +856 -0
  102. bootstack/assets/locales/en/LC_MESSAGES/bootstack.mo +0 -0
  103. bootstack/assets/locales/en/LC_MESSAGES/bootstack.po +878 -0
  104. bootstack/assets/locales/es/LC_MESSAGES/bootstack.mo +0 -0
  105. bootstack/assets/locales/es/LC_MESSAGES/bootstack.po +856 -0
  106. bootstack/assets/locales/fr/LC_MESSAGES/bootstack.mo +0 -0
  107. bootstack/assets/locales/fr/LC_MESSAGES/bootstack.po +856 -0
  108. bootstack/assets/locales/he/LC_MESSAGES/bootstack.mo +0 -0
  109. bootstack/assets/locales/he/LC_MESSAGES/bootstack.po +854 -0
  110. bootstack/assets/locales/hi/LC_MESSAGES/bootstack.mo +0 -0
  111. bootstack/assets/locales/hi/LC_MESSAGES/bootstack.po +845 -0
  112. bootstack/assets/locales/it/LC_MESSAGES/bootstack.mo +0 -0
  113. bootstack/assets/locales/it/LC_MESSAGES/bootstack.po +844 -0
  114. bootstack/assets/locales/ja/LC_MESSAGES/bootstack.mo +0 -0
  115. bootstack/assets/locales/ja/LC_MESSAGES/bootstack.po +917 -0
  116. bootstack/assets/locales/ko/LC_MESSAGES/bootstack.mo +0 -0
  117. bootstack/assets/locales/ko/LC_MESSAGES/bootstack.po +845 -0
  118. bootstack/assets/locales/nb/LC_MESSAGES/bootstack.mo +0 -0
  119. bootstack/assets/locales/nb/LC_MESSAGES/bootstack.po +844 -0
  120. bootstack/assets/locales/nl/LC_MESSAGES/bootstack.mo +0 -0
  121. bootstack/assets/locales/nl/LC_MESSAGES/bootstack.po +844 -0
  122. bootstack/assets/locales/pl/LC_MESSAGES/bootstack.mo +0 -0
  123. bootstack/assets/locales/pl/LC_MESSAGES/bootstack.po +845 -0
  124. bootstack/assets/locales/pt/LC_MESSAGES/bootstack.mo +0 -0
  125. bootstack/assets/locales/pt/LC_MESSAGES/bootstack.po +845 -0
  126. bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.mo +0 -0
  127. bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.po +845 -0
  128. bootstack/assets/locales/sl/LC_MESSAGES/bootstack.mo +0 -0
  129. bootstack/assets/locales/sl/LC_MESSAGES/bootstack.po +845 -0
  130. bootstack/assets/locales/sv/LC_MESSAGES/bootstack.mo +0 -0
  131. bootstack/assets/locales/sv/LC_MESSAGES/bootstack.po +845 -0
  132. bootstack/assets/locales/tr/LC_MESSAGES/bootstack.mo +0 -0
  133. bootstack/assets/locales/tr/LC_MESSAGES/bootstack.po +845 -0
  134. bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.mo +0 -0
  135. bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.po +845 -0
  136. bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.mo +0 -0
  137. bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.po +845 -0
  138. bootstack/cli/__init__.py +133 -0
  139. bootstack/cli/__main__.py +6 -0
  140. bootstack/cli/add.py +395 -0
  141. bootstack/cli/appicon.py +285 -0
  142. bootstack/cli/build.py +115 -0
  143. bootstack/cli/config.py +313 -0
  144. bootstack/cli/demo.py +564 -0
  145. bootstack/cli/dev.py +153 -0
  146. bootstack/cli/doctor.py +195 -0
  147. bootstack/cli/icons.py +98 -0
  148. bootstack/cli/promote.py +120 -0
  149. bootstack/cli/pyinstaller.py +268 -0
  150. bootstack/cli/run.py +95 -0
  151. bootstack/cli/start.py +117 -0
  152. bootstack/cli/templates/__init__.py +931 -0
  153. bootstack/clipboard.py +48 -0
  154. bootstack/constants.py +318 -0
  155. bootstack/data/README.md +615 -0
  156. bootstack/data/__init__.py +78 -0
  157. bootstack/data/_observable.py +276 -0
  158. bootstack/data/base.py +780 -0
  159. bootstack/data/file_source.py +367 -0
  160. bootstack/data/memory_source.py +450 -0
  161. bootstack/data/query.py +367 -0
  162. bootstack/data/readers.py +289 -0
  163. bootstack/data/sqlite_source.py +869 -0
  164. bootstack/data/types.py +354 -0
  165. bootstack/data/writers.py +232 -0
  166. bootstack/dev/__init__.py +36 -0
  167. bootstack/dev/_capture.py +141 -0
  168. bootstack/dev/_env.py +43 -0
  169. bootstack/dev/_registry.py +140 -0
  170. bootstack/dev/_reloader.py +351 -0
  171. bootstack/dev/_reset.py +50 -0
  172. bootstack/dev/_watcher.py +91 -0
  173. bootstack/dialogs/__init__.py +948 -0
  174. bootstack/dialogs/_impl/__init__.py +47 -0
  175. bootstack/dialogs/_impl/colorchooser.py +588 -0
  176. bootstack/dialogs/_impl/datedialog.py +450 -0
  177. bootstack/dialogs/_impl/dialog.py +594 -0
  178. bootstack/dialogs/_impl/filterdialog.py +358 -0
  179. bootstack/dialogs/_impl/fontdialog.py +364 -0
  180. bootstack/dialogs/_impl/formdialog.py +564 -0
  181. bootstack/dialogs/_impl/message.py +486 -0
  182. bootstack/dialogs/_impl/query.py +570 -0
  183. bootstack/errors.py +67 -0
  184. bootstack/events/__init__.py +111 -0
  185. bootstack/events/_event.py +135 -0
  186. bootstack/events/_payloads.py +539 -0
  187. bootstack/events/_subscription.py +38 -0
  188. bootstack/i18n/README.md +77 -0
  189. bootstack/i18n/__init__.py +23 -0
  190. bootstack/i18n/catalog.py +121 -0
  191. bootstack/i18n/intl_format.py +584 -0
  192. bootstack/i18n/msgcat.py +425 -0
  193. bootstack/i18n/specs.py +156 -0
  194. bootstack/images.py +563 -0
  195. bootstack/py.typed +1 -0
  196. bootstack/scheduling/__init__.py +11 -0
  197. bootstack/scheduling/_schedule.py +218 -0
  198. bootstack/shortcuts.py +21 -0
  199. bootstack/signals/README.md +98 -0
  200. bootstack/signals/__init__.py +10 -0
  201. bootstack/signals/integration.py +100 -0
  202. bootstack/signals/signal.py +353 -0
  203. bootstack/signals/types.py +6 -0
  204. bootstack/store.py +286 -0
  205. bootstack/streams/__init__.py +12 -0
  206. bootstack/streams/_stream.py +321 -0
  207. bootstack/style/__init__.py +38 -0
  208. bootstack/style/builders/__init__.py +51 -0
  209. bootstack/style/builders/badge.py +46 -0
  210. bootstack/style/builders/button.py +339 -0
  211. bootstack/style/builders/buttongroup.py +311 -0
  212. bootstack/style/builders/calendar.py +271 -0
  213. bootstack/style/builders/checkbutton.py +110 -0
  214. bootstack/style/builders/combobox.py +113 -0
  215. bootstack/style/builders/contextmenu.py +268 -0
  216. bootstack/style/builders/entry.py +82 -0
  217. bootstack/style/builders/expander.py +148 -0
  218. bootstack/style/builders/field.py +335 -0
  219. bootstack/style/builders/frame.py +50 -0
  220. bootstack/style/builders/label.py +28 -0
  221. bootstack/style/builders/labelframe.py +34 -0
  222. bootstack/style/builders/listview.py +369 -0
  223. bootstack/style/builders/menubar.py +91 -0
  224. bootstack/style/builders/menubutton.py +359 -0
  225. bootstack/style/builders/panedwindow.py +25 -0
  226. bootstack/style/builders/progressbar.py +67 -0
  227. bootstack/style/builders/radiobutton.py +99 -0
  228. bootstack/style/builders/scale.py +64 -0
  229. bootstack/style/builders/scrollbar.py +225 -0
  230. bootstack/style/builders/separator.py +49 -0
  231. bootstack/style/builders/sidenav.py +643 -0
  232. bootstack/style/builders/sizegrip.py +15 -0
  233. bootstack/style/builders/spinbox.py +119 -0
  234. bootstack/style/builders/switch.py +70 -0
  235. bootstack/style/builders/tabitem.py +204 -0
  236. bootstack/style/builders/togglegroup.py +294 -0
  237. bootstack/style/builders/toolbutton.py +275 -0
  238. bootstack/style/builders/tooltip.py +26 -0
  239. bootstack/style/builders/treeview.py +193 -0
  240. bootstack/style/builders/utils.py +455 -0
  241. bootstack/style/builders_tk/__init__.py +16 -0
  242. bootstack/style/builders_tk/defaults.py +229 -0
  243. bootstack/style/element.py +173 -0
  244. bootstack/style/fonts.py +123 -0
  245. bootstack/style/style.py +609 -0
  246. bootstack/style/style_builder_base.py +716 -0
  247. bootstack/style/style_builder_mixed.py +93 -0
  248. bootstack/style/style_builder_tk.py +109 -0
  249. bootstack/style/style_builder_ttk.py +353 -0
  250. bootstack/style/style_resolver.py +447 -0
  251. bootstack/style/theme.py +245 -0
  252. bootstack/style/theme_provider.py +471 -0
  253. bootstack/style/themes/__init__.py +128 -0
  254. bootstack/style/tk_patch.py +5 -0
  255. bootstack/style/token_maps.py +41 -0
  256. bootstack/style/types.py +32 -0
  257. bootstack/style/typography.py +523 -0
  258. bootstack/style/utility.py +746 -0
  259. bootstack/types.py +39 -0
  260. bootstack/validation/__init__.py +6 -0
  261. bootstack/validation/types.py +15 -0
  262. bootstack/validation/validation_result.py +17 -0
  263. bootstack/validation/validation_rules.py +205 -0
  264. bootstack/widgets/__init__.py +74 -0
  265. bootstack/widgets/_core/__init__.py +31 -0
  266. bootstack/widgets/_core/app_config.py +266 -0
  267. bootstack/widgets/_core/base.py +461 -0
  268. bootstack/widgets/_core/container.py +334 -0
  269. bootstack/widgets/_core/context.py +35 -0
  270. bootstack/widgets/_core/events.py +130 -0
  271. bootstack/widgets/_core/field_mixin.py +353 -0
  272. bootstack/widgets/_core/icon_image_props.py +72 -0
  273. bootstack/widgets/_core/image_binding.py +93 -0
  274. bootstack/widgets/_core/navmodel.py +345 -0
  275. bootstack/widgets/_core/options.py +210 -0
  276. bootstack/widgets/_core/selection_group.py +130 -0
  277. bootstack/widgets/_core/window_controls.py +96 -0
  278. bootstack/widgets/_core/window_menu.py +246 -0
  279. bootstack/widgets/_impl/__init__.py +1 -0
  280. bootstack/widgets/_impl/_internal/__init__.py +0 -0
  281. bootstack/widgets/_impl/_internal/wrapper_base.py +307 -0
  282. bootstack/widgets/_impl/_parts/__init__.py +11 -0
  283. bootstack/widgets/_impl/_parts/numberentry_part.py +385 -0
  284. bootstack/widgets/_impl/_parts/spinnerentry_part.py +434 -0
  285. bootstack/widgets/_impl/_parts/textentry_part.py +406 -0
  286. bootstack/widgets/_impl/composites/__init__.py +33 -0
  287. bootstack/widgets/_impl/composites/_dateutils.py +33 -0
  288. bootstack/widgets/_impl/composites/_image_fit.py +105 -0
  289. bootstack/widgets/_impl/composites/accordion.py +381 -0
  290. bootstack/widgets/_impl/composites/avatar.py +191 -0
  291. bootstack/widgets/_impl/composites/buttongroup.py +371 -0
  292. bootstack/widgets/_impl/composites/calendar.py +972 -0
  293. bootstack/widgets/_impl/composites/carousel.py +567 -0
  294. bootstack/widgets/_impl/composites/chart.py +882 -0
  295. bootstack/widgets/_impl/composites/compositeframe.py +298 -0
  296. bootstack/widgets/_impl/composites/contextmenu.py +1951 -0
  297. bootstack/widgets/_impl/composites/dateentry.py +404 -0
  298. bootstack/widgets/_impl/composites/dropdownbutton.py +325 -0
  299. bootstack/widgets/_impl/composites/expander.py +515 -0
  300. bootstack/widgets/_impl/composites/field.py +670 -0
  301. bootstack/widgets/_impl/composites/form.py +1066 -0
  302. bootstack/widgets/_impl/composites/gallery.py +551 -0
  303. bootstack/widgets/_impl/composites/list/__init__.py +15 -0
  304. bootstack/widgets/_impl/composites/list/listitem.py +802 -0
  305. bootstack/widgets/_impl/composites/list/listview.py +1433 -0
  306. bootstack/widgets/_impl/composites/menu/__init__.py +18 -0
  307. bootstack/widgets/_impl/composites/menu/model.py +358 -0
  308. bootstack/widgets/_impl/composites/menu/render_native.py +134 -0
  309. bootstack/widgets/_impl/composites/menu/render_themed.py +134 -0
  310. bootstack/widgets/_impl/composites/meter.py +860 -0
  311. bootstack/widgets/_impl/composites/numericentry.py +201 -0
  312. bootstack/widgets/_impl/composites/pagestack.py +395 -0
  313. bootstack/widgets/_impl/composites/passwordentry.py +142 -0
  314. bootstack/widgets/_impl/composites/pathentry.py +168 -0
  315. bootstack/widgets/_impl/composites/picture.py +289 -0
  316. bootstack/widgets/_impl/composites/radiogroup.py +511 -0
  317. bootstack/widgets/_impl/composites/scrolledtext.py +375 -0
  318. bootstack/widgets/_impl/composites/scrolledtext.pyi +186 -0
  319. bootstack/widgets/_impl/composites/scrollview.py +764 -0
  320. bootstack/widgets/_impl/composites/selectbox.py +1026 -0
  321. bootstack/widgets/_impl/composites/shell/__init__.py +40 -0
  322. bootstack/widgets/_impl/composites/shell/content_host.py +52 -0
  323. bootstack/widgets/_impl/composites/shell/layout.py +335 -0
  324. bootstack/widgets/_impl/composites/shell/nav_panel.py +345 -0
  325. bootstack/widgets/_impl/composites/shell/providers.py +558 -0
  326. bootstack/widgets/_impl/composites/shell/rail.py +117 -0
  327. bootstack/widgets/_impl/composites/shell/shell.py +581 -0
  328. bootstack/widgets/_impl/composites/shell/workspace.py +273 -0
  329. bootstack/widgets/_impl/composites/sidenav/__init__.py +16 -0
  330. bootstack/widgets/_impl/composites/sidenav/header.py +81 -0
  331. bootstack/widgets/_impl/composites/sidenav/separator.py +44 -0
  332. bootstack/widgets/_impl/composites/slider/__init__.py +7 -0
  333. bootstack/widgets/_impl/composites/slider/_shared.py +195 -0
  334. bootstack/widgets/_impl/composites/slider/rangeslider.py +982 -0
  335. bootstack/widgets/_impl/composites/slider/slider.py +851 -0
  336. bootstack/widgets/_impl/composites/spinnerentry.py +185 -0
  337. bootstack/widgets/_impl/composites/tableview/__init__.py +5 -0
  338. bootstack/widgets/_impl/composites/tableview/tableview.py +3388 -0
  339. bootstack/widgets/_impl/composites/tableview/types.py +169 -0
  340. bootstack/widgets/_impl/composites/tabs/__init__.py +23 -0
  341. bootstack/widgets/_impl/composites/tabs/tabitem.py +389 -0
  342. bootstack/widgets/_impl/composites/tabs/tabs.py +974 -0
  343. bootstack/widgets/_impl/composites/tabs/tabview.py +650 -0
  344. bootstack/widgets/_impl/composites/textarea/__init__.py +26 -0
  345. bootstack/widgets/_impl/composites/textarea/change.py +46 -0
  346. bootstack/widgets/_impl/composites/textarea/codeeditor.py +526 -0
  347. bootstack/widgets/_impl/composites/textarea/core.py +495 -0
  348. bootstack/widgets/_impl/composites/textarea/decoration.py +42 -0
  349. bootstack/widgets/_impl/composites/textarea/diff.py +127 -0
  350. bootstack/widgets/_impl/composites/textarea/extensions/__init__.py +1 -0
  351. bootstack/widgets/_impl/composites/textarea/extensions/bracket_matcher.py +138 -0
  352. bootstack/widgets/_impl/composites/textarea/extensions/indent_guides.py +158 -0
  353. bootstack/widgets/_impl/composites/textarea/extensions/line_numbers.py +138 -0
  354. bootstack/widgets/_impl/composites/textarea/extensions/pygments_highlighter.py +312 -0
  355. bootstack/widgets/_impl/composites/textarea/extensions/smart_indent.py +177 -0
  356. bootstack/widgets/_impl/composites/textarea/filter.py +171 -0
  357. bootstack/widgets/_impl/composites/textarea/search_overlay.py +459 -0
  358. bootstack/widgets/_impl/composites/textarea/sidebar.py +88 -0
  359. bootstack/widgets/_impl/composites/textarea/style_registry.py +178 -0
  360. bootstack/widgets/_impl/composites/textarea/textarea.py +606 -0
  361. bootstack/widgets/_impl/composites/textarea/undo.py +217 -0
  362. bootstack/widgets/_impl/composites/textentry.py +57 -0
  363. bootstack/widgets/_impl/composites/timeentry.py +176 -0
  364. bootstack/widgets/_impl/composites/toast.py +390 -0
  365. bootstack/widgets/_impl/composites/toast_stack.py +156 -0
  366. bootstack/widgets/_impl/composites/togglegroup.py +418 -0
  367. bootstack/widgets/_impl/composites/toolbar.py +608 -0
  368. bootstack/widgets/_impl/composites/tooltip.py +491 -0
  369. bootstack/widgets/_impl/composites/tree/__init__.py +7 -0
  370. bootstack/widgets/_impl/composites/tree/source_binding.py +136 -0
  371. bootstack/widgets/_impl/composites/tree/treeitem.py +393 -0
  372. bootstack/widgets/_impl/composites/tree/treenode.py +174 -0
  373. bootstack/widgets/_impl/composites/tree/treeview.py +841 -0
  374. bootstack/widgets/_impl/mixins/__init__.py +24 -0
  375. bootstack/widgets/_impl/mixins/configure_mixin.py +216 -0
  376. bootstack/widgets/_impl/mixins/entry_mixin.py +134 -0
  377. bootstack/widgets/_impl/mixins/font_mixin.py +368 -0
  378. bootstack/widgets/_impl/mixins/icon_mixin.py +61 -0
  379. bootstack/widgets/_impl/mixins/localization_mixin.py +253 -0
  380. bootstack/widgets/_impl/mixins/signal_mixin.py +268 -0
  381. bootstack/widgets/_impl/mixins/validation_mixin.py +226 -0
  382. bootstack/widgets/_impl/primitives/__init__.py +49 -0
  383. bootstack/widgets/_impl/primitives/_menubutton.py +107 -0
  384. bootstack/widgets/_impl/primitives/badge.py +45 -0
  385. bootstack/widgets/_impl/primitives/button.py +76 -0
  386. bootstack/widgets/_impl/primitives/card.py +45 -0
  387. bootstack/widgets/_impl/primitives/checkbutton.py +124 -0
  388. bootstack/widgets/_impl/primitives/checktoggle.py +62 -0
  389. bootstack/widgets/_impl/primitives/combobox.py +156 -0
  390. bootstack/widgets/_impl/primitives/entry.py +87 -0
  391. bootstack/widgets/_impl/primitives/flexframe.py +448 -0
  392. bootstack/widgets/_impl/primitives/frame.py +185 -0
  393. bootstack/widgets/_impl/primitives/gridframe.py +546 -0
  394. bootstack/widgets/_impl/primitives/label.py +84 -0
  395. bootstack/widgets/_impl/primitives/labelframe.py +54 -0
  396. bootstack/widgets/_impl/primitives/optionmenu.py +387 -0
  397. bootstack/widgets/_impl/primitives/packframe.py +227 -0
  398. bootstack/widgets/_impl/primitives/panedwindow.py +45 -0
  399. bootstack/widgets/_impl/primitives/progressbar.py +83 -0
  400. bootstack/widgets/_impl/primitives/radiobutton.py +115 -0
  401. bootstack/widgets/_impl/primitives/radiotoggle.py +54 -0
  402. bootstack/widgets/_impl/primitives/scrollbar.py +42 -0
  403. bootstack/widgets/_impl/primitives/separator.py +43 -0
  404. bootstack/widgets/_impl/primitives/sizegrip.py +33 -0
  405. bootstack/widgets/_impl/primitives/spinbox.py +95 -0
  406. bootstack/widgets/_impl/primitives/switch.py +44 -0
  407. bootstack/widgets/_impl/primitives/treeview.py +69 -0
  408. bootstack/widgets/app.py +371 -0
  409. bootstack/widgets/appshell.py +1179 -0
  410. bootstack/widgets/avatar.py +140 -0
  411. bootstack/widgets/boolean_controls.py +455 -0
  412. bootstack/widgets/button.py +224 -0
  413. bootstack/widgets/buttongroup.py +239 -0
  414. bootstack/widgets/calendar.py +195 -0
  415. bootstack/widgets/card.py +159 -0
  416. bootstack/widgets/carousel.py +241 -0
  417. bootstack/widgets/chart.py +302 -0
  418. bootstack/widgets/codeeditor.py +675 -0
  419. bootstack/widgets/contextmenu.py +371 -0
  420. bootstack/widgets/datatable.py +688 -0
  421. bootstack/widgets/datefield.py +395 -0
  422. bootstack/widgets/divider.py +60 -0
  423. bootstack/widgets/expander.py +579 -0
  424. bootstack/widgets/form.py +200 -0
  425. bootstack/widgets/gallery.py +250 -0
  426. bootstack/widgets/gauge.py +168 -0
  427. bootstack/widgets/grid.py +121 -0
  428. bootstack/widgets/groupbox.py +162 -0
  429. bootstack/widgets/label.py +229 -0
  430. bootstack/widgets/listview.py +354 -0
  431. bootstack/widgets/menubutton.py +419 -0
  432. bootstack/widgets/numberfield.py +431 -0
  433. bootstack/widgets/pagestack.py +345 -0
  434. bootstack/widgets/passwordfield.py +383 -0
  435. bootstack/widgets/pathfield.py +454 -0
  436. bootstack/widgets/picture.py +251 -0
  437. bootstack/widgets/progressbar.py +106 -0
  438. bootstack/widgets/radio_variants.py +271 -0
  439. bootstack/widgets/radiogroup.py +228 -0
  440. bootstack/widgets/scrollbar.py +64 -0
  441. bootstack/widgets/scrollview.py +186 -0
  442. bootstack/widgets/select.py +302 -0
  443. bootstack/widgets/selectbutton.py +182 -0
  444. bootstack/widgets/sidebar_toggle.py +130 -0
  445. bootstack/widgets/sizegrip.py +42 -0
  446. bootstack/widgets/slider.py +408 -0
  447. bootstack/widgets/spinbox.py +147 -0
  448. bootstack/widgets/spinnerfield.py +413 -0
  449. bootstack/widgets/splash.py +367 -0
  450. bootstack/widgets/splitview.py +482 -0
  451. bootstack/widgets/stacks.py +249 -0
  452. bootstack/widgets/statusbar.py +189 -0
  453. bootstack/widgets/tabs.py +425 -0
  454. bootstack/widgets/textarea.py +459 -0
  455. bootstack/widgets/textfield.py +410 -0
  456. bootstack/widgets/theme_toggle.py +92 -0
  457. bootstack/widgets/timefield.py +340 -0
  458. bootstack/widgets/toast.py +314 -0
  459. bootstack/widgets/togglegroup.py +231 -0
  460. bootstack/widgets/toolbar.py +347 -0
  461. bootstack/widgets/tooltip.py +94 -0
  462. bootstack/widgets/tree.py +648 -0
  463. bootstack/widgets/types.py +305 -0
  464. bootstack/widgets/window.py +307 -0
  465. bootstack-0.1.0.dist-info/METADATA +301 -0
  466. bootstack-0.1.0.dist-info/RECORD +471 -0
  467. bootstack-0.1.0.dist-info/WHEEL +5 -0
  468. bootstack-0.1.0.dist-info/entry_points.txt +2 -0
  469. bootstack-0.1.0.dist-info/licenses/LICENSE +21 -0
  470. bootstack-0.1.0.dist-info/licenses/NOTICE +10 -0
  471. 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