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,3388 @@
1
+ """TableView widget backed by an in-memory SQLite datasource.
2
+
3
+ The datasource performs filtering, sorting, and pagination while the widget
4
+ renders the current page in a Treeview with optional grouping, striping, and
5
+ context menus.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import logging
12
+ import os
13
+ from collections import OrderedDict
14
+ from tkinter import font as tkfont
15
+
16
+ from typing import Any, Callable
17
+ from typing_extensions import Literal, TypedDict, Unpack
18
+
19
+ from bootstack.widgets.types import Master, WidgetDensity
20
+
21
+ from bootstack.events import RowEvent, RowsEvent, SelectionEvent, ExportEvent
22
+ from bootstack._core.images import _ImageService
23
+ from bootstack.style.style import get_style
24
+ # Row identity and the set of hidden internal columns are read through the
25
+ # datasource's protocol-level helpers (`_record_id`, `_public_record`,
26
+ # `_internal_fields`) so the table works with any `DataSourceProtocol` source,
27
+ # not just SqliteDataSource. SqliteDataSource is imported only to create the
28
+ # default in-memory source when no `datasource=` is supplied.
29
+ from bootstack.data.sqlite_source import SqliteDataSource
30
+ from bootstack.data.query import col, any_of, all_of
31
+ from bootstack.widgets._impl.primitives.button import Button
32
+ from bootstack._runtime.utility import bind_right_click
33
+ from bootstack.widgets._impl.composites.contextmenu import ContextMenu
34
+ from bootstack.widgets._impl.composites.tooltip import ToolTip
35
+ from bootstack.widgets._impl.composites.dropdownbutton import DropdownButton
36
+ from bootstack.widgets._impl.primitives.entry import Entry
37
+ from bootstack.widgets._impl.primitives.frame import Frame, FrameKwargs
38
+ from bootstack.widgets._impl.primitives.label import Label
39
+ from bootstack.widgets._impl.primitives.scrollbar import Scrollbar
40
+ from bootstack.widgets._impl.primitives.progressbar import Progressbar
41
+ from bootstack.widgets._impl.composites.selectbox import SelectBox
42
+ from bootstack.widgets._impl.primitives.separator import Separator
43
+ from bootstack.widgets._impl.composites.textentry import TextEntry
44
+ from bootstack.widgets._impl.primitives.treeview import TreeView
45
+ from bootstack.i18n import MessageCatalog
46
+
47
+ from .types import (
48
+ parse_selection_mode as _parse_selection_mode,
49
+ build_editing_options as _build_editing_options,
50
+ build_selection_options as _build_selection_options,
51
+ build_filtering_options as _build_filtering_options,
52
+ build_exporting_options as _build_exporting_options,
53
+ build_paging_options as _build_paging_options,
54
+ build_search_options as _build_search_options,
55
+ build_row_alternation_options as _build_row_alternation_options,
56
+ )
57
+
58
+ logger = logging.getLogger(__name__)
59
+
60
+ # Max characters of the filter summary shown in the status bar before it is
61
+ # truncated (the full text is then revealed via a hover tooltip).
62
+ _FILTER_STATUS_MAXLEN = 48
63
+
64
+ # Read-batch size when streaming an export (a throughput knob, distinct from the
65
+ # on-screen page size).
66
+ _EXPORT_CHUNK_SIZE = 1000
67
+
68
+ # Soft cap for the materializing accessors (to_rows/to_csv); above this they
69
+ # raise and point the caller at the streaming API.
70
+ _EXPORT_MAX_MATERIALIZE = 100_000
71
+
72
+ # Above this row count the built-in "Save to file" runs asynchronously with a
73
+ # progress dialog; below it, a synchronous (instant) write.
74
+ _EXPORT_ASYNC_THRESHOLD = 5000
75
+
76
+ # Stripe strength: fraction of the elevated stripe color blended over the table
77
+ # surface (lower = fainter stripe). Tuned so the stripe stays a faint neutral
78
+ # that contrasts with the subtle accent selection.
79
+ _STRIPE_STRENGTH = 0.5
80
+
81
+ # Fixed pixel size for per-row selection marker icons (checkbox/dot). Rendered
82
+ # 1:1 into the row's icon slot — kept even and unscaled for crisp edges.
83
+ _MARKER_ICON_SIZE = 20
84
+
85
+ # The toolbar and footer are utility chrome around the data, so their widgets are
86
+ # always compact regardless of the table's row density.
87
+ _CHROME_DENSITY: WidgetDensity = 'compact'
88
+
89
+ # Group expand/collapse chevrons shown in the leading slot of group-header rows
90
+ # (the native tree indicator was removed from the item layout).
91
+ _GROUP_OPEN_ICON = 'chevron-down' # expanded
92
+ _GROUP_CLOSED_ICON = 'chevron-right' # collapsed
93
+
94
+
95
+ class _ExportJob:
96
+ """Drives a streamed export across the Tk event loop (cooperative chunking).
97
+
98
+ Each idle tick writes one chunk and reschedules, so the UI stays responsive
99
+ and the export can be cancelled between chunks. No worker threads — the
100
+ data source's SQLite connection is thread-affine.
101
+ """
102
+
103
+ def __init__(self, widget, chunks, write_chunk, close, total, *, on_progress, on_complete):
104
+ self._widget = widget # provides after_idle / after_cancel
105
+ self._chunks = chunks # iterator of raw-record chunks
106
+ self._write_chunk = write_chunk
107
+ self._close = close
108
+ self._total = total
109
+ self._on_progress = on_progress
110
+ self._on_complete = on_complete # (status, written, error)
111
+ self._written = 0
112
+ self._cancelled = False
113
+ self._done = False
114
+ self._after_id = None
115
+
116
+ def start(self) -> "_ExportJob":
117
+ self._after_id = self._widget.after_idle(self._step)
118
+ return self
119
+
120
+ def cancel(self) -> None:
121
+ """Request cancellation; takes effect before the next chunk."""
122
+ self._cancelled = True
123
+
124
+ def abort(self) -> None:
125
+ """Cancel and close the writer NOW (used on widget teardown).
126
+
127
+ Unlike `cancel()`, this doesn't wait for the next idle tick — the
128
+ scheduled step won't run after destroy — so it closes the writer and
129
+ finalizes synchronously.
130
+ """
131
+ if self._after_id is not None:
132
+ try:
133
+ self._widget.after_cancel(self._after_id)
134
+ except Exception:
135
+ pass
136
+ self._after_id = None
137
+ self._cancelled = True
138
+ self._finish("cancelled", None)
139
+
140
+ def _step(self) -> None:
141
+ self._after_id = None
142
+ if self._cancelled:
143
+ self._finish("cancelled", None)
144
+ return
145
+ try:
146
+ chunk = next(self._chunks, None)
147
+ except Exception as exc: # data-source read failed
148
+ self._finish("error", exc)
149
+ return
150
+ if chunk is None:
151
+ self._finish("completed", None)
152
+ return
153
+ try:
154
+ self._write_chunk(chunk)
155
+ except Exception as exc:
156
+ self._finish("error", exc)
157
+ return
158
+ self._written += len(chunk)
159
+ if self._on_progress is not None:
160
+ try:
161
+ self._on_progress(self._written, self._total)
162
+ except Exception:
163
+ logger.exception("Export progress callback failed")
164
+ self._after_id = self._widget.after_idle(self._step)
165
+
166
+ def _finish(self, status: str, error) -> None:
167
+ if self._done: # guard against double-finish (e.g. abort after natural end)
168
+ return
169
+ self._done = True
170
+ try:
171
+ self._close()
172
+ except Exception:
173
+ logger.exception("Failed to close export writer")
174
+ if self._on_complete is not None:
175
+ self._on_complete(status, self._written, error)
176
+
177
+
178
+ def _has_xlsxwriter() -> bool:
179
+ """Whether the optional XlsxWriter dependency (bootstack[excel]) is installed."""
180
+ return _module_available("xlsxwriter")
181
+
182
+
183
+ def _module_available(module: str) -> bool:
184
+ """Whether an optional dependency can be imported."""
185
+ import importlib.util
186
+ try:
187
+ return importlib.util.find_spec(module) is not None
188
+ except Exception:
189
+ return False
190
+
191
+
192
+ # Export formats the DataTable can offer. `kind` picks the engine: 'cooperative'
193
+ # uses the column-aware, cancelable, chunk-by-chunk exporter (good for huge CSVs);
194
+ # 'registry' streams through bootstack.data.writers (synchronous). `extra` names
195
+ # the pip extra to install when `available` is False.
196
+ _EXPORT_FORMATS: dict = {
197
+ "csv": {"ext": ".csv", "label": "CSV file", "kind": "cooperative", "extra": None, "available": lambda: True},
198
+ "tsv": {"ext": ".tsv", "label": "TSV file", "kind": "cooperative", "extra": None, "available": lambda: True},
199
+ "xlsx": {"ext": ".xlsx", "label": "Excel file", "kind": "cooperative", "extra": "excel", "available": _has_xlsxwriter},
200
+ "json": {"ext": ".json", "label": "JSON file", "kind": "registry", "extra": None, "available": lambda: True},
201
+ "jsonl": {"ext": ".jsonl", "label": "JSON Lines file", "kind": "registry", "extra": None, "available": lambda: True},
202
+ "xml": {"ext": ".xml", "label": "XML file", "kind": "registry", "extra": None, "available": lambda: True},
203
+ "parquet": {"ext": ".parquet", "label": "Parquet file", "kind": "registry", "extra": "parquet", "available": lambda: _module_available("pyarrow")},
204
+ "feather": {"ext": ".feather", "label": "Feather file", "kind": "registry", "extra": "parquet", "available": lambda: _module_available("pyarrow")},
205
+ "hdf5": {"ext": ".h5", "label": "HDF5 file", "kind": "registry", "extra": "hdf5", "available": lambda: _module_available("tables")},
206
+ }
207
+
208
+ # Map a file extension to a canonical export format name (including aliases).
209
+ _EXT_TO_EXPORT_FORMAT: dict = {spec["ext"]: name for name, spec in _EXPORT_FORMATS.items()}
210
+ _EXT_TO_EXPORT_FORMAT.update({
211
+ ".ndjson": "jsonl",
212
+ ".hdf5": "hdf5",
213
+ ".hdf": "hdf5",
214
+ ".arrow": "feather",
215
+ ".txt": "csv",
216
+ })
217
+
218
+ _TABLE_SEARCH_MODE_OPTIONS = [
219
+ ("table.search_mode_equals", "EQUALS"),
220
+ ("table.search_mode_contains", "CONTAINS"),
221
+ ("table.search_mode_starts_with", "STARTS WITH"),
222
+ ("table.search_mode_ends_with", "ENDS WITH"),
223
+ ]
224
+
225
+
226
+
227
+
228
+
229
+
230
+
231
+
232
+
233
+
234
+
235
+ class TableView(Frame):
236
+ """TableView backed by an in-memory SqliteDataSource.
237
+
238
+ Provides sortable headers, filtering/search, pagination or virtual scrolling,
239
+ optional grouping, column striping, and configurable exporting/editing.
240
+
241
+ """
242
+
243
+ def __init__(
244
+ self,
245
+ master: Master = None,
246
+ # Core data
247
+ columns: list[str | dict] | None = None,
248
+ rows: list | None = None,
249
+ datasource: SqliteDataSource | None = None,
250
+ # Selection & sorting
251
+ selection_mode: Literal['none', 'single', 'multi'] = 'single',
252
+ allow_select_all: bool = True,
253
+ sorting_mode: Literal['single', 'none'] = 'single',
254
+ # Filtering & search
255
+ enable_filtering: bool = True,
256
+ enable_header_filtering: bool = True,
257
+ enable_row_filtering: bool = True,
258
+ enable_search: bool = True,
259
+ broadcast_search: bool = False,
260
+ search_mode: Literal['standard', 'advanced'] = 'standard',
261
+ search_trigger: Literal['enter', 'input'] = 'enter',
262
+ # Paging & scrolling
263
+ paging_mode: Literal['standard', 'virtual'] = 'standard',
264
+ page_size: int = 25,
265
+ page_index: int = 0,
266
+ page_cache_size: int = 3,
267
+ show_vscrollbar: bool = True,
268
+ show_hscrollbar: bool = False,
269
+ # Editing
270
+ enable_adding: bool = False,
271
+ enable_editing: bool = False,
272
+ enable_deleting: bool = False,
273
+ form_options: dict | None = None,
274
+ # Exporting
275
+ enable_exporting: bool = False,
276
+ allow_export_selection: bool = True,
277
+ export_scope: Literal['page', 'all'] = 'page',
278
+ export_formats: tuple[str, ...] | None = None,
279
+ # Appearance & extras
280
+ striped: bool = False,
281
+ striped_background: str = 'background[+0.85]',
282
+ density: WidgetDensity = 'default',
283
+ allow_grouping: bool = False,
284
+ show_table_status: bool = True,
285
+ show_column_chooser: bool = False,
286
+ show_selection_controls: bool = False,
287
+ id_field: str = "id",
288
+ context_menus: Literal['none', 'headers', 'rows', 'all'] = 'all',
289
+ column_min_width: int = 40,
290
+ column_auto_width: bool = False,
291
+ **kwargs: Unpack[FrameKwargs],
292
+ ):
293
+ """
294
+ Create a TableView backed by an in-memory SqliteDataSource.
295
+
296
+ Args:
297
+ master: Parent widget.
298
+ columns: Column definitions (list of strings or dicts with keys like
299
+ "text", "key", "width", "minwidth").
300
+ rows: Initial data to load (list of dicts or row-like sequences).
301
+ datasource: Custom SqliteDataSource; if omitted, an in-memory source is created.
302
+ selection_mode: Selection mode ('none', 'single', 'multi'). Defaults to 'single'.
303
+ allow_select_all: Whether select-all is allowed. Defaults to True.
304
+ sorting_mode: Sorting mode ('single' or 'none'). Defaults to 'single'.
305
+ enable_filtering: Enable filtering features. Defaults to True.
306
+ enable_header_filtering: Show filter option in header context menu. Defaults to True.
307
+ enable_row_filtering: Show filter option in row context menu. Defaults to True.
308
+ enable_search: Show search bar. Defaults to True.
309
+ search_mode: Search mode ('standard' or 'advanced'). Defaults to 'standard'.
310
+ search_trigger: When to trigger search ('enter' or 'input'). Defaults to 'enter'.
311
+ paging_mode: Paging mode ('standard' or 'virtual'). Defaults to 'standard'.
312
+ page_size: Number of rows per page. Defaults to 25.
313
+ page_index: Initial page index. Defaults to 0.
314
+ page_cache_size: Number of pages to cache. Defaults to 3.
315
+ show_vscrollbar: Show vertical scrollbar. Defaults to True.
316
+ show_hscrollbar: Show horizontal scrollbar. Defaults to False.
317
+ enable_adding: Allow adding new rows. Defaults to False.
318
+ enable_editing: Allow editing existing rows. Defaults to False.
319
+ enable_deleting: Allow deleting rows. Defaults to False.
320
+ form_options: Options dict for the edit form dialog.
321
+ enable_exporting: Enable export functionality. Defaults to False.
322
+ allow_export_selection: Allow exporting selected rows. Defaults to True.
323
+ export_scope: Export scope ('page' or 'all'). Defaults to 'page'.
324
+ export_formats: Tuple of export formats (e.g., ('csv', 'xlsx')).
325
+ striped: Show alternating row colors. Defaults to False.
326
+ striped_background: Background color for striped rows. Defaults to 'background[+0.85]'.
327
+ density: Row compactness ('default' or 'compact'). Defaults to 'default'.
328
+ allow_grouping: Allow grouping rows via header context menu. Defaults to False.
329
+ show_table_status: Show filter/sort/group status labels and pager. Defaults to True.
330
+ show_column_chooser: Show column chooser button. Defaults to False.
331
+ show_selection_controls: Show per-row checkboxes in 'multi' selection
332
+ mode (a plain click then toggles rows). No effect in 'single'
333
+ mode or while grouped. Defaults to False.
334
+ id_field: Record field used as the stable row identity for the
335
+ auto-created data source. Ignored when `datasource` is provided.
336
+ Defaults to 'id'.
337
+ context_menus: Context menu visibility ('none', 'headers', 'rows', 'all').
338
+ Defaults to 'all'.
339
+ column_min_width: Global minimum width for columns. Defaults to 40.
340
+ column_auto_width: Automatically size columns to widest visible text.
341
+ Defaults to False.
342
+ **kwargs: Additional arguments passed through to Frame.
343
+ """
344
+ super().__init__(master, **kwargs)
345
+
346
+ # Build internal configuration dicts from flattened kwargs
347
+ self._editing = _build_editing_options(
348
+ enable_adding=enable_adding,
349
+ enable_editing=enable_editing,
350
+ enable_deleting=enable_deleting,
351
+ form_options=form_options,
352
+ )
353
+ self._paging = _build_paging_options(
354
+ paging_mode=paging_mode,
355
+ page_size=page_size,
356
+ page_index=page_index,
357
+ page_cache_size=page_cache_size,
358
+ show_vscrollbar=show_vscrollbar,
359
+ show_hscrollbar=show_hscrollbar,
360
+ )
361
+ self._exporting = _build_exporting_options(
362
+ enable_exporting=enable_exporting,
363
+ allow_export_selection=allow_export_selection,
364
+ export_scope=export_scope,
365
+ export_formats=export_formats,
366
+ )
367
+ self._warn_unavailable_export_formats()
368
+ self._filtering = _build_filtering_options(
369
+ enable_filtering=enable_filtering,
370
+ enable_header_filtering=enable_header_filtering,
371
+ enable_row_filtering=enable_row_filtering,
372
+ )
373
+ self._selection = _build_selection_options(
374
+ selection_mode=selection_mode,
375
+ allow_select_all=allow_select_all,
376
+ )
377
+ self._searchbar = _build_search_options(
378
+ enable_search=enable_search,
379
+ search_mode=search_mode,
380
+ search_trigger=search_trigger,
381
+ )
382
+ # The active free-text search term (composed with column filters into where()).
383
+ self._search_text: str = ""
384
+ # Tooltip showing the full filter summary when the status label is truncated.
385
+ self._filter_tooltip: ToolTip | None = None
386
+ # In-flight async export jobs, aborted on widget teardown.
387
+ self._export_jobs: set = set()
388
+ # The live add/edit form dialog while it is open (for capture/automation).
389
+ self._active_form_dialog = None
390
+ self._active_chooser_dialog = None
391
+ self._row_alternation = _build_row_alternation_options(
392
+ striped=striped,
393
+ striped_background=striped_background,
394
+ )
395
+ # Treeview row density, forwarded to the TreeView style options.
396
+ self._density: WidgetDensity = density
397
+ # Per-row selection markers (checkbox/dot) in the leading icon slot.
398
+ self._selection_indicators = bool(show_selection_controls)
399
+ # Cache of rendered marker icons keyed by (name, size, color).
400
+ self._marker_icons: dict = {}
401
+
402
+ self._search_mode_map: dict[str, str] = {}
403
+ self._broadcast_search: bool = broadcast_search
404
+ # Suppress the table's own _on_source_change re-load while a broadcast
405
+ # search is being applied (the load is done directly in _apply_where).
406
+ self._suppressing_search_broadcast: bool = False
407
+ self._sorting = sorting_mode
408
+ self._show_table_status = show_table_status
409
+ self._show_column_chooser = show_column_chooser
410
+ self._allow_grouping = allow_grouping
411
+ self._context_menus = (context_menus or 'all').lower()
412
+ self._column_min_width = max(0, column_min_width)
413
+ self._column_auto_width = column_auto_width
414
+ self._datasource = datasource or SqliteDataSource(
415
+ ':memory:', page_size=self._paging['page_size'], id_field=id_field
416
+ )
417
+
418
+ self._page_cache: OrderedDict[int, list[dict]] = OrderedDict()
419
+ self._column_defs = columns or []
420
+ self._column_keys: list[str] = []
421
+ self._heading_texts: list[str] = []
422
+ self._sort_state: dict[str, bool] = {} # key -> ascending
423
+ self._current_page = self._paging['page_index']
424
+ self._loading_next = False
425
+ self._heading_fg: str | None = None
426
+ self._icon_sort_up = None
427
+ self._icon_sort_down = None
428
+ self._column_anchors: list[str] = []
429
+ self._column_formats: dict[int, Any] = {} # idx -> resolved display formatter (or None)
430
+ self._column_filters: dict[str, list] = {} # key -> list of allowed values
431
+ self._column_types: dict[str, str] = {}
432
+ self._alignment_sample: list[dict] | None = None
433
+ self._row_map: dict[str, dict] = {}
434
+ self._row_menu: ContextMenu | None = None
435
+ # The row a right-click context menu targets. Right-click never changes
436
+ # the selection (that is a left-click affordance), so the row menu acts
437
+ # on this clicked row instead — see `_context_iids`.
438
+ self._context_iid: str | None = None
439
+ self._display_columns: list[int] = []
440
+ self._header_menu: ContextMenu | None = None
441
+ self._header_menu_col: int | None = None
442
+ self._row_menu_col: int | None = None
443
+ self._cached_total_count: int | None = None
444
+ self._group_by_key: str | None = None
445
+ self._group_parents: dict[str | None, str] = {}
446
+ self._hidden_rows: dict[str, tuple[str, int]] = {}
447
+
448
+ self._resolve_column_keys()
449
+
450
+ seeded_records: list[dict] | None = None
451
+ if rows:
452
+ # Seed the source silently — the table renders this data itself below,
453
+ # and the change subscription is not yet attached.
454
+ with self._silence_source():
455
+ try:
456
+ if self._column_keys:
457
+ # Avoid per-row dict conversion when we already know the column order
458
+ self._datasource.load(rows, column_keys=self._column_keys)
459
+ seeded_records = None
460
+ else:
461
+ seeded_records = self._to_records(rows)
462
+ self._datasource.load(seeded_records)
463
+ except Exception:
464
+ # Last-resort fallback to dict conversion if direct load fails
465
+ seeded_records = self._to_records(rows)
466
+ try:
467
+ self._datasource.load(seeded_records)
468
+ except Exception:
469
+ seeded_records = []
470
+
471
+ self._ensure_column_metadata(seeded_records)
472
+
473
+ # UI
474
+ self._build_toolbar()
475
+ self._build_tree()
476
+ if self._show_table_status or not self._paging['mode'] == 'virtual':
477
+ self._build_footer()
478
+
479
+ # Initial load
480
+ self._load_page(0)
481
+
482
+ # Auto-refresh when the data source changes from the outside (a shared
483
+ # source mutated directly, or a background-thread feed). Every mutation
484
+ # this table makes itself is wrapped in `_silence_source()`, so this
485
+ # handler only fires for genuinely external changes. The hub marshals
486
+ # the callback onto the main thread.
487
+ self._change_sub = None
488
+ on_change = getattr(self._datasource, 'on_change', None)
489
+ if callable(on_change):
490
+ try:
491
+ self._change_sub = on_change(self._on_source_change)
492
+ except Exception:
493
+ self._change_sub = None
494
+ self.bind('<Destroy>', self._on_table_destroy, add='+')
495
+ # The alternating-row stripe + theme-colored marker/chevron/sort-arrow
496
+ # icons are imperative (not refreshed by the ttk style rebuild), so the
497
+ # unified theme walk re-applies them via `_bs_apply_theme` when the table
498
+ # is on screen.
499
+
500
+ def _silence_source(self):
501
+ """Context manager suppressing source change broadcasts for our own writes."""
502
+ silence = getattr(self._datasource, '_silence', None)
503
+ if callable(silence):
504
+ return silence()
505
+ return contextlib.nullcontext()
506
+
507
+ @contextlib.contextmanager
508
+ def _apply_view_to_source(self):
509
+ """Temporarily apply this table's local filter/sort to the source (silenced).
510
+
511
+ Saves the source's current where/order state, applies the table's own
512
+ search + column-filter condition and sort order for the duration of the
513
+ block, then restores the source to its original state. This lets each
514
+ DataTable maintain independent view state over a shared source without
515
+ permanently mutating it.
516
+ """
517
+ ds = self._datasource
518
+ old_filter = getattr(ds, '_filter', None)
519
+ old_sort = list(getattr(ds, '_sort', []))
520
+ # Combine the source's own filter with this table's local search/column
521
+ # filters so source-level constraints (e.g. ds.where(...) called from
522
+ # app code) are respected alongside the table's per-view state.
523
+ # When broadcast_search is on the table already wrote its search condition
524
+ # to the source, so old_filter IS the combined condition — don't add local
525
+ # conditions again or they'd be applied twice.
526
+ if self._broadcast_search:
527
+ combined = old_filter
528
+ else:
529
+ combined = all_of(
530
+ old_filter,
531
+ self._build_search_condition(),
532
+ self._build_column_filter_condition(),
533
+ )
534
+ # Use the table's local sort when set; fall back to the source's sort
535
+ # so a source-level order() is respected when no column header is clicked.
536
+ sort_args = [k if asc else f"-{k}" for k, asc in self._sort_state.items()]
537
+ effective_sort = sort_args if sort_args else old_sort
538
+ with self._silence_source():
539
+ ds.where(combined)
540
+ ds.order(*effective_sort)
541
+ try:
542
+ yield
543
+ finally:
544
+ with self._silence_source():
545
+ try:
546
+ ds.where(old_filter)
547
+ finally:
548
+ ds.order(*old_sort)
549
+
550
+ def _on_source_change(self, event=None) -> None:
551
+ """Reload the current page after an external data-source change."""
552
+ if self._suppressing_search_broadcast:
553
+ # Consume the suppression — this is the hub flush that the broadcast
554
+ # write in _apply_where() triggered. The table already loaded page 0
555
+ # directly, so skip the redundant reload but allow future changes
556
+ # through by clearing the flag.
557
+ self._suppressing_search_broadcast = False
558
+ return
559
+ try:
560
+ self._clear_cache()
561
+ self._load_page(self._current_page)
562
+ except Exception:
563
+ logger.exception("Failed to reload table after data-source change")
564
+
565
+ def _on_table_destroy(self, event=None) -> None:
566
+ """Release subscriptions, in-flight exports, and the tooltip on destroy."""
567
+ if event is not None and getattr(event, 'widget', None) is not self:
568
+ return
569
+ # The theme subscription is released by the Frame base hook's own
570
+ # <Destroy> handler (publisher unsubscribe).
571
+ sub = self._change_sub
572
+ self._change_sub = None
573
+ if sub is not None:
574
+ try:
575
+ sub.cancel()
576
+ except Exception:
577
+ pass
578
+ # Abort any in-flight async export: its idle step won't run post-destroy,
579
+ # so close the writer + remove the partial file synchronously now.
580
+ for job in list(self._export_jobs):
581
+ try:
582
+ job.abort()
583
+ except Exception:
584
+ logger.exception("Failed to abort export job on destroy")
585
+ self._export_jobs.clear()
586
+ # Tear down the filter tooltip (unbinds its handlers, cancels its timer).
587
+ tooltip = self._filter_tooltip
588
+ self._filter_tooltip = None
589
+ if tooltip is not None:
590
+ try:
591
+ tooltip.destroy()
592
+ except Exception:
593
+ pass
594
+
595
+ # ------------------------------------------------------------------ Public API
596
+ def set_data(self, rows: list) -> None:
597
+ """Replace data in the datasource and refresh the grid."""
598
+ with self._silence_source():
599
+ if self._column_keys:
600
+ self._datasource.load(rows, column_keys=self._column_keys)
601
+ seeded_records = None
602
+ else:
603
+ seeded_records = self._to_records(rows)
604
+ self._datasource.load(seeded_records)
605
+ self._ensure_column_metadata(seeded_records)
606
+ self._clear_cache()
607
+ self._alignment_sample = None # re-sample for column alignment on new data
608
+ self._load_page(0)
609
+
610
+ # ------------------------------------------------------------------ Public data/selection API
611
+ def _public_record(self, rec: dict | None) -> dict:
612
+ """Return a user-facing copy of `rec` — internal columns stripped, `id` surfaced."""
613
+ return self._datasource._public_record(rec)
614
+
615
+ def _record_id(self, rec: dict | None) -> Any:
616
+ """Stable identity of a raw record, via the datasource's id accessor."""
617
+ return self._datasource._record_id(rec) if rec else None
618
+
619
+ def _internal_fields(self) -> "frozenset[str]":
620
+ """Raw-record keys the datasource treats as internal (hidden from users)."""
621
+ return self._datasource._internal_fields()
622
+
623
+ def _iid_for_id(self, rid: Any) -> str | None:
624
+ """Find the row handle of a currently-rendered row by its record `id`."""
625
+ for iid, rec in self._row_map.items():
626
+ if self._record_id(rec) == rid:
627
+ return iid
628
+ return None
629
+
630
+ @property
631
+ def selected_rows(self) -> list[dict]:
632
+ """List of record dicts for the current selection."""
633
+ rows: list[dict] = []
634
+ for iid in self._tree.selection():
635
+ if iid in self._row_map:
636
+ rows.append(self._public_record(self._row_map[iid]))
637
+ return rows
638
+
639
+ # ------------------------------------------------------------------ Public row/column manipulation
640
+ def insert_rows(self, rows: list) -> None:
641
+ """Insert new rows via the datasource and refresh."""
642
+ recs = self._to_records(rows)
643
+ inserted: list[dict] = []
644
+ with self._silence_source():
645
+ for rec in recs:
646
+ try:
647
+ # Strip any public 'id' — the datasource assigns the id.
648
+ payload = {k: v for k, v in dict(rec).items() if k != "id"}
649
+ new_id = self._datasource.insert(payload)
650
+ record = self._public_record(payload)
651
+ if new_id is not None:
652
+ record["id"] = new_id
653
+ inserted.append(record)
654
+ except Exception:
655
+ logger.exception("Failed to insert record")
656
+ if inserted:
657
+ self._clear_cache()
658
+ self._load_page(self._current_page)
659
+ self.event_generate("<<RowsInsert>>", data=RowsEvent(records=inserted))
660
+
661
+ def update_rows(self, rows: list[dict]) -> None:
662
+ """Update rows by record `id`; each dict must include an `id` key."""
663
+ updated: list[dict] = []
664
+ with self._silence_source():
665
+ for rec in rows:
666
+ rec_id = rec.get("id")
667
+ if rec_id is None:
668
+ continue
669
+ _hidden = self._internal_fields()
670
+ updates = {k: v for k, v in rec.items() if k != "id" and k not in _hidden}
671
+ try:
672
+ self._datasource.update(rec_id, updates)
673
+ updated.append(dict(rec))
674
+ except Exception:
675
+ logger.exception("Failed to update record id=%s", rec_id)
676
+ if updated:
677
+ self._clear_cache()
678
+ self._load_page(self._current_page)
679
+ self.event_generate("<<RowsUpdate>>", data=RowsEvent(records=updated))
680
+
681
+ def delete_rows(self, rows_or_ids: list) -> None:
682
+ """Delete rows by record `id`, or by record dicts containing an `id` key."""
683
+ deleted: list[dict] = []
684
+ with self._silence_source():
685
+ for item in rows_or_ids:
686
+ rec_id = None
687
+ rec = {}
688
+ if isinstance(item, dict):
689
+ rec = dict(item)
690
+ rec_id = item.get("id")
691
+ else:
692
+ rec_id = item
693
+ if rec_id is None:
694
+ continue
695
+ try:
696
+ self._datasource.delete(rec_id)
697
+ if not rec:
698
+ rec = {"id": rec_id}
699
+ deleted.append(rec)
700
+ except Exception:
701
+ logger.exception("Failed to delete record id=%s", rec_id)
702
+ if deleted:
703
+ self._clear_cache()
704
+ self._load_page(self._current_page)
705
+ self.event_generate("<<RowsDelete>>", data=RowsEvent(records=deleted))
706
+
707
+ def insert_columns(self, *_args, **_kwargs) -> None:
708
+ """Not currently supported; columns are defined at construction time."""
709
+ raise NotImplementedError("Dynamic column insertion is not supported yet")
710
+
711
+ def delete_columns(self, indices: list[int]) -> None:
712
+ """Hide columns at the given indices."""
713
+ self.hide_columns(indices)
714
+
715
+ def move_rows(self, iids: list[str], to_index: int) -> None:
716
+ """Move the given rows to a target index in the root list."""
717
+ children = list(self._tree.get_children(""))
718
+ to_index = max(0, min(len(children), to_index))
719
+ for offset, iid in enumerate(iids):
720
+ try:
721
+ self._tree.move(iid, "", to_index + offset)
722
+ except Exception:
723
+ pass
724
+ self._apply_row_alternation()
725
+ moved_recs = [self._row_map.get(i) for i in iids if i in self._row_map]
726
+ if moved_recs:
727
+ records = [self._public_record(r) for r in moved_recs]
728
+ self.event_generate("<<RowsMove>>", data=RowsEvent(records=records))
729
+
730
+ def move_columns(self, from_index: int, to_index: int) -> None:
731
+ """Reorder a column from one index to another."""
732
+ if from_index < 0 or from_index >= len(self._display_columns):
733
+ return
734
+ to_index = max(0, min(len(self._display_columns) - 1, to_index))
735
+ col_id = self._display_columns.pop(from_index)
736
+ self._display_columns.insert(to_index, col_id)
737
+ self._tree.configure(displaycolumns=self._display_columns)
738
+
739
+ def hide_rows(self, iids: list[str]) -> None:
740
+ """Hide rows from view (not removed from datasource)."""
741
+ for iid in iids:
742
+ try:
743
+ parent = self._tree.parent(iid)
744
+ children = list(self._tree.get_children(parent))
745
+ idx = children.index(iid)
746
+ self._hidden_rows[iid] = (parent, idx)
747
+ self._tree.detach(iid)
748
+ except Exception:
749
+ pass
750
+
751
+ def unhide_rows(self, iids: list[str] | None = None) -> None:
752
+ """Restore previously hidden rows."""
753
+ targets = iids or list(self._hidden_rows.keys())
754
+ for iid in targets:
755
+ if iid not in self._hidden_rows:
756
+ continue
757
+ parent, idx = self._hidden_rows.pop(iid)
758
+ try:
759
+ self._tree.move(iid, parent, idx)
760
+ except Exception:
761
+ pass
762
+ self._apply_row_alternation()
763
+
764
+ def hide_columns(self, indices: list[int]) -> None:
765
+ """Remove columns from the displayed set."""
766
+ for idx in indices:
767
+ if idx in self._display_columns:
768
+ self._display_columns.remove(idx)
769
+ if not self._display_columns and self._heading_texts:
770
+ self._display_columns = list(range(len(self._heading_texts)))
771
+ self._tree.configure(displaycolumns=self._display_columns)
772
+
773
+ def unhide_columns(self, indices: list[int]) -> None:
774
+ """Add columns back into the displayed set."""
775
+ changed = False
776
+ for idx in indices:
777
+ if idx not in self._display_columns and 0 <= idx < len(self._heading_texts):
778
+ self._display_columns.append(idx)
779
+ changed = True
780
+ if changed:
781
+ self._display_columns = sorted(self._display_columns)
782
+ self._tree.configure(displaycolumns=self._display_columns)
783
+
784
+ def select_rows(self, ids: list) -> None:
785
+ """Select rows by record `id` (only those currently rendered)."""
786
+ iids = [iid for iid in (self._iid_for_id(rid) for rid in ids) if iid]
787
+ if iids:
788
+ self._tree.selection_set(iids)
789
+
790
+ def deselect_rows(self, ids: list | None = None) -> None:
791
+ """Clear the selection, or remove specific rows by record `id`."""
792
+ if not ids:
793
+ self._tree.selection_remove(self._tree.selection())
794
+ return
795
+ iids = [iid for iid in (self._iid_for_id(rid) for rid in ids) if iid]
796
+ if iids:
797
+ self._tree.selection_remove(iids)
798
+
799
+ def scroll_to_row(self, rid: Any) -> None:
800
+ """Ensure the row with the given record `id` is visible."""
801
+ iid = self._iid_for_id(rid)
802
+ if iid is None:
803
+ return
804
+ try:
805
+ self._tree.see(iid)
806
+ except Exception:
807
+ pass
808
+
809
+ # ------------------------------------------------------------------ Pagination helpers
810
+ def next_page(self) -> None:
811
+ self._next_page()
812
+
813
+ def previous_page(self) -> None:
814
+ self._prev_page()
815
+
816
+ def first_page(self) -> None:
817
+ self._first_page()
818
+
819
+ def last_page(self) -> None:
820
+ self._last_page()
821
+
822
+ def go_to_page(self, index: int) -> None:
823
+ self._load_page(max(0, index))
824
+
825
+ @property
826
+ def current_page(self) -> int:
827
+ """Zero-based index of the page currently shown."""
828
+ return self._current_page
829
+
830
+ @property
831
+ def page_count(self) -> int:
832
+ """Total number of pages for the current filter/search."""
833
+ return self._total_pages()
834
+
835
+ # ------------------------------------------------------------------ Filter/Sort/Group API
836
+ def get_filters(self) -> dict[str, list]:
837
+ """Return the active column filters as `{column_key: allowed_values}`."""
838
+ return {k: list(v) for k, v in self._column_filters.items()}
839
+
840
+ def clear_filters(self) -> None:
841
+ """Remove all active column filters (leaves the search term intact)."""
842
+ self._clear_filter_cmd()
843
+
844
+ def set_filter(self, column: str, values=None) -> None:
845
+ """Set a column filter (or clear it when `values` is None); composes with search."""
846
+ if values is None:
847
+ self._column_filters.pop(column, None)
848
+ else:
849
+ self._column_filters[column] = list(values)
850
+ self._apply_where()
851
+
852
+ def get_search(self) -> str:
853
+ """Return the active free-text search term."""
854
+ return self._search_text
855
+
856
+ def set_search(self, text: str) -> None:
857
+ """Set the free-text search term and re-apply (leaves column filters intact)."""
858
+ text = text or ""
859
+ entry = getattr(self, "_search_entry", None)
860
+ if entry is not None:
861
+ entry.delete(0, "end")
862
+ entry.insert(0, text)
863
+ self._search_text = text
864
+ self._apply_where()
865
+
866
+ def clear_search(self) -> None:
867
+ """Clear the free-text search term (leaves column filters intact)."""
868
+ self._clear_search()
869
+
870
+ def get_sorting(self) -> dict[str, bool]:
871
+ """Return a copy of the current sort state {column_key: ascending}."""
872
+ return dict(self._sort_state)
873
+
874
+ def set_sorting(self, key: str, ascending: bool = True) -> None:
875
+ self._sort_state = {key: ascending}
876
+ self._clear_cache()
877
+ self._update_heading_icons()
878
+ self._load_page(0)
879
+ self._update_status_labels()
880
+
881
+ def clear_sorting(self) -> None:
882
+ self._clear_sort()
883
+
884
+ def get_grouping(self) -> str | None:
885
+ return self._group_by_key
886
+
887
+ def set_grouping(self, key: str | None) -> None:
888
+ if not key:
889
+ self._ungroup_all()
890
+ return
891
+ if key not in self._column_keys:
892
+ return
893
+ self._group_by_key = key
894
+ self._group_parents.clear()
895
+ self._sort_state = {key: True}
896
+ self._clear_cache()
897
+ self._update_heading_icons()
898
+ self._load_page(0)
899
+ self._update_status_labels()
900
+
901
+ def clear_grouping(self) -> None:
902
+ self._ungroup_all()
903
+
904
+ # ------------------------------------------------------------------ Group expand/collapse
905
+ def expand_all(self) -> None:
906
+ for iid in self._tree.get_children(""):
907
+ try:
908
+ self._tree.item(iid, open=True)
909
+ except Exception:
910
+ pass
911
+
912
+ def collapse_all(self) -> None:
913
+ for iid in self._tree.get_children(""):
914
+ try:
915
+ self._tree.item(iid, open=False)
916
+ except Exception:
917
+ pass
918
+
919
+ def expand_group(self, group_value) -> None:
920
+ parent = self._group_parents.get(group_value)
921
+ if parent:
922
+ try:
923
+ self._tree.item(parent, open=True)
924
+ except Exception:
925
+ pass
926
+
927
+ def collapse_group(self, group_value) -> None:
928
+ parent = self._group_parents.get(group_value)
929
+ if parent:
930
+ try:
931
+ self._tree.item(parent, open=False)
932
+ except Exception:
933
+ pass
934
+
935
+ def select_all(self) -> None:
936
+ """Select all visible rows."""
937
+ self._tree.selection_set(self._tree.get_children(""))
938
+
939
+ def deselect_all(self) -> None:
940
+ """Clear the selection."""
941
+ self._tree.selection_remove(self._tree.selection())
942
+
943
+ # ------------------------------------------------------------------ UI
944
+
945
+ def _resolve_alternating_row_color(self):
946
+ from bootstack.style.utility import mix_colors
947
+
948
+ style = get_style()
949
+ builder = style.style_builder
950
+ color_token = self._row_alternation.get('accent', 'background[+1]')
951
+
952
+ try:
953
+ stripe = builder.color(color_token)
954
+ surface = builder.color('content')
955
+ # Soften the stripe toward the table surface so it stays a faint
956
+ # neutral that contrasts with the subtle accent selection.
957
+ background = mix_colors(stripe, surface, _STRIPE_STRENGTH)
958
+ except Exception:
959
+ background = builder.color('background')
960
+
961
+ try:
962
+ foreground = builder.on_color(background)
963
+ except Exception:
964
+ foreground = builder.color('foreground')
965
+ return background, foreground
966
+
967
+ def _resolve_column_keys(self) -> None:
968
+ if not self._column_defs:
969
+ return
970
+ for idx, col in enumerate(self._column_defs):
971
+ if isinstance(col, str):
972
+ self._column_keys.append(col)
973
+ elif isinstance(col, dict):
974
+ self._column_keys.append(col.get("key") or col.get("text") or str(idx))
975
+ else:
976
+ self._column_keys.append(str(col))
977
+
978
+ def _ensure_column_metadata(self, sample_records: list[dict] | None) -> None:
979
+ """Guarantee we have column keys/defs before the Treeview is built."""
980
+ if self._column_keys:
981
+ return
982
+
983
+ inferred: list[str] = []
984
+ if sample_records:
985
+ first = sample_records[0]
986
+ if isinstance(first, dict):
987
+ inferred = list(first.keys())
988
+ if not inferred:
989
+ inferred = getattr(self._datasource, "_columns", []) or []
990
+
991
+ _hidden = self._internal_fields()
992
+ inferred = [c for c in inferred if c not in _hidden]
993
+ if not inferred:
994
+ inferred = ["value"]
995
+
996
+ self._column_keys = inferred
997
+ if not self._column_defs:
998
+ self._column_defs = [{"text": c} for c in self._column_keys]
999
+
1000
+ def _build_toolbar(self) -> None:
1001
+ bar = Frame(self, name="toolbar")
1002
+ # Grid in column 0 only so the toolbar's right edge stops at the
1003
+ # tree's right edge instead of extending past the vsb.
1004
+ bar.grid(row=0, column=0, sticky="ew", pady=(0, 4))
1005
+
1006
+ if self._searchbar['enabled']:
1007
+ self._search_entry = TextEntry(bar, density=_CHROME_DENSITY)
1008
+ self._search_entry.insert_addon(Label, 'before', icon="search", icon_only=True)
1009
+ self._search_entry.insert_addon(Button, 'after', icon="x-lg", icon_only=True, command=self._clear_search)
1010
+ # Only reserve a 6 px right gap when the advanced-mode SelectBox
1011
+ # follows the entry; otherwise the entry hugs the toolbar edge.
1012
+ search_padx = (0, 6) if self._searchbar['mode'] == 'advanced' else 0
1013
+ self._search_entry.pack(side="left", fill="x", expand=True, padx=search_padx)
1014
+ trigger = str(self._searchbar.get('event', 'enter')).lower()
1015
+ if trigger == 'input':
1016
+ self._search_entry.on_input(lambda _e: self._run_search())
1017
+ else:
1018
+ self._search_entry.on_enter(lambda _e: self._run_search())
1019
+ # Clear filter when the box is emptied, but do not search on every keystroke
1020
+ self._search_entry.on_input(lambda _e: self._clear_search() if not self._search_entry.get() else None)
1021
+
1022
+ if self._searchbar['mode'] == 'advanced':
1023
+ search_items = []
1024
+ self._search_mode_map = {}
1025
+ for token, code in _TABLE_SEARCH_MODE_OPTIONS:
1026
+ label = MessageCatalog.translate(token)
1027
+ search_items.append(label)
1028
+ self._search_mode_map[label] = code
1029
+ default_value = search_items[0] if search_items else "EQUALS"
1030
+ self._search_mode = SelectBox(
1031
+ bar,
1032
+ items=search_items,
1033
+ value=default_value,
1034
+ width=14,
1035
+ allow_custom_values=False,
1036
+ enable_search=False,
1037
+ density=_CHROME_DENSITY,
1038
+ )
1039
+ self._search_mode.pack(side="left", padx=(0, 6))
1040
+
1041
+ if self._show_column_chooser:
1042
+ self._column_chooser_btn = Button(
1043
+ bar,
1044
+ icon="layout-three-columns",
1045
+ icon_only=True,
1046
+ accent="foreground",
1047
+ variant="ghost",
1048
+ density=_CHROME_DENSITY,
1049
+ command=self._show_column_chooser_dialog,
1050
+ )
1051
+ self._column_chooser_btn.pack(side="right", padx=(4, 0))
1052
+
1053
+ if self._exporting['enabled']:
1054
+ export_items = [
1055
+ {"type": "command", "key": "export_copy", "text": "Copy to clipboard", "command": self._copy_to_clipboard},
1056
+ {"type": "command", "key": "export_save", "text": "Save to file", "command": self._save_to_file},
1057
+ ]
1058
+ self._export_btn = DropdownButton(
1059
+ bar,
1060
+ icon="download",
1061
+ icon_only=True,
1062
+ accent="foreground",
1063
+ variant="ghost",
1064
+ compound="image",
1065
+ density=_CHROME_DENSITY,
1066
+ items=export_items,
1067
+ show_dropdown_button=False,
1068
+ )
1069
+ self._export_btn.pack(side="right")
1070
+
1071
+ if self._editing['adding']:
1072
+ Button(
1073
+ bar,
1074
+ icon="plus-circle",
1075
+ text="table.add_record",
1076
+ accent="foreground",
1077
+ variant="ghost",
1078
+ density=_CHROME_DENSITY,
1079
+ command=self._open_new_record,
1080
+ ).pack(side="right", padx=(0, 4))
1081
+
1082
+ def _build_tree(self) -> None:
1083
+ cols = [self._col_text(c) for c in self._column_defs] or self._column_keys
1084
+
1085
+ # Grid layout for the TableView body:
1086
+ # row 0: toolbar (col 0)
1087
+ # row 1: tree (col 0) | vsb (col 1, only this row)
1088
+ # row 2: hsb (col 0)
1089
+ # row 3: footer (col 0)
1090
+ # Column 0 expands; column 1 takes the vsb's natural width when present.
1091
+ self.grid_columnconfigure(0, weight=1)
1092
+ self.grid_rowconfigure(1, weight=1)
1093
+
1094
+ self._tree = TreeView(
1095
+ self,
1096
+ columns=list(range(len(cols))),
1097
+ selectmode=_parse_selection_mode(self._selection['mode']),
1098
+ show="headings",
1099
+ density=self._density,
1100
+ )
1101
+ # Inset the tree by the focus-ring affordance baked into sibling
1102
+ # entry images so the tree's content edge lines up with the visible
1103
+ # edge of the toolbar/footer entries (search box, pagination input).
1104
+ from bootstack.style.style_builder_base import StyleBuilderBase
1105
+ affordance = StyleBuilderBase.scale_from_source(8)
1106
+ self._tree.grid(row=1, column=0, sticky="nsew", padx=affordance)
1107
+ self._display_columns = list(range(len(cols)))
1108
+
1109
+ if self._paging['yscroll']:
1110
+ self._vsb = Scrollbar(self, orient="vertical", command=self._tree.yview)
1111
+ self._vsb.grid(row=1, column=1, sticky="ns")
1112
+ if self._paging['mode'] == "virtual":
1113
+ self._tree.configure(yscrollcommand=self._on_scroll)
1114
+ else:
1115
+ self._tree.configure(yscrollcommand=self._vsb.set)
1116
+ else:
1117
+ self._vsb = None
1118
+
1119
+ if self._paging['xscroll']:
1120
+ self._hsb = Scrollbar(self, orient="horizontal", command=self._tree.xview)
1121
+ # Mirror the tree's affordance inset so the hsb aligns with the
1122
+ # tree content and stops at the same right edge.
1123
+ self._hsb.grid(row=2, column=0, sticky="ew", padx=affordance)
1124
+ self._tree.configure(xscrollcommand=self._hsb.set)
1125
+ else:
1126
+ self._hsb = None
1127
+
1128
+ self._heading_texts = []
1129
+ self._column_anchors = []
1130
+ stretch_columns = not self._paging['xscroll'] # allow natural width when xscroll is enabled
1131
+ for idx, text in enumerate(cols):
1132
+ self._heading_texts.append(text)
1133
+ anchor = self._determine_anchor(idx)
1134
+ self._column_anchors.append(anchor)
1135
+ heading_kwargs = {"text": text, "anchor": anchor}
1136
+ # Don't use heading command - we'll handle clicks via Button-1 binding
1137
+ self._tree.heading(idx, **heading_kwargs)
1138
+ # Apply per-column width overrides, fall back to global defaults
1139
+ width = 120
1140
+ minwidth = self._column_min_width
1141
+ if idx < len(self._column_defs):
1142
+ coldef = self._column_defs[idx]
1143
+ if isinstance(coldef, dict):
1144
+ width = coldef.get("width", width)
1145
+ minwidth = coldef.get("minwidth", coldef.get("min_width", minwidth))
1146
+ self._tree.column(idx, anchor=anchor, width=width, minwidth=minwidth, stretch=stretch_columns)
1147
+ self._update_heading_icons()
1148
+ self._tree.bind("<Button-1>", self._on_header_click)
1149
+ self._tree.bind("<<TreeviewSelect>>", self._on_selection_event)
1150
+ # Keep group-header chevrons in sync with their open/closed state.
1151
+ self._tree.bind("<<TreeviewOpen>>", self._refresh_group_chevrons, add="+")
1152
+ self._tree.bind("<<TreeviewClose>>", self._refresh_group_chevrons, add="+")
1153
+ self._tree.bind("<ButtonRelease-1>", self._on_row_click_event)
1154
+ # Escape clears the selection (also reachable in single-select mode, where
1155
+ # clicking can't return to an empty selection). Bound on the tree widget
1156
+ # only (add='+'), so it fires solely when the tree has focus and never
1157
+ # clobbers dialog/menu/search Escape handling, which own their own focus.
1158
+ self._tree.bind("<Escape>", lambda _e: self.deselect_all(), add="+")
1159
+ if self._context_menus != "none":
1160
+ bind_right_click(self._tree, self._on_tree_context)
1161
+ if self._editing['updating']:
1162
+ self._tree.bind("<Double-1>", self._on_row_double_click)
1163
+ # Track resize events to rebalance grouped layouts
1164
+ self._tree.bind("<Configure>", self._on_tree_configure)
1165
+
1166
+ def _build_footer(self) -> None:
1167
+ bar = Frame(self)
1168
+ # Same column 0 as the toolbar so the footer aligns with the table
1169
+ # content and stops at the vsb edge.
1170
+ bar.grid(row=3, column=0, sticky="ew", pady=(6, 0))
1171
+ self._footer_bar = bar
1172
+
1173
+ # A divider separating the footer from the table body. It lives inside
1174
+ # the footer bar, so it shows and hides together with the footer.
1175
+ self._footer_sep = Separator(bar, orient="horizontal")
1176
+ self._footer_sep.pack(side="top", fill="x", pady=(0, 6))
1177
+
1178
+ status_frame = Frame(bar)
1179
+ status_frame.pack(side="left", fill="x", expand=True)
1180
+ self._filter_label = Label(status_frame, text="", anchor="w", accent="muted", font="caption")
1181
+ self._filter_label.pack(side="left", padx=(0, 4))
1182
+ self._sort_label = Label(status_frame, text="", anchor="w", accent="muted", font="caption")
1183
+ self._sort_label.pack(side="left", padx=(8, 4))
1184
+
1185
+ # The pager (page entry + nav) is hidden when there is only one page.
1186
+ pager = Frame(bar)
1187
+ pager.pack(side="right")
1188
+ self._pager_frame = pager
1189
+ info_frame = Frame(pager)
1190
+ info_frame.pack(side='left')
1191
+ Label(info_frame, text="table.page", font="caption").pack(side='left')
1192
+ self._page_entry = Entry(info_frame, width=6, justify="center", density=_CHROME_DENSITY)
1193
+ self._page_entry.bind("<Return>", self._jump_page)
1194
+ self._page_entry.pack(side="left", padx=8)
1195
+ self._page_label = Label(info_frame, text="", font="caption")
1196
+ self._page_label.pack(side="left", padx=(0, 8))
1197
+
1198
+ Separator(pager, orient="vertical").pack(side="left", fill="y", padx=8)
1199
+
1200
+ btn_frame = Frame(pager)
1201
+ btn_frame.pack(side="left")
1202
+ Button(btn_frame, icon="chevron-double-left", accent="foreground", variant="ghost", icon_only=True, density=_CHROME_DENSITY, command=self._first_page).pack(
1203
+ side="left")
1204
+ Button(btn_frame, icon="chevron-left", icon_only=True, accent="foreground", variant="ghost", density=_CHROME_DENSITY, command=self._prev_page).pack(
1205
+ side="left")
1206
+ Button(btn_frame, icon="chevron-right", icon_only=True, accent="foreground", variant="ghost", density=_CHROME_DENSITY, command=self._next_page).pack(
1207
+ side="left")
1208
+ Button(btn_frame, icon="chevron-double-right", icon_only=True, accent="foreground", variant="ghost", density=_CHROME_DENSITY, command=self._last_page).pack(
1209
+ side="left")
1210
+
1211
+ self._update_footer_visibility()
1212
+
1213
+ def _update_footer_visibility(self) -> None:
1214
+ """Hide the pager on a single page and collapse the footer when empty.
1215
+
1216
+ `show_status_bar=False` hides the whole footer.
1217
+ """
1218
+ bar = getattr(self, "_footer_bar", None)
1219
+ if bar is None:
1220
+ return
1221
+ if not self._show_table_status:
1222
+ bar.grid_remove()
1223
+ return
1224
+ multipage = self._total_pages() > 1
1225
+ has_status = bool(self._filter_label.cget("text")) or bool(self._sort_label.cget("text"))
1226
+ if multipage:
1227
+ if not self._pager_frame.winfo_manager():
1228
+ self._pager_frame.pack(side="right")
1229
+ else:
1230
+ self._pager_frame.pack_forget()
1231
+ if multipage or has_status:
1232
+ bar.grid()
1233
+ else:
1234
+ bar.grid_remove()
1235
+
1236
+ # ------------------------------------------------------------------ Helpers
1237
+ def _col_text(self, col) -> str:
1238
+ if isinstance(col, str):
1239
+ return col
1240
+ if isinstance(col, dict):
1241
+ return col.get("text") or col.get("key") or ""
1242
+ return str(col)
1243
+
1244
+ def _header_context_enabled(self) -> bool:
1245
+ return self._context_menus in ("all", "headers")
1246
+
1247
+ def _row_context_enabled(self) -> bool:
1248
+ return self._context_menus in ("all", "rows")
1249
+
1250
+ def _determine_anchor(self, idx: int) -> str:
1251
+ """Pick an anchor for the given column index.
1252
+
1253
+ Priority:
1254
+ 1) Explicit anchor/align in column definition
1255
+ 2) Explicit dtype/type hint in column definition (numeric -> right)
1256
+ 3) Numeric columns -> right
1257
+ 4) Default -> left
1258
+ """
1259
+ if idx < len(self._column_defs):
1260
+ coldef = self._column_defs[idx]
1261
+ if isinstance(coldef, dict):
1262
+ anchor = coldef.get("anchor") or coldef.get("align")
1263
+ if anchor:
1264
+ return anchor
1265
+ # Allow a dtype/type hint on the column definition
1266
+ dtype = coldef.get("dtype") or coldef.get("type")
1267
+ if dtype:
1268
+ dtype_upper = str(dtype).upper()
1269
+ if any(t in dtype_upper for t in ("INT", "REAL", "NUM", "DECIMAL", "DOUBLE", "FLOAT")):
1270
+ return "e"
1271
+ if "TEXT" in dtype_upper or "STR" in dtype_upper or "CHAR" in dtype_upper:
1272
+ return "w"
1273
+ # Infer from type
1274
+ key = self._column_keys[idx] if idx < len(self._column_keys) else None
1275
+ ctype = self._get_column_type(key) if key else ""
1276
+ if ctype and any(t in ctype.upper() for t in ("INT", "REAL", "NUM", "DECIMAL", "DOUBLE", "FLOAT")):
1277
+ return "e"
1278
+ # Fallback: sample values to detect numeric strings
1279
+ if self._is_numeric_sample(idx):
1280
+ return "e"
1281
+ return "w"
1282
+
1283
+ def _get_column_type(self, key: str | None) -> str:
1284
+ if not key:
1285
+ return ""
1286
+ if key in self._column_types:
1287
+ return self._column_types[key]
1288
+ # Try PRAGMA table_info
1289
+ try:
1290
+ cur = self._datasource.conn.execute(f"PRAGMA table_info({self._datasource._table})")
1291
+ for cid, name, ctype, *_rest in cur.fetchall():
1292
+ if name == key:
1293
+ self._column_types[key] = ctype or ""
1294
+ return self._column_types[key]
1295
+ except Exception:
1296
+ pass
1297
+ return ""
1298
+
1299
+ def _load_alignment_sample(self) -> list[dict]:
1300
+ if self._alignment_sample is not None:
1301
+ return self._alignment_sample
1302
+ try:
1303
+ sample = self._datasource.page(0)
1304
+ except Exception:
1305
+ sample = []
1306
+ self._alignment_sample = sample or []
1307
+ return self._alignment_sample
1308
+
1309
+ def _is_numeric_sample(self, idx: int) -> bool:
1310
+ """Check sample values to decide if a column with text storage is numeric-like."""
1311
+ key = self._column_keys[idx] if idx < len(self._column_keys) else None
1312
+ if not key:
1313
+ return False
1314
+ sample = self._load_alignment_sample()
1315
+ if not sample:
1316
+ return False
1317
+
1318
+ def is_num(val) -> bool:
1319
+ if val is None or val == "":
1320
+ return True
1321
+ try:
1322
+ float(val)
1323
+ return True
1324
+ except Exception:
1325
+ return False
1326
+
1327
+ seen = 0
1328
+ for rec in sample[: min(20, len(sample))]:
1329
+ if key not in rec:
1330
+ continue
1331
+ seen += 1
1332
+ if not is_num(rec.get(key)):
1333
+ return False
1334
+ return seen > 0
1335
+
1336
+ def _to_records(self, rows: list) -> list[dict]:
1337
+ records: list[dict] = []
1338
+ if not rows:
1339
+ return records
1340
+ keys = self._column_keys or [str(i) for i in range(len(rows[0]))]
1341
+ for rec in rows:
1342
+ if isinstance(rec, dict):
1343
+ records.append(rec)
1344
+ else:
1345
+ records.append({k: rec[i] if i < len(rec) else "" for i, k in enumerate(keys)})
1346
+ return records
1347
+
1348
+ def _refresh_tree(self, records: list[dict]) -> None:
1349
+ # Preserve the selection across the rebuild for rows that remain visible.
1350
+ # Selection is view/page-scoped: a sort keeps every row (selection fully
1351
+ # preserved), a search keeps the still-matching rows, and rows no longer
1352
+ # shown drop out. Captured by record id since the row handles are rebuilt.
1353
+ prev_selected_ids = {
1354
+ self._record_id(self._row_map[iid])
1355
+ for iid in self._tree.selection() if iid in self._row_map
1356
+ }
1357
+ self._tree.delete(*self._tree.get_children())
1358
+ self._row_map.clear()
1359
+ if not self._column_keys and records:
1360
+ self._column_keys = list(records[0].keys())
1361
+ grouped = bool(self._group_by_key) and self._group_by_key in self._column_keys
1362
+ self._apply_group_show_state(grouped)
1363
+ if grouped:
1364
+ self._render_grouped(records)
1365
+ else:
1366
+ self._render_flat(records)
1367
+ self._apply_row_alternation()
1368
+ if prev_selected_ids:
1369
+ restore = [iid for iid, rec in self._row_map.items()
1370
+ if self._record_id(rec) in prev_selected_ids]
1371
+ if restore:
1372
+ self._tree.selection_set(restore)
1373
+ self._update_selection_markers()
1374
+
1375
+ def _append_tree(self, records: list[dict]) -> None:
1376
+ # Grouped mode rebuilds the view instead of appending to keep hierarchy consistent
1377
+ if self._group_by_key:
1378
+ self._refresh_tree(records)
1379
+ return
1380
+ stripe = self._row_alternation.get('enabled', False) and not self._group_by_key
1381
+ start_idx = len(self._tree.get_children(""))
1382
+ for offset, rec in enumerate(records):
1383
+ values = self._display_values(rec)
1384
+ tags = ("altrow",) if stripe and (start_idx + offset) % 2 == 1 else ()
1385
+ iid = self._tree.insert("", "end", values=values, tags=tags)
1386
+ self._row_map[iid] = rec
1387
+ self._apply_row_alternation()
1388
+ self._update_selection_markers()
1389
+
1390
+ def _total_pages(self) -> int:
1391
+ try:
1392
+ if self._cached_total_count is None:
1393
+ with self._apply_view_to_source():
1394
+ self._cached_total_count = self._datasource.count
1395
+ total = self._cached_total_count
1396
+ size = getattr(self._datasource, "page_size", self._paging['page_size']) or 1
1397
+ return max(1, (total + size - 1) // size)
1398
+ except Exception:
1399
+ return 1
1400
+
1401
+ # ------------------------------------------------------------------ Paging
1402
+ def _load_page(self, page: int, append: bool = False) -> None:
1403
+ if not append and page in self._page_cache:
1404
+ records = self._page_cache[page]
1405
+ else:
1406
+ with self._apply_view_to_source():
1407
+ try:
1408
+ records = self._datasource.page(page)
1409
+ if self._cached_total_count is None:
1410
+ self._cached_total_count = self._datasource.count
1411
+ except Exception:
1412
+ records = []
1413
+ if not append:
1414
+ self._remember_page(page, records)
1415
+ self._current_page = max(0, page)
1416
+ try:
1417
+ if append:
1418
+ self._append_tree(records)
1419
+ else:
1420
+ self._refresh_tree(records)
1421
+ if self._column_auto_width:
1422
+ self._auto_size_columns(records if not append else None)
1423
+ self._update_page_label()
1424
+ finally:
1425
+ self._loading_next = False
1426
+
1427
+ def _update_page_label(self) -> None:
1428
+ if hasattr(self, "_page_entry"):
1429
+ self._page_entry.delete(0, 'end')
1430
+ self._page_entry.insert(0, str(self._current_page + 1))
1431
+ if hasattr(self, "_page_label"):
1432
+ of_text = MessageCatalog.translate("table.of")
1433
+ self._page_label.configure(text=f"{of_text} {self._total_pages()}")
1434
+ if self._show_table_status:
1435
+ self._update_status_labels()
1436
+
1437
+ def _first_page(self) -> None:
1438
+ self._load_page(0)
1439
+
1440
+ def _prev_page(self) -> None:
1441
+ self._load_page(max(0, self._current_page - 1))
1442
+
1443
+ def _next_page(self) -> None:
1444
+ self._load_page(min(self._total_pages() - 1, self._current_page + 1))
1445
+
1446
+ def _last_page(self) -> None:
1447
+ self._load_page(self._total_pages() - 1)
1448
+
1449
+ def _jump_page(self, _event=None) -> None:
1450
+ try:
1451
+ target = int(self._page_entry.get()) - 1
1452
+ except Exception:
1453
+ return
1454
+ target = max(0, min(self._total_pages() - 1, target))
1455
+ self._load_page(target)
1456
+
1457
+ def _on_scroll(self, first: float, last: float) -> None:
1458
+ """Drive scrollbar and trigger lazy loading when near the bottom."""
1459
+ # Grouped mode disables virtual scroll append to avoid breaking hierarchy
1460
+ if self._group_by_key:
1461
+ self._vsb.set(first, last)
1462
+ return
1463
+ try:
1464
+ first_f = float(first)
1465
+ last_f = float(last)
1466
+ except Exception:
1467
+ self._vsb.set(first, last)
1468
+ return
1469
+
1470
+ self._vsb.set(first_f, last_f)
1471
+ if (
1472
+ self._paging['mode'] == "virtual"
1473
+ and last_f >= 0.85 # prefetch a bit earlier for smoother scrolling
1474
+ and not self._loading_next
1475
+ and hasattr(self._datasource, "has_next_page")
1476
+ and self._datasource.has_next_page()
1477
+ ):
1478
+ # Load next page and keep appending rows
1479
+ self._loading_next = True
1480
+ self._load_page(self._current_page + 1, append=True)
1481
+
1482
+ # ------------------------------------------------------------------ Search & sort
1483
+ def _build_search_condition(self):
1484
+ """Build the search condition from the active search term (or None)."""
1485
+ text = self._search_text
1486
+ if not text or not self._column_keys:
1487
+ return None
1488
+ if hasattr(self, "_search_mode") and self._search_mode_map:
1489
+ display_mode = self._search_mode.get()
1490
+ mode = self._search_mode_map.get(display_mode, "CONTAINS")
1491
+ else:
1492
+ mode = "CONTAINS"
1493
+ mode_upper = mode.upper().replace(" ", "_")
1494
+ if mode_upper == "STARTS_WITH":
1495
+ make = lambda c: col(c).startswith(text)
1496
+ elif mode_upper == "ENDS_WITH":
1497
+ make = lambda c: col(c).endswith(text)
1498
+ elif mode_upper == "EQUALS":
1499
+ make = lambda c: col(c) == text
1500
+ else: # CONTAINS (default)
1501
+ make = lambda c: col(c).contains(text)
1502
+ return any_of(*(make(c) for c in self._column_keys))
1503
+
1504
+ def _apply_where(self) -> None:
1505
+ """Recompute and apply the table's local filter state.
1506
+
1507
+ When `broadcast_search` is enabled, the combined condition is written to
1508
+ the source un-silenced so that all other views (charts, etc.) re-render.
1509
+ The table suppresses its own `_on_source_change` callback for this write
1510
+ to avoid a double page-load.
1511
+ """
1512
+ if self._broadcast_search:
1513
+ condition = all_of(
1514
+ self._build_search_condition(),
1515
+ self._build_column_filter_condition(),
1516
+ )
1517
+ try:
1518
+ # Set the flag BEFORE the write; clear it in _on_source_change
1519
+ # (not here in finally) so it is still True when the hub's
1520
+ # after_idle flush fires, preventing a redundant second reload.
1521
+ self._suppressing_search_broadcast = True
1522
+ self._datasource.where(condition)
1523
+ except Exception:
1524
+ logger.exception("Failed to broadcast search filter")
1525
+ self._suppressing_search_broadcast = False # clear on error
1526
+ self._clear_cache()
1527
+ self._load_page(0)
1528
+ self._update_status_labels()
1529
+
1530
+ def _run_search(self) -> None:
1531
+ self._search_text = self._search_entry.get()
1532
+ self._apply_where()
1533
+
1534
+ def _clear_search(self) -> None:
1535
+ entry = getattr(self, "_search_entry", None)
1536
+ if entry is not None:
1537
+ entry.delete(0, 'end')
1538
+ self._search_text = ""
1539
+ self._apply_where()
1540
+
1541
+ def _on_sort(self, column_index: int) -> None:
1542
+ if column_index >= len(self._column_keys):
1543
+ return
1544
+ key = self._column_keys[column_index]
1545
+ asc = not self._sort_state.get(key, True)
1546
+ # Clear other sort states to keep single-column sort
1547
+ self._sort_state = {key: asc}
1548
+ self._clear_cache()
1549
+ self._update_heading_icons()
1550
+ self._load_page(0)
1551
+ self._update_status_labels()
1552
+
1553
+ def _column_heading(self, key: str) -> str:
1554
+ """Display heading for a column key (falls back to the key)."""
1555
+ try:
1556
+ idx = self._column_keys.index(key)
1557
+ return self._heading_texts[idx] if idx < len(self._heading_texts) else key
1558
+ except Exception:
1559
+ return key
1560
+
1561
+ def _filter_description(self) -> str:
1562
+ """Human-readable summary of the active search term and column filters.
1563
+
1564
+ The search term spans all columns (`'term' in any column`); each column
1565
+ filter reads like `Heading='value'` (or `Heading in (...)` for several).
1566
+ """
1567
+ def fmt(v) -> str:
1568
+ return "(blank)" if v is None else f"'{v}'"
1569
+
1570
+ parts = []
1571
+ if self._search_text:
1572
+ parts.append(f"{fmt(self._search_text)} in any column")
1573
+ for key, values in self._column_filters.items():
1574
+ heading = self._column_heading(key)
1575
+ vals = list(values)
1576
+ if len(vals) == 1:
1577
+ parts.append(f"{heading}={fmt(vals[0])}")
1578
+ else:
1579
+ parts.append(f"{heading} in ({', '.join(fmt(v) for v in vals)})")
1580
+ return ", ".join(parts)
1581
+
1582
+ def _update_filter_tooltip(self, text: str) -> None:
1583
+ """Show a hover tooltip with the full filter text, or remove it."""
1584
+ if not hasattr(self, "_filter_label"):
1585
+ return
1586
+ if text:
1587
+ if self._filter_tooltip is None:
1588
+ self._filter_tooltip = ToolTip(self._filter_label, text=text)
1589
+ else:
1590
+ self._filter_tooltip._text = text
1591
+ elif self._filter_tooltip is not None:
1592
+ self._filter_tooltip.destroy()
1593
+ self._filter_tooltip = None
1594
+
1595
+ def _update_status_labels(self) -> None:
1596
+ # Filter — summarize the active search term and any column filters,
1597
+ # truncating the label and revealing the full text via tooltip on overflow.
1598
+ filter_txt = ""
1599
+ tooltip_txt = ""
1600
+ try:
1601
+ description = self._filter_description()
1602
+ if description:
1603
+ shown = description
1604
+ if len(description) > _FILTER_STATUS_MAXLEN:
1605
+ shown = description[: _FILTER_STATUS_MAXLEN - 1].rstrip() + "…"
1606
+ filter_txt = MessageCatalog.translate("table.filter_status", shown)
1607
+ if shown != description:
1608
+ tooltip_txt = MessageCatalog.translate("table.filter_status", description)
1609
+ except Exception:
1610
+ pass
1611
+ self._update_filter_tooltip(tooltip_txt)
1612
+ # Sort
1613
+ sort_txt = ""
1614
+ try:
1615
+ if self._sort_state and not self._group_by_key:
1616
+ key, ascending = next(iter(self._sort_state.items()))
1617
+ heading = self._column_heading(key)
1618
+ direction = "↑" if ascending else "↓"
1619
+ sort_txt = MessageCatalog.translate("table.sort_status", f"{heading} {direction}")
1620
+ except Exception:
1621
+ pass
1622
+ group_txt = ""
1623
+ if self._group_by_key:
1624
+ try:
1625
+ col_idx = self._column_keys.index(self._group_by_key)
1626
+ heading_text = self._heading_texts[col_idx] if col_idx < len(
1627
+ self._heading_texts) else self._group_by_key
1628
+ except Exception:
1629
+ heading_text = self._group_by_key
1630
+ group_txt = MessageCatalog.translate("table.group_status", heading_text)
1631
+
1632
+ if hasattr(self, "_filter_label"):
1633
+ self._filter_label.configure(text=filter_txt)
1634
+ if hasattr(self, "_sort_label"):
1635
+ joined = " | ".join([t for t in (sort_txt, group_txt) if t])
1636
+ self._sort_label.configure(text=joined)
1637
+ self._update_footer_visibility()
1638
+
1639
+ # ------------------------------------------------------------------ Row context menu
1640
+ def _dismiss_context_menus(self) -> None:
1641
+ """Hide any open built-in row/header context menu (idempotent)."""
1642
+ for menu in (self._row_menu, self._header_menu):
1643
+ if menu is not None:
1644
+ try:
1645
+ menu.hide()
1646
+ except Exception:
1647
+ pass
1648
+
1649
+ def _ensure_row_menu(self) -> None:
1650
+ if not self._row_context_enabled():
1651
+ return
1652
+ if self._row_menu:
1653
+ return
1654
+ # Activation is wired upstream by bind_right_click on the tree so the
1655
+ # row vs header dispatch can run before the (lazily built) menu
1656
+ # decides which one to show.
1657
+ menu = ContextMenu(master=self, target=self._tree, attach='sw', trigger=None)
1658
+ if not self._sorting == 'none':
1659
+ menu.add_command(text="table.sort_asc", command=lambda: self._sort_selection(True))
1660
+ menu.add_command(text="table.sort_desc", command=lambda: self._sort_selection(False))
1661
+
1662
+ if self._filtering['row_menu_filtering']:
1663
+ menu.add_separator()
1664
+ menu.add_command(text="table.filter_by_value", command=self._filter_by_value)
1665
+ menu.add_command(text="table.hide_select", command=self._hide_selection)
1666
+ menu.add_command(text="table.clear_filters", command=self._clear_filter_cmd)
1667
+
1668
+ menu.add_separator()
1669
+ menu.add_command(text="table.move_up", command=self._move_row_up)
1670
+ menu.add_command(text="table.move_down", command=self._move_row_down)
1671
+ menu.add_command(text="table.move_top", command=self._move_row_top)
1672
+ menu.add_command(text="table.move_bottom", command=self._move_row_bottom)
1673
+
1674
+ if self._editing['updating'] or self._editing['deleting']:
1675
+ menu.add_separator()
1676
+ if self._editing['updating']:
1677
+ menu.add_command(text="table.edit", command=self._edit_selected_row)
1678
+ if self._editing['deleting']:
1679
+ menu.add_command(text="table.delete_row", command=self._delete_selected_row)
1680
+ self._row_menu = menu
1681
+
1682
+ def _on_row_context(self, event) -> None:
1683
+ if not self._row_context_enabled():
1684
+ return
1685
+ iid = self._tree.identify_row(event.y)
1686
+ col_id = self._tree.identify_column(event.x)
1687
+ try:
1688
+ col_idx = int(col_id.strip("#")) - 1
1689
+ except Exception:
1690
+ col_idx = 0
1691
+ # Right-click does not alter the selection (left-click owns that); it
1692
+ # only records which row the menu targets and opens the menu there.
1693
+ self._context_iid = iid or None
1694
+ if not iid:
1695
+ return # empty space or a group-header row — no row menu
1696
+ rec = self._row_map.get(iid, {})
1697
+ self.event_generate("<<RowRightClick>>", data=RowEvent(record=self._public_record(rec), id=self._record_id(rec)))
1698
+ self._row_menu_col = col_idx
1699
+ self._ensure_row_menu()
1700
+ self._row_menu.show(position=(event.x_root, event.y_root))
1701
+
1702
+ def _on_row_double_click(self, event) -> None:
1703
+ region = self._tree.identify_region(event.x, event.y)
1704
+ if region == "heading":
1705
+ return
1706
+ iid = self._tree.identify_row(event.y)
1707
+ if not iid:
1708
+ return
1709
+ rec = self._row_map.get(iid, {})
1710
+ self.event_generate("<<RowDoubleClick>>", data=RowEvent(record=self._public_record(rec), id=self._record_id(rec)))
1711
+ if self._editing['updating']:
1712
+ self._open_form_dialog(rec)
1713
+
1714
+ def _open_new_record(self) -> None:
1715
+ if not self._editing['adding']:
1716
+ return
1717
+ self._open_form_dialog(None)
1718
+
1719
+ def new_row(self, defaults: dict | None = None) -> dict | None:
1720
+ """Open the built-in New Record dialog; return the new record or None."""
1721
+ return self._open_form_dialog(None, defaults=defaults)
1722
+
1723
+ def edit_row(self, record_id) -> dict | None:
1724
+ """Open the built-in Edit Record dialog for `record_id`; return it or None."""
1725
+ record = self._datasource.get(record_id)
1726
+ if record is None:
1727
+ return None
1728
+ return self._open_form_dialog(record)
1729
+
1730
+ def _open_form_dialog(self, record: dict | None, *, defaults: dict | None = None) -> dict | None:
1731
+ from bootstack.dialogs._impl.formdialog import FormDialog
1732
+
1733
+ try:
1734
+ # Ensure geometry info is current so centering uses real widget bounds
1735
+ self.update_idletasks()
1736
+ except Exception:
1737
+ pass
1738
+ dialog_master = self.winfo_toplevel() if hasattr(self, "winfo_toplevel") else self
1739
+
1740
+ form_items = self._build_form_items()
1741
+ initial_data = dict(record) if record else dict(defaults or {})
1742
+
1743
+ form_options = dict(self._editing['form'])
1744
+ form_options.setdefault('col_count', 2)
1745
+ form_options.setdefault('min_col_width', 260)
1746
+ form_options.setdefault('scrollable', True)
1747
+ form_options.setdefault('resizable', True)
1748
+
1749
+ # Build buttons: Cancel, Delete (only for existing records), Save
1750
+ record_id = self._record_id(record)
1751
+ if record and record_id is not None:
1752
+ buttons: list[str | dict] = ['Cancel']
1753
+ if self._editing['deleting']:
1754
+ buttons.append({"text": "Delete", "role": "secondary", "result": "delete"})
1755
+ buttons.append("Save")
1756
+ else:
1757
+ buttons = ["Cancel", "Save"]
1758
+
1759
+ dialog = FormDialog(
1760
+ parent=dialog_master,
1761
+ title="Edit Record" if record else "New Record",
1762
+ data=initial_data,
1763
+ items=form_items,
1764
+ col_count=form_options.get('col_count', 2),
1765
+ min_col_width=form_options.get('min_col_width', 260),
1766
+ scrollable=form_options.get('scrollable', True),
1767
+ buttons=buttons,
1768
+ resizable=(True, True) if form_options.get('resizable', True) else (False, False),
1769
+ )
1770
+
1771
+ self._active_form_dialog = dialog
1772
+ try:
1773
+ dialog.show() # centers over the parent window (the table)
1774
+ finally:
1775
+ self._active_form_dialog = None
1776
+ result = dialog.result
1777
+
1778
+ if result is None:
1779
+ return None
1780
+
1781
+ # Handle delete action
1782
+ if result == "delete" and record and record_id is not None:
1783
+ deleted = self._public_record(record)
1784
+ try:
1785
+ with self._silence_source():
1786
+ self._datasource.delete(record_id)
1787
+ self._clear_cache()
1788
+ self._load_page(self._current_page)
1789
+ self.event_generate("<<RowsDelete>>", data=RowsEvent(records=[deleted]))
1790
+ except Exception:
1791
+ logger.exception("Failed to delete record id=%s", record_id)
1792
+ return None
1793
+
1794
+ data = result
1795
+ new_id = None
1796
+ if record and record_id is not None:
1797
+ rec_id = record_id
1798
+ updates = dict(data)
1799
+ for _f in self._internal_fields():
1800
+ updates.pop(_f, None)
1801
+ try:
1802
+ with self._silence_source():
1803
+ self._datasource.update(rec_id, updates)
1804
+ except Exception:
1805
+ logger.exception("Failed to update record id=%s", rec_id)
1806
+ return None
1807
+ saved_id, change_event = rec_id, "<<RowsUpdate>>"
1808
+ else:
1809
+ try:
1810
+ with self._silence_source():
1811
+ new_id = self._datasource.insert(dict(data))
1812
+ except Exception:
1813
+ logger.exception("Failed to create record from %s", data)
1814
+ return None
1815
+ saved_id, change_event = new_id, "<<RowsInsert>>"
1816
+ self._clear_cache()
1817
+ target_page = self._current_page
1818
+ if not record:
1819
+ # After creating, compute last page using fresh count so the new row is visible
1820
+ located_page = self._find_record_page(new_id) if new_id is not None else None
1821
+ target_page = located_page if located_page is not None else max(0, self._total_pages() - 1)
1822
+ self._load_page(target_page)
1823
+ if new_id is not None:
1824
+ self._focus_record(new_id)
1825
+ saved = None
1826
+ if saved_id is not None:
1827
+ fresh = self._datasource.get(saved_id)
1828
+ if fresh is not None:
1829
+ saved = self._public_record(fresh)
1830
+ if saved is not None:
1831
+ self.event_generate(change_event, data=RowsEvent(records=[saved]))
1832
+ return saved
1833
+
1834
+ def _build_form_items(self) -> list[dict]:
1835
+ items: list[dict] = []
1836
+ for idx, key in enumerate(self._column_keys):
1837
+ coldef = self._column_defs[idx] if idx < len(self._column_defs) else key
1838
+ label = self._col_text(coldef)
1839
+ editor_opts = {}
1840
+ editor = None
1841
+ dtype = None
1842
+ readonly = False
1843
+ if isinstance(coldef, dict):
1844
+ editor_opts = dict(coldef.get("editor_options", {}))
1845
+ editor = coldef.get("editor")
1846
+ dtype = coldef.get("dtype") or coldef.get("type")
1847
+ readonly = bool(coldef.get("readonly", False))
1848
+ if coldef.get("required"):
1849
+ editor_opts.setdefault("required", True)
1850
+ # Show validation messages to avoid layout jump on first error
1851
+ editor_opts.setdefault("show_message", True)
1852
+ items.append(
1853
+ {
1854
+ "key": key,
1855
+ "label": label,
1856
+ "dtype": dtype,
1857
+ "editor": editor,
1858
+ "editor_options": {**editor_opts},
1859
+ "readonly": readonly,
1860
+ "type": "field",
1861
+ }
1862
+ )
1863
+ return items
1864
+
1865
+ def _context_iids(self) -> tuple[str, ...]:
1866
+ """Row id(s) a row-menu command acts on.
1867
+
1868
+ The menu targets the right-clicked row. When that row is part of the
1869
+ current (left-click) selection, the whole selection is the target — the
1870
+ intuitive multi-select behavior — otherwise just the clicked row. Either
1871
+ way the selection itself is left unchanged.
1872
+ """
1873
+ clicked = self._context_iid
1874
+ selection = tuple(self._tree.selection())
1875
+ if clicked and clicked in selection:
1876
+ return selection
1877
+ if clicked:
1878
+ return (clicked,)
1879
+ return selection
1880
+
1881
+ def _filter_by_value(self) -> None:
1882
+ selection = self._context_iids()
1883
+ if not selection:
1884
+ return
1885
+ iid = selection[0]
1886
+ col_idx = max(0, min(self._row_menu_col or 0, len(self._column_keys) - 1))
1887
+ key = self._column_keys[col_idx]
1888
+ rec = self._row_map.get(iid)
1889
+ if rec is None:
1890
+ return
1891
+ # Use the stored record value (real type, handles None) rather than the
1892
+ # Tk display string, so the filter and status read correctly.
1893
+ self._column_filters[key] = [rec.get(key)]
1894
+ self._apply_where()
1895
+
1896
+ def _sort_selection(self, ascending: bool) -> None:
1897
+ selection = self._context_iids()
1898
+ if not selection:
1899
+ return
1900
+ iid = selection[0]
1901
+ col_idx = max(0, min(self._row_menu_col or 0, len(self._column_keys) - 1))
1902
+ key = self._column_keys[col_idx]
1903
+ self._sort_state = {key: ascending}
1904
+ self._clear_cache()
1905
+ self._update_heading_icons()
1906
+ self._load_page(0)
1907
+
1908
+ def _clear_filter_cmd(self) -> None:
1909
+ self._column_filters.clear()
1910
+ self._apply_where()
1911
+
1912
+ def _move_row_up(self) -> None:
1913
+ self._move_row_relative(-1)
1914
+
1915
+ def _move_row_down(self) -> None:
1916
+ self._move_row_relative(1)
1917
+
1918
+ def _move_row_top(self) -> None:
1919
+ self._move_row_absolute(0)
1920
+
1921
+ def _move_row_bottom(self) -> None:
1922
+ children = list(self._tree.get_children())
1923
+ if children:
1924
+ self._move_row_absolute(len(children) - 1)
1925
+
1926
+ def _move_row_relative(self, delta: int) -> None:
1927
+ sel = list(self._context_iids())
1928
+ if not sel:
1929
+ return
1930
+ target_iid = sel[0]
1931
+ children = list(self._tree.get_children())
1932
+ try:
1933
+ idx = children.index(target_iid)
1934
+ except ValueError:
1935
+ return
1936
+ new_idx = max(0, min(len(children) - 1, idx + delta))
1937
+ if new_idx == idx:
1938
+ return
1939
+ self._tree.move(target_iid, "", new_idx)
1940
+ self._apply_row_alternation()
1941
+ rec = self._row_map.get(target_iid)
1942
+ if rec:
1943
+ self.event_generate("<<RowsMove>>", data=RowsEvent(records=[self._public_record(rec)]))
1944
+
1945
+ def _move_row_absolute(self, new_idx: int) -> None:
1946
+ sel = list(self._context_iids())
1947
+ if not sel:
1948
+ return
1949
+ target_iid = sel[0]
1950
+ children = list(self._tree.get_children())
1951
+ new_idx = max(0, min(len(children) - 1, new_idx))
1952
+ self._tree.move(target_iid, "", new_idx)
1953
+ self._apply_row_alternation()
1954
+ rec = self._row_map.get(target_iid)
1955
+ if rec:
1956
+ self.event_generate("<<RowsMove>>", data=RowsEvent(records=[self._public_record(rec)]))
1957
+
1958
+ def _hide_selection(self) -> None:
1959
+ sel = list(self._context_iids())
1960
+ for iid in sel:
1961
+ self._tree.delete(iid)
1962
+ self._row_map.pop(iid, None)
1963
+
1964
+ def _edit_selected_row(self) -> None:
1965
+ """Open the form dialog for the right-clicked row."""
1966
+ sel = list(self._context_iids())
1967
+ if not sel:
1968
+ return
1969
+ iid = sel[0]
1970
+ rec = self._row_map.get(iid, {})
1971
+ self._open_form_dialog(rec)
1972
+
1973
+ def _delete_selected_row(self) -> None:
1974
+ """Delete the right-clicked row from the datasource."""
1975
+ sel = list(self._context_iids())
1976
+ if not sel:
1977
+ return
1978
+ iid = sel[0]
1979
+ rec = self._row_map.get(iid, {})
1980
+ rec_id = self._record_id(rec)
1981
+ if rec_id is not None:
1982
+ try:
1983
+ with self._silence_source():
1984
+ self._datasource.delete(rec_id)
1985
+ self._clear_cache()
1986
+ self._load_page(self._current_page)
1987
+ self.event_generate("<<RowsDelete>>", data=RowsEvent(records=[self._public_record(rec)]))
1988
+ except Exception:
1989
+ logger.exception("Failed to delete record id=%s", rec_id)
1990
+
1991
+ def _delete_selection(self) -> None:
1992
+ sel = list(self._tree.selection())
1993
+ deleted_records: list[dict] = []
1994
+ changed = False
1995
+ with self._silence_source():
1996
+ for iid in sel:
1997
+ rec = dict(self._row_map.get(iid) or {})
1998
+ if rec:
1999
+ deleted_records.append(rec)
2000
+ rec_id = self._record_id(rec)
2001
+ if rec_id is not None:
2002
+ try:
2003
+ self._datasource.delete(rec_id)
2004
+ changed = True
2005
+ except Exception:
2006
+ pass
2007
+ self._row_map.pop(iid, None)
2008
+ if changed:
2009
+ self._clear_cache()
2010
+ self._load_page(self._current_page)
2011
+ if deleted_records:
2012
+ records = [self._public_record(r) for r in deleted_records]
2013
+ self.event_generate("<<RowsDelete>>", data=RowsEvent(records=records))
2014
+
2015
+ # ------------------------------------------------------------------ Cache helpers
2016
+ def _clear_cache(self) -> None:
2017
+ if self._page_cache:
2018
+ self._page_cache.clear()
2019
+ # Invalidate total count cache when data/filter/sort changes
2020
+ self._cached_total_count = None
2021
+
2022
+ def _load_heading_icons(self) -> None:
2023
+ """Load and cache heading icons (sort arrows) sized to match the heading color."""
2024
+ try:
2025
+ fg = self._get_heading_fg()
2026
+ if fg == self._heading_fg and self._icon_sort_up:
2027
+ return
2028
+ self._heading_fg = fg
2029
+ self._icon_sort_up = _ImageService.get_icon("sort-up", 16, fg)
2030
+ self._icon_sort_down = _ImageService.get_icon("sort-down", 16, fg)
2031
+ except Exception:
2032
+ self._icon_sort_up = None
2033
+ self._icon_sort_down = None
2034
+
2035
+ def _get_heading_fg(self) -> str:
2036
+ """Resolve a heading foreground color with light-biased fallbacks."""
2037
+ style = get_style()
2038
+ ttk_style = self._tree.cget('style')
2039
+ # Try configured value first
2040
+ return style.configure(f"{ttk_style}.Heading", 'foreground')
2041
+
2042
+ def _update_heading_icons(self) -> None:
2043
+ """Apply sort direction icons to headings."""
2044
+ if not self._heading_texts:
2045
+ return
2046
+ self._load_heading_icons()
2047
+ for idx, text in enumerate(self._heading_texts):
2048
+ image = ""
2049
+ if idx < len(self._column_keys):
2050
+ key = self._column_keys[idx]
2051
+ state = self._sort_state.get(key)
2052
+ if state is True:
2053
+ image = self._icon_sort_up if self._icon_sort_up else ""
2054
+ elif state is False:
2055
+ image = self._icon_sort_down if self._icon_sort_down else ""
2056
+ self._tree.heading(idx, text=text, image=image)
2057
+
2058
+ def _remember_page(self, page: int, records: list[dict]) -> None:
2059
+ if self._paging['cache_size'] <= 0:
2060
+ return
2061
+ # Move/update LRU cache
2062
+ if page in self._page_cache:
2063
+ self._page_cache.pop(page)
2064
+ self._page_cache[page] = records
2065
+ if len(self._page_cache) > self._paging['cache_size']:
2066
+ self._page_cache.popitem(last=False)
2067
+
2068
+ def _focus_record(self, record_id) -> None:
2069
+ """Select and scroll to a record by id if it's on the current page."""
2070
+ try:
2071
+ rid = str(record_id)
2072
+ for iid, rec in self._row_map.items():
2073
+ if str(self._record_id(rec)) == rid:
2074
+ self._tree.selection_set(iid)
2075
+ self._tree.see(iid)
2076
+ break
2077
+ except Exception:
2078
+ pass
2079
+
2080
+ def _find_record_page(self, record_id) -> int | None:
2081
+ """Locate the page index containing the given record id, if available."""
2082
+ try:
2083
+ rid = str(record_id)
2084
+ total_pages = self._total_pages()
2085
+ for page_idx in range(total_pages):
2086
+ try:
2087
+ with self._apply_view_to_source():
2088
+ rows = self._datasource.page(page_idx)
2089
+ except Exception:
2090
+ break
2091
+ if any(str(self._record_id(rec)) == rid for rec in rows):
2092
+ return page_idx
2093
+ except Exception:
2094
+ pass
2095
+ return None
2096
+
2097
+ def _auto_size_columns(self, records: list[dict] | None = None) -> None:
2098
+ """Auto-size columns to the widest value among current rows/headings."""
2099
+ if not self._column_keys:
2100
+ return
2101
+ try:
2102
+ style = get_style()
2103
+ # Prefer the Treeview body font; fall back to TLabel/body or default
2104
+ tv_style = self._tree.cget("style") or "Treeview"
2105
+ body_font = (
2106
+ style.lookup(tv_style, "font")
2107
+ or style.lookup("TLabel", "font")
2108
+ or getattr(style, "fonts", {}).get("body")
2109
+ or "TkDefaultFont"
2110
+ )
2111
+ content_font = tkfont.nametofont(body_font)
2112
+ except Exception:
2113
+ content_font = None
2114
+
2115
+ pad_px = 20
2116
+
2117
+ # Gather samples from headings, provided records, and current tree values
2118
+ tree_samples = []
2119
+ for iid in self._tree.get_children(""):
2120
+ tree_samples.append(self._tree.item(iid, "values"))
2121
+ for ciid in self._tree.get_children(iid):
2122
+ tree_samples.append(self._tree.item(ciid, "values"))
2123
+
2124
+ for idx, key in enumerate(self._column_keys):
2125
+ samples = []
2126
+ if idx < len(self._heading_texts):
2127
+ samples.append(str(self._heading_texts[idx]))
2128
+ if records:
2129
+ for rec in records:
2130
+ samples.append(str(rec.get(key, "")))
2131
+ for vals in tree_samples:
2132
+ if idx < len(vals):
2133
+ samples.append(str(vals[idx]))
2134
+
2135
+ # Honor explicit column width if provided
2136
+ explicit_width = None
2137
+ if idx < len(self._column_defs):
2138
+ coldef = self._column_defs[idx]
2139
+ if isinstance(coldef, dict):
2140
+ explicit_width = coldef.get("width")
2141
+
2142
+ if explicit_width is not None:
2143
+ try:
2144
+ self._tree.column(idx, width=explicit_width, minwidth=self._column_min_width)
2145
+ except Exception:
2146
+ pass
2147
+ continue
2148
+
2149
+ text = max(samples, key=len) if samples else ""
2150
+ if content_font:
2151
+ width = content_font.measure(text) + pad_px
2152
+ else:
2153
+ width = 0
2154
+ # Fallback to simple char-based estimate to avoid under-measuring
2155
+ char_estimate = len(text) * 10 + pad_px
2156
+ width = max(width, char_estimate, self._column_min_width)
2157
+ # Cap width to available viewport so we don't force the tree wider than its frame
2158
+ try:
2159
+ avail = max(0, int(self._tree.winfo_width()) - pad_px)
2160
+ if avail > 0:
2161
+ width = min(width, avail)
2162
+ except Exception:
2163
+ pass
2164
+ try:
2165
+ self._tree.column(idx, width=width, minwidth=self._column_min_width)
2166
+ except Exception:
2167
+ pass
2168
+
2169
+ def _bs_apply_theme(self, _event: Any = None) -> None:
2170
+ """Re-resolve imperatively-colored visuals on a theme change.
2171
+
2172
+ The ttk style rebuild refreshes neither the imperative stripe tag colors
2173
+ nor the theme-colored marker / group-chevron / sort-arrow PhotoImages —
2174
+ the latter are cached by color and held on rows, so without this they
2175
+ keep their old-theme tint after a light/dark toggle.
2176
+ """
2177
+ try:
2178
+ self._apply_row_alternation()
2179
+ except Exception:
2180
+ pass
2181
+ # Drop the by-color icon cache and re-apply every theme-colored glyph so
2182
+ # it re-renders against the new theme. Each call no-ops when its glyph
2183
+ # isn't in play (e.g. markers only in multi-select, chevrons only in
2184
+ # group mode), so this is safe regardless of the current configuration.
2185
+ try:
2186
+ self._marker_icons.clear()
2187
+ self._update_selection_markers()
2188
+ self._refresh_group_chevrons()
2189
+ self._update_heading_icons()
2190
+ except Exception:
2191
+ pass
2192
+
2193
+ def _apply_row_alternation(self) -> None:
2194
+ """Apply alternating row colors via a tag."""
2195
+ enabled = self._row_alternation.get('enabled', False)
2196
+ if not enabled or self._group_by_key:
2197
+ return
2198
+ bg, fg = self._resolve_alternating_row_color()
2199
+ try:
2200
+ self._tree.tag_configure("altrow", background=bg, foreground=fg)
2201
+ # Some themes honor the "striped" tag name; configure it too
2202
+ self._tree.tag_configure("striped", background=bg, foreground=fg)
2203
+ except Exception:
2204
+ return
2205
+
2206
+ queue = list(self._tree.get_children(""))
2207
+ idx = 0
2208
+ while queue:
2209
+ iid = queue.pop(0)
2210
+ try:
2211
+ tags = list(self._tree.item(iid, "tags") or [])
2212
+ if idx % 2 == 1:
2213
+ if "altrow" not in tags:
2214
+ tags.append("altrow")
2215
+ if "striped" not in tags:
2216
+ tags.append("striped")
2217
+ else:
2218
+ tags = [t for t in tags if t not in ("altrow", "striped")]
2219
+ self._tree.item(iid, tags=tags)
2220
+ except Exception:
2221
+ pass
2222
+ queue.extend(list(self._tree.get_children(iid)))
2223
+ idx += 1
2224
+
2225
+ # ------------------------------------------------------------------ Selection markers
2226
+ def _selection_markers_active(self) -> bool:
2227
+ """Whether per-row selection checkboxes should be shown right now.
2228
+
2229
+ Multi-select only — single-select relies on the row highlight alone, and
2230
+ grouped mode reserves the leading slot for the expand/collapse control.
2231
+ """
2232
+ return (
2233
+ self._selection_indicators
2234
+ and not self._group_by_key
2235
+ and self._selection.get('mode', 'single') == 'multi'
2236
+ )
2237
+
2238
+ def _marker_icon_specs(self) -> tuple[int, str]:
2239
+ """Resolve (size, color) for marker icons from the active theme.
2240
+
2241
+ Rendered at a fixed even pixel size: the glyph is blitted 1:1 into the
2242
+ row, so a clean target size keeps it crisp and avoids the resampling
2243
+ softness that DPI-scaling a small icon introduces.
2244
+ """
2245
+ builder = get_style().style_builder
2246
+ try:
2247
+ color = builder.on_color(builder.color('content'))
2248
+ except Exception:
2249
+ color = '#000000'
2250
+ return _MARKER_ICON_SIZE, color
2251
+
2252
+ def _marker_column_width(self) -> int:
2253
+ """Width for the tree column (#0) when it hosts a selection marker.
2254
+
2255
+ Accounts for the icon plus the indicator/spacer/padding the shared item
2256
+ layout reserves ahead of the image slot.
2257
+ """
2258
+ size, _ = self._marker_icon_specs()
2259
+ try:
2260
+ pad = get_style().style_builder.scale(24)
2261
+ except Exception:
2262
+ pad = 24
2263
+ return int(size + pad)
2264
+
2265
+ def _marker_accent_color(self) -> str:
2266
+ """Solid accent color used to fill the checked/selected marker glyph."""
2267
+ builder = get_style().style_builder
2268
+ token = self._selection.get('accent', 'primary')
2269
+ try:
2270
+ return builder.color(token)
2271
+ except Exception:
2272
+ try:
2273
+ return builder.color('primary')
2274
+ except Exception:
2275
+ return self._marker_icon_specs()[1]
2276
+
2277
+ def _marker_unchecked_color(self) -> str:
2278
+ """Color for the empty (unchecked) box outline — a muted neutral."""
2279
+ builder = get_style().style_builder
2280
+ try:
2281
+ return builder.color('muted')
2282
+ except Exception:
2283
+ return self._marker_icon_specs()[1]
2284
+
2285
+ def _marker_icon(self, name: str | None, color: str | None = None):
2286
+ """Return a cached marker icon for the current theme.
2287
+
2288
+ `color` overrides the default neutral foreground (used to fill the
2289
+ checked/selected glyph with the accent). A `name` of None yields a
2290
+ transparent placeholder so unmarked rows keep their text alignment.
2291
+ """
2292
+ size, default_color = self._marker_icon_specs()
2293
+ use_color = color or default_color
2294
+ key = (name, size, use_color)
2295
+ cached = self._marker_icons.get(key)
2296
+ if cached is not None:
2297
+ return cached
2298
+ try:
2299
+ if name is None:
2300
+ from bootstack.style.utility import create_transparent_image
2301
+ img = create_transparent_image(size, size)
2302
+ else:
2303
+ img = _ImageService.get_icon(name, size, use_color)
2304
+ except Exception:
2305
+ return None
2306
+ self._marker_icons[key] = img
2307
+ return img
2308
+
2309
+ def _update_selection_markers(self) -> None:
2310
+ """Mirror the current selection as a per-row checkbox (multi-select).
2311
+
2312
+ Visual only — selection is still driven by clicks/keyboard. The checked
2313
+ box is filled with the accent; the unchecked box is a muted outline.
2314
+ """
2315
+ if not self._selection_markers_active():
2316
+ return
2317
+ selected = set(self._tree.selection())
2318
+ on_icon = self._marker_icon('check-square-fill', self._marker_accent_color())
2319
+ off_icon = self._marker_icon('square', self._marker_unchecked_color())
2320
+ for iid in self._tree.get_children(""):
2321
+ img = on_icon if iid in selected else off_icon
2322
+ try:
2323
+ self._tree.item(iid, image=img if img is not None else "")
2324
+ except Exception:
2325
+ pass
2326
+
2327
+ # ------------------------------------------------------------------ Group chevrons
2328
+ def _chevron_icon(self, opened: bool):
2329
+ """Cached expand/collapse chevron for a group-header row, neutral-toned."""
2330
+ name = _GROUP_OPEN_ICON if opened else _GROUP_CLOSED_ICON
2331
+ return self._marker_icon(name)
2332
+
2333
+ def _toggle_group_open(self, iid) -> None:
2334
+ """Flip a group header's open state and swap its chevron to match.
2335
+
2336
+ Setting `open` programmatically does not fire `<<TreeviewOpen/Close>>`,
2337
+ so the chevron is updated here directly (the event bindings still cover
2338
+ keyboard-driven expand/collapse).
2339
+ """
2340
+ try:
2341
+ new_state = not bool(int(self._tree.item(iid, "open") or 0))
2342
+ self._tree.item(iid, open=new_state, image=self._chevron_icon(new_state))
2343
+ except Exception:
2344
+ pass
2345
+
2346
+ def _refresh_group_chevrons(self, _event=None) -> None:
2347
+ """Sync every group header's chevron to its current open/closed state."""
2348
+ if not self._group_by_key:
2349
+ return
2350
+ for iid in self._group_parents.values():
2351
+ try:
2352
+ opened = bool(int(self._tree.item(iid, "open") or 0))
2353
+ img = self._chevron_icon(opened)
2354
+ if img is not None:
2355
+ self._tree.item(iid, image=img)
2356
+ except Exception:
2357
+ pass
2358
+
2359
+ def _rebalance_grouped_widths(self) -> None:
2360
+ """Distribute available width across data columns when grouped so the left tree column is included."""
2361
+ # Only rebalance when grouping is active and xscroll is off (otherwise user can scroll)
2362
+ if not self._group_by_key or self._paging['xscroll']:
2363
+ return
2364
+ try:
2365
+ tree_width = max(0, int(self._tree.winfo_width()))
2366
+ group_width = max(0, int(self._tree.column("#0", option="width") or 0))
2367
+ vsb_width = 0
2368
+ if getattr(self, "_vsb", None):
2369
+ try:
2370
+ self._vsb.update_idletasks()
2371
+ if self._vsb.winfo_ismapped():
2372
+ vsb_width = int(self._vsb.winfo_width())
2373
+ except Exception:
2374
+ vsb_width = 0
2375
+ # Leave a small cushion to avoid oscillating scrollbar
2376
+ available = tree_width - group_width - vsb_width - 8
2377
+ if available <= 0:
2378
+ return
2379
+ cols = [c for c in self._display_columns if c < len(self._heading_texts)]
2380
+ if not cols:
2381
+ return
2382
+ width = max(self._column_min_width, available // len(cols))
2383
+ for c in cols:
2384
+ self._tree.column(c, width=width, stretch=True)
2385
+ # Keep the group column fixed so only data columns flex
2386
+ self._tree.column("#0", stretch=False)
2387
+ except Exception:
2388
+ pass
2389
+
2390
+ def _on_tree_configure(self, _event=None) -> None:
2391
+ """Handle resize events to keep grouped layouts sized to the available width."""
2392
+ self._rebalance_grouped_widths()
2393
+
2394
+ # ------------------------------------------------------------------ Export helpers
2395
+ # ------------------------------------------------------------------ Export — data access
2396
+ def _export_columns(self) -> tuple[list[str], list[str]]:
2397
+ """Return `(header_texts, keys)` for the displayed columns (no internal columns)."""
2398
+ _hidden = self._internal_fields()
2399
+ keys = [k for k in self._column_keys if k not in _hidden]
2400
+ headers = [self._column_heading(k) for k in keys]
2401
+ return headers, keys
2402
+
2403
+ def _ds_page_size(self) -> int:
2404
+ """Page size used by the data source (matches what `_load_page` renders)."""
2405
+ return getattr(self._datasource, "page_size", self._paging['page_size'])
2406
+
2407
+ def _scope_count(self, scope: str) -> int:
2408
+ """Number of rows the given scope would export."""
2409
+ if scope == "selection":
2410
+ return len(self._tree.selection())
2411
+ with self._apply_view_to_source():
2412
+ if scope == "page":
2413
+ psize = self._ds_page_size()
2414
+ start = self._current_page * psize
2415
+ return len(self._datasource.page_slice(start, psize))
2416
+ return self._datasource.count
2417
+
2418
+ def _iter_raw_chunks(self, scope: str, chunk_size: int):
2419
+ """Yield lists of raw records for `scope`, paging through large 'all' scopes."""
2420
+ if scope == "selection":
2421
+ rows = [self._row_map[iid] for iid in self._tree.selection() if iid in self._row_map]
2422
+ if rows:
2423
+ yield rows
2424
+ return
2425
+ # The view (this table's filter/sort) is applied to the SHARED source only
2426
+ # for the duration of each read, never across a `yield` — otherwise a
2427
+ # caller that stops iterating early (e.g. `break`s out of `iter_rows`)
2428
+ # would suspend the generator inside the context manager and leave the
2429
+ # shared source clobbered until GC, defeating per-view isolation.
2430
+ if scope == "page":
2431
+ with self._apply_view_to_source():
2432
+ psize = self._ds_page_size()
2433
+ start = self._current_page * psize
2434
+ rows = self._datasource.page_slice(start, psize)
2435
+ if rows:
2436
+ yield rows
2437
+ return
2438
+ # 'all' (the filtered/sorted set) — page through so memory stays flat.
2439
+ with self._apply_view_to_source():
2440
+ total = self._datasource.count
2441
+ offset = 0
2442
+ while offset < total:
2443
+ with self._apply_view_to_source():
2444
+ chunk = self._datasource.page_slice(offset, chunk_size)
2445
+ if not chunk:
2446
+ break
2447
+ yield chunk
2448
+ offset += len(chunk)
2449
+
2450
+ def _to_delimited(self, rows: list, delimiter: str) -> str:
2451
+ """Serialize `rows` (raw records) as delimited text over the displayed columns."""
2452
+ import csv
2453
+ import io
2454
+
2455
+ headers, keys = self._export_columns()
2456
+ buf = io.StringIO()
2457
+ writer = csv.writer(buf, delimiter=delimiter)
2458
+ writer.writerow(headers)
2459
+ writer.writerows([[rec.get(k, "") for k in keys] for rec in rows])
2460
+ return buf.getvalue()
2461
+
2462
+ def _guard_materialize(self, scope: str, max_rows: int | None) -> None:
2463
+ if max_rows is None:
2464
+ return
2465
+ n = self._scope_count(scope)
2466
+ if n > max_rows:
2467
+ raise ValueError(
2468
+ f"{n} rows exceeds max_rows={max_rows}; use iter_rows() or "
2469
+ f"export_file() to stream large exports."
2470
+ )
2471
+
2472
+ def to_rows(self, scope: Literal["all", "page", "selection"] = "all", *, max_rows: int | None = _EXPORT_MAX_MATERIALIZE) -> list[dict]:
2473
+ """Return the scope's records as a list of dicts (materialized — small data).
2474
+
2475
+ Raises if the row count exceeds `max_rows`; use `iter_rows()` for large data.
2476
+ """
2477
+ self._guard_materialize(scope, max_rows)
2478
+ return [self._public_record(r) for chunk in self._iter_raw_chunks(scope, _EXPORT_CHUNK_SIZE) for r in chunk]
2479
+
2480
+ def to_csv(self, scope: Literal["all", "page", "selection"] = "all", *, max_rows: int | None = _EXPORT_MAX_MATERIALIZE) -> str:
2481
+ """Return the scope's data as a CSV string (materialized — small data).
2482
+
2483
+ Raises if the row count exceeds `max_rows`; use `export_file()` for large data.
2484
+ """
2485
+ self._guard_materialize(scope, max_rows)
2486
+ rows = [r for chunk in self._iter_raw_chunks(scope, _EXPORT_CHUNK_SIZE) for r in chunk]
2487
+ return self._to_delimited(rows, ",")
2488
+
2489
+ def iter_rows(self, scope: Literal["all", "page", "selection"] = "all", chunk_size: int = _EXPORT_CHUNK_SIZE):
2490
+ """Lazily yield the scope's records one at a time, paging the data source."""
2491
+ for chunk in self._iter_raw_chunks(scope, chunk_size):
2492
+ for rec in chunk:
2493
+ yield self._public_record(rec)
2494
+
2495
+ # ------------------------------------------------------------------ Export — file/clipboard
2496
+ def _configured_export_formats(self) -> list[str]:
2497
+ """The export formats this table offers (the `export_formats` config)."""
2498
+ configured = self._exporting.get('formats') or ('csv',)
2499
+ return [f for f in configured if f in _EXPORT_FORMATS]
2500
+
2501
+ def _available_export_formats(self) -> list[str]:
2502
+ """Configured formats whose optional dependency (if any) is installed."""
2503
+ return [f for f in self._configured_export_formats() if _EXPORT_FORMATS[f]["available"]()]
2504
+
2505
+ def _warn_unavailable_export_formats(self) -> None:
2506
+ """Warn (developer-facing) about configured formats missing their dependency."""
2507
+ for fmt in self._configured_export_formats():
2508
+ spec = _EXPORT_FORMATS[fmt]
2509
+ if not spec["available"]():
2510
+ logger.warning(
2511
+ "DataTable export_formats includes %r, but its dependency is not "
2512
+ "installed (pip install bootstack[%s]); it is hidden from the export "
2513
+ "menu until then.", fmt, spec["extra"],
2514
+ )
2515
+
2516
+ def _resolve_format(self, path: str, fmt: str | None) -> str:
2517
+ """Resolve the export format from an explicit `fmt` or the path extension.
2518
+
2519
+ Validates against the configured `export_formats` and that the format's
2520
+ optional dependency (if any) is installed.
2521
+ """
2522
+ if fmt:
2523
+ name = fmt.lower()
2524
+ else:
2525
+ ext = os.path.splitext(path)[1].lower()
2526
+ name = _EXT_TO_EXPORT_FORMAT.get(ext)
2527
+ configured = self._configured_export_formats()
2528
+ if name not in _EXPORT_FORMATS or name not in configured:
2529
+ raise ValueError(
2530
+ f"Export format {name!r} is not enabled. Configured formats: "
2531
+ f"{', '.join(configured)}. Add it via export_formats=."
2532
+ )
2533
+ spec = _EXPORT_FORMATS[name]
2534
+ if not spec["available"]():
2535
+ raise RuntimeError(
2536
+ f"{name} export requires an optional dependency: "
2537
+ f"pip install bootstack[{spec['extra']}]."
2538
+ )
2539
+ return name
2540
+
2541
+ def _make_writer(self, path: str, fmt: str, headers: list[str], keys: list[str]):
2542
+ """Open a streaming writer for `fmt`; return `(write_chunk, close)`.
2543
+
2544
+ Both writers append incrementally (csv row-by-row, xlsx in
2545
+ constant-memory mode), so the full dataset is never materialized.
2546
+ """
2547
+ if fmt == "xlsx":
2548
+ import xlsxwriter
2549
+
2550
+ workbook = xlsxwriter.Workbook(path, {"constant_memory": True})
2551
+ worksheet = workbook.add_worksheet()
2552
+ bold = workbook.add_format({"bold": True})
2553
+ for c, header in enumerate(headers):
2554
+ worksheet.write(0, c, header, bold)
2555
+ state = {"row": 1}
2556
+
2557
+ def write_chunk(chunk):
2558
+ row_idx = state["row"]
2559
+ for rec in chunk:
2560
+ for c, key in enumerate(keys):
2561
+ worksheet.write(row_idx, c, rec.get(key))
2562
+ row_idx += 1
2563
+ state["row"] = row_idx
2564
+
2565
+ return write_chunk, workbook.close
2566
+
2567
+ import csv
2568
+
2569
+ delimiter = "\t" if fmt == "tsv" else ","
2570
+ handle = open(path, "w", newline="", encoding="utf-8")
2571
+ writer = csv.writer(handle, delimiter=delimiter)
2572
+ writer.writerow(headers)
2573
+
2574
+ def write_chunk(chunk):
2575
+ writer.writerows([[rec.get(k, "") for k in keys] for rec in chunk])
2576
+
2577
+ return write_chunk, handle.close
2578
+
2579
+ def export_file(
2580
+ self,
2581
+ path: str,
2582
+ scope: Literal["all", "page", "selection"] = "all",
2583
+ *,
2584
+ format: str | None = None,
2585
+ chunk_size: int = _EXPORT_CHUNK_SIZE,
2586
+ on_progress=None,
2587
+ ) -> int:
2588
+ """Stream the scope's data to `path`, paging so memory stays flat.
2589
+
2590
+ Synchronous. Format is inferred from the path extension unless `format`
2591
+ is given. `on_progress(written, total)` is called after each chunk.
2592
+ Returns the number of rows written.
2593
+ """
2594
+ fmt = self._resolve_format(path, format)
2595
+ if _EXPORT_FORMATS[fmt]["kind"] == "registry":
2596
+ return self._export_file_registry(path, scope, fmt, chunk_size, on_progress)
2597
+ headers, keys = self._export_columns()
2598
+ total = self._scope_count(scope)
2599
+ write_chunk, close = self._make_writer(path, fmt, headers, keys)
2600
+ written = 0
2601
+ try:
2602
+ for chunk in self._iter_raw_chunks(scope, chunk_size):
2603
+ write_chunk(chunk)
2604
+ written += len(chunk)
2605
+ if on_progress is not None:
2606
+ on_progress(written, total)
2607
+ finally:
2608
+ close()
2609
+ self._emit_export(target="file", fmt=fmt, path=path, count=written)
2610
+ return written
2611
+
2612
+ def _export_file_registry(self, path, scope, fmt, chunk_size, on_progress) -> int:
2613
+ """Synchronously stream a registry-format export over the displayed columns.
2614
+
2615
+ Records are projected to the exported columns (what the table shows) and
2616
+ streamed through `bootstack.data.writers`, so the export carries the same
2617
+ columns as CSV/XLSX. (For the full record set, use
2618
+ `table.data_source.save(path)`.)
2619
+ """
2620
+ from bootstack.data.writers import write_records
2621
+
2622
+ _headers, keys = self._export_columns()
2623
+ total = self._scope_count(scope)
2624
+ counter = {"n": 0}
2625
+
2626
+ def _records():
2627
+ for chunk in self._iter_raw_chunks(scope, chunk_size):
2628
+ for raw in chunk:
2629
+ pub = self._public_record(raw)
2630
+ yield {k: pub.get(k) for k in keys}
2631
+ counter["n"] += len(chunk)
2632
+ if on_progress is not None:
2633
+ on_progress(counter["n"], total)
2634
+
2635
+ write_records(path, _records(), format=fmt)
2636
+ self._emit_export(target="file", fmt=fmt, path=path, count=counter["n"])
2637
+ return counter["n"]
2638
+
2639
+ def export_file_async(
2640
+ self,
2641
+ path: str,
2642
+ scope: Literal["all", "page", "selection"] = "all",
2643
+ *,
2644
+ format: Literal["csv", "tsv", "xlsx"] | None = None,
2645
+ chunk_size: int = _EXPORT_CHUNK_SIZE,
2646
+ on_progress=None,
2647
+ on_done=None,
2648
+ ) -> _ExportJob:
2649
+ """Stream to `path` without blocking the UI; return a cancelable job.
2650
+
2651
+ Writes one chunk per idle tick. `on_progress(written, total)` fires per
2652
+ chunk; `on_done(status, written, error)` fires once at the end with
2653
+ `status` in `'completed'` / `'cancelled'` / `'error'`. Cancel or error
2654
+ removes the partial file. Call `job.cancel()` to stop.
2655
+ """
2656
+ fmt = self._resolve_format(path, format)
2657
+ if _EXPORT_FORMATS[fmt]["kind"] == "registry":
2658
+ raise ValueError(
2659
+ f"Async export supports csv/tsv/xlsx; {fmt!r} writes synchronously — "
2660
+ f"use export_file()."
2661
+ )
2662
+ headers, keys = self._export_columns()
2663
+ total = self._scope_count(scope)
2664
+ write_chunk, close = self._make_writer(path, fmt, headers, keys)
2665
+ chunks = self._iter_raw_chunks(scope, chunk_size)
2666
+
2667
+ def _complete(status, written, error):
2668
+ self._export_jobs.discard(job)
2669
+ if status == "completed":
2670
+ self._emit_export(target="file", fmt=fmt, path=path, count=written)
2671
+ else:
2672
+ try:
2673
+ os.remove(path)
2674
+ except OSError:
2675
+ pass
2676
+ if status == "error":
2677
+ logger.error("Async export to %s failed", path, exc_info=error)
2678
+ if on_done is not None:
2679
+ on_done(status, written, error)
2680
+
2681
+ job = _ExportJob(
2682
+ self, chunks, write_chunk, close, total,
2683
+ on_progress=on_progress, on_complete=_complete,
2684
+ )
2685
+ self._export_jobs.add(job)
2686
+ return job.start()
2687
+
2688
+ def _emit_export(self, *, target: str, fmt: str, path: str | None, count: int, records=None) -> None:
2689
+ self.event_generate(
2690
+ "<<Export>>",
2691
+ data=ExportEvent(count=count, target=target, format=fmt, path=path, records=records or []),
2692
+ )
2693
+
2694
+ def _default_scope(self) -> str:
2695
+ """Scope for the built-in export actions: the selection if any, else all."""
2696
+ if self._exporting.get('allow_export_selection', True) and self._tree.selection():
2697
+ return "selection"
2698
+ return "all"
2699
+
2700
+ def _update_export_labels(self) -> None:
2701
+ """Reflect the implicit export scope (selection vs all) in the menu labels."""
2702
+ btn = getattr(self, "_export_btn", None)
2703
+ tree = getattr(self, "_tree", None)
2704
+ if btn is None or tree is None:
2705
+ return
2706
+ count = len(tree.selection())
2707
+ if self._exporting.get('allow_export_selection', True) and count > 0:
2708
+ copy_text = f"Copy selection ({count:,})"
2709
+ save_text = f"Save selection ({count:,})"
2710
+ else:
2711
+ copy_text = "Copy to clipboard"
2712
+ save_text = "Save to file"
2713
+ try:
2714
+ btn.configure_item("export_copy", text=copy_text)
2715
+ btn.configure_item("export_save", text=save_text)
2716
+ except Exception:
2717
+ logger.exception("Failed to update export menu labels")
2718
+
2719
+ def _copy_to_clipboard(self) -> None:
2720
+ """Copy the default scope to the clipboard as tab-separated text."""
2721
+ scope = self._default_scope()
2722
+ rows = [r for chunk in self._iter_raw_chunks(scope, _EXPORT_CHUNK_SIZE) for r in chunk]
2723
+ if not rows:
2724
+ return
2725
+ try:
2726
+ self.clipboard_clear()
2727
+ self.clipboard_append(self._to_delimited(rows, "\t"))
2728
+ except Exception:
2729
+ logger.exception("Failed to copy table to clipboard")
2730
+ return
2731
+ self._emit_export(
2732
+ target="clipboard", fmt="tsv", path=None, count=len(rows),
2733
+ records=[self._public_record(r) for r in rows],
2734
+ )
2735
+
2736
+ def _save_to_file(self) -> None:
2737
+ """Prompt for a destination and stream the default scope to it.
2738
+
2739
+ Small exports run synchronously (instant); large ones run on the event
2740
+ loop with a progress dialog so the UI stays responsive and cancelable.
2741
+ """
2742
+ from tkinter import filedialog
2743
+
2744
+ available = self._available_export_formats() or ["csv"]
2745
+ filetypes = [(_EXPORT_FORMATS[f]["label"], "*" + _EXPORT_FORMATS[f]["ext"]) for f in available]
2746
+ filetypes.append(("All files", "*.*"))
2747
+ default_fmt = available[0]
2748
+ default_ext = _EXPORT_FORMATS[default_fmt]["ext"]
2749
+
2750
+ scope = self._default_scope()
2751
+ path = filedialog.asksaveasfilename(
2752
+ parent=self.winfo_toplevel(),
2753
+ title=MessageCatalog.translate("table.export"),
2754
+ defaultextension=default_ext,
2755
+ initialfile="table_export" + default_ext,
2756
+ filetypes=filetypes,
2757
+ )
2758
+ if not path:
2759
+ return
2760
+
2761
+ # Registry formats write synchronously; only the cooperative formats
2762
+ # (csv/tsv/xlsx) use the async progress path for very large exports.
2763
+ try:
2764
+ fmt = self._resolve_format(path, None)
2765
+ except Exception:
2766
+ logger.exception("Failed to resolve export format for %s", path)
2767
+ return
2768
+ cooperative = _EXPORT_FORMATS[fmt]["kind"] == "cooperative"
2769
+ if not cooperative or self._scope_count(scope) <= _EXPORT_ASYNC_THRESHOLD:
2770
+ try:
2771
+ self.export_file(path, scope=scope)
2772
+ except Exception:
2773
+ logger.exception("Failed to export table to %s", path)
2774
+ return
2775
+ self._save_to_file_with_progress(path, scope)
2776
+
2777
+ def _save_to_file_with_progress(self, path: str, scope: str) -> None:
2778
+ """Run a large export on the event loop behind a non-blocking progress window.
2779
+
2780
+ The window does not block (no nested loop), so the export's chunk steps
2781
+ run on the live main loop and the bar updates as it goes.
2782
+ """
2783
+ from bootstack._runtime.toplevel import Toplevel
2784
+
2785
+ total = self._scope_count(scope)
2786
+ parent = self.winfo_toplevel()
2787
+ state: dict = {"job": None, "destroyed": False}
2788
+
2789
+ top = Toplevel(
2790
+ title=MessageCatalog.translate("table.export"),
2791
+ master=parent,
2792
+ transient=parent,
2793
+ minsize=(440, 120),
2794
+ resizable=(False, False),
2795
+ center_on_parent=True,
2796
+ )
2797
+ frame = Frame(top, padding=14)
2798
+ frame.pack(fill="both", expand=True)
2799
+ label = Label(frame, text=f"Exporting 0 of {total:,}…")
2800
+ label.pack(anchor="w", pady=(0, 8))
2801
+ bar = Progressbar(frame, mode="determinate", maximum=max(total, 1), value=0)
2802
+ bar.pack(fill="x", pady=(0, 8))
2803
+ Label(frame, text="Close this window to cancel.", accent="muted").pack(anchor="w")
2804
+
2805
+ def cancel_export():
2806
+ """Cancel the running job (its partial file is removed on cancel)."""
2807
+ if state["job"] is not None:
2808
+ state["job"].cancel()
2809
+
2810
+ def destroy_window():
2811
+ if state["destroyed"]:
2812
+ return
2813
+ state["destroyed"] = True
2814
+ try:
2815
+ top.destroy()
2816
+ except Exception:
2817
+ pass
2818
+
2819
+ def on_progress(written, count):
2820
+ if not state["destroyed"]:
2821
+ bar.set(written)
2822
+ label.configure(text=f"Exporting {written:,} of {count:,}…")
2823
+
2824
+ def on_done(status, written, error):
2825
+ destroy_window()
2826
+ if status == "error":
2827
+ logger.error("Export to %s failed", path, exc_info=error)
2828
+
2829
+ def request_close():
2830
+ # Closing the window cancels the export and removes the partial file.
2831
+ state["destroyed"] = True
2832
+ cancel_export()
2833
+ return None
2834
+
2835
+ top.add_close_handler(request_close)
2836
+ top.show() # Toplevel starts withdrawn; reveal it before the export runs.
2837
+ state["job"] = self.export_file_async(path, scope, on_progress=on_progress, on_done=on_done)
2838
+
2839
+ # ------------------------------------------------------------------ Header click handling
2840
+ def _toggle_select_active(self) -> bool:
2841
+ """Whether a plain click should toggle a row (checklist behavior).
2842
+
2843
+ Active whenever the multi-select checkboxes are visible, where users
2844
+ expect a click to add/remove a row without holding Ctrl/Shift.
2845
+ """
2846
+ return self._selection_markers_active()
2847
+
2848
+ def _on_header_click(self, event):
2849
+ """Handle left-click: header sorting, or toggle-select with checkboxes."""
2850
+ # A left-click on the tree dismisses an open context menu. Do it
2851
+ # explicitly: the toggle-select and group-row branches below return
2852
+ # 'break', which stops the event before it reaches the window-level
2853
+ # outside-click handler that would otherwise close the menu.
2854
+ self._dismiss_context_menus()
2855
+ region = self._tree.identify_region(event.x, event.y)
2856
+ if region == "heading":
2857
+ if self._sorting == 'none':
2858
+ return None
2859
+ col_id = self._tree.identify_column(event.x) # e.g. "#1"
2860
+ try:
2861
+ display_idx = int(col_id.strip("#")) - 1
2862
+ except Exception:
2863
+ return None
2864
+ if display_idx < 0 or display_idx >= len(self._display_columns):
2865
+ return None
2866
+ column_idx = self._display_columns[display_idx]
2867
+ self._on_sort(column_idx)
2868
+ return None
2869
+
2870
+ # A row with children is expandable — clicking anywhere on it toggles
2871
+ # open/closed (the native indicator that used to own this click was
2872
+ # removed from the item layout). Leaf rows have no children, so they
2873
+ # fall through to normal selection below.
2874
+ iid = self._tree.identify_row(event.y)
2875
+ if iid and self._tree.get_children(iid):
2876
+ self._toggle_group_open(iid)
2877
+ return "break"
2878
+
2879
+ # Body click with checkboxes showing: treat the list like a checklist —
2880
+ # a plain click toggles the row in/out of the selection (no modifier),
2881
+ # and "break" suppresses ttk's default replace-the-selection behavior.
2882
+ if self._toggle_select_active():
2883
+ iid = self._tree.identify_row(event.y)
2884
+ if iid:
2885
+ if iid in self._tree.selection():
2886
+ self._tree.selection_remove(iid)
2887
+ else:
2888
+ self._tree.selection_add(iid)
2889
+ return "break"
2890
+ return None
2891
+
2892
+ def _filter_header_column(self) -> None:
2893
+ """Show filter dialog for the currently selected header column."""
2894
+ col = self._header_menu_col
2895
+ if col is None or col >= len(self._column_keys):
2896
+ return
2897
+ self._show_column_filter_dialog(col)
2898
+
2899
+ def _show_column_filter_dialog(self, column_idx: int) -> None:
2900
+ """Show FilterDialog with distinct values for the column."""
2901
+ from bootstack.dialogs._impl.filterdialog import FilterDialog
2902
+
2903
+ if column_idx >= len(self._column_keys):
2904
+ return
2905
+
2906
+ key = self._column_keys[column_idx]
2907
+ heading_text = self._heading_texts[column_idx] if column_idx < len(self._heading_texts) else key
2908
+
2909
+ # Get distinct values from datasource
2910
+ try:
2911
+ distinct_values = self._datasource.get_distinct_values(key)
2912
+ except Exception:
2913
+ distinct_values = []
2914
+
2915
+ if not distinct_values:
2916
+ return
2917
+
2918
+ empty_text = MessageCatalog.translate("table.empty")
2919
+ # Build items for the filter dialog
2920
+ current_filter = self._column_filters.get(key)
2921
+ items = []
2922
+ for val in distinct_values:
2923
+ display_text = str(val) if val is not None else empty_text
2924
+ selected = current_filter is None or val in current_filter
2925
+ items.append(
2926
+ {
2927
+ "text": display_text,
2928
+ "value": val,
2929
+ "selected": selected
2930
+ })
2931
+
2932
+ # Position dialog below the header
2933
+ col_id = f"#{self._display_columns.index(column_idx) + 1}" if column_idx in self._display_columns else "#1"
2934
+ pos_x = self._tree.winfo_rootx()
2935
+ pos_y = self._tree.winfo_rooty()
2936
+
2937
+ tree_items = self._tree.get_children()
2938
+ if tree_items:
2939
+ bbox = self._tree.bbox(tree_items[0], col_id)
2940
+ if bbox:
2941
+ pos_x = self._tree.winfo_rootx() + bbox[0]
2942
+ pos_y = self._tree.winfo_rooty() + bbox[1] + 2
2943
+
2944
+ dialog = FilterDialog(
2945
+ master=self.winfo_toplevel(),
2946
+ title=MessageCatalog.translate("table.filter_column", heading_text),
2947
+ items=items,
2948
+ enable_search=True,
2949
+ enable_select_all=True,
2950
+ undecorated=True
2951
+ )
2952
+
2953
+ result = dialog.show(position=(pos_x, pos_y))
2954
+
2955
+ if result is not None:
2956
+ self._apply_column_filter(key, result, distinct_values)
2957
+
2958
+ def _apply_column_filter(self, key: str, selected_values: list, all_values: list) -> None:
2959
+ """Apply column filter based on selected values."""
2960
+ # If all values selected, clear the filter for this column
2961
+ if set(selected_values) == set(all_values):
2962
+ self._column_filters.pop(key, None)
2963
+ else:
2964
+ self._column_filters[key] = selected_values
2965
+
2966
+ # Build combined WHERE clause from all column filters
2967
+ self._rebuild_filter_where()
2968
+
2969
+ def _build_column_filter_condition(self):
2970
+ """Build the combined condition from all active column filters (or None)."""
2971
+ clauses = []
2972
+ for key, values in self._column_filters.items():
2973
+ if not values:
2974
+ # No values selected = match nothing for this column
2975
+ clauses.append(col(key).is_in([]))
2976
+ continue
2977
+ non_null = [v for v in values if v is not None]
2978
+ parts = []
2979
+ if non_null:
2980
+ parts.append(col(key).is_in(non_null))
2981
+ if None in values:
2982
+ parts.append(col(key).is_null())
2983
+ clause = any_of(*parts)
2984
+ if clause is not None:
2985
+ clauses.append(clause)
2986
+ return all_of(*clauses)
2987
+
2988
+ def _rebuild_filter_where(self) -> None:
2989
+ """Re-apply the combined where() after a column filter change."""
2990
+ self._apply_where()
2991
+
2992
+ # ------------------------------------------------------------------ Context dispatch
2993
+ def _on_tree_context(self, event) -> None:
2994
+ if self._context_menus == "none":
2995
+ return
2996
+ region = self._tree.identify_region(event.x, event.y)
2997
+ if region == "heading":
2998
+ if not self._header_context_enabled():
2999
+ return
3000
+ self._on_header_context(event)
3001
+ else:
3002
+ if not self._row_context_enabled():
3003
+ return
3004
+ self._on_row_context(event)
3005
+
3006
+ def _on_selection_event(self, _event=None) -> None:
3007
+ """Forward selection changes to subscribers."""
3008
+ rows = self.selected_rows
3009
+ ids = [r.get("id") for r in rows]
3010
+ self._update_export_labels()
3011
+ self._update_selection_markers()
3012
+ self.event_generate("<<SelectionChange>>", data=SelectionEvent(records=rows, ids=ids))
3013
+
3014
+ def _on_row_click_event(self, event) -> None:
3015
+ region = self._tree.identify_region(event.x, event.y)
3016
+ if region == "heading":
3017
+ return
3018
+ iid = self._tree.identify_row(event.y)
3019
+ if not iid or iid not in self._row_map:
3020
+ return # empty space or a group-header row (no record)
3021
+ rec = self._row_map.get(iid, {})
3022
+ self.event_generate("<<RowClick>>", data=RowEvent(record=self._public_record(rec), id=self._record_id(rec)))
3023
+
3024
+ # ------------------------------------------------------------------ Header context menu
3025
+ def _ensure_header_menu(self) -> None:
3026
+ if not self._header_context_enabled():
3027
+ return
3028
+ if self._header_menu:
3029
+ return
3030
+ menu = ContextMenu(master=self, target=self._tree, trigger=None)
3031
+ menu.add_command(text="table.align_left", icon="align-start", command=self._align_header_left)
3032
+ menu.add_command(text="table.align_center", icon="align-center", command=self._align_header_center)
3033
+ menu.add_command(text="table.align_right", icon="align-end", command=self._align_header_right)
3034
+ menu.add_separator()
3035
+ menu.add_command(text="table.move_left", icon="arrow-left", command=self._move_header_left)
3036
+ menu.add_command(text="table.move_right", icon="arrow-right", command=self._move_header_right)
3037
+ menu.add_command(text="table.move_first", icon="arrow-bar-left", command=self._move_header_first)
3038
+ menu.add_command(text="table.move_last", icon="arrow-bar-right", command=self._move_header_last)
3039
+ menu.add_separator()
3040
+ menu.add_command(text="table.hide_column", icon="eye-slash", command=self._hide_header_column)
3041
+ menu.add_command(text="table.show_all", icon="eye", command=self._show_all_columns)
3042
+ if self._allow_grouping:
3043
+ menu.add_separator()
3044
+ menu.add_command(text="table.group_by_column", command=self._group_header_column)
3045
+ menu.add_command(text="table.ungroup_all", command=self._ungroup_all)
3046
+ menu.add_separator()
3047
+ menu.add_command(text="table.reset", icon="arrow-counterclockwise", command=self._reset_table)
3048
+ menu.add_separator()
3049
+ if not self._sorting == 'none':
3050
+ menu.add_command(text="table.clear_sort", icon="x-lg", command=self._clear_sort)
3051
+ self._header_menu = menu
3052
+
3053
+ def _on_header_context(self, event) -> None:
3054
+ if not self._header_context_enabled():
3055
+ return
3056
+ # Only handle header clicks
3057
+ if self._tree.identify_region(event.x, event.y) != "heading":
3058
+ return
3059
+ col_id = self._tree.identify_column(event.x) # e.g. "#1"
3060
+ try:
3061
+ idx = int(col_id.strip("#")) - 1
3062
+ except Exception:
3063
+ return
3064
+ if idx < 0 or idx >= len(self._display_columns):
3065
+ return
3066
+ self._header_menu_col = self._display_columns[idx]
3067
+ self._ensure_header_menu()
3068
+
3069
+ # Try to position at bottom-left of the clicked header
3070
+ pos_x, pos_y = event.x_root, event.y_root
3071
+ items = self._tree.get_children()
3072
+ if items:
3073
+ bbox = self._tree.bbox(items[0], col_id)
3074
+ if bbox:
3075
+ # bbox is relative to the widget; bbox[1] is header height offset
3076
+ pos_x = self._tree.winfo_rootx() + bbox[0]
3077
+ pos_y = self._tree.winfo_rooty() + bbox[1] + 2
3078
+ self._header_menu.show(position=(pos_x, pos_y))
3079
+
3080
+ def _align_header_left(self) -> None:
3081
+ self._set_heading_anchor("w")
3082
+
3083
+ def _align_header_center(self) -> None:
3084
+ self._set_heading_anchor("center")
3085
+
3086
+ def _align_header_right(self) -> None:
3087
+ self._set_heading_anchor("e")
3088
+
3089
+ def _set_heading_anchor(self, anchor: str) -> None:
3090
+ """Align only the header text for the selected column."""
3091
+ col = self._header_menu_col
3092
+ if col is None:
3093
+ return
3094
+ self._tree.heading(col, anchor=anchor)
3095
+ self._tree.column(col, anchor=anchor)
3096
+
3097
+ def _move_header_left(self) -> None:
3098
+ self._move_column(-1)
3099
+
3100
+ def _move_header_right(self) -> None:
3101
+ self._move_column(1)
3102
+
3103
+ def _move_header_first(self) -> None:
3104
+ self._move_column(to_index=0)
3105
+
3106
+ def _move_header_last(self) -> None:
3107
+ self._move_column(to_index=len(self._display_columns) - 1)
3108
+
3109
+ def _move_column(self, delta: int | None = None, to_index: int | None = None) -> None:
3110
+ col = self._header_menu_col
3111
+ if col is None or col not in self._display_columns:
3112
+ return
3113
+ current_pos = self._display_columns.index(col)
3114
+ if to_index is not None:
3115
+ new_pos = max(0, min(len(self._display_columns) - 1, to_index))
3116
+ else:
3117
+ new_pos = current_pos + (delta or 0)
3118
+ new_pos = max(0, min(len(self._display_columns) - 1, new_pos))
3119
+ if new_pos == current_pos:
3120
+ return
3121
+ self._display_columns.pop(current_pos)
3122
+ self._display_columns.insert(new_pos, col)
3123
+ self._tree.configure(displaycolumns=self._display_columns)
3124
+
3125
+ def _hide_header_column(self) -> None:
3126
+ col = self._header_menu_col
3127
+ if col is None or col not in self._display_columns:
3128
+ return
3129
+ self._display_columns.remove(col)
3130
+ if not self._display_columns:
3131
+ self._display_columns = list(range(len(self._heading_texts)))
3132
+ self._tree.configure(displaycolumns=self._display_columns)
3133
+
3134
+ def _show_all_columns(self) -> None:
3135
+ if not self._heading_texts:
3136
+ return
3137
+ self._display_columns = list(range(len(self._heading_texts)))
3138
+ self._tree.configure(displaycolumns=self._display_columns)
3139
+
3140
+ def _show_column_chooser_dialog(self) -> None:
3141
+ """Show a dialog to select which columns are visible."""
3142
+ from bootstack.dialogs._impl.filterdialog import FilterDialog
3143
+
3144
+ if not self._heading_texts:
3145
+ return
3146
+
3147
+ # Build items for the filter dialog
3148
+ items = []
3149
+ for idx, text in enumerate(self._heading_texts):
3150
+ items.append(
3151
+ {
3152
+ "text": text,
3153
+ "value": idx,
3154
+ "selected": idx in self._display_columns
3155
+ })
3156
+
3157
+ # Calculate position: align dialog's top-right to button's bottom-right
3158
+ btn = self._column_chooser_btn
3159
+ btn.update_idletasks()
3160
+ btn_right = btn.winfo_rootx() + btn.winfo_width()
3161
+ btn_bottom = btn.winfo_rooty() + btn.winfo_height()
3162
+ dialog_width = 250 # FilterDialog has fixed width of 250
3163
+ pos_x = btn_right - dialog_width - 2 # 2px west
3164
+ pos_y = btn_bottom + 2 # 2px south
3165
+
3166
+ dialog = FilterDialog(
3167
+ master=self.winfo_toplevel(),
3168
+ title="Columns",
3169
+ items=items,
3170
+ enable_search=False,
3171
+ enable_select_all=True,
3172
+ undecorated=True
3173
+ )
3174
+
3175
+ self._active_chooser_dialog = dialog
3176
+ try:
3177
+ result = dialog.show(position=(pos_x, pos_y))
3178
+ finally:
3179
+ self._active_chooser_dialog = None
3180
+
3181
+ if result is not None:
3182
+ # Update display columns based on selection
3183
+ self._display_columns = [idx for idx in result if isinstance(idx, int)]
3184
+ if not self._display_columns:
3185
+ # Ensure at least one column is visible
3186
+ self._display_columns = list(range(len(self._heading_texts)))
3187
+ self._tree.configure(displaycolumns=self._display_columns)
3188
+
3189
+ def _reset_table(self) -> None:
3190
+ # Reset sort, columns visibility/order, and reload first page
3191
+ self._display_columns = list(range(len(self._heading_texts)))
3192
+ self._tree.configure(displaycolumns=self._display_columns)
3193
+ self._clear_sort()
3194
+
3195
+ # ------------------------------------------------------------------ Grouping
3196
+ def _group_header_column(self) -> None:
3197
+ """Group current view by the selected header column."""
3198
+ col = self._header_menu_col
3199
+ if col is None or col >= len(self._column_keys):
3200
+ return
3201
+ key = self._column_keys[col]
3202
+ self._group_by_key = key
3203
+ self._group_parents.clear()
3204
+ self._sort_state = {key: True}
3205
+ self._clear_cache()
3206
+ self._update_heading_icons()
3207
+ self._load_page(0)
3208
+ self._update_status_labels()
3209
+
3210
+ def _ungroup_all(self) -> None:
3211
+ """Return to flat view."""
3212
+ if not self._group_by_key:
3213
+ return
3214
+ self._group_by_key = None
3215
+ self._group_parents.clear()
3216
+ self._apply_group_show_state(False)
3217
+ self._load_page(self._current_page)
3218
+ self._update_status_labels()
3219
+
3220
+ def _grouping_primary_index(self) -> int | None:
3221
+ """Display column promoted into the tree column (#0) while grouped.
3222
+
3223
+ The first visible column that isn't the group-by column: its values move
3224
+ into #0 (indented under the group headers), so #0 becomes a real data
3225
+ column rather than a reserved group-only column.
3226
+ """
3227
+ if not self._group_by_key or self._group_by_key not in self._column_keys:
3228
+ return None
3229
+ group_idx = self._column_keys.index(self._group_by_key)
3230
+ for c in self._display_columns:
3231
+ if c != group_idx:
3232
+ return c
3233
+ return None
3234
+
3235
+ def _apply_group_show_state(self, grouped: bool) -> None:
3236
+ """Toggle tree column visibility when grouping."""
3237
+ if grouped:
3238
+ self._tree.configure(show="tree headings")
3239
+ group_idx = self._column_keys.index(self._group_by_key)
3240
+ primary = self._grouping_primary_index()
3241
+ # #0 becomes the first non-group column: take its heading + width, and
3242
+ # drop both it and the group column out of the value columns (the group
3243
+ # appears as the header rows; the primary appears in #0).
3244
+ if primary is not None:
3245
+ heading_text = self._heading_texts[primary] if primary < len(self._heading_texts) else ""
3246
+ try:
3247
+ width = int(self._tree.column(primary, option="width")) or 200
3248
+ except Exception:
3249
+ width = 200
3250
+ self._tree.heading("#0", text=heading_text, anchor="w")
3251
+ self._tree.column("#0", width=max(width, 160), minwidth=120, anchor="w", stretch=False)
3252
+ else:
3253
+ self._tree.heading("#0", text="", anchor="w")
3254
+ self._tree.column("#0", width=200, minwidth=120, anchor="w", stretch=False)
3255
+ try:
3256
+ hidden = {group_idx, primary}
3257
+ visible = [c for c in self._display_columns if c not in hidden]
3258
+ self._tree.configure(displaycolumns=visible or self._display_columns)
3259
+ except Exception:
3260
+ pass
3261
+ try:
3262
+ # Reset horizontal view so the group column is not scrolled out
3263
+ self._tree.xview_moveto(0)
3264
+ except Exception:
3265
+ pass
3266
+ self._rebalance_grouped_widths()
3267
+ elif self._selection_markers_active():
3268
+ # Reveal a narrow tree column to host the per-row selection marker.
3269
+ self._tree.configure(show="tree headings")
3270
+ self._tree.heading("#0", text="")
3271
+ marker_w = self._marker_column_width()
3272
+ self._tree.column("#0", width=marker_w, minwidth=marker_w, anchor="center", stretch=False)
3273
+ self._restore_data_columns()
3274
+ else:
3275
+ self._tree.configure(show="headings")
3276
+ # Keep the tree column narrow/inert when unused
3277
+ self._tree.heading("#0", text="")
3278
+ self._tree.column("#0", width=0, minwidth=0, stretch=False)
3279
+ self._restore_data_columns()
3280
+
3281
+ def _restore_data_columns(self) -> None:
3282
+ """Show the full set of data columns (used when not grouped)."""
3283
+ try:
3284
+ self._tree.configure(displaycolumns=self._display_columns)
3285
+ except Exception:
3286
+ pass
3287
+ try:
3288
+ stretch_cols = not self._paging['xscroll']
3289
+ for idx in range(len(self._heading_texts)):
3290
+ self._tree.column(idx, stretch=stretch_cols)
3291
+ except Exception:
3292
+ pass
3293
+
3294
+ # ------------------------------------------------------------------ Cell formatting
3295
+ def _column_formatter(self, idx: int):
3296
+ """Resolve (and cache) a column's display formatter callable, or None.
3297
+
3298
+ A column's `format` may be a format-spec string (applied as
3299
+ `spec.format(value)`) or a callable `(value) -> str`.
3300
+ """
3301
+ if idx in self._column_formats:
3302
+ return self._column_formats[idx]
3303
+ formatter = None
3304
+ if 0 <= idx < len(self._column_defs):
3305
+ coldef = self._column_defs[idx]
3306
+ if isinstance(coldef, dict):
3307
+ spec = coldef.get("format")
3308
+ if callable(spec):
3309
+ formatter = spec
3310
+ elif isinstance(spec, str) and spec:
3311
+ formatter = (lambda s: (lambda v: s.format(v)))(spec)
3312
+ self._column_formats[idx] = formatter
3313
+ return formatter
3314
+
3315
+ def _format_cell(self, idx: int, value):
3316
+ """Apply a column's display formatter to a value (raw value on failure)."""
3317
+ if value is None or value == "":
3318
+ return value
3319
+ formatter = self._column_formatter(idx)
3320
+ if formatter is None:
3321
+ return value
3322
+ try:
3323
+ return formatter(value)
3324
+ except Exception:
3325
+ return value
3326
+
3327
+ def _display_values(self, rec: dict) -> list:
3328
+ """Build the formatted value row for display (record stays raw in _row_map)."""
3329
+ return [self._format_cell(i, rec.get(k, "")) for i, k in enumerate(self._column_keys)]
3330
+
3331
+ def _render_flat(self, records: list[dict]) -> None:
3332
+ """Insert records as flat rows."""
3333
+ stripe = self._row_alternation.get('enabled', False) and not self._group_by_key
3334
+ for idx, rec in enumerate(records):
3335
+ values = self._display_values(rec)
3336
+ tags = ("altrow",) if stripe and idx % 2 == 1 else ()
3337
+ iid = self._tree.insert("", "end", values=values, tags=tags)
3338
+ self._row_map[iid] = rec
3339
+
3340
+ def _render_grouped(self, records: list[dict]) -> None:
3341
+ """Insert records under group-header nodes.
3342
+
3343
+ Group headers are root-level nodes whose label sits in the tree column
3344
+ (#0). Each child carries its primary field (the first non-group column)
3345
+ as #0 text — indented under its group — with the full record kept in the
3346
+ value columns (the primary + group columns are hidden from those).
3347
+ """
3348
+ key = self._group_by_key
3349
+ if not key or key not in self._column_keys:
3350
+ self._render_flat(records)
3351
+ return
3352
+ primary_idx = self._grouping_primary_index()
3353
+ primary_key = self._column_keys[primary_idx] if primary_idx is not None else None
3354
+ groups: OrderedDict[str | None, list[dict]] = OrderedDict()
3355
+ for rec in records:
3356
+ groups.setdefault(rec.get(key), []).append(rec)
3357
+ self._group_parents.clear()
3358
+ for val, items in groups.items():
3359
+ label_val = "(None)" if val is None else str(val)
3360
+ label = f"{label_val} ({len(items)})"
3361
+ parent_iid = self._tree.insert(
3362
+ "", "end", text=label, open=True, image=self._chevron_icon(True)
3363
+ )
3364
+ self._group_parents[val] = parent_iid
3365
+ # Transparent stand-in the same size as the chevron, so a child's
3366
+ # depth-indent isn't cancelled out by the parent's chevron width
3367
+ # (keeps the child names visibly nested under their group).
3368
+ leaf_image = self._marker_icon(None)
3369
+ for rec in items:
3370
+ values = self._display_values(rec)
3371
+ primary_text = "" if primary_idx is None else str(
3372
+ self._format_cell(primary_idx, rec.get(primary_key, ""))
3373
+ )
3374
+ iid = self._tree.insert(
3375
+ parent_iid, "end", text=primary_text, values=values, image=leaf_image
3376
+ )
3377
+ self._row_map[iid] = rec
3378
+
3379
+ def _clear_sort(self) -> None:
3380
+ self._sort_state.clear()
3381
+ self._clear_cache()
3382
+ self._update_heading_icons()
3383
+ self._load_page(0)
3384
+ self._update_status_labels()
3385
+
3386
+
3387
+ # Backwards-compatible alias for the legacy Tableview name
3388
+ Tableview = TableView