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,841 @@
1
+ """Internal TreeView — a virtualized recycle view over a tree of TreeNodes.
2
+
3
+ This is the engine behind the public `Tree`. It mirrors the internal ListView
4
+ recycle view (row-widget pool, window recycling, custom scrollbar, keyboard
5
+ nav) but its data provider is a *flattened visible-node list* recomputed from
6
+ the node hierarchy whenever a branch is expanded, collapsed, or mutated.
7
+
8
+ Note: this `TreeView` is unrelated to the ttk `TreeView` primitive in
9
+ `_impl/primitives/treeview.py` (still used by DataTable). They live in separate
10
+ modules and are never imported together.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from tkinter import TclError
16
+ from typing import Any, Callable, Iterator, Literal, Optional
17
+
18
+ from bootstack.widgets._impl.composites.tree.treeitem import TreeItem, EMPTY
19
+ from bootstack.widgets._impl.composites.tree.treenode import TreeNode
20
+ from bootstack.widgets._impl.primitives.frame import Frame, FrameKwargs
21
+ from bootstack.widgets._impl.primitives.scrollbar import Scrollbar
22
+ from bootstack.widgets.types import Master, ScrollbarVariant
23
+ from typing_extensions import Unpack
24
+
25
+ VISIBLE_ROWS = 20
26
+ ROW_HEIGHT = 32
27
+ OVERSCAN_ROWS = 2
28
+
29
+
30
+ class TreeView(Frame):
31
+ """A virtual-scrolling hierarchical view backed by `TreeNode` handles."""
32
+
33
+ def __init__(
34
+ self,
35
+ master: Master = None,
36
+ *,
37
+ selection_mode: Literal["none", "single", "multi"] = "single",
38
+ show_selection_controls: bool = False,
39
+ select_on_click: bool = True,
40
+ indent: int = 16,
41
+ striped: bool = False,
42
+ striped_background: str = "background[+0.85]",
43
+ show_separator: bool = False,
44
+ scrollbar_visibility: Literal["always", "never"] = "always",
45
+ enable_hover: bool = True,
46
+ density: Literal["default", "compact"] = "default",
47
+ accent_selection: bool = False,
48
+ scrollbar_variant: ScrollbarVariant = "thin",
49
+ **kwargs: Unpack[FrameKwargs],
50
+ ) -> None:
51
+ _user_accent = kwargs.get("accent")
52
+ super().__init__(master, variant="container", ttk_class="ListView.TFrame", **kwargs)
53
+ self._accent = _user_accent or "primary"
54
+ self._accent_selection = accent_selection
55
+
56
+ self.winsys = self.tk.call("tk", "windowingsystem")
57
+
58
+ self._selection_mode = selection_mode
59
+ self._show_selection_controls = show_selection_controls
60
+ self._select_on_click = select_on_click
61
+ self._indent = indent
62
+ self._striped = striped
63
+ self._striped_background = striped_background
64
+ self._show_separator = show_separator
65
+ self._scrollbar_visibility = scrollbar_visibility
66
+ self._scrollbar_variant = scrollbar_variant
67
+ self._enable_hover = enable_hover
68
+ self._density = density
69
+
70
+ # Hierarchy + derived flat view.
71
+ self._roots: list[TreeNode] = []
72
+ self._visible: list[dict] = []
73
+
74
+ # Selection / focus state (identity based; survives collapse).
75
+ self._selected: set[TreeNode] = set()
76
+ # Partially-selected parents (tri-state cascade populates this in the
77
+ # multi-select checkpoint); shown as a 'mixed' marker on the control.
78
+ self._mixed: set[TreeNode] = set()
79
+ self._focused_node: Optional[TreeNode] = None
80
+
81
+ # Type-ahead.
82
+ self._typeahead = ""
83
+ self._typeahead_after: Optional[str] = None
84
+
85
+ # Pending relayout `after` id (coalesced; cancelled on destroy so a
86
+ # queued callback never fires on a torn-down widget).
87
+ self._relayout_after: Optional[str] = None
88
+
89
+ # Virtual scrolling state.
90
+ self._start_index = 0
91
+ self._prev_start_index = 0
92
+ self._visible_rows = VISIBLE_ROWS
93
+ self._row_height = ROW_HEIGHT
94
+ self._page_size = VISIBLE_ROWS + OVERSCAN_ROWS
95
+ self._rows: list[TreeItem] = []
96
+ self._mousewheel_bound: set = set()
97
+
98
+ from bootstack.style.style import get_style
99
+ self._scrollbar_gutter = get_style().style_builder.scale(6)
100
+ sb_surface = getattr(self, '_surface', None)
101
+ sb_kw = {'surface': sb_surface} if sb_surface else {}
102
+ self._scrollbar = Scrollbar(
103
+ self, orient="vertical", command=self._on_scroll,
104
+ variant=self._scrollbar_variant, **sb_kw)
105
+ if self._scrollbar_visibility == "always":
106
+ self._scrollbar.pack(side="right", fill="y", padx=(self._scrollbar_gutter, 0))
107
+
108
+ self._container = Frame(self, variant="container", ttk_class="ListView.TFrame")
109
+ self._container.pack(side="left", fill="both", expand=True)
110
+
111
+ self._ensure_row_pool(self._page_size)
112
+
113
+ self.bind("<Configure>", self._on_resize, add="+")
114
+ self._bind_scroll_events(self)
115
+ self._bind_scroll_events(self._container)
116
+
117
+ # Row -> container events.
118
+ self._container.bind("<<TreeItemSelect>>", self._on_item_select, add="+")
119
+ self._container.bind("<<TreeItemActivate>>", self._on_item_activate, add="+")
120
+ self._container.bind("<<TreeItemToggle>>", self._on_item_toggle, add="+")
121
+ self._container.bind("<<TreeItemRightClick>>", self._on_item_right_click, add="+")
122
+ self._container.bind("<<TreeItemFocus>>", self._on_item_focus, add="+")
123
+
124
+ self._bind_keys(self)
125
+ self._bind_keys(self._container)
126
+
127
+ self._relayout_after = self.after(10, self._remeasure_and_relayout)
128
+
129
+ # ================= hierarchy / flatten =================
130
+
131
+ def _base_record(self, node: TreeNode, depth: int) -> dict:
132
+ expanded = node.expanded
133
+ if expanded and node.open_icon:
134
+ icon = node.open_icon
135
+ elif not expanded and node.closed_icon:
136
+ icon = node.closed_icon
137
+ else:
138
+ icon = node.icon
139
+ return dict(
140
+ node=node, depth=depth, label=node.label, icon=icon,
141
+ expandable=node.expandable, expanded=expanded,
142
+ )
143
+
144
+ def _flatten(self) -> None:
145
+ out: list[dict] = []
146
+
147
+ def walk(nodes: list[TreeNode], depth: int) -> None:
148
+ for n in nodes:
149
+ out.append(self._base_record(n, depth))
150
+ if n.expanded and n.children:
151
+ walk(n.children, depth + 1)
152
+
153
+ walk(self._roots, 0)
154
+ self._visible = out
155
+
156
+ def _iter_all_nodes(self) -> Iterator[TreeNode]:
157
+ """Depth-first over every node (including collapsed), in tree order."""
158
+ def walk(nodes: list[TreeNode]) -> Iterator[TreeNode]:
159
+ for n in nodes:
160
+ yield n
161
+ yield from walk(n.children)
162
+ yield from walk(self._roots)
163
+
164
+ def _index_of(self, node: TreeNode) -> int:
165
+ for i, rec in enumerate(self._visible):
166
+ if rec["node"] is node:
167
+ return i
168
+ return -1
169
+
170
+ @property
171
+ def count(self) -> int:
172
+ return len(self._visible)
173
+
174
+ def _on_structure_changed(self) -> None:
175
+ """Re-flatten and relayout after expand/collapse/mutation."""
176
+ self._flatten()
177
+ # Drop selection/focus for nodes no longer in the tree.
178
+ live = set(self._iter_all_nodes())
179
+ self._selected &= live
180
+ self._mixed &= live
181
+ if self._focused_node is not None and self._focused_node not in live:
182
+ self._focused_node = None
183
+ self._remeasure_and_relayout()
184
+
185
+ # ================= row pool / recycling =================
186
+
187
+ def _ensure_row_pool(self, needed: int) -> None:
188
+ while len(self._rows) < needed:
189
+ row = TreeItem(
190
+ self._container, indent=self._indent, focusable=True,
191
+ hoverable=self._enable_hover, selection_mode=self._selection_mode,
192
+ show_selection_controls=self._show_selection_controls,
193
+ select_on_click=self._select_on_click,
194
+ show_separator=self._show_separator, density=self._density,
195
+ accent=self._accent, accent_selection=self._accent_selection,
196
+ )
197
+ row.pack(fill="x")
198
+ self._rows.append(row)
199
+ self._bind_keys(row)
200
+ self._apply_widget_surface(row, len(self._rows) - 1)
201
+ while len(self._rows) > needed:
202
+ row = self._rows.pop()
203
+ # Drop this row's (and its descendants') mousewheel-bound ids so the
204
+ # set doesn't grow without bound and a reused Tk path can't be
205
+ # mistaken for already-bound.
206
+ prefix = str(row)
207
+ self._mousewheel_bound = {
208
+ w for w in self._mousewheel_bound if not w.startswith(prefix)
209
+ }
210
+ row.pack_forget()
211
+ try:
212
+ row.destroy()
213
+ except TclError:
214
+ pass
215
+
216
+ def _clamp_indices(self) -> None:
217
+ max_start = max(0, self.count - self._visible_rows)
218
+ self._start_index = max(0, min(self._start_index, max_start))
219
+
220
+ def _update_rows(self) -> None:
221
+ self._clamp_indices()
222
+ scroll_distance = self._start_index - self._prev_start_index
223
+ if abs(scroll_distance) <= 3 and scroll_distance != 0:
224
+ self._recycle_rows(scroll_distance)
225
+ else:
226
+ self._full_update_rows()
227
+ self._prev_start_index = self._start_index
228
+ total = max(1, self.count)
229
+ first = self._start_index / total
230
+ last = min(1.0, (self._start_index + self._visible_rows) / total)
231
+ self._scrollbar.set(first, last)
232
+
233
+ def _recycle_rows(self, scroll_distance: int) -> None:
234
+ if scroll_distance > 0:
235
+ for _ in range(scroll_distance):
236
+ if not self._rows:
237
+ break
238
+ top = self._rows.pop(0)
239
+ self._update_single_row(top, self._start_index + len(self._rows))
240
+ top.pack_forget()
241
+ top.pack(side="top", fill="x")
242
+ self._rows.append(top)
243
+ elif scroll_distance < 0:
244
+ for _ in range(abs(scroll_distance)):
245
+ if not self._rows:
246
+ break
247
+ bottom = self._rows.pop()
248
+ self._update_single_row(bottom, self._start_index)
249
+ bottom.pack_forget()
250
+ if self._rows:
251
+ bottom.pack(side="top", fill="x", before=self._rows[0])
252
+ else:
253
+ bottom.pack(side="top", fill="x")
254
+ self._rows.insert(0, bottom)
255
+
256
+ def _full_update_rows(self) -> None:
257
+ for i, row in enumerate(self._rows):
258
+ self._update_single_row(row, self._start_index + i)
259
+
260
+ def _render_record(self, data_index: int) -> dict:
261
+ if data_index < 0 or data_index >= self.count:
262
+ return EMPTY
263
+ base = self._visible[data_index]
264
+ node = base["node"]
265
+ rec = dict(base)
266
+ rec["selected"] = node in self._selected
267
+ rec["focused"] = node is self._focused_node
268
+ rec["mixed"] = node in self._mixed
269
+ rec["item_index"] = data_index
270
+ return rec
271
+
272
+ def _update_single_row(self, row: TreeItem, data_index: int) -> None:
273
+ row.update_data(self._render_record(data_index))
274
+ # Re-apply the stripe surface by DATA index (idempotent): a recycled row
275
+ # showing a different index must take that index's color, else striping
276
+ # parity drifts under scrolling.
277
+ self._apply_widget_surface(row, data_index)
278
+ self._bind_mousewheel_recursive(row)
279
+
280
+ def _apply_widget_surface(self, row: TreeItem, widget_index: int) -> None:
281
+ base_surface = getattr(self, "_surface", "background")
282
+ if not self._striped:
283
+ surface = base_surface
284
+ else:
285
+ surface = self._striped_background if widget_index % 2 == 1 else base_surface
286
+ if hasattr(row, "set_surface"):
287
+ row.set_surface(surface)
288
+
289
+ # ================= scrolling =================
290
+
291
+ def _on_scroll(self, *args) -> None:
292
+ if args[0] == "moveto":
293
+ fraction = float(args[1])
294
+ max_start = max(0, self.count - self._visible_rows)
295
+ self._start_index = int(round(fraction * max_start))
296
+ elif args[0] == "scroll":
297
+ amount = int(args[1])
298
+ step = max(1, self._visible_rows // 2) if args[2] == "pages" else 1
299
+ self._start_index += amount * step
300
+ self._clamp_indices()
301
+ self._update_rows()
302
+
303
+ def _bind_scroll_events(self, widget) -> None:
304
+ if self.winsys.lower() == "x11":
305
+ widget.bind("<Button-4>", self._on_mousewheel, add="+")
306
+ widget.bind("<Button-5>", self._on_mousewheel, add="+")
307
+ else:
308
+ widget.bind("<MouseWheel>", self._on_mousewheel, add="+")
309
+
310
+ def _bind_mousewheel_recursive(self, widget) -> None:
311
+ wid = str(widget)
312
+ if wid not in self._mousewheel_bound:
313
+ self._bind_scroll_events(widget)
314
+ self._mousewheel_bound.add(wid)
315
+ try:
316
+ for child in widget.winfo_children():
317
+ self._bind_mousewheel_recursive(child)
318
+ except Exception:
319
+ pass
320
+
321
+ def _on_mousewheel(self, event) -> None:
322
+ under = self.winfo_containing(event.x_root, event.y_root)
323
+ if under is None:
324
+ return
325
+ cur, is_child = under, False
326
+ while cur is not None:
327
+ if cur == self:
328
+ is_child = True
329
+ break
330
+ try:
331
+ cur = cur.master
332
+ except AttributeError:
333
+ break
334
+ if not is_child:
335
+ return
336
+ if self.winsys.lower() == "x11":
337
+ delta = -1 if getattr(event, "num", 0) == 4 else 1
338
+ else:
339
+ delta = -1 if event.delta > 0 else 1
340
+ self._start_index += delta
341
+ self._clamp_indices()
342
+ self._update_rows()
343
+
344
+ def _on_resize(self, event) -> None:
345
+ if event.widget == self:
346
+ if self._relayout_after is not None:
347
+ try:
348
+ self.after_cancel(self._relayout_after)
349
+ except Exception:
350
+ pass
351
+ self._relayout_after = self.after_idle(self._remeasure_and_relayout)
352
+
353
+ def _remeasure_and_relayout(self) -> None:
354
+ self._relayout_after = None
355
+ if not self._rows or not self.winfo_exists():
356
+ return
357
+ rh = self._rows[0].winfo_height()
358
+ if rh <= 1:
359
+ rh = self._rows[0].winfo_reqheight()
360
+ if rh and rh != self._row_height:
361
+ self._row_height = rh
362
+ container_height = self._container.winfo_height()
363
+ if container_height > 0:
364
+ visible = max(1, container_height // max(1, self._row_height))
365
+ page_size = visible + OVERSCAN_ROWS
366
+ if visible != self._visible_rows or page_size != self._page_size:
367
+ self._visible_rows = visible
368
+ self._page_size = page_size
369
+ self._ensure_row_pool(self._page_size)
370
+ self._clamp_indices()
371
+ self._prev_start_index = -999 # force a full update
372
+ self._update_rows()
373
+
374
+ # ================= row event handlers =================
375
+
376
+ def _on_item_select(self, event) -> None:
377
+ node = event.data
378
+ if not isinstance(node, TreeNode):
379
+ return
380
+ if self._selection_mode == "none":
381
+ self._focused_node = node
382
+ self._update_rows()
383
+ else:
384
+ # single replaces, multi toggles (so a row click with visible
385
+ # checkboxes reads as a checklist) — handled in select().
386
+ self.select(node)
387
+
388
+ def _on_item_activate(self, event) -> None:
389
+ node = event.data
390
+ if isinstance(node, TreeNode):
391
+ self._focused_node = node
392
+ # Double-clicking an expandable row toggles it (Explorer convention),
393
+ # in addition to firing the activate event.
394
+ if node.expandable:
395
+ self.toggle(node)
396
+ self._emit("<<TreeActivate>>", node)
397
+
398
+ def _on_item_toggle(self, event) -> None:
399
+ node = event.data
400
+ if isinstance(node, TreeNode):
401
+ self.toggle(node)
402
+
403
+ def _on_item_right_click(self, event) -> None:
404
+ data = event.data
405
+ if isinstance(data, dict) and isinstance(data.get("node"), TreeNode):
406
+ self._focused_node = data["node"]
407
+ self._update_rows()
408
+ self._emit("<<TreeRightClick>>", data)
409
+
410
+ def _on_item_focus(self, event) -> None:
411
+ node = event.data
412
+ if isinstance(node, TreeNode):
413
+ self._focused_node = node
414
+
415
+ def _emit(self, sequence: str, data: Any) -> None:
416
+ try:
417
+ self.event_generate(sequence, data=data)
418
+ except TclError:
419
+ pass
420
+
421
+ # ================= public-ish API (called by the wrapper) =================
422
+
423
+ def add(self, label: str = "", *, parent: Optional[TreeNode] = None,
424
+ children: Optional[list] = None, index: Optional[int] = None,
425
+ **kwargs: Any) -> TreeNode:
426
+ node = TreeNode(label, **kwargs)
427
+ node._tree = self
428
+ node.parent = parent
429
+ siblings = parent.children if parent is not None else self._roots
430
+ if index is None:
431
+ siblings.append(node)
432
+ else:
433
+ siblings.insert(index, node)
434
+ if children:
435
+ for spec in children:
436
+ self._add_from_spec(spec, parent=node)
437
+ self._on_structure_changed()
438
+ return node
439
+
440
+ def _attach_subtree(self, node: TreeNode) -> None:
441
+ """Point `node` and every descendant at this tree (so node convenience
442
+ methods work for a pre-built subtree passed as a spec)."""
443
+ node._tree = self
444
+ for d in node.descendants():
445
+ d._tree = self
446
+
447
+ def _add_from_spec(self, spec: Any, parent: TreeNode) -> TreeNode:
448
+ """Add a node from a dict spec (declarative `nodes=`) or a TreeNode."""
449
+ if isinstance(spec, TreeNode):
450
+ spec.parent = parent
451
+ parent.children.append(spec)
452
+ self._attach_subtree(spec)
453
+ return spec
454
+ if isinstance(spec, str):
455
+ spec = {"label": spec}
456
+ spec = dict(spec)
457
+ children = spec.pop("children", None)
458
+ node = TreeNode(**spec)
459
+ node._tree = self
460
+ node.parent = parent
461
+ parent.children.append(node)
462
+ if children:
463
+ for child_spec in children:
464
+ self._add_from_spec(child_spec, parent=node)
465
+ return node
466
+
467
+ def load(self, specs: list) -> None:
468
+ """Replace the whole tree from a declarative list of specs."""
469
+ self._roots = []
470
+ self._selected.clear()
471
+ self._mixed.clear()
472
+ self._focused_node = None
473
+ for spec in specs or []:
474
+ self._add_root_from_spec(spec)
475
+ self._on_structure_changed()
476
+
477
+ def _add_root_from_spec(self, spec: Any) -> TreeNode:
478
+ if isinstance(spec, TreeNode):
479
+ spec.parent = None
480
+ self._roots.append(spec)
481
+ self._attach_subtree(spec)
482
+ return spec
483
+ if isinstance(spec, str):
484
+ spec = {"label": spec}
485
+ spec = dict(spec)
486
+ children = spec.pop("children", None)
487
+ node = TreeNode(**spec)
488
+ node._tree = self
489
+ node.parent = None
490
+ self._roots.append(node)
491
+ if children:
492
+ for child_spec in children:
493
+ self._add_from_spec(child_spec, parent=node)
494
+ return node
495
+
496
+ def remove(self, node: TreeNode) -> None:
497
+ siblings = node.parent.children if node.parent is not None else self._roots
498
+ try:
499
+ siblings.remove(node)
500
+ except ValueError:
501
+ return
502
+ self._on_structure_changed()
503
+
504
+ def move(self, node: TreeNode, new_parent: Optional[TreeNode], index: Any = "end") -> None:
505
+ # Guard against cycles: a node cannot be moved into itself or one of its
506
+ # own descendants (that would make _flatten recurse forever).
507
+ if new_parent is node or (new_parent is not None and node in new_parent.ancestors()):
508
+ raise ValueError("cannot move a node into itself or one of its descendants")
509
+ siblings = node.parent.children if node.parent is not None else self._roots
510
+ try:
511
+ siblings.remove(node)
512
+ except ValueError:
513
+ return
514
+ node.parent = new_parent
515
+ target = new_parent.children if new_parent is not None else self._roots
516
+ if index == "end" or index is None or index >= len(target):
517
+ target.append(node)
518
+ else:
519
+ target.insert(int(index), node)
520
+ self._on_structure_changed()
521
+
522
+ def clear(self) -> None:
523
+ self._roots = []
524
+ self._selected.clear()
525
+ self._mixed.clear()
526
+ self._focused_node = None
527
+ self._on_structure_changed()
528
+
529
+ # ---- expansion ----
530
+
531
+ def expand(self, node: TreeNode) -> None:
532
+ if not node.expandable:
533
+ return
534
+ if node.loader is not None and not node._loaded:
535
+ self._load_children(node)
536
+ if not node.expanded:
537
+ node.expanded = True
538
+ self._on_structure_changed()
539
+ self._emit("<<TreeExpand>>", node)
540
+
541
+ def collapse(self, node: TreeNode) -> None:
542
+ if node.expanded:
543
+ node.expanded = False
544
+ self._on_structure_changed()
545
+ self._emit("<<TreeCollapse>>", node)
546
+
547
+ def toggle(self, node: TreeNode) -> None:
548
+ if node.expanded:
549
+ self.collapse(node)
550
+ else:
551
+ self.expand(node)
552
+
553
+ def _load_children(self, node: TreeNode) -> None:
554
+ # Mark loaded up front so a re-entrant expand during the loader call
555
+ # can't trigger a second load. Loaders should handle their own errors;
556
+ # a raising loader yields an empty (leaf) node.
557
+ node._loaded = True
558
+ try:
559
+ result = node.loader(node) if node.loader else None
560
+ except Exception:
561
+ result = None
562
+ for spec in (result or []):
563
+ self._add_from_spec(spec, parent=node)
564
+ # Deferred cascade: if this parent was selected while its lazy children
565
+ # were still unloaded, the freshly loaded children inherit that state.
566
+ if node in self._selected:
567
+ for child in node.children:
568
+ self._set_subtree_selected(child, True)
569
+
570
+ def reload_children(self, node: TreeNode) -> None:
571
+ """Refresh a lazy node: drop its loaded children and fetch them again.
572
+
573
+ Only meaningful for nodes created with a `loader`. If the node is
574
+ expanded the children reload immediately; otherwise they reload on the
575
+ next expand. A no-op for non-lazy nodes.
576
+ """
577
+ if node.loader is None:
578
+ return
579
+ node.children = []
580
+ node._loaded = False
581
+ if node.expanded:
582
+ self._load_children(node)
583
+ self._on_structure_changed()
584
+
585
+ def expand_all(self) -> None:
586
+ # Repeat passes until nothing new appears: lazily-loaded children only
587
+ # exist after their parent's loader runs, so a single pass would miss
588
+ # deeper lazy levels. Each lazy node loads at most once (`_loaded`), so
589
+ # this terminates for any finite tree.
590
+ while True:
591
+ changed = False
592
+ for node in list(self._iter_all_nodes()):
593
+ if node.loader is not None and not node._loaded:
594
+ self._load_children(node)
595
+ changed = True
596
+ if node.expandable and not node.expanded:
597
+ node.expanded = True
598
+ changed = True
599
+ if not changed:
600
+ break
601
+ self._on_structure_changed()
602
+
603
+ def collapse_all(self) -> None:
604
+ for node in self._iter_all_nodes():
605
+ node.expanded = False
606
+ self._on_structure_changed()
607
+
608
+ def reveal(self, node: TreeNode) -> None:
609
+ for anc in list(node.ancestors()):
610
+ if anc.loader is not None and not anc._loaded:
611
+ self._load_children(anc)
612
+ anc.expanded = True
613
+ self._flatten()
614
+ self._focused_node = node
615
+ idx = self._index_of(node)
616
+ if idx >= 0:
617
+ self._scroll_into_view(idx)
618
+ self._update_rows()
619
+
620
+ # ---- selection ----
621
+
622
+ def _emit_selection_changed(self) -> None:
623
+ from bootstack.events import TreeSelectionEvent
624
+ self._emit("<<TreeSelectionChange>>", TreeSelectionEvent(nodes=self.get_selected()))
625
+
626
+ def _set_subtree_selected(self, node: TreeNode, selected: bool) -> None:
627
+ """Select/deselect a node and cascade to its LOADED descendants.
628
+
629
+ A node with unloaded lazy children is set on its own (deferred cascade);
630
+ its children inherit the state when they load (see `_load_children`).
631
+ """
632
+ if node.loader is not None and not node._loaded:
633
+ targets = [node] # defer cascade until children load
634
+ else:
635
+ targets = [node, *node.descendants()]
636
+ for n in targets:
637
+ if selected:
638
+ self._selected.add(n)
639
+ self._mixed.discard(n)
640
+ else:
641
+ self._selected.discard(n)
642
+ self._mixed.discard(n)
643
+
644
+ def _recompute_ancestors(self, node: TreeNode) -> None:
645
+ """Recompute each ancestor's tri-state from its children."""
646
+ for anc in node.ancestors():
647
+ kids = anc.children
648
+ if not kids:
649
+ continue
650
+ n_selected = sum(1 for c in kids if c in self._selected)
651
+ any_mixed = any(c in self._mixed for c in kids)
652
+ if not any_mixed and n_selected == len(kids):
653
+ self._selected.add(anc)
654
+ self._mixed.discard(anc)
655
+ elif not any_mixed and n_selected == 0:
656
+ self._selected.discard(anc)
657
+ self._mixed.discard(anc)
658
+ else:
659
+ self._selected.discard(anc)
660
+ self._mixed.add(anc)
661
+
662
+ def select(self, node: TreeNode) -> None:
663
+ if self._selection_mode == "none":
664
+ return
665
+ before = (frozenset(self._selected), frozenset(self._mixed))
666
+ if self._selection_mode == "single":
667
+ self._selected = {node}
668
+ self._mixed.clear()
669
+ else:
670
+ # Tri-state cascade: toggle the node's whole subtree, then refresh
671
+ # ancestors. A mixed (partial) parent toggles ON (selects all).
672
+ self._set_subtree_selected(node, node not in self._selected)
673
+ self._recompute_ancestors(node)
674
+ self._focused_node = node
675
+ self._update_rows()
676
+ # Only announce a change if the selection set actually moved (re-clicking
677
+ # an already-selected single node shouldn't re-fire the event).
678
+ if before != (frozenset(self._selected), frozenset(self._mixed)):
679
+ self._emit_selection_changed()
680
+
681
+ def deselect(self, node: TreeNode) -> None:
682
+ if node in self._selected or node in self._mixed:
683
+ self._set_subtree_selected(node, False)
684
+ self._recompute_ancestors(node)
685
+ self._update_rows()
686
+ self._emit_selection_changed()
687
+
688
+ def select_all(self) -> None:
689
+ if self._selection_mode != "multi":
690
+ return
691
+ self._selected = set(self._iter_all_nodes())
692
+ self._mixed.clear()
693
+ self._update_rows()
694
+ self._emit_selection_changed()
695
+
696
+ def clear_selection(self) -> None:
697
+ if self._selected or self._mixed:
698
+ self._selected.clear()
699
+ self._mixed.clear()
700
+ self._update_rows()
701
+ self._emit_selection_changed()
702
+
703
+ def get_selected(self) -> list[TreeNode]:
704
+ sel = self._selected
705
+ return [n for n in self._iter_all_nodes() if n in sel]
706
+
707
+ # ================= keyboard navigation =================
708
+
709
+ def _bind_keys(self, widget) -> None:
710
+ widget.bind("<Down>", self._on_down, add="+")
711
+ widget.bind("<Up>", self._on_up, add="+")
712
+ widget.bind("<Left>", self._on_left, add="+")
713
+ widget.bind("<Right>", self._on_right, add="+")
714
+ widget.bind("<Home>", self._on_home, add="+")
715
+ widget.bind("<End>", self._on_end, add="+")
716
+ widget.bind("<Return>", self._on_return, add="+")
717
+ widget.bind("<space>", self._on_space, add="+")
718
+ widget.bind("<Key>", self._on_typeahead, add="+")
719
+
720
+ def _scroll_into_view(self, index: int) -> None:
721
+ if index < self._start_index:
722
+ self._start_index = index
723
+ self._clamp_indices()
724
+ elif index >= self._start_index + self._visible_rows:
725
+ self._start_index = index - self._visible_rows + 1
726
+ self._clamp_indices()
727
+
728
+ def _focus_index(self, index: int) -> str:
729
+ if self.count == 0 or index < 0 or index >= self.count:
730
+ return "break"
731
+ self._scroll_into_view(index)
732
+ self._focused_node = self._visible[index]["node"]
733
+ self._update_rows()
734
+ visual = index - self._start_index
735
+ if 0 <= visual < len(self._rows):
736
+ try:
737
+ # Keyboard navigation -> show the focus ring (visual_focus).
738
+ self._rows[visual].focus_set(visual_focus=True)
739
+ except TclError:
740
+ pass
741
+ return "break"
742
+
743
+ def _focused_index(self) -> int:
744
+ if self._focused_node is None:
745
+ return -1
746
+ return self._index_of(self._focused_node)
747
+
748
+ def _on_down(self, event) -> str:
749
+ if self.count == 0:
750
+ return "break"
751
+ cur = self._focused_index()
752
+ nxt = self._start_index if cur < 0 else min(cur + 1, self.count - 1)
753
+ return self._focus_index(nxt)
754
+
755
+ def _on_up(self, event) -> str:
756
+ if self.count == 0:
757
+ return "break"
758
+ cur = self._focused_index()
759
+ nxt = self._start_index if cur < 0 else max(cur - 1, 0)
760
+ return self._focus_index(nxt)
761
+
762
+ def _on_left(self, event) -> str:
763
+ node = self._focused_node
764
+ if node is None:
765
+ return "break"
766
+ if node.expandable and node.expanded:
767
+ self.collapse(node)
768
+ return self._focus_index(self._index_of(node))
769
+ if node.parent is not None:
770
+ return self._focus_index(self._index_of(node.parent))
771
+ return "break"
772
+
773
+ def _on_right(self, event) -> str:
774
+ node = self._focused_node
775
+ if node is None:
776
+ return "break"
777
+ if node.expandable and not node.expanded:
778
+ self.expand(node)
779
+ return self._focus_index(self._index_of(node))
780
+ if node.children:
781
+ return self._focus_index(self._index_of(node.children[0]))
782
+ return "break"
783
+
784
+ def _on_home(self, event) -> str:
785
+ return self._focus_index(0)
786
+
787
+ def _on_end(self, event) -> str:
788
+ return self._focus_index(self.count - 1)
789
+
790
+ def _on_return(self, event) -> str:
791
+ node = self._focused_node
792
+ if node is not None:
793
+ self._emit("<<TreeActivate>>", node)
794
+ return "break"
795
+
796
+ def _on_space(self, event) -> str:
797
+ node = self._focused_node
798
+ if node is None:
799
+ return "break"
800
+ # Space toggles selection (in multi, this is the checklist toggle).
801
+ self.select(node)
802
+ return "break"
803
+
804
+ def _on_typeahead(self, event) -> Optional[str]:
805
+ ch = event.char
806
+ if not ch or not ch.isprintable() or ch in (" ",):
807
+ return None
808
+ self._typeahead += ch.lower()
809
+ if self._typeahead_after is not None:
810
+ try:
811
+ self.after_cancel(self._typeahead_after)
812
+ except Exception:
813
+ pass
814
+ self._typeahead_after = self.after(700, self._clear_typeahead)
815
+ start = max(0, self._focused_index())
816
+ order = list(range(start + 1, self.count)) + list(range(0, start + 1))
817
+ for i in order:
818
+ label = (self._visible[i]["label"] or "").lower()
819
+ if label.startswith(self._typeahead):
820
+ return self._focus_index(i)
821
+ return "break"
822
+
823
+ def _clear_typeahead(self) -> None:
824
+ self._typeahead = ""
825
+ self._typeahead_after = None
826
+
827
+ @property
828
+ def roots(self) -> list[TreeNode]:
829
+ return self._roots
830
+
831
+ def destroy(self) -> None:
832
+ """Cancel pending `after` callbacks before tearing down."""
833
+ for attr in ("_relayout_after", "_typeahead_after"):
834
+ after_id = getattr(self, attr, None)
835
+ if after_id is not None:
836
+ try:
837
+ self.after_cancel(after_id)
838
+ except Exception:
839
+ pass
840
+ setattr(self, attr, None)
841
+ super().destroy()