bootstack 0.1.0a1__py3-none-any.whl

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