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,345 @@
1
+ """Headless navigation state for the application shell.
2
+
3
+ `NavModel` is the single source of truth for shell navigation: the ordered set
4
+ of workspaces, which workspace is active, the active page *per* workspace
5
+ (persistent memory), the sidebar's `hidden -> compact -> expanded` axis, and the
6
+ detail-dock visibility. It is deliberately **Tk-free and testable headless** —
7
+ controls dispatch into it via the transition methods, and view regions subscribe
8
+ to it via `subscribe()`.
9
+
10
+ The model encodes two design invariants:
11
+
12
+ - The sidebar is a single linear axis `hidden -> compact -> expanded`, so an
13
+ incoherent state such as "hidden but expanded" is unrepresentable.
14
+ - The rail (workspace switcher) renders only when there is more than one
15
+ workspace, so a single-tier app is the degenerate one-workspace case with no
16
+ branching in the consumer.
17
+
18
+ See `development/appshell-navigation-spec.md` for the full design.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from typing import Callable, Literal
25
+
26
+ from bootstack._core import NavigationError
27
+ from bootstack.streams import Handle
28
+
29
+
30
+ SidebarMode = Literal["hidden", "compact", "expanded"]
31
+ """The sidebar's position on its single linear visibility/compaction axis."""
32
+
33
+ ProviderKind = Literal["pages", "list", "tree", "custom"]
34
+ """Which navigation provider fills a workspace's sidebar panel."""
35
+
36
+ NavFacet = Literal["workspace", "page", "sidebar", "dock", "structure"]
37
+ """Which part of the navigation state a `NavChange` describes."""
38
+
39
+
40
+ @dataclass
41
+ class WorkspaceState:
42
+ """A workspace registered in the navigation model.
43
+
44
+ A workspace owns one sidebar panel (filled by a single provider) and
45
+ remembers its own active page. The rail renders one icon per workspace.
46
+ """
47
+
48
+ key: str
49
+ """Unique identifier for the workspace."""
50
+ text: str = ""
51
+ """Rail label / tooltip text."""
52
+ icon: str | dict | None = None
53
+ """Rail glyph — an icon name or icon-spec dict."""
54
+ is_footer: bool = False
55
+ """Whether the rail icon is pinned to the bottom of the rail."""
56
+ provider: ProviderKind = "pages"
57
+ """The provider that fills this workspace's sidebar panel."""
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class NavChange:
62
+ """A notification emitted to subscribers when the model changes.
63
+
64
+ Only the field(s) relevant to `facet` are populated; subscribers filter on
65
+ `facet` and read the corresponding field (or the model itself).
66
+ """
67
+
68
+ facet: NavFacet
69
+ """Which part of the state changed."""
70
+ workspace: str | None = None
71
+ """The affected workspace key, for `'workspace'`/`'page'`/`'structure'`."""
72
+ page: str | None = None
73
+ """The newly active page key, for `'page'`."""
74
+ sidebar_mode: SidebarMode | None = None
75
+ """The new sidebar mode, for `'sidebar'`."""
76
+ dock_visible: bool | None = None
77
+ """The new dock visibility, for `'dock'`."""
78
+
79
+
80
+ class NavModel:
81
+ """Observable, Tk-free navigation state for the shell.
82
+
83
+ Construct empty, register workspaces with `add_workspace()`, then drive
84
+ selection and visibility through the transition methods. Subscribe with
85
+ `subscribe()` to react to changes.
86
+
87
+ Args:
88
+ sidebar_mode: Initial sidebar mode. Default `'expanded'`.
89
+ dock_visible: Initial detail-dock visibility. Default `False`.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ *,
95
+ sidebar_mode: SidebarMode = "expanded",
96
+ dock_visible: bool = False,
97
+ ) -> None:
98
+ self._workspaces: list[WorkspaceState] = []
99
+ self._index: dict[str, WorkspaceState] = {}
100
+ self._active_workspace: str | None = None
101
+ self._active_page: dict[str, str] = {}
102
+ self._sidebar_mode: SidebarMode = sidebar_mode
103
+ # The mode to restore when un-hiding; never 'hidden'.
104
+ self._restore_mode: SidebarMode = (
105
+ sidebar_mode if sidebar_mode != "hidden" else "expanded"
106
+ )
107
+ self._dock_visible: bool = dock_visible
108
+ self._listeners: list[Callable[[NavChange], None]] = []
109
+
110
+ # ----- Read state -----
111
+
112
+ @property
113
+ def workspaces(self) -> tuple[WorkspaceState, ...]:
114
+ """All workspaces in insertion order (footers included)."""
115
+ return tuple(self._workspaces)
116
+
117
+ @property
118
+ def active_workspace(self) -> str | None:
119
+ """Key of the active workspace, or `None` if none is active."""
120
+ return self._active_workspace
121
+
122
+ @property
123
+ def sidebar_mode(self) -> SidebarMode:
124
+ """The sidebar's current position on the `hidden/compact/expanded` axis."""
125
+ return self._sidebar_mode
126
+
127
+ @property
128
+ def sidebar_visible(self) -> bool:
129
+ """Whether the sidebar is shown (i.e. not `'hidden'`)."""
130
+ return self._sidebar_mode != "hidden"
131
+
132
+ @property
133
+ def dock_visible(self) -> bool:
134
+ """Whether the detail/inspector dock is shown."""
135
+ return self._dock_visible
136
+
137
+ @property
138
+ def rail_visible(self) -> bool:
139
+ """Whether the workspace rail renders — true with more than one workspace.
140
+
141
+ A single-tier app has one workspace, so the rail is hidden; the switcher
142
+ is therefore optional with no branching in the consumer.
143
+ """
144
+ return len(self._workspaces) > 1
145
+
146
+ def workspace(self, key: str) -> WorkspaceState:
147
+ """Return the workspace registered under `key`.
148
+
149
+ Raises:
150
+ NavigationError: If no workspace with that key exists.
151
+ """
152
+ if key not in self._index:
153
+ raise NavigationError(f"No workspace with key {key!r}")
154
+ return self._index[key]
155
+
156
+ def has_workspace(self, key: str) -> bool:
157
+ """Return whether a workspace is registered under `key`."""
158
+ return key in self._index
159
+
160
+ def active_page(self, workspace: str | None = None) -> str | None:
161
+ """Return the remembered active page for a workspace.
162
+
163
+ Args:
164
+ workspace: Workspace key; defaults to the active workspace.
165
+
166
+ Returns:
167
+ The remembered page key, or `None` if the workspace has none yet.
168
+ """
169
+ ws_key = workspace if workspace is not None else self._active_workspace
170
+ if ws_key is None:
171
+ return None
172
+ return self._active_page.get(ws_key)
173
+
174
+ # ----- Transitions -----
175
+
176
+ def add_workspace(
177
+ self,
178
+ key: str,
179
+ *,
180
+ text: str = "",
181
+ icon: str | dict | None = None,
182
+ is_footer: bool = False,
183
+ provider: ProviderKind = "pages",
184
+ ) -> WorkspaceState:
185
+ """Register a workspace and return its state.
186
+
187
+ The first non-footer workspace added becomes active automatically, so a
188
+ single-tier app shows content without an explicit `select_workspace()`.
189
+
190
+ Args:
191
+ key: Unique workspace identifier.
192
+ text: Rail label / tooltip.
193
+ icon: Rail glyph (icon name or icon-spec dict).
194
+ is_footer: Pin the rail icon to the rail bottom.
195
+ provider: Provider kind filling the workspace's sidebar.
196
+
197
+ Returns:
198
+ The created `WorkspaceState`.
199
+
200
+ Raises:
201
+ ValueError: If `key` is empty.
202
+ NavigationError: If `key` is already registered.
203
+ """
204
+ if not key:
205
+ raise ValueError("workspace key must be non-empty")
206
+ if key in self._index:
207
+ raise NavigationError(f"Workspace {key!r} already exists")
208
+
209
+ ws = WorkspaceState(
210
+ key=key, text=text, icon=icon, is_footer=is_footer, provider=provider
211
+ )
212
+ self._workspaces.append(ws)
213
+ self._index[key] = ws
214
+
215
+ if self._active_workspace is None and not is_footer:
216
+ self._active_workspace = key
217
+
218
+ self._notify(NavChange(facet="structure", workspace=key))
219
+ return ws
220
+
221
+ def select_workspace(self, key: str) -> None:
222
+ """Make `key` the active workspace (no effect on sidebar visibility).
223
+
224
+ This is the lower-level primitive; `activate_rail()` layers the VS Code
225
+ rail gesture on top.
226
+
227
+ Raises:
228
+ NavigationError: If no workspace with that key exists.
229
+ """
230
+ if key not in self._index:
231
+ raise NavigationError(f"No workspace with key {key!r}")
232
+ if key == self._active_workspace:
233
+ return
234
+ self._active_workspace = key
235
+ self._notify(NavChange(facet="workspace", workspace=key))
236
+
237
+ def select_page(self, key: str, *, workspace: str | None = None) -> None:
238
+ """Set the active page for a workspace (its remembered page).
239
+
240
+ Args:
241
+ key: Page key.
242
+ workspace: Workspace to set it on; defaults to the active workspace.
243
+
244
+ Raises:
245
+ NavigationError: If the target workspace does not exist.
246
+ ValueError: If there is no active workspace and none is given.
247
+ """
248
+ ws_key = workspace if workspace is not None else self._active_workspace
249
+ if ws_key is None:
250
+ raise ValueError("no active workspace to select a page on")
251
+ if ws_key not in self._index:
252
+ raise NavigationError(f"No workspace with key {ws_key!r}")
253
+ if self._active_page.get(ws_key) == key:
254
+ return
255
+ self._active_page[ws_key] = key
256
+ self._notify(NavChange(facet="page", workspace=ws_key, page=key))
257
+
258
+ def activate_rail(self, key: str) -> None:
259
+ """Handle a rail icon click — the VS Code workspace-switch gesture.
260
+
261
+ Clicking the **active** workspace's icon hides the sidebar (or shows it
262
+ again if already hidden); clicking a **different** workspace's icon
263
+ switches to it and shows the sidebar.
264
+
265
+ Raises:
266
+ NavigationError: If no workspace with that key exists.
267
+ """
268
+ if key not in self._index:
269
+ raise NavigationError(f"No workspace with key {key!r}")
270
+ if key == self._active_workspace:
271
+ self.toggle_sidebar()
272
+ else:
273
+ self.select_workspace(key)
274
+ self.show_sidebar()
275
+
276
+ def set_sidebar_mode(self, mode: SidebarMode) -> None:
277
+ """Set the sidebar mode directly.
278
+
279
+ Setting a non-hidden mode records it as the mode to restore when the
280
+ sidebar is next un-hidden.
281
+
282
+ Args:
283
+ mode: `'hidden'`, `'compact'`, or `'expanded'`.
284
+
285
+ Raises:
286
+ ValueError: If `mode` is not a valid sidebar mode.
287
+ """
288
+ if mode not in ("hidden", "compact", "expanded"):
289
+ raise ValueError(f"invalid sidebar mode: {mode!r}")
290
+ if mode == self._sidebar_mode:
291
+ return
292
+ if mode == "hidden":
293
+ self._restore_mode = self._sidebar_mode # current is non-hidden here
294
+ else:
295
+ self._restore_mode = mode
296
+ self._sidebar_mode = mode
297
+ self._notify(NavChange(facet="sidebar", sidebar_mode=mode))
298
+
299
+ def show_sidebar(self) -> None:
300
+ """Show the sidebar, restoring the last non-hidden mode."""
301
+ if self._sidebar_mode == "hidden":
302
+ self.set_sidebar_mode(self._restore_mode)
303
+
304
+ def hide_sidebar(self) -> None:
305
+ """Hide the sidebar entirely (the visibility axis)."""
306
+ self.set_sidebar_mode("hidden")
307
+
308
+ def toggle_sidebar(self) -> None:
309
+ """Toggle sidebar visibility — the hamburger / shortcut action."""
310
+ if self._sidebar_mode == "hidden":
311
+ self.show_sidebar()
312
+ else:
313
+ self.hide_sidebar()
314
+
315
+ def toggle_dock(self) -> None:
316
+ """Toggle the detail/inspector dock visibility (its own axis)."""
317
+ self._dock_visible = not self._dock_visible
318
+ self._notify(NavChange(facet="dock", dock_visible=self._dock_visible))
319
+
320
+ # ----- Subscription -----
321
+
322
+ def subscribe(self, listener: Callable[[NavChange], None]) -> Handle:
323
+ """Register `listener` for change notifications.
324
+
325
+ Args:
326
+ listener: Called with a `NavChange` on every state transition.
327
+
328
+ Returns:
329
+ A cancellable `Handle` (call `.cancel()`, or use as a context
330
+ manager) that unsubscribes the listener.
331
+ """
332
+ self._listeners.append(listener)
333
+
334
+ def unsubscribe() -> None:
335
+ try:
336
+ self._listeners.remove(listener)
337
+ except ValueError:
338
+ pass
339
+
340
+ return Handle(unsubscribe)
341
+
342
+ def _notify(self, change: NavChange) -> None:
343
+ # Iterate a copy so a listener may unsubscribe during dispatch.
344
+ for listener in list(self._listeners):
345
+ listener(change)
@@ -0,0 +1,210 @@
1
+ """Shared normalization for the selection family's option shape.
2
+
3
+ `Select`, `SelectButton`, `RadioGroup`, and `ToggleGroup` all accept the same
4
+ `Option` shape (a plain string, a `(text, value)` tuple, or an `OptionDict`).
5
+ This module turns any of those into a canonical `OptionRecord` so every widget
6
+ shares one coercion + validation path.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Iterable, NamedTuple
11
+
12
+ from bootstack.widgets.types import Option
13
+
14
+
15
+ class OptionRecord(NamedTuple):
16
+ """A normalized option — what every selection widget consumes internally."""
17
+
18
+ text: str
19
+ """Display label."""
20
+ value: Any
21
+ """Stored and emitted value."""
22
+ extras: dict
23
+ """Reserved per-option extras (e.g. `icon`, `disabled`); empty for now."""
24
+
25
+
26
+ def normalize_option(opt: Option) -> OptionRecord:
27
+ """Coerce a single `Option` to an `OptionRecord`.
28
+
29
+ The dict form is a *data bag*: `text` is required (and `value` defaults to
30
+ it) UNLESS the dict carries an `icon` and a `value`, in which case `text` may
31
+ be omitted to request an icon-only option (`text` becomes `''`). `text`/
32
+ `value` and the recognized `icon`/`disabled` act on rendering; every other
33
+ key rides along as carried data, retrievable via the widget's `selection`.
34
+ Unrecognized keys are NOT rejected — the dict route is opt-in, so the caller
35
+ accepts the risk of a mistyped key.
36
+
37
+ Args:
38
+ opt: A plain string, a `(text, value)` tuple, or an `OptionDict`.
39
+
40
+ Returns:
41
+ The normalized `(text, value, extras)` record.
42
+
43
+ Raises:
44
+ TypeError: If `opt` is not a string, 2-tuple, or dict, or if a dict
45
+ `text` is not a string.
46
+ ValueError: If a tuple does not have exactly two items, or a dict has
47
+ neither a `text` key nor an `icon` + `value` pair.
48
+ """
49
+ if isinstance(opt, str):
50
+ return OptionRecord(opt, opt, {})
51
+
52
+ if isinstance(opt, dict):
53
+ if "text" in opt:
54
+ text = opt["text"]
55
+ if not isinstance(text, str):
56
+ raise TypeError(f"option 'text' must be a string, got {type(text).__name__}: {opt!r}")
57
+ elif "icon" in opt and "value" in opt:
58
+ # Icon-only option: the label is omitted on purpose. The widget
59
+ # renders the glyph alone and infers icon-only from the empty text.
60
+ text = ""
61
+ else:
62
+ raise ValueError(
63
+ "option dict needs a 'text' key (or 'icon' + 'value' for an "
64
+ f"icon-only option): {opt!r}"
65
+ )
66
+ value = opt["value"] if "value" in opt else text
67
+ # The bag: every key except text/value, including the reserved
68
+ # icon/disabled (carried now, interpreted by the widget later).
69
+ extras = {k: opt[k] for k in opt if k not in ("text", "value")}
70
+ return OptionRecord(text, value, extras)
71
+
72
+ if isinstance(opt, tuple):
73
+ if len(opt) != 2:
74
+ raise ValueError(f"option tuple must be (text, value), got {len(opt)} items: {opt!r}")
75
+ text, value = opt
76
+ if not isinstance(text, str):
77
+ raise TypeError(f"option text must be a string, got {type(text).__name__}: {opt!r}")
78
+ return OptionRecord(text, value, {})
79
+
80
+ raise TypeError(
81
+ f"option must be a str, (text, value) tuple, or dict, got {type(opt).__name__}: {opt!r}"
82
+ )
83
+
84
+
85
+ def normalize_options(opts: Iterable[Option] | None) -> list[OptionRecord]:
86
+ """Coerce an iterable of `Option` values to a list of `OptionRecord`.
87
+
88
+ Args:
89
+ opts: Options to normalize, or `None` for an empty list.
90
+ """
91
+ if not opts:
92
+ return []
93
+ return [normalize_option(opt) for opt in opts]
94
+
95
+
96
+ def record_to_dict(record: OptionRecord) -> dict:
97
+ """Render an `OptionRecord` as a public `{'text', 'value', ...extras}` dict."""
98
+ return {"text": record.text, "value": record.value, **record.extras}
99
+
100
+
101
+ def record_field(record: OptionRecord, field: str) -> Any:
102
+ """Read one field from a record by name, spanning `text`/`value`/extras.
103
+
104
+ The record is treated as the flat `{'text', 'value', ...extras}` bag, so a
105
+ grouping or display selector can name any carried key (including `text` or
106
+ `value`).
107
+
108
+ Args:
109
+ record: The normalized option record.
110
+ field: The key to read.
111
+
112
+ Returns:
113
+ The field's value, or `None` when the record does not carry it.
114
+ """
115
+ if field == "text":
116
+ return record.text
117
+ if field == "value":
118
+ return record.value
119
+ return record.extras.get(field)
120
+
121
+
122
+ def cluster_records(
123
+ records: list[OptionRecord], group_by: str | None
124
+ ) -> list[tuple[str | None, list[OptionRecord]]]:
125
+ """Cluster records into groups by a named bag field, for presentation.
126
+
127
+ Buckets appear in **first-appearance** order — the order each distinct group
128
+ value is first seen while walking `records`. Within a bucket the source
129
+ order is preserved. A record whose `group_by` field is absent, `None`, or
130
+ blank falls into the ungrouped bucket (keyed `None`), which renders without a
131
+ header; that bucket too keeps its first-appearance position.
132
+
133
+ Grouping is purely presentational: every record is returned exactly once and
134
+ unchanged, so `value`/`selection`/`options` are unaffected.
135
+
136
+ Args:
137
+ records: The normalized option records, in source order.
138
+ group_by: The field name to cluster by, or `None` to skip grouping (the
139
+ whole list is returned as a single ungrouped bucket).
140
+
141
+ Returns:
142
+ An ordered list of `(group_label, records)` pairs. `group_label` is the
143
+ group's value coerced to a string, or `None` for the ungrouped bucket.
144
+ """
145
+ if not group_by:
146
+ return [(None, list(records))]
147
+ order: list[str | None] = []
148
+ buckets: dict[str | None, list[OptionRecord]] = {}
149
+ for rec in records:
150
+ raw = record_field(rec, group_by)
151
+ label = None if raw is None or str(raw) == "" else str(raw)
152
+ if label not in buckets:
153
+ buckets[label] = []
154
+ order.append(label)
155
+ buckets[label].append(rec)
156
+ return [(label, buckets[label]) for label in order]
157
+
158
+
159
+ def option_display(record: OptionRecord) -> tuple[Any, bool]:
160
+ """Return the `(icon, disabled)` rendering hints from a record's extras.
161
+
162
+ These are the two recognized extra keys that the selection widgets act on:
163
+ `icon` (an icon spec rendered beside the label, or `None`) and `disabled`
164
+ (when truthy the option is shown dimmed and is not user-selectable). Every
165
+ other extra is inert carried data. Each widget maps the pair onto its own
166
+ primitive (`icon=`/`state='disabled'` for the button-backed widgets).
167
+
168
+ Args:
169
+ record: The normalized option record.
170
+
171
+ Returns:
172
+ A `(icon, disabled)` tuple — `icon` is the spec or `None`; `disabled`
173
+ is a plain `bool`.
174
+ """
175
+ return record.extras.get("icon"), bool(record.extras.get("disabled"))
176
+
177
+
178
+ def option_localize(record: OptionRecord) -> Any:
179
+ """Return a record's per-option `localize` override, or `None` if absent.
180
+
181
+ The `localize` key rides in the option's extras (the data bag), like `icon`
182
+ and `disabled`. When present it overrides the widget's group-level
183
+ `localize=` for just this option; `None` means defer to the group setting.
184
+
185
+ Args:
186
+ record: The normalized option record.
187
+
188
+ Returns:
189
+ The `localize` override (`True`/`False`/`'auto'`), or `None` when the
190
+ option does not carry one.
191
+ """
192
+ return record.extras.get("localize")
193
+
194
+
195
+ def option_is_icon_only(record: OptionRecord) -> bool:
196
+ """Whether an option should render as icon-only (a glyph with no label).
197
+
198
+ Inferred — there is no explicit flag: an option is icon-only when it carries
199
+ an `icon` and its `text` is blank. This lets a compact toggle/radio group be
200
+ expressed as `[{'icon': 'list', 'value': 'list'}, ...]` without the caller
201
+ setting `icon_only` per button.
202
+
203
+ Args:
204
+ record: The normalized option record.
205
+
206
+ Returns:
207
+ `True` when the record has an icon and no (non-whitespace) label.
208
+ """
209
+ icon = record.extras.get("icon")
210
+ return icon is not None and not (record.text or "").strip()
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from bootstack.widgets._core.options import OptionRecord, record_to_dict, option_localize
6
+
7
+
8
+ class SelectionGroupMixin:
9
+ """Shared item-management surface for the option-group widgets.
10
+
11
+ Mixed into `RadioGroup` and `ToggleGroup`, which both wrap an internal
12
+ composite exposing `keys()`, `configure_item()`, and `remove()`. Each class
13
+ keeps its own `add()` (the option semantics differ) and its own value /
14
+ change surface; only the value-keyed item operations live here.
15
+
16
+ The internal composites store only each option's text and value, so the
17
+ mixin keeps the normalized records here (the data bag) — keyed by the
18
+ stringified value, matching the selection variable's coercion — so
19
+ `selection` can hand back each option's full `{text, value, ...extras}`.
20
+ """
21
+
22
+ _internal: Any
23
+
24
+ def _set_option_records(self, records: list[OptionRecord]) -> None:
25
+ """Seed the option records from a normalized construction list."""
26
+ self._option_records: dict[str, OptionRecord] = {
27
+ str(rec.value): rec for rec in records
28
+ }
29
+
30
+ def _add_option_record(self, text: str, value: Any, extras: dict | None = None) -> None:
31
+ """Register a record for an option added at runtime."""
32
+ if not hasattr(self, "_option_records"):
33
+ self._option_records = {}
34
+ self._option_records[str(value)] = OptionRecord(text, value, extras or {})
35
+
36
+ def _record_dict_for(self, value: Any) -> dict | None:
37
+ """Return the `{text, value, ...extras}` dict for a value, or None."""
38
+ if value is None or value == "":
39
+ return None
40
+ rec = getattr(self, "_option_records", {}).get(str(value))
41
+ return record_to_dict(rec) if rec is not None else None
42
+
43
+ @staticmethod
44
+ def _option_render(icon: Any, disabled: bool, text: str | None = None) -> tuple[dict, dict]:
45
+ """Map the `icon`/`disabled` hints to (internal-add kwargs, extras).
46
+
47
+ The internal group forwards `icon`/`state` to each button primitive; the
48
+ extras dict is what `selection` hands back. An icon with a blank `text`
49
+ is inferred as icon-only (the button shows the glyph alone). Returns a
50
+ pair so a single call seeds both the rendered button and the carried
51
+ record.
52
+ """
53
+ add_kwargs: dict[str, Any] = {}
54
+ extras: dict[str, Any] = {}
55
+ if icon is not None:
56
+ add_kwargs["icon"] = icon
57
+ extras["icon"] = icon
58
+ if not (text or "").strip():
59
+ add_kwargs["icon_only"] = True
60
+ if disabled:
61
+ add_kwargs["state"] = "disabled"
62
+ extras["disabled"] = True
63
+ return add_kwargs, extras
64
+
65
+ def _resolve_localize(self, record: OptionRecord | None = None, override: Any = None) -> Any:
66
+ """Resolve the effective `localize` for an option.
67
+
68
+ Precedence: an explicit per-item `override` (from `add(localize=)`), then
69
+ the option's own `localize` key (carried in its data bag), then the
70
+ widget's group-level `localize=`. `None` at every level means defer to
71
+ the app's `localize_mode`, so it is not forwarded to the item.
72
+
73
+ Args:
74
+ record: The option's normalized record, when adding from options.
75
+ override: A per-call `localize` from `add()`.
76
+
77
+ Returns:
78
+ The resolved `localize` mode, or `None` to inherit the app default.
79
+ """
80
+ if override is not None:
81
+ return override
82
+ if record is not None:
83
+ item = option_localize(record)
84
+ if item is not None:
85
+ return item
86
+ return getattr(self, "_localize", None)
87
+
88
+ def remove(self, value: Any) -> None:
89
+ """Remove the option identified by `value`.
90
+
91
+ Args:
92
+ value: The value the option was added with.
93
+
94
+ Raises:
95
+ KeyError: If no option with that value exists.
96
+ """
97
+ self._internal.remove(value)
98
+ getattr(self, "_option_records", {}).pop(str(value), None)
99
+
100
+ def configure_item(
101
+ self,
102
+ value: Any,
103
+ *,
104
+ label: str | None = None,
105
+ disabled: bool | None = None,
106
+ ) -> None:
107
+ """Update a single option in place, without rebuilding the group.
108
+
109
+ Args:
110
+ value: The option to update (the value it was added with).
111
+ label: New display text, when given.
112
+ disabled: When given, disable (`True`) or re-enable (`False`) just
113
+ this option. A later group-level `disabled` change resets every
114
+ option's state, so apply per-option states after it.
115
+
116
+ Raises:
117
+ KeyError: If no option with that value exists.
118
+ """
119
+ if label is not None:
120
+ self._internal.configure_item(value, text=label)
121
+ if disabled is not None:
122
+ self._internal.configure_item(value, state="disabled" if disabled else "normal")
123
+
124
+ def __len__(self) -> int:
125
+ """The number of options in the group."""
126
+ return len(self._internal.keys())
127
+
128
+ def __contains__(self, value: Any) -> bool:
129
+ """Whether an option with the given `value` is in the group."""
130
+ return value in self._internal.keys()