bootstack 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (419) hide show
  1. bootstack/__init__.py +249 -0
  2. bootstack/__main__.py +5 -0
  3. bootstack/api/__init__.py +127 -0
  4. bootstack/api/app.py +30 -0
  5. bootstack/api/constants.py +3 -0
  6. bootstack/api/data.py +23 -0
  7. bootstack/api/dialogs.py +44 -0
  8. bootstack/api/i18n.py +17 -0
  9. bootstack/api/localization.py +16 -0
  10. bootstack/api/menu.py +7 -0
  11. bootstack/api/style.py +23 -0
  12. bootstack/api/utils.py +24 -0
  13. bootstack/api/widgets.py +137 -0
  14. bootstack/assets/__init__.py +24 -0
  15. bootstack/assets/bootstack-transparent.png +0 -0
  16. bootstack/assets/bootstack.ico +0 -0
  17. bootstack/assets/bootstack.png +0 -0
  18. bootstack/assets/elements/__init__.py +0 -0
  19. bootstack/assets/elements/badge-pill.png +0 -0
  20. bootstack/assets/elements/badge-square.png +0 -0
  21. bootstack/assets/elements/border.png +0 -0
  22. bootstack/assets/elements/button-compact.png +0 -0
  23. bootstack/assets/elements/button-default.png +0 -0
  24. bootstack/assets/elements/button-group-horizontal-after-compact.png +0 -0
  25. bootstack/assets/elements/button-group-horizontal-after-default.png +0 -0
  26. bootstack/assets/elements/button-group-horizontal-before-compact.png +0 -0
  27. bootstack/assets/elements/button-group-horizontal-before-default.png +0 -0
  28. bootstack/assets/elements/button-group-horizontal-center-compact.png +0 -0
  29. bootstack/assets/elements/button-group-horizontal-center-default.png +0 -0
  30. bootstack/assets/elements/button-group-vertical-after-compact.png +0 -0
  31. bootstack/assets/elements/button-group-vertical-after-default.png +0 -0
  32. bootstack/assets/elements/button-group-vertical-before-compact.png +0 -0
  33. bootstack/assets/elements/button-group-vertical-before-default.png +0 -0
  34. bootstack/assets/elements/button-group-vertical-center-compact.png +0 -0
  35. bootstack/assets/elements/button-group-vertical-center-default.png +0 -0
  36. bootstack/assets/elements/checkbox-checked.png +0 -0
  37. bootstack/assets/elements/checkbox-indeterminate.png +0 -0
  38. bootstack/assets/elements/checkbox-unchecked.png +0 -0
  39. bootstack/assets/elements/field.png +0 -0
  40. bootstack/assets/elements/input-after-compact.png +0 -0
  41. bootstack/assets/elements/input-after-default.png +0 -0
  42. bootstack/assets/elements/input-before-compact.png +0 -0
  43. bootstack/assets/elements/input-before-default.png +0 -0
  44. bootstack/assets/elements/input-compact.png +0 -0
  45. bootstack/assets/elements/input-default.png +0 -0
  46. bootstack/assets/elements/list-item-separated.png +0 -0
  47. bootstack/assets/elements/list-item.png +0 -0
  48. bootstack/assets/elements/manifest.toml +480 -0
  49. bootstack/assets/elements/menu-item.png +0 -0
  50. bootstack/assets/elements/nav-button-compact.png +0 -0
  51. bootstack/assets/elements/nav-button-default.png +0 -0
  52. bootstack/assets/elements/nav-icon-button-compact.png +0 -0
  53. bootstack/assets/elements/nav-icon-button-default.png +0 -0
  54. bootstack/assets/elements/notebook-client-border.png +0 -0
  55. bootstack/assets/elements/notebook-tab-active.png +0 -0
  56. bootstack/assets/elements/notebook-tab-bar.png +0 -0
  57. bootstack/assets/elements/notebook-tab-normal.png +0 -0
  58. bootstack/assets/elements/notebook-tab-pill.png +0 -0
  59. bootstack/assets/elements/progress-bar-horizontal-striped.png +0 -0
  60. bootstack/assets/elements/progress-bar-solid.png +0 -0
  61. bootstack/assets/elements/progress-bar-thin.png +0 -0
  62. bootstack/assets/elements/progress-bar-vertical-striped.png +0 -0
  63. bootstack/assets/elements/radio-selected.png +0 -0
  64. bootstack/assets/elements/radio-unselected.png +0 -0
  65. bootstack/assets/elements/scrollbar-horizontal.png +0 -0
  66. bootstack/assets/elements/scrollbar-vertical.png +0 -0
  67. bootstack/assets/elements/slider-handle-focus.png +0 -0
  68. bootstack/assets/elements/slider-handle.png +0 -0
  69. bootstack/assets/elements/slider-track-horizontal.png +0 -0
  70. bootstack/assets/elements/slider-track-vertical.png +0 -0
  71. bootstack/assets/elements/switch-off.png +0 -0
  72. bootstack/assets/elements/switch-on.png +0 -0
  73. bootstack/assets/elements/tabs-bar-horizontal.png +0 -0
  74. bootstack/assets/elements/tabs-bar-vertical.png +0 -0
  75. bootstack/assets/elements/tabs-pill.png +0 -0
  76. bootstack/assets/locales/ar/LC_MESSAGES/bootstack.mo +0 -0
  77. bootstack/assets/locales/ar/LC_MESSAGES/bootstack.po +853 -0
  78. bootstack/assets/locales/bg/LC_MESSAGES/bootstack.po +875 -0
  79. bootstack/assets/locales/cs/LC_MESSAGES/bootstack.mo +0 -0
  80. bootstack/assets/locales/cs/LC_MESSAGES/bootstack.po +853 -0
  81. bootstack/assets/locales/da/LC_MESSAGES/bootstack.mo +0 -0
  82. bootstack/assets/locales/da/LC_MESSAGES/bootstack.po +853 -0
  83. bootstack/assets/locales/de/LC_MESSAGES/bootstack.mo +0 -0
  84. bootstack/assets/locales/de/LC_MESSAGES/bootstack.po +853 -0
  85. bootstack/assets/locales/en/LC_MESSAGES/bootstack.mo +0 -0
  86. bootstack/assets/locales/en/LC_MESSAGES/bootstack.po +875 -0
  87. bootstack/assets/locales/es/LC_MESSAGES/bootstack.mo +0 -0
  88. bootstack/assets/locales/es/LC_MESSAGES/bootstack.po +853 -0
  89. bootstack/assets/locales/fr/LC_MESSAGES/bootstack.mo +0 -0
  90. bootstack/assets/locales/fr/LC_MESSAGES/bootstack.po +853 -0
  91. bootstack/assets/locales/he/LC_MESSAGES/bootstack.mo +0 -0
  92. bootstack/assets/locales/he/LC_MESSAGES/bootstack.po +851 -0
  93. bootstack/assets/locales/hi/LC_MESSAGES/bootstack.mo +0 -0
  94. bootstack/assets/locales/hi/LC_MESSAGES/bootstack.po +842 -0
  95. bootstack/assets/locales/it/LC_MESSAGES/bootstack.mo +0 -0
  96. bootstack/assets/locales/it/LC_MESSAGES/bootstack.po +841 -0
  97. bootstack/assets/locales/ja/LC_MESSAGES/bootstack.mo +0 -0
  98. bootstack/assets/locales/ja/LC_MESSAGES/bootstack.po +914 -0
  99. bootstack/assets/locales/ko/LC_MESSAGES/bootstack.mo +0 -0
  100. bootstack/assets/locales/ko/LC_MESSAGES/bootstack.po +842 -0
  101. bootstack/assets/locales/nb/LC_MESSAGES/bootstack.mo +0 -0
  102. bootstack/assets/locales/nb/LC_MESSAGES/bootstack.po +841 -0
  103. bootstack/assets/locales/nl/LC_MESSAGES/bootstack.mo +0 -0
  104. bootstack/assets/locales/nl/LC_MESSAGES/bootstack.po +841 -0
  105. bootstack/assets/locales/pl/LC_MESSAGES/bootstack.mo +0 -0
  106. bootstack/assets/locales/pl/LC_MESSAGES/bootstack.po +842 -0
  107. bootstack/assets/locales/pt/LC_MESSAGES/bootstack.mo +0 -0
  108. bootstack/assets/locales/pt/LC_MESSAGES/bootstack.po +842 -0
  109. bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.mo +0 -0
  110. bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.po +842 -0
  111. bootstack/assets/locales/sl/LC_MESSAGES/bootstack.mo +0 -0
  112. bootstack/assets/locales/sl/LC_MESSAGES/bootstack.po +842 -0
  113. bootstack/assets/locales/sv/LC_MESSAGES/bootstack.mo +0 -0
  114. bootstack/assets/locales/sv/LC_MESSAGES/bootstack.po +842 -0
  115. bootstack/assets/locales/tr/LC_MESSAGES/bootstack.mo +0 -0
  116. bootstack/assets/locales/tr/LC_MESSAGES/bootstack.po +842 -0
  117. bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.mo +0 -0
  118. bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.po +842 -0
  119. bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.mo +0 -0
  120. bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.po +842 -0
  121. bootstack/assets/themes/__init__.py +0 -0
  122. bootstack/assets/themes/amber-dark.json +32 -0
  123. bootstack/assets/themes/amber-light.json +32 -0
  124. bootstack/assets/themes/aurora-dark.json +32 -0
  125. bootstack/assets/themes/aurora-light.json +32 -0
  126. bootstack/assets/themes/bootstrap-dark.json +32 -0
  127. bootstack/assets/themes/bootstrap-light.json +32 -0
  128. bootstack/assets/themes/classic-dark.json +32 -0
  129. bootstack/assets/themes/classic-light.json +32 -0
  130. bootstack/assets/themes/docs-dark.json +32 -0
  131. bootstack/assets/themes/docs-light.json +32 -0
  132. bootstack/assets/themes/forest-dark.json +32 -0
  133. bootstack/assets/themes/forest-light.json +32 -0
  134. bootstack/assets/themes/ocean-dark.json +32 -0
  135. bootstack/assets/themes/ocean-light.json +32 -0
  136. bootstack/assets/themes/rose-dark.json +32 -0
  137. bootstack/assets/themes/rose-light.json +32 -0
  138. bootstack/assets/widgets/__init__.py +0 -0
  139. bootstack/assets/widgets/badge-default.png +0 -0
  140. bootstack/assets/widgets/badge-pill.png +0 -0
  141. bootstack/assets/widgets/border.png +0 -0
  142. bootstack/assets/widgets/button-group-horizontal-after.png +0 -0
  143. bootstack/assets/widgets/button-group-horizontal-before.png +0 -0
  144. bootstack/assets/widgets/button-group-horizontal-center.png +0 -0
  145. bootstack/assets/widgets/button-group-vertical-after.png +0 -0
  146. bootstack/assets/widgets/button-group-vertical-before.png +0 -0
  147. bootstack/assets/widgets/button-group-vertical-center.png +0 -0
  148. bootstack/assets/widgets/button.png +0 -0
  149. bootstack/assets/widgets/checkbox-checked.png +0 -0
  150. bootstack/assets/widgets/checkbox-indeterminate.png +0 -0
  151. bootstack/assets/widgets/checkbox-unchecked.png +0 -0
  152. bootstack/assets/widgets/field.png +0 -0
  153. bootstack/assets/widgets/icon-button.png +0 -0
  154. bootstack/assets/widgets/input-inner.png +0 -0
  155. bootstack/assets/widgets/input-prefix.png +0 -0
  156. bootstack/assets/widgets/input-suffix.png +0 -0
  157. bootstack/assets/widgets/input.png +0 -0
  158. bootstack/assets/widgets/list-item-focus.png +0 -0
  159. bootstack/assets/widgets/list-item-separated.png +0 -0
  160. bootstack/assets/widgets/menu-item-separated.png +0 -0
  161. bootstack/assets/widgets/notebook-client-border.png +0 -0
  162. bootstack/assets/widgets/notebook-pill-active.png +0 -0
  163. bootstack/assets/widgets/notebook-pill-inactive.png +0 -0
  164. bootstack/assets/widgets/notebook-tab-active.png +0 -0
  165. bootstack/assets/widgets/notebook-tab-border.png +0 -0
  166. bootstack/assets/widgets/notebook-tab-normal.png +0 -0
  167. bootstack/assets/widgets/notebook-underline.png +0 -0
  168. bootstack/assets/widgets/progress-bar-horizontal-default.png +0 -0
  169. bootstack/assets/widgets/progress-bar-horizontal-striped.png +0 -0
  170. bootstack/assets/widgets/progress-bar-vertical-default.png +0 -0
  171. bootstack/assets/widgets/progress-bar-vertical-striped.png +0 -0
  172. bootstack/assets/widgets/progress-trough-horizontal.png +0 -0
  173. bootstack/assets/widgets/progress-trough-vertical.png +0 -0
  174. bootstack/assets/widgets/radio-selected.png +0 -0
  175. bootstack/assets/widgets/radio-unselected.png +0 -0
  176. bootstack/assets/widgets/scrollbar-horizontal-rounded.png +0 -0
  177. bootstack/assets/widgets/scrollbar-vertical-rounded.png +0 -0
  178. bootstack/assets/widgets/separator-horizontal.png +0 -0
  179. bootstack/assets/widgets/separator-vertical.png +0 -0
  180. bootstack/assets/widgets/slider-handle-focus.png +0 -0
  181. bootstack/assets/widgets/slider-handle.png +0 -0
  182. bootstack/assets/widgets/slider-track-horizontal.png +0 -0
  183. bootstack/assets/widgets/slider-track-vertical.png +0 -0
  184. bootstack/assets/widgets/switch-off.png +0 -0
  185. bootstack/assets/widgets/switch-on.png +0 -0
  186. bootstack/assets/widgets/tabs-bar-horizontal.png +0 -0
  187. bootstack/assets/widgets/tabs-bar-vertical.png +0 -0
  188. bootstack/assets/widgets/tabs-pill.png +0 -0
  189. bootstack/cli/__init__.py +124 -0
  190. bootstack/cli/__main__.py +6 -0
  191. bootstack/cli/add.py +439 -0
  192. bootstack/cli/build.py +115 -0
  193. bootstack/cli/config.py +287 -0
  194. bootstack/cli/demo.py +1267 -0
  195. bootstack/cli/doctor.py +195 -0
  196. bootstack/cli/list_cmd.py +71 -0
  197. bootstack/cli/promote.py +120 -0
  198. bootstack/cli/pyinstaller.py +246 -0
  199. bootstack/cli/run.py +99 -0
  200. bootstack/cli/start.py +105 -0
  201. bootstack/cli/templates/__init__.py +861 -0
  202. bootstack/constants.py +325 -0
  203. bootstack/core/__init__.py +34 -0
  204. bootstack/core/capabilities/__init__.py +45 -0
  205. bootstack/core/capabilities/after.py +103 -0
  206. bootstack/core/capabilities/bind.py +154 -0
  207. bootstack/core/capabilities/bindtags.py +112 -0
  208. bootstack/core/capabilities/busy.py +61 -0
  209. bootstack/core/capabilities/clipboard.py +88 -0
  210. bootstack/core/capabilities/focus.py +118 -0
  211. bootstack/core/capabilities/grab.py +65 -0
  212. bootstack/core/capabilities/grid.py +188 -0
  213. bootstack/core/capabilities/localization.py +231 -0
  214. bootstack/core/capabilities/pack.py +119 -0
  215. bootstack/core/capabilities/place.py +92 -0
  216. bootstack/core/capabilities/selection.py +136 -0
  217. bootstack/core/capabilities/signals.py +242 -0
  218. bootstack/core/capabilities/winfo.py +315 -0
  219. bootstack/core/colorutils.py +234 -0
  220. bootstack/core/exceptions.py +95 -0
  221. bootstack/core/images.py +283 -0
  222. bootstack/core/localization/README.md +90 -0
  223. bootstack/core/localization/__init__.py +13 -0
  224. bootstack/core/localization/intl_format.py +580 -0
  225. bootstack/core/localization/msgcat.py +425 -0
  226. bootstack/core/localization/specs.py +143 -0
  227. bootstack/core/mixins/__init__.py +1 -0
  228. bootstack/core/mixins/ttk_state.py +35 -0
  229. bootstack/core/mixins/widget.py +132 -0
  230. bootstack/core/publisher.py +147 -0
  231. bootstack/core/signals/README.md +112 -0
  232. bootstack/core/signals/__init__.py +8 -0
  233. bootstack/core/signals/integration.py +100 -0
  234. bootstack/core/signals/signal.py +317 -0
  235. bootstack/core/signals/types.py +4 -0
  236. bootstack/core/validation/__init__.py +5 -0
  237. bootstack/core/validation/types.py +13 -0
  238. bootstack/core/validation/validation_result.py +17 -0
  239. bootstack/core/validation/validation_rules.py +112 -0
  240. bootstack/core/variables.py +62 -0
  241. bootstack/datasource/README.md +607 -0
  242. bootstack/datasource/__init__.py +51 -0
  243. bootstack/datasource/base.py +474 -0
  244. bootstack/datasource/file_source.py +541 -0
  245. bootstack/datasource/memory_source.py +482 -0
  246. bootstack/datasource/sqlite_source.py +453 -0
  247. bootstack/datasource/types.py +259 -0
  248. bootstack/dialogs/__init__.py +56 -0
  249. bootstack/dialogs/colorchooser.py +674 -0
  250. bootstack/dialogs/colordropper.py +257 -0
  251. bootstack/dialogs/datedialog.py +404 -0
  252. bootstack/dialogs/dialog.py +514 -0
  253. bootstack/dialogs/filterdialog.py +358 -0
  254. bootstack/dialogs/fontdialog.py +339 -0
  255. bootstack/dialogs/formdialog.py +541 -0
  256. bootstack/dialogs/message.py +489 -0
  257. bootstack/dialogs/query.py +561 -0
  258. bootstack/py.typed +1 -0
  259. bootstack/runtime/__init__.py +3 -0
  260. bootstack/runtime/app.py +879 -0
  261. bootstack/runtime/base_window.py +786 -0
  262. bootstack/runtime/events.py +399 -0
  263. bootstack/runtime/menu.py +510 -0
  264. bootstack/runtime/shortcuts.py +423 -0
  265. bootstack/runtime/tk_patch.py +31 -0
  266. bootstack/runtime/toplevel.py +131 -0
  267. bootstack/runtime/utility.py +371 -0
  268. bootstack/runtime/visual_focus.py +228 -0
  269. bootstack/runtime/window_utilities.py +1043 -0
  270. bootstack/style/__init__.py +5498 -0
  271. bootstack/style/bootstyle.py +507 -0
  272. bootstack/style/bootstyle_builder_base.py +752 -0
  273. bootstack/style/bootstyle_builder_mixed.py +93 -0
  274. bootstack/style/bootstyle_builder_tk.py +109 -0
  275. bootstack/style/bootstyle_builder_ttk.py +354 -0
  276. bootstack/style/builders/__init__.py +51 -0
  277. bootstack/style/builders/badge.py +44 -0
  278. bootstack/style/builders/button.py +453 -0
  279. bootstack/style/builders/buttongroup.py +344 -0
  280. bootstack/style/builders/calendar.py +271 -0
  281. bootstack/style/builders/checkbutton.py +95 -0
  282. bootstack/style/builders/combobox.py +112 -0
  283. bootstack/style/builders/contextmenu.py +268 -0
  284. bootstack/style/builders/entry.py +83 -0
  285. bootstack/style/builders/expander.py +171 -0
  286. bootstack/style/builders/field.py +312 -0
  287. bootstack/style/builders/frame.py +27 -0
  288. bootstack/style/builders/label.py +28 -0
  289. bootstack/style/builders/labelframe.py +41 -0
  290. bootstack/style/builders/listview.py +267 -0
  291. bootstack/style/builders/menubar.py +74 -0
  292. bootstack/style/builders/menubutton.py +408 -0
  293. bootstack/style/builders/notebook.py +316 -0
  294. bootstack/style/builders/panedwindow.py +25 -0
  295. bootstack/style/builders/progressbar.py +71 -0
  296. bootstack/style/builders/radiobutton.py +68 -0
  297. bootstack/style/builders/scale.py +66 -0
  298. bootstack/style/builders/scrollbar.py +360 -0
  299. bootstack/style/builders/separator.py +45 -0
  300. bootstack/style/builders/sidenav.py +313 -0
  301. bootstack/style/builders/sizegrip.py +15 -0
  302. bootstack/style/builders/spinbox.py +119 -0
  303. bootstack/style/builders/switch.py +67 -0
  304. bootstack/style/builders/tabitem.py +205 -0
  305. bootstack/style/builders/toolbutton.py +260 -0
  306. bootstack/style/builders/tooltip.py +26 -0
  307. bootstack/style/builders/treeview.py +269 -0
  308. bootstack/style/builders/utils.py +404 -0
  309. bootstack/style/builders_tk/__init__.py +16 -0
  310. bootstack/style/builders_tk/defaults.py +229 -0
  311. bootstack/style/element.py +173 -0
  312. bootstack/style/style.py +499 -0
  313. bootstack/style/theme_provider.py +449 -0
  314. bootstack/style/tk_patch.py +5 -0
  315. bootstack/style/token_maps.py +42 -0
  316. bootstack/style/types.py +32 -0
  317. bootstack/style/typography.py +527 -0
  318. bootstack/style/utility.py +696 -0
  319. bootstack/themes/__init__.py +12 -0
  320. bootstack/themes/standard.py +415 -0
  321. bootstack/themes/user.py +45 -0
  322. bootstack/widgets/__init__.py +53 -0
  323. bootstack/widgets/composites/__init__.py +38 -0
  324. bootstack/widgets/composites/accordion.py +385 -0
  325. bootstack/widgets/composites/appshell.py +445 -0
  326. bootstack/widgets/composites/buttongroup.py +391 -0
  327. bootstack/widgets/composites/calendar.py +914 -0
  328. bootstack/widgets/composites/compositeframe.py +282 -0
  329. bootstack/widgets/composites/contextmenu.py +1754 -0
  330. bootstack/widgets/composites/dateentry.py +261 -0
  331. bootstack/widgets/composites/dropdownbutton.py +190 -0
  332. bootstack/widgets/composites/expander.py +508 -0
  333. bootstack/widgets/composites/field.py +448 -0
  334. bootstack/widgets/composites/floodgauge.py +434 -0
  335. bootstack/widgets/composites/form.py +983 -0
  336. bootstack/widgets/composites/labeledscale.py +209 -0
  337. bootstack/widgets/composites/list/__init__.py +15 -0
  338. bootstack/widgets/composites/list/listitem.py +733 -0
  339. bootstack/widgets/composites/list/listview.py +1507 -0
  340. bootstack/widgets/composites/menubar.py +303 -0
  341. bootstack/widgets/composites/meter.py +882 -0
  342. bootstack/widgets/composites/numericentry.py +183 -0
  343. bootstack/widgets/composites/pagestack.py +330 -0
  344. bootstack/widgets/composites/passwordentry.py +149 -0
  345. bootstack/widgets/composites/pathentry.py +223 -0
  346. bootstack/widgets/composites/radiogroup.py +466 -0
  347. bootstack/widgets/composites/scrolledtext.py +388 -0
  348. bootstack/widgets/composites/scrolledtext.pyi +186 -0
  349. bootstack/widgets/composites/scrollview.py +675 -0
  350. bootstack/widgets/composites/selectbox.py +544 -0
  351. bootstack/widgets/composites/sidenav/__init__.py +24 -0
  352. bootstack/widgets/composites/sidenav/group.py +485 -0
  353. bootstack/widgets/composites/sidenav/header.py +83 -0
  354. bootstack/widgets/composites/sidenav/item.py +413 -0
  355. bootstack/widgets/composites/sidenav/separator.py +51 -0
  356. bootstack/widgets/composites/sidenav/view.py +919 -0
  357. bootstack/widgets/composites/spinnerentry.py +232 -0
  358. bootstack/widgets/composites/tableview/__init__.py +5 -0
  359. bootstack/widgets/composites/tableview/tableview.py +2254 -0
  360. bootstack/widgets/composites/tableview/types.py +169 -0
  361. bootstack/widgets/composites/tabs/__init__.py +6 -0
  362. bootstack/widgets/composites/tabs/tabitem.py +372 -0
  363. bootstack/widgets/composites/tabs/tabs.py +478 -0
  364. bootstack/widgets/composites/tabs/tabview.py +352 -0
  365. bootstack/widgets/composites/textentry.py +90 -0
  366. bootstack/widgets/composites/timeentry.py +189 -0
  367. bootstack/widgets/composites/toast.py +364 -0
  368. bootstack/widgets/composites/togglegroup.py +382 -0
  369. bootstack/widgets/composites/toolbar.py +393 -0
  370. bootstack/widgets/composites/tooltip.py +404 -0
  371. bootstack/widgets/internal/__init__.py +0 -0
  372. bootstack/widgets/internal/wrapper_base.py +304 -0
  373. bootstack/widgets/mixins/__init__.py +25 -0
  374. bootstack/widgets/mixins/configure_mixin.py +186 -0
  375. bootstack/widgets/mixins/entry_mixin.py +70 -0
  376. bootstack/widgets/mixins/font_mixin.py +346 -0
  377. bootstack/widgets/mixins/icon_mixin.py +38 -0
  378. bootstack/widgets/mixins/localization_mixin.py +255 -0
  379. bootstack/widgets/mixins/signal_mixin.py +272 -0
  380. bootstack/widgets/mixins/validation_mixin.py +204 -0
  381. bootstack/widgets/parts/__init__.py +11 -0
  382. bootstack/widgets/parts/numberentry_part.py +345 -0
  383. bootstack/widgets/parts/spinnerentry_part.py +394 -0
  384. bootstack/widgets/parts/textentry_part.py +344 -0
  385. bootstack/widgets/primitives/__init__.py +55 -0
  386. bootstack/widgets/primitives/badge.py +44 -0
  387. bootstack/widgets/primitives/button.py +89 -0
  388. bootstack/widgets/primitives/card.py +66 -0
  389. bootstack/widgets/primitives/checkbutton.py +124 -0
  390. bootstack/widgets/primitives/checktoggle.py +53 -0
  391. bootstack/widgets/primitives/combobox.py +165 -0
  392. bootstack/widgets/primitives/entry.py +98 -0
  393. bootstack/widgets/primitives/frame.py +206 -0
  394. bootstack/widgets/primitives/gridframe.py +479 -0
  395. bootstack/widgets/primitives/label.py +95 -0
  396. bootstack/widgets/primitives/labelframe.py +63 -0
  397. bootstack/widgets/primitives/menubutton.py +118 -0
  398. bootstack/widgets/primitives/notebook.py +551 -0
  399. bootstack/widgets/primitives/optionmenu.py +248 -0
  400. bootstack/widgets/primitives/packframe.py +228 -0
  401. bootstack/widgets/primitives/panedwindow.py +58 -0
  402. bootstack/widgets/primitives/progressbar.py +95 -0
  403. bootstack/widgets/primitives/radiobutton.py +115 -0
  404. bootstack/widgets/primitives/radiotoggle.py +50 -0
  405. bootstack/widgets/primitives/scale.py +85 -0
  406. bootstack/widgets/primitives/scrollbar.py +56 -0
  407. bootstack/widgets/primitives/separator.py +56 -0
  408. bootstack/widgets/primitives/sizegrip.py +47 -0
  409. bootstack/widgets/primitives/spinbox.py +91 -0
  410. bootstack/widgets/primitives/switch.py +41 -0
  411. bootstack/widgets/primitives/treeview.py +77 -0
  412. bootstack/widgets/types.py +20 -0
  413. bootstack-0.1.0a1.dist-info/METADATA +196 -0
  414. bootstack-0.1.0a1.dist-info/RECORD +419 -0
  415. bootstack-0.1.0a1.dist-info/WHEEL +5 -0
  416. bootstack-0.1.0a1.dist-info/entry_points.txt +2 -0
  417. bootstack-0.1.0a1.dist-info/licenses/LICENSE +22 -0
  418. bootstack-0.1.0a1.dist-info/licenses/NOTICE +10 -0
  419. bootstack-0.1.0a1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2254 @@
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 logging
11
+ from collections import OrderedDict
12
+ from tkinter import font as tkfont
13
+
14
+ from typing_extensions import Literal
15
+
16
+ from bootstack.widgets.types import Master
17
+
18
+ from ttkbootstrap_icons_bs import BootstrapIcon
19
+ from bootstack.style.style import get_style
20
+ from bootstack.datasource.sqlite_source import SqliteDataSource
21
+ from bootstack.widgets.primitives.button import Button
22
+ from bootstack.runtime.utility import bind_right_click
23
+ from bootstack.widgets.composites.contextmenu import ContextMenu
24
+ from bootstack.widgets.composites.dropdownbutton import DropdownButton
25
+ from bootstack.widgets.primitives.entry import Entry
26
+ from bootstack.widgets.primitives.frame import Frame
27
+ from bootstack.widgets.primitives.label import Label
28
+ from bootstack.widgets.primitives.scrollbar import Scrollbar
29
+ from bootstack.widgets.composites.selectbox import SelectBox
30
+ from bootstack.widgets.primitives.separator import Separator
31
+ from bootstack.widgets.composites.textentry import TextEntry
32
+ from bootstack.widgets.primitives.treeview import TreeView
33
+ from bootstack.core.localization import MessageCatalog
34
+
35
+ from .types import (
36
+ parse_selection_mode as _parse_selection_mode,
37
+ build_editing_options as _build_editing_options,
38
+ build_selection_options as _build_selection_options,
39
+ build_filtering_options as _build_filtering_options,
40
+ build_exporting_options as _build_exporting_options,
41
+ build_paging_options as _build_paging_options,
42
+ build_search_options as _build_search_options,
43
+ build_row_alternation_options as _build_row_alternation_options,
44
+ )
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ _TABLE_SEARCH_MODE_OPTIONS = [
49
+ ("table.search_mode_equals", "EQUALS"),
50
+ ("table.search_mode_contains", "CONTAINS"),
51
+ ("table.search_mode_starts_with", "STARTS WITH"),
52
+ ("table.search_mode_ends_with", "ENDS WITH"),
53
+ ("table.search_mode_sql", "SQL"),
54
+ ]
55
+
56
+
57
+ class TableView(Frame):
58
+ """TableView backed by an in-memory SqliteDataSource.
59
+
60
+ Provides sortable headers, filtering/search, pagination or virtual scrolling,
61
+ optional grouping, column striping, and configurable exporting/editing.
62
+
63
+ !!! note "Events"
64
+ - `<<SelectionChange>>`: Fired when row selection changes. `event.data = {'records': list[dict], 'iids': list[str]}`
65
+ - `<<RowClick>>`: Fired on single row click. `event.data = {'record': dict, 'iid': str}`
66
+ - `<<RowDoubleClick>>`: Fired on row double-click. `event.data = {'record': dict, 'iid': str}`
67
+ - `<<RowRightClick>>`: Fired on row right-click. `event.data = {'record': dict, 'iid': str}`
68
+ - `<<RowInsert>>`: Fired when rows are inserted. `event.data = {'records': list[dict]}`
69
+ - `<<RowUpdate>>`: Fired when rows are updated. `event.data = {'records': list[dict]}`
70
+ - `<<RowDelete>>`: Fired when rows are deleted. `event.data = {'records': list[dict]}`
71
+ - `<<RowMove>>`: Fired when rows are moved/reordered. `event.data = {'records': list[dict]}`
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ master: Master = None,
77
+ # Core data
78
+ columns: list[str | dict] | None = None,
79
+ rows: list | None = None,
80
+ datasource: SqliteDataSource | None = None,
81
+ # Selection & sorting
82
+ selection_mode: Literal['none', 'single', 'multi'] = 'single',
83
+ allow_select_all: bool = True,
84
+ sorting_mode: Literal['single', 'none'] = 'single',
85
+ # Filtering & search
86
+ enable_filtering: bool = True,
87
+ enable_header_filtering: bool = True,
88
+ enable_row_filtering: bool = True,
89
+ enable_search: bool = True,
90
+ search_mode: Literal['standard', 'advanced'] = 'standard',
91
+ search_trigger: Literal['enter', 'input'] = 'enter',
92
+ # Paging & scrolling
93
+ paging_mode: Literal['standard', 'virtual'] = 'standard',
94
+ page_size: int = 25,
95
+ page_index: int = 0,
96
+ page_cache_size: int = 3,
97
+ show_vscrollbar: bool = True,
98
+ show_hscrollbar: bool = False,
99
+ # Editing
100
+ enable_adding: bool = False,
101
+ enable_editing: bool = False,
102
+ enable_deleting: bool = False,
103
+ form_options: dict | None = None,
104
+ # Exporting
105
+ enable_exporting: bool = False,
106
+ allow_export_selection: bool = True,
107
+ export_scope: Literal['page', 'all'] = 'page',
108
+ export_formats: tuple[str, ...] | None = None,
109
+ # Appearance & extras
110
+ striped: bool = False,
111
+ striped_background: str = 'background[+1]',
112
+ allow_grouping: bool = False,
113
+ show_table_status: bool = True,
114
+ show_column_chooser: bool = False,
115
+ context_menus: Literal['none', 'headers', 'rows', 'all'] = 'all',
116
+ column_min_width: int = 40,
117
+ column_auto_width: bool = False,
118
+ **kwargs,
119
+ ):
120
+ """
121
+ Create a TableView backed by an in-memory SqliteDataSource.
122
+
123
+ Args:
124
+ master: Parent widget.
125
+ columns: Column definitions (list of strings or dicts with keys like
126
+ "text", "key", "width", "minwidth").
127
+ rows: Initial data to load (list of dicts or row-like sequences).
128
+ datasource: Custom SqliteDataSource; if omitted, an in-memory source is created.
129
+ selection_mode: Selection mode ('none', 'single', 'multi'). Defaults to 'single'.
130
+ allow_select_all: Whether select-all is allowed. Defaults to True.
131
+ sorting_mode: Sorting mode ('single' or 'none'). Defaults to 'single'.
132
+ enable_filtering: Enable filtering features. Defaults to True.
133
+ enable_header_filtering: Show filter option in header context menu. Defaults to True.
134
+ enable_row_filtering: Show filter option in row context menu. Defaults to True.
135
+ enable_search: Show search bar. Defaults to True.
136
+ search_mode: Search mode ('standard' or 'advanced'). Defaults to 'standard'.
137
+ search_trigger: When to trigger search ('enter' or 'input'). Defaults to 'enter'.
138
+ paging_mode: Paging mode ('standard' or 'virtual'). Defaults to 'standard'.
139
+ page_size: Number of rows per page. Defaults to 25.
140
+ page_index: Initial page index. Defaults to 0.
141
+ page_cache_size: Number of pages to cache. Defaults to 3.
142
+ show_vscrollbar: Show vertical scrollbar. Defaults to True.
143
+ show_hscrollbar: Show horizontal scrollbar. Defaults to False.
144
+ enable_adding: Allow adding new rows. Defaults to False.
145
+ enable_editing: Allow editing existing rows. Defaults to False.
146
+ enable_deleting: Allow deleting rows. Defaults to False.
147
+ form_options: Options dict for the edit form dialog.
148
+ enable_exporting: Enable export functionality. Defaults to False.
149
+ allow_export_selection: Allow exporting selected rows. Defaults to True.
150
+ export_scope: Export scope ('page' or 'all'). Defaults to 'page'.
151
+ export_formats: Tuple of export formats (e.g., ('csv', 'xlsx')).
152
+ striped: Show alternating row colors. Defaults to False.
153
+ striped_background: Background color for striped rows. Defaults to 'background[+1]'.
154
+ allow_grouping: Allow grouping rows via header context menu. Defaults to False.
155
+ show_table_status: Show filter/sort/group status labels and pager. Defaults to True.
156
+ show_column_chooser: Show column chooser button. Defaults to False.
157
+ context_menus: Context menu visibility ('none', 'headers', 'rows', 'all').
158
+ Defaults to 'all'.
159
+ column_min_width: Global minimum width for columns. Defaults to 40.
160
+ column_auto_width: Automatically size columns to widest visible text.
161
+ Defaults to False.
162
+ **kwargs: Additional arguments passed through to Frame.
163
+ """
164
+ super().__init__(master, **kwargs)
165
+
166
+ # Build internal configuration dicts from flattened kwargs
167
+ self._editing = _build_editing_options(
168
+ enable_adding=enable_adding,
169
+ enable_editing=enable_editing,
170
+ enable_deleting=enable_deleting,
171
+ form_options=form_options,
172
+ )
173
+ self._paging = _build_paging_options(
174
+ paging_mode=paging_mode,
175
+ page_size=page_size,
176
+ page_index=page_index,
177
+ page_cache_size=page_cache_size,
178
+ show_vscrollbar=show_vscrollbar,
179
+ show_hscrollbar=show_hscrollbar,
180
+ )
181
+ self._exporting = _build_exporting_options(
182
+ enable_exporting=enable_exporting,
183
+ allow_export_selection=allow_export_selection,
184
+ export_scope=export_scope,
185
+ export_formats=export_formats,
186
+ )
187
+ self._filtering = _build_filtering_options(
188
+ enable_filtering=enable_filtering,
189
+ enable_header_filtering=enable_header_filtering,
190
+ enable_row_filtering=enable_row_filtering,
191
+ )
192
+ self._selection = _build_selection_options(
193
+ selection_mode=selection_mode,
194
+ allow_select_all=allow_select_all,
195
+ )
196
+ self._searchbar = _build_search_options(
197
+ enable_search=enable_search,
198
+ search_mode=search_mode,
199
+ search_trigger=search_trigger,
200
+ )
201
+ # User-facing filter description (e.g., "fin" or a SQL expression in
202
+ # advanced SQL mode). Falls back to the raw datasource WHERE clause
203
+ # when set externally.
204
+ self._filter_summary: str = ""
205
+ self._row_alternation = _build_row_alternation_options(
206
+ striped=striped,
207
+ striped_background=striped_background,
208
+ )
209
+
210
+ self._search_mode_map: dict[str, str] = {}
211
+ self._sorting = sorting_mode
212
+ self._show_table_status = show_table_status
213
+ self._show_column_chooser = show_column_chooser
214
+ self._allow_grouping = allow_grouping
215
+ self._context_menus = (context_menus or 'all').lower()
216
+ self._column_min_width = max(0, column_min_width)
217
+ self._column_auto_width = column_auto_width
218
+ self._datasource = datasource or SqliteDataSource(':memory:', page_size=self._paging['page_size'])
219
+
220
+ self._page_cache: OrderedDict[int, list[dict]] = OrderedDict()
221
+ self._column_defs = columns or []
222
+ self._column_keys: list[str] = []
223
+ self._heading_texts: list[str] = []
224
+ self._sort_state: dict[str, bool] = {} # key -> ascending
225
+ self._current_page = self._paging['page_index']
226
+ self._loading_next = False
227
+ self._heading_fg: str | None = None
228
+ self._icon_sort_up = None
229
+ self._icon_sort_down = None
230
+ self._column_anchors: list[str] = []
231
+ self._column_filters: dict[str, list] = {} # key -> list of allowed values
232
+ self._column_types: dict[str, str] = {}
233
+ self._alignment_sample: list[dict] | None = None
234
+ self._row_map: dict[str, dict] = {}
235
+ self._row_menu: ContextMenu | None = None
236
+ self._display_columns: list[int] = []
237
+ self._header_menu: ContextMenu | None = None
238
+ self._header_menu_col: int | None = None
239
+ self._cached_total_count: int | None = None
240
+ self._group_by_key: str | None = None
241
+ self._group_parents: dict[str | None, str] = {}
242
+ self._hidden_rows: dict[str, tuple[str, int]] = {}
243
+
244
+ self._resolve_column_keys()
245
+
246
+ seeded_records: list[dict] | None = None
247
+ if rows:
248
+ try:
249
+ if self._column_keys:
250
+ # Avoid per-row dict conversion when we already know the column order
251
+ self._datasource.set_data(rows, column_keys=self._column_keys)
252
+ seeded_records = None
253
+ else:
254
+ seeded_records = self._to_records(rows)
255
+ self._datasource.set_data(seeded_records)
256
+ except Exception:
257
+ # Last-resort fallback to dict conversion if direct load fails
258
+ seeded_records = self._to_records(rows)
259
+ try:
260
+ self._datasource.set_data(seeded_records)
261
+ except Exception:
262
+ seeded_records = []
263
+
264
+ self._ensure_column_metadata(seeded_records)
265
+
266
+ # UI
267
+ self._build_toolbar()
268
+ self._build_tree()
269
+ if self._show_table_status or not self._paging['mode'] == 'virtual':
270
+ self._build_footer()
271
+
272
+ # Initial load
273
+ self._load_page(0)
274
+
275
+ # ------------------------------------------------------------------ Public API
276
+ def set_data(self, rows: list) -> None:
277
+ """Replace data in the datasource and refresh the grid."""
278
+ if self._column_keys:
279
+ self._datasource.set_data(rows, column_keys=self._column_keys)
280
+ seeded_records = None
281
+ else:
282
+ seeded_records = self._to_records(rows)
283
+ self._datasource.set_data(seeded_records)
284
+ self._ensure_column_metadata(seeded_records)
285
+ self._clear_cache()
286
+ self._load_page(0)
287
+
288
+ # ------------------------------------------------------------------ Public event API
289
+ def on_selection_changed(self, callback) -> str:
290
+ """Bind to `<<SelectionChange>>`. Callback receives `event.data = {'records': list[dict], 'iids': list[str]}`."""
291
+ return self.bind("<<SelectionChange>>", callback, add=True)
292
+
293
+ def off_selection_changed(self, bind_id: str | None = None) -> None:
294
+ """Unbind from `<<SelectionChange>>`."""
295
+ self.unbind("<<SelectionChange>>", bind_id)
296
+
297
+ def on_row_click(self, callback) -> str:
298
+ """Bind to `<<RowClick>>`. Callback receives `event.data = {'record': dict, 'iid': str}`."""
299
+ return self.bind("<<RowClick>>", callback, add=True)
300
+
301
+ def off_row_click(self, bind_id: str | None = None) -> None:
302
+ """Unbind from `<<RowClick>>`."""
303
+ self.unbind("<<RowClick>>", bind_id)
304
+
305
+ def on_row_double_click(self, callback) -> str:
306
+ """Bind to `<<RowDoubleClick>>`. Callback receives `event.data = {'record': dict, 'iid': str}`."""
307
+ return self.bind("<<RowDoubleClick>>", callback, add=True)
308
+
309
+ def off_row_double_click(self, bind_id: str | None = None) -> None:
310
+ """Unbind from `<<RowDoubleClick>>`."""
311
+ self.unbind("<<RowDoubleClick>>", bind_id)
312
+
313
+ def on_row_right_click(self, callback) -> str:
314
+ """Bind to `<<RowRightClick>>`. Callback receives `event.data = {'record': dict, 'iid': str}`."""
315
+ return self.bind("<<RowRightClick>>", callback, add=True)
316
+
317
+ def off_row_right_click(self, bind_id: str | None = None) -> None:
318
+ """Unbind from `<<RowRightClick>>`."""
319
+ self.unbind("<<RowRightClick>>", bind_id)
320
+
321
+ def on_row_deleted(self, callback) -> str:
322
+ """Bind to `<<RowDelete>>`. Callback receives `event.data = {'records': list[dict]}`."""
323
+ return self.bind("<<RowDelete>>", callback, add=True)
324
+
325
+ def off_row_deleted(self, bind_id: str | None = None) -> None:
326
+ """Unbind from `<<RowDelete>>`."""
327
+ self.unbind("<<RowDelete>>", bind_id)
328
+
329
+ def on_row_inserted(self, callback) -> str:
330
+ """Bind to `<<RowInsert>>`. Callback receives `event.data = {'records': list[dict]}`."""
331
+ return self.bind("<<RowInsert>>", callback, add=True)
332
+
333
+ def off_row_inserted(self, bind_id: str | None = None) -> None:
334
+ """Unbind from `<<RowInsert>>`."""
335
+ self.unbind("<<RowInsert>>", bind_id)
336
+
337
+ def on_row_updated(self, callback) -> str:
338
+ """Bind to `<<RowUpdate>>`. Callback receives `event.data = {'records': list[dict]}`."""
339
+ return self.bind("<<RowUpdate>>", callback, add=True)
340
+
341
+ def off_row_updated(self, bind_id: str | None = None) -> None:
342
+ """Unbind from `<<RowUpdate>>`."""
343
+ self.unbind("<<RowUpdate>>", bind_id)
344
+
345
+ def on_row_moved(self, callback) -> str:
346
+ """Bind to `<<RowMove>>`. Callback receives `event.data = {'records': list[dict]}`."""
347
+ return self.bind("<<RowMove>>", callback, add=True)
348
+
349
+ def off_row_moved(self, bind_id: str | None = None) -> None:
350
+ """Unbind from `<<RowMove>>`."""
351
+ self.unbind("<<RowMove>>", bind_id)
352
+
353
+ # ------------------------------------------------------------------ Public data/selection API
354
+ @property
355
+ def selected_rows(self) -> list[dict]:
356
+ """List of record dicts for the current Treeview selection."""
357
+ rows: list[dict] = []
358
+ for iid in self._tree.selection():
359
+ if iid in self._row_map:
360
+ rows.append(self._row_map[iid])
361
+ return rows
362
+
363
+ @property
364
+ def visible_rows(self) -> list[dict]:
365
+ """List of record dicts for rows currently rendered (flat traversal)."""
366
+ rows: list[dict] = []
367
+ queue = list(self._tree.get_children(""))
368
+ while queue:
369
+ iid = queue.pop(0)
370
+ if iid in self._row_map:
371
+ rows.append(self._row_map[iid])
372
+ queue.extend(list(self._tree.get_children(iid)))
373
+ return rows
374
+
375
+ # ------------------------------------------------------------------ Public row/column manipulation
376
+ def insert_rows(self, rows: list) -> None:
377
+ """Insert new rows via the datasource and refresh."""
378
+ recs = self._to_records(rows)
379
+ inserted: list[dict] = []
380
+ for rec in recs:
381
+ try:
382
+ new_id = self._datasource.create_record(dict(rec))
383
+ rec = dict(rec)
384
+ if new_id is not None:
385
+ rec["id"] = new_id
386
+ inserted.append(rec)
387
+ except Exception:
388
+ logger.exception("Failed to insert record")
389
+ if inserted:
390
+ self._clear_cache()
391
+ self._load_page(self._current_page)
392
+ self.event_generate("<<RowInsert>>", data={"records": inserted})
393
+
394
+ def update_rows(self, rows: list[dict]) -> None:
395
+ """Update rows by id; each dict must include an 'id' key."""
396
+ updated: list[dict] = []
397
+ for rec in rows:
398
+ rec_id = rec.get("id")
399
+ if rec_id is None:
400
+ continue
401
+ updates = {k: v for k, v in rec.items() if k != "id"}
402
+ try:
403
+ self._datasource.update_record(rec_id, updates)
404
+ updated.append(rec)
405
+ except Exception:
406
+ logger.exception("Failed to update record id=%s", rec_id)
407
+ if updated:
408
+ self._clear_cache()
409
+ self._load_page(self._current_page)
410
+ self.event_generate("<<RowUpdate>>", data={"records": updated})
411
+
412
+ def delete_rows(self, rows_or_ids: list) -> None:
413
+ """Delete rows by id or row dicts containing an id key."""
414
+ deleted: list[dict] = []
415
+ for item in rows_or_ids:
416
+ rec_id = None
417
+ rec = {}
418
+ if isinstance(item, dict):
419
+ rec = item
420
+ rec_id = item.get("id")
421
+ else:
422
+ rec_id = item
423
+ if rec_id is None:
424
+ continue
425
+ try:
426
+ self._datasource.delete_record(rec_id)
427
+ if not rec:
428
+ rec = {"id": rec_id}
429
+ deleted.append(rec)
430
+ except Exception:
431
+ logger.exception("Failed to delete record id=%s", rec_id)
432
+ if deleted:
433
+ self._clear_cache()
434
+ self._load_page(self._current_page)
435
+ self.event_generate("<<RowDelete>>", data={"records": deleted})
436
+
437
+ def insert_columns(self, *_args, **_kwargs) -> None:
438
+ """Not currently supported; columns are defined at construction time."""
439
+ raise NotImplementedError("Dynamic column insertion is not supported yet")
440
+
441
+ def delete_columns(self, indices: list[int]) -> None:
442
+ """Hide columns at the given indices."""
443
+ self.hide_columns(indices)
444
+
445
+ def move_rows(self, iids: list[str], to_index: int) -> None:
446
+ """Move the given rows to a target index in the root list."""
447
+ children = list(self._tree.get_children(""))
448
+ to_index = max(0, min(len(children), to_index))
449
+ for offset, iid in enumerate(iids):
450
+ try:
451
+ self._tree.move(iid, "", to_index + offset)
452
+ except Exception:
453
+ pass
454
+ self._apply_row_alternation()
455
+ moved_recs = [self._row_map.get(i) for i in iids if i in self._row_map]
456
+ if moved_recs:
457
+ self.event_generate("<<RowMove>>", data={"records": moved_recs})
458
+
459
+ def move_columns(self, from_index: int, to_index: int) -> None:
460
+ """Reorder a column from one index to another."""
461
+ if from_index < 0 or from_index >= len(self._display_columns):
462
+ return
463
+ to_index = max(0, min(len(self._display_columns) - 1, to_index))
464
+ col_id = self._display_columns.pop(from_index)
465
+ self._display_columns.insert(to_index, col_id)
466
+ self._tree.configure(displaycolumns=self._display_columns)
467
+
468
+ def hide_rows(self, iids: list[str]) -> None:
469
+ """Hide rows from view (not removed from datasource)."""
470
+ for iid in iids:
471
+ try:
472
+ parent = self._tree.parent(iid)
473
+ children = list(self._tree.get_children(parent))
474
+ idx = children.index(iid)
475
+ self._hidden_rows[iid] = (parent, idx)
476
+ self._tree.detach(iid)
477
+ except Exception:
478
+ pass
479
+
480
+ def unhide_rows(self, iids: list[str] | None = None) -> None:
481
+ """Restore previously hidden rows."""
482
+ targets = iids or list(self._hidden_rows.keys())
483
+ for iid in targets:
484
+ if iid not in self._hidden_rows:
485
+ continue
486
+ parent, idx = self._hidden_rows.pop(iid)
487
+ try:
488
+ self._tree.move(iid, parent, idx)
489
+ except Exception:
490
+ pass
491
+ self._apply_row_alternation()
492
+
493
+ def hide_columns(self, indices: list[int]) -> None:
494
+ """Remove columns from the displayed set."""
495
+ for idx in indices:
496
+ if idx in self._display_columns:
497
+ self._display_columns.remove(idx)
498
+ if not self._display_columns and self._heading_texts:
499
+ self._display_columns = list(range(len(self._heading_texts)))
500
+ self._tree.configure(displaycolumns=self._display_columns)
501
+
502
+ def unhide_columns(self, indices: list[int]) -> None:
503
+ """Add columns back into the displayed set."""
504
+ changed = False
505
+ for idx in indices:
506
+ if idx not in self._display_columns and 0 <= idx < len(self._heading_texts):
507
+ self._display_columns.append(idx)
508
+ changed = True
509
+ if changed:
510
+ self._display_columns = sorted(self._display_columns)
511
+ self._tree.configure(displaycolumns=self._display_columns)
512
+
513
+ def select_rows(self, iids: list[str]) -> None:
514
+ """Select the given row ids."""
515
+ self._tree.selection_set(iids)
516
+
517
+ def deselect_rows(self, iids: list[str] | None = None) -> None:
518
+ """Clear selection or remove specific iids from selection."""
519
+ if not iids:
520
+ self._tree.selection_remove(self._tree.selection())
521
+ else:
522
+ self._tree.selection_remove(iids)
523
+
524
+ def scroll_to_row(self, iid: str) -> None:
525
+ """Ensure the given row is visible."""
526
+ try:
527
+ self._tree.see(iid)
528
+ except Exception:
529
+ pass
530
+
531
+ # ------------------------------------------------------------------ Pagination helpers
532
+ def next_page(self) -> None:
533
+ self._next_page()
534
+
535
+ def previous_page(self) -> None:
536
+ self._prev_page()
537
+
538
+ def first_page(self) -> None:
539
+ self._first_page()
540
+
541
+ def last_page(self) -> None:
542
+ self._last_page()
543
+
544
+ def go_to_page(self, index: int) -> None:
545
+ self._load_page(max(0, index))
546
+
547
+ # ------------------------------------------------------------------ Filter/Sort/Group API
548
+ def get_filters(self) -> str:
549
+ """Return current SQL where clause string (if any)."""
550
+ try:
551
+ return getattr(self._datasource, "_where", "") or ""
552
+ except Exception:
553
+ return ""
554
+
555
+ def set_filters(self, where: str) -> None:
556
+ try:
557
+ self._datasource.set_filter(where or "")
558
+ except Exception:
559
+ return
560
+ self._clear_cache()
561
+ self._load_page(0)
562
+ self._update_status_labels()
563
+
564
+ def clear_filters(self) -> None:
565
+ self._clear_filter_cmd()
566
+
567
+ def get_sorting(self) -> dict[str, bool]:
568
+ """Return a copy of the current sort state {column_key: ascending}."""
569
+ return dict(self._sort_state)
570
+
571
+ def set_sorting(self, key: str, ascending: bool = True) -> None:
572
+ quoted_key = self._quote_col(key)
573
+ order = "ASC" if ascending else "DESC"
574
+ try:
575
+ self._datasource.set_sort(f"{quoted_key} {order}")
576
+ except Exception:
577
+ return
578
+ self._sort_state = {key: ascending}
579
+ self._clear_cache()
580
+ self._update_heading_icons()
581
+ self._load_page(0)
582
+ self._update_status_labels()
583
+
584
+ def clear_sorting(self) -> None:
585
+ self._clear_sort()
586
+
587
+ def get_grouping(self) -> str | None:
588
+ return self._group_by_key
589
+
590
+ def set_grouping(self, key: str | None) -> None:
591
+ if not key:
592
+ self._ungroup_all()
593
+ return
594
+ if key not in self._column_keys:
595
+ return
596
+ self._group_by_key = key
597
+ self._group_parents.clear()
598
+ try:
599
+ quoted_key = self._quote_col(key)
600
+ self._datasource.set_sort(f"{quoted_key} ASC")
601
+ except Exception:
602
+ pass
603
+ self._sort_state = {key: True}
604
+ self._clear_cache()
605
+ self._update_heading_icons()
606
+ self._load_page(0)
607
+ self._update_status_labels()
608
+
609
+ def clear_grouping(self) -> None:
610
+ self._ungroup_all()
611
+
612
+ # ------------------------------------------------------------------ Group expand/collapse
613
+ def expand_all(self) -> None:
614
+ for iid in self._tree.get_children(""):
615
+ try:
616
+ self._tree.item(iid, open=True)
617
+ except Exception:
618
+ pass
619
+
620
+ def collapse_all(self) -> None:
621
+ for iid in self._tree.get_children(""):
622
+ try:
623
+ self._tree.item(iid, open=False)
624
+ except Exception:
625
+ pass
626
+
627
+ def expand_group(self, group_value) -> None:
628
+ parent = self._group_parents.get(group_value)
629
+ if parent:
630
+ try:
631
+ self._tree.item(parent, open=True)
632
+ except Exception:
633
+ pass
634
+
635
+ def collapse_group(self, group_value) -> None:
636
+ parent = self._group_parents.get(group_value)
637
+ if parent:
638
+ try:
639
+ self._tree.item(parent, open=False)
640
+ except Exception:
641
+ pass
642
+
643
+ def select_all(self) -> None:
644
+ """Select all visible rows."""
645
+ self._tree.selection_set(self._tree.get_children(""))
646
+
647
+ def deselect_all(self) -> None:
648
+ """Clear the selection."""
649
+ self._tree.selection_remove(self._tree.selection())
650
+
651
+ # ------------------------------------------------------------------ UI
652
+
653
+ def _resolve_alternating_row_color(self):
654
+ style = get_style()
655
+ color_token = self._row_alternation.get('accent', 'background[+1]')
656
+
657
+ try:
658
+ background = style.style_builder.color(color_token)
659
+ except Exception:
660
+ background = style.style_builder.color('background')
661
+
662
+ try:
663
+ foreground = style.style_builder.on_color(background)
664
+ except Exception:
665
+ foreground = style.style_builder.color('foreground')
666
+ return background, foreground
667
+
668
+ def _resolve_column_keys(self) -> None:
669
+ if not self._column_defs:
670
+ return
671
+ for idx, col in enumerate(self._column_defs):
672
+ if isinstance(col, str):
673
+ self._column_keys.append(col)
674
+ elif isinstance(col, dict):
675
+ self._column_keys.append(col.get("key") or col.get("text") or str(idx))
676
+ else:
677
+ self._column_keys.append(str(col))
678
+
679
+ def _ensure_column_metadata(self, sample_records: list[dict] | None) -> None:
680
+ """Guarantee we have column keys/defs before the Treeview is built."""
681
+ if self._column_keys:
682
+ return
683
+
684
+ inferred: list[str] = []
685
+ if sample_records:
686
+ first = sample_records[0]
687
+ if isinstance(first, dict):
688
+ inferred = list(first.keys())
689
+ if not inferred:
690
+ inferred = getattr(self._datasource, "_columns", []) or []
691
+
692
+ inferred = [c for c in inferred if c not in ("id", "selected")]
693
+ if not inferred:
694
+ inferred = ["value"]
695
+
696
+ self._column_keys = inferred
697
+ if not self._column_defs:
698
+ self._column_defs = [{"text": c} for c in self._column_keys]
699
+
700
+ def _build_toolbar(self) -> None:
701
+ bar = Frame(self, name="toolbar")
702
+ # Grid in column 0 only so the toolbar's right edge stops at the
703
+ # tree's right edge instead of extending past the vsb.
704
+ bar.grid(row=0, column=0, sticky="ew", pady=(0, 4))
705
+
706
+ if self._searchbar['enabled']:
707
+ self._search_entry = TextEntry(bar)
708
+ self._search_entry.insert_addon(Label, 'before', icon="search", icon_only=True)
709
+ self._search_entry.insert_addon(Button, 'after', icon="x-lg", icon_only=True, command=self._clear_search)
710
+ # Only reserve a 6 px right gap when the advanced-mode SelectBox
711
+ # follows the entry; otherwise the entry hugs the toolbar edge.
712
+ search_padx = (0, 6) if self._searchbar['mode'] == 'advanced' else 0
713
+ self._search_entry.pack(side="left", fill="x", expand=True, padx=search_padx)
714
+ trigger = str(self._searchbar.get('event', 'enter')).lower()
715
+ if trigger == 'input':
716
+ self._search_entry.on_input(lambda _e: self._run_search())
717
+ else:
718
+ self._search_entry.on_enter(lambda _e: self._run_search())
719
+ # Clear filter when the box is emptied, but do not search on every keystroke
720
+ self._search_entry.on_input(lambda _e: self._clear_search() if not self._search_entry.get() else None)
721
+
722
+ if self._searchbar['mode'] == 'advanced':
723
+ search_items = []
724
+ self._search_mode_map = {}
725
+ for token, code in _TABLE_SEARCH_MODE_OPTIONS:
726
+ label = MessageCatalog.translate(token)
727
+ search_items.append(label)
728
+ self._search_mode_map[label] = code
729
+ default_value = search_items[0] if search_items else "EQUALS"
730
+ self._search_mode = SelectBox(
731
+ bar,
732
+ items=search_items,
733
+ value=default_value,
734
+ width=14,
735
+ allow_custom_values=False,
736
+ enable_search=False,
737
+ )
738
+ self._search_mode.pack(side="left", padx=(0, 6))
739
+
740
+ if self._show_column_chooser:
741
+ self._column_chooser_btn = Button(
742
+ bar,
743
+ icon="layout-three-columns",
744
+ icon_only=True,
745
+ accent="foreground",
746
+ variant="ghost",
747
+ command=self._show_column_chooser_dialog,
748
+ )
749
+ self._column_chooser_btn.pack(side="right", padx=(4, 0))
750
+
751
+ if self._exporting['enabled']:
752
+ export_items = []
753
+ if self._exporting['export_scope'] == 'all':
754
+ export_items.append({"type": "command", "text": "table.export_all", "command": self._export_all})
755
+ if self._exporting["allow_export_selection"]:
756
+ export_items.append({"type": "command", "text": "table.export_selection", "command": self._export_selection})
757
+ if self._exporting['export_scope'] == "page":
758
+ export_items.append({"type": "command", "text": "table.export_page", "command": self._export_page})
759
+ if not export_items:
760
+ export_items.append({"type": "command", "text": "table.export_all", "command": self._export_all})
761
+ DropdownButton(
762
+ bar,
763
+ icon="download",
764
+ icon_only=True,
765
+ accent="foreground",
766
+ variant="ghost",
767
+ compound="image",
768
+ items=export_items,
769
+ show_dropdown_button=False,
770
+ ).pack(side="right")
771
+
772
+ if self._editing['adding']:
773
+ Button(
774
+ bar,
775
+ icon="plus-lg",
776
+ text="table.add_record",
777
+ accent="foreground",
778
+ variant="ghost",
779
+ command=self._open_new_record,
780
+ ).pack(side="right", padx=(0, 4))
781
+
782
+ def _build_tree(self) -> None:
783
+ cols = [self._col_text(c) for c in self._column_defs] or self._column_keys
784
+
785
+ # Grid layout for the TableView body:
786
+ # row 0: toolbar (col 0)
787
+ # row 1: tree (col 0) | vsb (col 1, only this row)
788
+ # row 2: hsb (col 0)
789
+ # row 3: footer (col 0)
790
+ # Column 0 expands; column 1 takes the vsb's natural width when present.
791
+ self.grid_columnconfigure(0, weight=1)
792
+ self.grid_rowconfigure(1, weight=1)
793
+
794
+ self._tree = TreeView(
795
+ self,
796
+ columns=list(range(len(cols))),
797
+ selectmode=_parse_selection_mode(self._selection['mode']),
798
+ show="headings"
799
+ )
800
+ # Inset the tree by the focus-ring affordance baked into sibling
801
+ # entry images so the tree's content edge lines up with the visible
802
+ # edge of the toolbar/footer entries (search box, pagination input).
803
+ from bootstack.style.bootstyle_builder_base import BootstyleBuilderBase
804
+ affordance = BootstyleBuilderBase.scale_from_source(8)
805
+ self._tree.grid(row=1, column=0, sticky="nsew", padx=affordance)
806
+ self._display_columns = list(range(len(cols)))
807
+
808
+ if self._paging['yscroll']:
809
+ self._vsb = Scrollbar(self, orient="vertical", command=self._tree.yview)
810
+ self._vsb.grid(row=1, column=1, sticky="ns")
811
+ if self._paging['mode'] == "virtual":
812
+ self._tree.configure(yscrollcommand=self._on_scroll)
813
+ else:
814
+ self._tree.configure(yscrollcommand=self._vsb.set)
815
+ else:
816
+ self._vsb = None
817
+
818
+ if self._paging['xscroll']:
819
+ self._hsb = Scrollbar(self, orient="horizontal", command=self._tree.xview)
820
+ # Mirror the tree's affordance inset so the hsb aligns with the
821
+ # tree content and stops at the same right edge.
822
+ self._hsb.grid(row=2, column=0, sticky="ew", padx=affordance)
823
+ self._tree.configure(xscrollcommand=self._hsb.set)
824
+ else:
825
+ self._hsb = None
826
+
827
+ self._heading_texts = []
828
+ self._column_anchors = []
829
+ stretch_columns = not self._paging['xscroll'] # allow natural width when xscroll is enabled
830
+ for idx, text in enumerate(cols):
831
+ self._heading_texts.append(text)
832
+ anchor = self._determine_anchor(idx)
833
+ self._column_anchors.append(anchor)
834
+ heading_kwargs = {"text": text, "anchor": anchor}
835
+ # Don't use heading command - we'll handle clicks via Button-1 binding
836
+ self._tree.heading(idx, **heading_kwargs)
837
+ # Apply per-column width overrides, fall back to global defaults
838
+ width = 120
839
+ minwidth = self._column_min_width
840
+ if idx < len(self._column_defs):
841
+ coldef = self._column_defs[idx]
842
+ if isinstance(coldef, dict):
843
+ width = coldef.get("width", width)
844
+ minwidth = coldef.get("minwidth", coldef.get("min_width", minwidth))
845
+ self._tree.column(idx, anchor=anchor, width=width, minwidth=minwidth, stretch=stretch_columns)
846
+ self._update_heading_icons()
847
+ self._tree.bind("<Button-1>", self._on_header_click)
848
+ self._tree.bind("<<TreeviewSelect>>", self._on_selection_event)
849
+ self._tree.bind("<ButtonRelease-1>", self._on_row_click_event)
850
+ if self._context_menus != "none":
851
+ bind_right_click(self._tree, self._on_tree_context)
852
+ if self._editing['updating']:
853
+ self._tree.bind("<Double-1>", self._on_row_double_click)
854
+ # Track resize events to rebalance grouped layouts
855
+ self._tree.bind("<Configure>", self._on_tree_configure)
856
+
857
+ def _build_footer(self) -> None:
858
+ bar = Frame(self)
859
+ # Same column 0 as the toolbar so the footer aligns with the table
860
+ # content and stops at the vsb edge.
861
+ bar.grid(row=3, column=0, sticky="ew", pady=(4, 0))
862
+ status_frame = Frame(bar)
863
+ status_frame.pack(side="left", fill="x", expand=True)
864
+ self._filter_label = Label(status_frame, text="", anchor="w", accent="secondary")
865
+ self._filter_label.pack(side="left", padx=(0, 4))
866
+ self._sort_label = Label(status_frame, text="", anchor="w", accent="secondary")
867
+ self._sort_label.pack(side="left", padx=(8, 4))
868
+
869
+ if not self._show_table_status:
870
+ status_frame.pack_forget()
871
+ Frame(bar).pack(side='left', fill='x', expand=True) # spacer
872
+ info_frame = Frame(bar)
873
+ info_frame.pack(side='left')
874
+ Label(info_frame, text="table.page").pack(side='left')
875
+ self._page_entry = Entry(info_frame, width=6, justify="center")
876
+ self._page_entry.bind("<Return>", self._jump_page)
877
+ self._page_entry.pack(side="left", padx=8)
878
+ self._page_label = Label(info_frame, text="")
879
+ self._page_label.pack(side="left", padx=(0, 8))
880
+
881
+ sep = Separator(bar, orient="vertical")
882
+ sep.pack(side="left", fill="y", padx=8)
883
+
884
+ btn_frame = Frame(bar)
885
+ btn_frame.pack(side="right")
886
+ Button(btn_frame, icon="chevron-double-left", accent="foreground", variant="ghost", icon_only=True, command=self._first_page).pack(
887
+ side="left")
888
+ Button(btn_frame, icon="chevron-left", icon_only=True, accent="foreground", variant="ghost", command=self._prev_page).pack(
889
+ side="left")
890
+ Button(btn_frame, icon="chevron-right", icon_only=True, accent="foreground", variant="ghost", command=self._next_page).pack(
891
+ side="left")
892
+ Button(btn_frame, icon="chevron-double-right", icon_only=True, accent="foreground", variant="ghost", command=self._last_page).pack(
893
+ side="left")
894
+
895
+ # ------------------------------------------------------------------ Helpers
896
+ def _col_text(self, col) -> str:
897
+ if isinstance(col, str):
898
+ return col
899
+ if isinstance(col, dict):
900
+ return col.get("text") or col.get("key") or ""
901
+ return str(col)
902
+
903
+ def _header_context_enabled(self) -> bool:
904
+ return self._context_menus in ("all", "headers")
905
+
906
+ def _row_context_enabled(self) -> bool:
907
+ return self._context_menus in ("all", "rows")
908
+
909
+ def _quote_col(self, key: str) -> str:
910
+ """Quote column identifiers for safe SQL usage (handles reserved names)."""
911
+ try:
912
+ quote_fn = getattr(self._datasource, "_quote_identifier", None)
913
+ if callable(quote_fn):
914
+ return quote_fn(key)
915
+ except Exception:
916
+ pass
917
+ text = str(key).replace('"', '""')
918
+ return f'"{text}"'
919
+
920
+ def _determine_anchor(self, idx: int) -> str:
921
+ """Pick an anchor for the given column index.
922
+
923
+ Priority:
924
+ 1) Explicit anchor/align in column definition
925
+ 2) Explicit dtype/type hint in column definition (numeric -> right)
926
+ 3) Numeric columns -> right
927
+ 4) Default -> left
928
+ """
929
+ if idx < len(self._column_defs):
930
+ coldef = self._column_defs[idx]
931
+ if isinstance(coldef, dict):
932
+ anchor = coldef.get("anchor") or coldef.get("align")
933
+ if anchor:
934
+ return anchor
935
+ # Allow a dtype/type hint on the column definition
936
+ dtype = coldef.get("dtype") or coldef.get("type")
937
+ if dtype:
938
+ dtype_upper = str(dtype).upper()
939
+ if any(t in dtype_upper for t in ("INT", "REAL", "NUM", "DECIMAL", "DOUBLE", "FLOAT")):
940
+ return "e"
941
+ if "TEXT" in dtype_upper or "STR" in dtype_upper or "CHAR" in dtype_upper:
942
+ return "w"
943
+ # Infer from type
944
+ key = self._column_keys[idx] if idx < len(self._column_keys) else None
945
+ ctype = self._get_column_type(key) if key else ""
946
+ if ctype and any(t in ctype.upper() for t in ("INT", "REAL", "NUM", "DECIMAL", "DOUBLE", "FLOAT")):
947
+ return "e"
948
+ # Fallback: sample values to detect numeric strings
949
+ if self._is_numeric_sample(idx):
950
+ return "e"
951
+ return "w"
952
+
953
+ def _get_column_type(self, key: str | None) -> str:
954
+ if not key:
955
+ return ""
956
+ if key in self._column_types:
957
+ return self._column_types[key]
958
+ # Try PRAGMA table_info
959
+ try:
960
+ cur = self._datasource.conn.execute(f"PRAGMA table_info({self._datasource._table})")
961
+ for cid, name, ctype, *_rest in cur.fetchall():
962
+ if name == key:
963
+ self._column_types[key] = ctype or ""
964
+ return self._column_types[key]
965
+ except Exception:
966
+ pass
967
+ return ""
968
+
969
+ def _load_alignment_sample(self) -> list[dict]:
970
+ if self._alignment_sample is not None:
971
+ return self._alignment_sample
972
+ try:
973
+ sample = self._datasource.get_page(0)
974
+ except Exception:
975
+ sample = []
976
+ self._alignment_sample = sample or []
977
+ return self._alignment_sample
978
+
979
+ def _is_numeric_sample(self, idx: int) -> bool:
980
+ """Check sample values to decide if a column with text storage is numeric-like."""
981
+ key = self._column_keys[idx] if idx < len(self._column_keys) else None
982
+ if not key:
983
+ return False
984
+ sample = self._load_alignment_sample()
985
+ if not sample:
986
+ return False
987
+
988
+ def is_num(val) -> bool:
989
+ if val is None or val == "":
990
+ return True
991
+ try:
992
+ float(val)
993
+ return True
994
+ except Exception:
995
+ return False
996
+
997
+ seen = 0
998
+ for rec in sample[: min(20, len(sample))]:
999
+ if key not in rec:
1000
+ continue
1001
+ seen += 1
1002
+ if not is_num(rec.get(key)):
1003
+ return False
1004
+ return seen > 0
1005
+
1006
+ def _to_records(self, rows: list) -> list[dict]:
1007
+ records: list[dict] = []
1008
+ if not rows:
1009
+ return records
1010
+ keys = self._column_keys or [str(i) for i in range(len(rows[0]))]
1011
+ for rec in rows:
1012
+ if isinstance(rec, dict):
1013
+ records.append(rec)
1014
+ else:
1015
+ records.append({k: rec[i] if i < len(rec) else "" for i, k in enumerate(keys)})
1016
+ return records
1017
+
1018
+ def _refresh_tree(self, records: list[dict]) -> None:
1019
+ self._tree.delete(*self._tree.get_children())
1020
+ self._row_map.clear()
1021
+ if not self._column_keys and records:
1022
+ self._column_keys = list(records[0].keys())
1023
+ grouped = bool(self._group_by_key) and self._group_by_key in self._column_keys
1024
+ self._apply_group_show_state(grouped)
1025
+ if grouped:
1026
+ self._render_grouped(records)
1027
+ else:
1028
+ self._render_flat(records)
1029
+ self._apply_row_alternation()
1030
+
1031
+ def _append_tree(self, records: list[dict]) -> None:
1032
+ # Grouped mode rebuilds the view instead of appending to keep hierarchy consistent
1033
+ if self._group_by_key:
1034
+ self._refresh_tree(records)
1035
+ return
1036
+ stripe = self._row_alternation.get('enabled', False) and not self._group_by_key
1037
+ start_idx = len(self._tree.get_children(""))
1038
+ for offset, rec in enumerate(records):
1039
+ values = [rec.get(k, "") for k in self._column_keys]
1040
+ tags = ("altrow",) if stripe and (start_idx + offset) % 2 == 1 else ()
1041
+ iid = self._tree.insert("", "end", values=values, tags=tags)
1042
+ self._row_map[iid] = rec
1043
+ self._apply_row_alternation()
1044
+
1045
+ def _total_pages(self) -> int:
1046
+ try:
1047
+ # Use cached count to avoid expensive COUNT(*) queries on every navigation
1048
+ if self._cached_total_count is None:
1049
+ self._cached_total_count = self._datasource.total_count()
1050
+ total = self._cached_total_count
1051
+ size = getattr(self._datasource, "page_size", self._paging['page_size']) or 1
1052
+ return max(1, (total + size - 1) // size)
1053
+ except Exception:
1054
+ return 1
1055
+
1056
+ # ------------------------------------------------------------------ Paging
1057
+ def _load_page(self, page: int, append: bool = False) -> None:
1058
+ if not append and page in self._page_cache:
1059
+ records = self._page_cache[page]
1060
+ else:
1061
+ try:
1062
+ records = self._datasource.get_page(page)
1063
+ except Exception:
1064
+ records = []
1065
+ if not append:
1066
+ self._remember_page(page, records)
1067
+ self._current_page = max(0, page)
1068
+ try:
1069
+ if append:
1070
+ self._append_tree(records)
1071
+ else:
1072
+ self._refresh_tree(records)
1073
+ if self._column_auto_width:
1074
+ self._auto_size_columns(records if not append else None)
1075
+ self._update_page_label()
1076
+ finally:
1077
+ self._loading_next = False
1078
+
1079
+ def _update_page_label(self) -> None:
1080
+ if hasattr(self, "_page_entry"):
1081
+ self._page_entry.delete(0, 'end')
1082
+ self._page_entry.insert(0, str(self._current_page + 1))
1083
+ if hasattr(self, "_page_label"):
1084
+ of_text = MessageCatalog.translate("table.of")
1085
+ self._page_label.configure(text=f"{of_text} {self._total_pages()}")
1086
+ if self._show_table_status:
1087
+ self._update_status_labels()
1088
+
1089
+ def _first_page(self) -> None:
1090
+ self._load_page(0)
1091
+
1092
+ def _prev_page(self) -> None:
1093
+ self._load_page(max(0, self._current_page - 1))
1094
+
1095
+ def _next_page(self) -> None:
1096
+ self._load_page(min(self._total_pages() - 1, self._current_page + 1))
1097
+
1098
+ def _last_page(self) -> None:
1099
+ self._load_page(self._total_pages() - 1)
1100
+
1101
+ def _jump_page(self, _event=None) -> None:
1102
+ try:
1103
+ target = int(self._page_entry.get()) - 1
1104
+ except Exception:
1105
+ return
1106
+ target = max(0, min(self._total_pages() - 1, target))
1107
+ self._load_page(target)
1108
+
1109
+ def _on_scroll(self, first: float, last: float) -> None:
1110
+ """Drive scrollbar and trigger lazy loading when near the bottom."""
1111
+ # Grouped mode disables virtual scroll append to avoid breaking hierarchy
1112
+ if self._group_by_key:
1113
+ self._vsb.set(first, last)
1114
+ return
1115
+ try:
1116
+ first_f = float(first)
1117
+ last_f = float(last)
1118
+ except Exception:
1119
+ self._vsb.set(first, last)
1120
+ return
1121
+
1122
+ self._vsb.set(first_f, last_f)
1123
+ if (
1124
+ self._paging['mode'] == "virtual"
1125
+ and last_f >= 0.85 # prefetch a bit earlier for smoother scrolling
1126
+ and not self._loading_next
1127
+ and hasattr(self._datasource, "has_next_page")
1128
+ and self._datasource.has_next_page()
1129
+ ):
1130
+ # Load next page and keep appending rows
1131
+ self._loading_next = True
1132
+ self._load_page(self._current_page + 1, append=True)
1133
+
1134
+ # ------------------------------------------------------------------ Search & sort
1135
+ def _run_search(self) -> None:
1136
+ text = self._search_entry.get()
1137
+ if hasattr(self, "_search_mode") and self._search_mode_map:
1138
+ display_mode = self._search_mode.get()
1139
+ mode = self._search_mode_map.get(display_mode, "CONTAINS")
1140
+ else:
1141
+ mode = "CONTAINS"
1142
+ colnames = self._column_keys
1143
+ quoted_cols = [self._quote_col(c) for c in colnames]
1144
+ where = ""
1145
+ mode_upper = mode.upper().replace(" ", "_")
1146
+ if text and quoted_cols:
1147
+ crit = text.replace("'", "''")
1148
+ if mode_upper == "CONTAINS":
1149
+ where = " OR ".join([f"{c} LIKE '%{crit}%'" for c in quoted_cols])
1150
+ elif mode_upper == "STARTS_WITH":
1151
+ where = " OR ".join([f"{c} LIKE '{crit}%'" for c in quoted_cols])
1152
+ elif mode_upper == "ENDS_WITH":
1153
+ where = " OR ".join([f"{c} LIKE '%{crit}'" for c in quoted_cols])
1154
+ elif mode_upper == "SQL":
1155
+ where = text
1156
+ else: # equals
1157
+ where = " OR ".join([f"{c} = '{crit}'" for c in quoted_cols])
1158
+ # In SQL mode the user typed the expression themselves, so showing it
1159
+ # back is meaningful. For all other modes, show just the search term.
1160
+ if mode_upper == "SQL":
1161
+ self._filter_summary = text
1162
+ else:
1163
+ self._filter_summary = repr(text) if text else ""
1164
+ try:
1165
+ self._datasource.set_filter(where)
1166
+ except Exception:
1167
+ logger.exception("Failed to apply search filter: %s", where)
1168
+ self._clear_cache()
1169
+ self._load_page(0)
1170
+ self._update_status_labels()
1171
+
1172
+ def _clear_search(self) -> None:
1173
+ self._search_entry.delete(0, 'end')
1174
+ self._filter_summary = ""
1175
+ try:
1176
+ self._datasource.set_filter("")
1177
+ except Exception:
1178
+ pass
1179
+ self._clear_cache()
1180
+ self._load_page(0)
1181
+ self._update_status_labels()
1182
+
1183
+ def _on_sort(self, column_index: int) -> None:
1184
+ if column_index >= len(self._column_keys):
1185
+ return
1186
+ key = self._column_keys[column_index]
1187
+ quoted_key = self._quote_col(key)
1188
+ asc = not self._sort_state.get(key, True)
1189
+ # Clear other sort states to keep single-column sort
1190
+ self._sort_state = {key: asc}
1191
+ order = "ASC" if asc else "DESC"
1192
+ try:
1193
+ self._datasource.set_sort(f"{quoted_key} {order}")
1194
+ except Exception:
1195
+ pass
1196
+ self._clear_cache()
1197
+ self._update_heading_icons()
1198
+ self._load_page(0)
1199
+ self._update_status_labels()
1200
+
1201
+ def _update_status_labels(self) -> None:
1202
+ # Filter — prefer the user-friendly summary set by the search bar.
1203
+ # Fall back to the raw WHERE clause only when an external caller set
1204
+ # the filter (so we have no friendlier description).
1205
+ filter_txt = ""
1206
+ try:
1207
+ description = self._filter_summary
1208
+ if not description:
1209
+ description = getattr(self._datasource, "_where", "") or ""
1210
+ if description:
1211
+ filter_txt = MessageCatalog.translate("table.filter_status", description)
1212
+ except Exception:
1213
+ pass
1214
+ # Sort
1215
+ sort_txt = ""
1216
+ try:
1217
+ order = getattr(self._datasource, "_order_by", "")
1218
+ if order:
1219
+ sort_txt = MessageCatalog.translate("table.sort_status", order)
1220
+ except Exception:
1221
+ pass
1222
+ group_txt = ""
1223
+ if self._group_by_key:
1224
+ try:
1225
+ col_idx = self._column_keys.index(self._group_by_key)
1226
+ heading_text = self._heading_texts[col_idx] if col_idx < len(
1227
+ self._heading_texts) else self._group_by_key
1228
+ except Exception:
1229
+ heading_text = self._group_by_key
1230
+ group_txt = MessageCatalog.translate("table.group_status", heading_text)
1231
+
1232
+ if hasattr(self, "_filter_label"):
1233
+ self._filter_label.configure(text=filter_txt)
1234
+ if hasattr(self, "_sort_label"):
1235
+ joined = " | ".join([t for t in (sort_txt, group_txt) if t])
1236
+ self._sort_label.configure(text=joined)
1237
+
1238
+ # ------------------------------------------------------------------ Row context menu
1239
+ def _ensure_row_menu(self) -> None:
1240
+ if not self._row_context_enabled():
1241
+ return
1242
+ if self._row_menu:
1243
+ return
1244
+ # Activation is wired upstream by bind_right_click on the tree so the
1245
+ # row vs header dispatch can run before the (lazily built) menu
1246
+ # decides which one to show.
1247
+ menu = ContextMenu(master=self, target=self._tree, attach='sw', trigger=None)
1248
+ if not self._sorting == 'none':
1249
+ menu.add_command(text="table.sort_asc", command=lambda: self._sort_selection(True))
1250
+ menu.add_command(text="table.sort_desc", command=lambda: self._sort_selection(False))
1251
+
1252
+ if self._filtering['row_menu_filtering']:
1253
+ menu.add_separator()
1254
+ menu.add_command(text="table.filter_by_value", command=self._filter_by_value)
1255
+ menu.add_command(text="table.hide_select", command=self._hide_selection)
1256
+ menu.add_command(text="table.clear_filters", command=self._clear_filter_cmd)
1257
+
1258
+ menu.add_separator()
1259
+ menu.add_command(text="table.move_up", command=self._move_row_up)
1260
+ menu.add_command(text="table.move_down", command=self._move_row_down)
1261
+ menu.add_command(text="table.move_top", command=self._move_row_top)
1262
+ menu.add_command(text="table.move_bottom", command=self._move_row_bottom)
1263
+
1264
+ if self._editing['updating'] or self._editing['deleting']:
1265
+ menu.add_separator()
1266
+ if self._editing['updating']:
1267
+ menu.add_command(text="table.edit", command=self._edit_selected_row)
1268
+ if self._editing['deleting']:
1269
+ menu.add_command(text="table.delete_row", command=self._delete_selected_row)
1270
+ self._row_menu = menu
1271
+
1272
+ def _on_row_context(self, event) -> None:
1273
+ if not self._row_context_enabled():
1274
+ return
1275
+ iid = self._tree.identify_row(event.y)
1276
+ col_id = self._tree.identify_column(event.x)
1277
+ try:
1278
+ col_idx = int(col_id.strip("#")) - 1
1279
+ except Exception:
1280
+ col_idx = 0
1281
+ if iid:
1282
+ if iid not in self._tree.selection():
1283
+ self._tree.selection_set(iid)
1284
+ rec = self._row_map.get(iid, {})
1285
+ self.event_generate("<<RowRightClick>>", data={"record": rec, "iid": iid})
1286
+ if not self._tree.selection():
1287
+ return
1288
+ self._row_menu_col = col_idx
1289
+ self._ensure_row_menu()
1290
+ self._row_menu.show(position=(event.x_root, event.y_root))
1291
+
1292
+ def _on_row_double_click(self, event) -> None:
1293
+ region = self._tree.identify_region(event.x, event.y)
1294
+ if region == "heading":
1295
+ return
1296
+ iid = self._tree.identify_row(event.y)
1297
+ if not iid:
1298
+ return
1299
+ rec = self._row_map.get(iid, {})
1300
+ self.event_generate("<<RowDoubleClick>>", data={"record": rec, "iid": iid})
1301
+ if self._editing['updating']:
1302
+ self._open_form_dialog(rec)
1303
+
1304
+ def _open_new_record(self) -> None:
1305
+ if not self._editing['adding']:
1306
+ return
1307
+ self._open_form_dialog(None)
1308
+
1309
+ def _open_form_dialog(self, record: dict | None) -> None:
1310
+ from bootstack.dialogs.formdialog import FormDialog
1311
+
1312
+ try:
1313
+ # Ensure geometry info is current so centering uses real widget bounds
1314
+ self.update_idletasks()
1315
+ except Exception:
1316
+ pass
1317
+ dialog_master = self.winfo_toplevel() if hasattr(self, "winfo_toplevel") else self
1318
+
1319
+ form_items = self._build_form_items()
1320
+ initial_data = dict(record) if record else {}
1321
+
1322
+ form_options = dict(self._editing['form'])
1323
+ form_options.setdefault('col_count', 2)
1324
+ form_options.setdefault('min_col_width', 260)
1325
+ form_options.setdefault('scrollable', True)
1326
+ form_options.setdefault('resizable', True)
1327
+
1328
+ # Build buttons: Cancel, Delete (only for existing records), Save
1329
+ if record and "id" in record:
1330
+ buttons: list[str | dict] = ['Cancel']
1331
+ if self._editing['deleting']:
1332
+ buttons.append({"text": "Delete", "role": "secondary", "result": "delete"})
1333
+ buttons.append("Save")
1334
+ else:
1335
+ buttons = ["Cancel", "Save"]
1336
+
1337
+ dialog = FormDialog(
1338
+ master=dialog_master,
1339
+ title="Edit Record" if record else "New Record",
1340
+ data=initial_data,
1341
+ items=form_items,
1342
+ col_count=form_options.get('col_count', 2),
1343
+ min_col_width=form_options.get('min_col_width', 260),
1344
+ scrollable=form_options.get('scrollable', True),
1345
+ buttons=buttons,
1346
+ resizable=(True, True) if form_options.get('resizable', True) else (False, False),
1347
+ )
1348
+
1349
+ dialog.show(anchor_to="screen")
1350
+ result = dialog.result
1351
+
1352
+ if result is None:
1353
+ return
1354
+
1355
+ # Handle delete action
1356
+ if result == "delete" and record and "id" in record:
1357
+ try:
1358
+ self._datasource.delete_record(record["id"])
1359
+ self._clear_cache()
1360
+ self._load_page(self._current_page)
1361
+ except Exception:
1362
+ logger.exception("Failed to delete record id=%s", record["id"])
1363
+ return
1364
+
1365
+ data = result
1366
+ new_id = None
1367
+ if record and "id" in record:
1368
+ rec_id = record["id"]
1369
+ updates = dict(data)
1370
+ updates.pop("id", None)
1371
+ try:
1372
+ logger.debug("Updating record id=%s with %s", rec_id, updates)
1373
+ self._datasource.update_record(rec_id, updates)
1374
+ except Exception:
1375
+ logger.exception("Failed to update record id=%s", rec_id)
1376
+ return
1377
+ else:
1378
+ try:
1379
+ logger.debug("Creating record %s", data)
1380
+ new_id = self._datasource.create_record(dict(data))
1381
+ logger.debug("Created record id=%s (total=%s)", new_id, self._datasource.total_count())
1382
+ except Exception:
1383
+ logger.exception("Failed to create record from %s", data)
1384
+ return
1385
+ self._clear_cache()
1386
+ target_page = self._current_page
1387
+ if not record:
1388
+ # After creating, compute last page using fresh count so the new row is visible
1389
+ located_page = self._find_record_page(new_id) if new_id is not None else None
1390
+ target_page = located_page if located_page is not None else max(0, self._total_pages() - 1)
1391
+ self._load_page(target_page)
1392
+ if new_id is not None:
1393
+ self._focus_record(new_id)
1394
+
1395
+ def _build_form_items(self) -> list[dict]:
1396
+ items: list[dict] = []
1397
+ for idx, key in enumerate(self._column_keys):
1398
+ coldef = self._column_defs[idx] if idx < len(self._column_defs) else key
1399
+ label = self._col_text(coldef)
1400
+ editor_opts = {}
1401
+ editor = None
1402
+ dtype = None
1403
+ readonly = False
1404
+ if isinstance(coldef, dict):
1405
+ editor_opts = dict(coldef.get("editor_options", {}))
1406
+ editor = coldef.get("editor")
1407
+ dtype = coldef.get("dtype") or coldef.get("type")
1408
+ readonly = bool(coldef.get("readonly", False))
1409
+ if coldef.get("required"):
1410
+ editor_opts.setdefault("required", True)
1411
+ # Show validation messages to avoid layout jump on first error
1412
+ editor_opts.setdefault("show_message", True)
1413
+ items.append(
1414
+ {
1415
+ "key": key,
1416
+ "label": label,
1417
+ "dtype": dtype,
1418
+ "editor": editor,
1419
+ "editor_options": {**editor_opts},
1420
+ "readonly": readonly,
1421
+ "type": "field",
1422
+ }
1423
+ )
1424
+ return items
1425
+
1426
+ def _filter_by_value(self) -> None:
1427
+ selection = self._tree.selection()
1428
+ if not selection:
1429
+ return
1430
+ iid = selection[0]
1431
+ col_idx = max(0, min(self._row_menu_col or 0, len(self._column_keys) - 1))
1432
+ key = self._column_keys[col_idx]
1433
+ quoted_key = self._quote_col(key)
1434
+ values = self._tree.item(iid, "values")
1435
+ if col_idx >= len(values):
1436
+ return
1437
+ val = values[col_idx]
1438
+ crit = str(val).replace("'", "''")
1439
+ where = f"{quoted_key} = '{crit}'"
1440
+ try:
1441
+ self._datasource.set_filter(where)
1442
+ except Exception:
1443
+ return
1444
+ self._clear_cache()
1445
+ self._load_page(0)
1446
+
1447
+ def _sort_selection(self, ascending: bool) -> None:
1448
+ selection = self._tree.selection()
1449
+ if not selection:
1450
+ return
1451
+ iid = selection[0]
1452
+ col_idx = max(0, min(self._row_menu_col or 0, len(self._column_keys) - 1))
1453
+ key = self._column_keys[col_idx]
1454
+ quoted_key = self._quote_col(key)
1455
+ self._sort_state = {key: ascending}
1456
+ order = "ASC" if ascending else "DESC"
1457
+ try:
1458
+ self._datasource.set_sort(f"{quoted_key} {order}")
1459
+ except Exception:
1460
+ pass
1461
+ self._clear_cache()
1462
+ self._update_heading_icons()
1463
+ self._load_page(0)
1464
+
1465
+ def _clear_filter_cmd(self) -> None:
1466
+ try:
1467
+ self._datasource.set_filter("")
1468
+ except Exception:
1469
+ pass
1470
+ self._clear_cache()
1471
+ self._load_page(0)
1472
+ self._update_status_labels()
1473
+
1474
+ def _move_row_up(self) -> None:
1475
+ self._move_row_relative(-1)
1476
+
1477
+ def _move_row_down(self) -> None:
1478
+ self._move_row_relative(1)
1479
+
1480
+ def _move_row_top(self) -> None:
1481
+ self._move_row_absolute(0)
1482
+
1483
+ def _move_row_bottom(self) -> None:
1484
+ children = list(self._tree.get_children())
1485
+ if children:
1486
+ self._move_row_absolute(len(children) - 1)
1487
+
1488
+ def _move_row_relative(self, delta: int) -> None:
1489
+ sel = list(self._tree.selection())
1490
+ if not sel:
1491
+ return
1492
+ target_iid = sel[0]
1493
+ children = list(self._tree.get_children())
1494
+ try:
1495
+ idx = children.index(target_iid)
1496
+ except ValueError:
1497
+ return
1498
+ new_idx = max(0, min(len(children) - 1, idx + delta))
1499
+ if new_idx == idx:
1500
+ return
1501
+ self._tree.move(target_iid, "", new_idx)
1502
+ self._apply_row_alternation()
1503
+ rec = self._row_map.get(target_iid)
1504
+ if rec:
1505
+ self.event_generate("<<RowMove>>", data={"records": [rec]})
1506
+
1507
+ def _move_row_absolute(self, new_idx: int) -> None:
1508
+ sel = list(self._tree.selection())
1509
+ if not sel:
1510
+ return
1511
+ target_iid = sel[0]
1512
+ children = list(self._tree.get_children())
1513
+ new_idx = max(0, min(len(children) - 1, new_idx))
1514
+ self._tree.move(target_iid, "", new_idx)
1515
+ self._apply_row_alternation()
1516
+ rec = self._row_map.get(target_iid)
1517
+ if rec:
1518
+ self.event_generate("<<RowMove>>", data={"records": [rec]})
1519
+
1520
+ def _hide_selection(self) -> None:
1521
+ sel = list(self._tree.selection())
1522
+ for iid in sel:
1523
+ self._tree.delete(iid)
1524
+ self._row_map.pop(iid, None)
1525
+
1526
+ def _edit_selected_row(self) -> None:
1527
+ """Open the form dialog for the first selected row."""
1528
+ sel = list(self._tree.selection())
1529
+ if not sel:
1530
+ return
1531
+ iid = sel[0]
1532
+ rec = self._row_map.get(iid, {})
1533
+ self._open_form_dialog(rec)
1534
+
1535
+ def _delete_selected_row(self) -> None:
1536
+ """Delete the first selected row from the datasource."""
1537
+ sel = list(self._tree.selection())
1538
+ if not sel:
1539
+ return
1540
+ iid = sel[0]
1541
+ rec = self._row_map.get(iid, {})
1542
+ rec_id = rec.get("id")
1543
+ if rec_id is not None:
1544
+ try:
1545
+ self._datasource.delete_record(rec_id)
1546
+ self._clear_cache()
1547
+ self._load_page(self._current_page)
1548
+ self.event_generate("<<RowDelete>>", data={"records": [rec]})
1549
+ except Exception:
1550
+ logger.exception("Failed to delete record id=%s", rec_id)
1551
+
1552
+ def _delete_selection(self) -> None:
1553
+ sel = list(self._tree.selection())
1554
+ deleted_records: list[dict] = []
1555
+ changed = False
1556
+ for iid in sel:
1557
+ rec = dict(self._row_map.get(iid) or {})
1558
+ if rec:
1559
+ deleted_records.append(rec)
1560
+ rec_id = rec.get("id")
1561
+ if rec_id is not None:
1562
+ try:
1563
+ self._datasource.delete_record(rec_id)
1564
+ changed = True
1565
+ except Exception:
1566
+ pass
1567
+ self._row_map.pop(iid, None)
1568
+ if changed:
1569
+ self._clear_cache()
1570
+ self._load_page(self._current_page)
1571
+ if deleted_records:
1572
+ self.event_generate("<<RowDelete>>", data={"records": deleted_records})
1573
+
1574
+ # ------------------------------------------------------------------ Cache helpers
1575
+ def _clear_cache(self) -> None:
1576
+ if self._page_cache:
1577
+ self._page_cache.clear()
1578
+ # Invalidate total count cache when data/filter/sort changes
1579
+ self._cached_total_count = None
1580
+
1581
+ def _load_heading_icons(self) -> None:
1582
+ """Load and cache heading icons (sort arrows) sized to match the heading color."""
1583
+ try:
1584
+ fg = self._get_heading_fg()
1585
+ if fg == self._heading_fg and self._icon_sort_up:
1586
+ return
1587
+ self._heading_fg = fg
1588
+ self._icon_sort_up = BootstrapIcon("sort-up", 20, fg)
1589
+ self._icon_sort_down = BootstrapIcon("sort-down", 20, fg)
1590
+ except Exception:
1591
+ self._icon_sort_up = None
1592
+ self._icon_sort_down = None
1593
+
1594
+ def _get_heading_fg(self) -> str:
1595
+ """Resolve a heading foreground color with light-biased fallbacks."""
1596
+ style = get_style()
1597
+ ttk_style = self._tree.cget('style')
1598
+ # Try configured value first
1599
+ return style.configure(f"{ttk_style}.Heading", 'foreground')
1600
+
1601
+ def _update_heading_icons(self) -> None:
1602
+ """Apply sort direction icons to headings."""
1603
+ if not self._heading_texts:
1604
+ return
1605
+ self._load_heading_icons()
1606
+ for idx, text in enumerate(self._heading_texts):
1607
+ image = ""
1608
+ if idx < len(self._column_keys):
1609
+ key = self._column_keys[idx]
1610
+ state = self._sort_state.get(key)
1611
+ if state is True:
1612
+ image = self._icon_sort_up if self._icon_sort_up else ""
1613
+ elif state is False:
1614
+ image = self._icon_sort_down if self._icon_sort_down else ""
1615
+ self._tree.heading(idx, text=text, image=image)
1616
+
1617
+ def _remember_page(self, page: int, records: list[dict]) -> None:
1618
+ if self._paging['cache_size'] <= 0:
1619
+ return
1620
+ # Move/update LRU cache
1621
+ if page in self._page_cache:
1622
+ self._page_cache.pop(page)
1623
+ self._page_cache[page] = records
1624
+ if len(self._page_cache) > self._paging['cache_size']:
1625
+ self._page_cache.popitem(last=False)
1626
+
1627
+ def _focus_record(self, record_id) -> None:
1628
+ """Select and scroll to a record by id if it's on the current page."""
1629
+ try:
1630
+ rid = str(record_id)
1631
+ for iid, rec in self._row_map.items():
1632
+ if str(rec.get("id")) == rid:
1633
+ self._tree.selection_set(iid)
1634
+ self._tree.see(iid)
1635
+ break
1636
+ except Exception:
1637
+ pass
1638
+
1639
+ def _find_record_page(self, record_id) -> int | None:
1640
+ """Locate the page index containing the given record id, if available."""
1641
+ try:
1642
+ rid = str(record_id)
1643
+ total_pages = self._total_pages()
1644
+ for page_idx in range(total_pages):
1645
+ try:
1646
+ rows = self._datasource.get_page(page_idx)
1647
+ except Exception:
1648
+ break
1649
+ if any(str(rec.get("id")) == rid for rec in rows):
1650
+ return page_idx
1651
+ except Exception:
1652
+ pass
1653
+ return None
1654
+
1655
+ def _auto_size_columns(self, records: list[dict] | None = None) -> None:
1656
+ """Auto-size columns to the widest value among current rows/headings."""
1657
+ if not self._column_keys:
1658
+ return
1659
+ try:
1660
+ style = get_style()
1661
+ # Prefer the Treeview body font; fall back to TLabel/body or default
1662
+ tv_style = self._tree.cget("style") or "Treeview"
1663
+ body_font = (
1664
+ style.lookup(tv_style, "font")
1665
+ or style.lookup("TLabel", "font")
1666
+ or getattr(style, "fonts", {}).get("body")
1667
+ or "TkDefaultFont"
1668
+ )
1669
+ content_font = tkfont.nametofont(body_font)
1670
+ except Exception:
1671
+ content_font = None
1672
+
1673
+ pad_px = 20
1674
+
1675
+ # Gather samples from headings, provided records, and current tree values
1676
+ tree_samples = []
1677
+ for iid in self._tree.get_children(""):
1678
+ tree_samples.append(self._tree.item(iid, "values"))
1679
+ for ciid in self._tree.get_children(iid):
1680
+ tree_samples.append(self._tree.item(ciid, "values"))
1681
+
1682
+ for idx, key in enumerate(self._column_keys):
1683
+ samples = []
1684
+ if idx < len(self._heading_texts):
1685
+ samples.append(str(self._heading_texts[idx]))
1686
+ if records:
1687
+ for rec in records:
1688
+ samples.append(str(rec.get(key, "")))
1689
+ for vals in tree_samples:
1690
+ if idx < len(vals):
1691
+ samples.append(str(vals[idx]))
1692
+
1693
+ # Honor explicit column width if provided
1694
+ explicit_width = None
1695
+ if idx < len(self._column_defs):
1696
+ coldef = self._column_defs[idx]
1697
+ if isinstance(coldef, dict):
1698
+ explicit_width = coldef.get("width")
1699
+
1700
+ if explicit_width is not None:
1701
+ try:
1702
+ self._tree.column(idx, width=explicit_width, minwidth=self._column_min_width)
1703
+ except Exception:
1704
+ pass
1705
+ continue
1706
+
1707
+ text = max(samples, key=len) if samples else ""
1708
+ if content_font:
1709
+ width = content_font.measure(text) + pad_px
1710
+ else:
1711
+ width = 0
1712
+ # Fallback to simple char-based estimate to avoid under-measuring
1713
+ char_estimate = len(text) * 10 + pad_px
1714
+ width = max(width, char_estimate, self._column_min_width)
1715
+ # Cap width to available viewport so we don't force the tree wider than its frame
1716
+ try:
1717
+ avail = max(0, int(self._tree.winfo_width()) - pad_px)
1718
+ if avail > 0:
1719
+ width = min(width, avail)
1720
+ except Exception:
1721
+ pass
1722
+ try:
1723
+ self._tree.column(idx, width=width, minwidth=self._column_min_width)
1724
+ except Exception:
1725
+ pass
1726
+
1727
+ def _apply_row_alternation(self) -> None:
1728
+ """Apply alternating row colors via a tag."""
1729
+ enabled = self._row_alternation.get('enabled', False)
1730
+ if not enabled or self._group_by_key:
1731
+ return
1732
+ bg, fg = self._resolve_alternating_row_color()
1733
+ try:
1734
+ self._tree.tag_configure("altrow", background=bg, foreground=fg)
1735
+ # Some themes honor the "striped" tag name; configure it too
1736
+ self._tree.tag_configure("striped", background=bg, foreground=fg)
1737
+ except Exception:
1738
+ return
1739
+
1740
+ queue = list(self._tree.get_children(""))
1741
+ idx = 0
1742
+ while queue:
1743
+ iid = queue.pop(0)
1744
+ try:
1745
+ tags = list(self._tree.item(iid, "tags") or [])
1746
+ if idx % 2 == 1:
1747
+ if "altrow" not in tags:
1748
+ tags.append("altrow")
1749
+ if "striped" not in tags:
1750
+ tags.append("striped")
1751
+ else:
1752
+ tags = [t for t in tags if t not in ("altrow", "striped")]
1753
+ self._tree.item(iid, tags=tags)
1754
+ except Exception:
1755
+ pass
1756
+ queue.extend(list(self._tree.get_children(iid)))
1757
+ idx += 1
1758
+
1759
+ def _rebalance_grouped_widths(self) -> None:
1760
+ """Distribute available width across data columns when grouped so the left tree column is included."""
1761
+ # Only rebalance when grouping is active and xscroll is off (otherwise user can scroll)
1762
+ if not self._group_by_key or self._paging['xscroll']:
1763
+ return
1764
+ try:
1765
+ tree_width = max(0, int(self._tree.winfo_width()))
1766
+ group_width = max(0, int(self._tree.column("#0", option="width") or 0))
1767
+ vsb_width = 0
1768
+ if getattr(self, "_vsb", None):
1769
+ try:
1770
+ self._vsb.update_idletasks()
1771
+ if self._vsb.winfo_ismapped():
1772
+ vsb_width = int(self._vsb.winfo_width())
1773
+ except Exception:
1774
+ vsb_width = 0
1775
+ # Leave a small cushion to avoid oscillating scrollbar
1776
+ available = tree_width - group_width - vsb_width - 8
1777
+ if available <= 0:
1778
+ return
1779
+ cols = [c for c in self._display_columns if c < len(self._heading_texts)]
1780
+ if not cols:
1781
+ return
1782
+ width = max(self._column_min_width, available // len(cols))
1783
+ for c in cols:
1784
+ self._tree.column(c, width=width, stretch=True)
1785
+ # Keep the group column fixed so only data columns flex
1786
+ self._tree.column("#0", stretch=False)
1787
+ except Exception:
1788
+ pass
1789
+
1790
+ def _on_tree_configure(self, _event=None) -> None:
1791
+ """Handle resize events to keep grouped layouts sized to the available width."""
1792
+ self._rebalance_grouped_widths()
1793
+
1794
+ # ------------------------------------------------------------------ Export helpers
1795
+ def _export_all(self) -> None:
1796
+ try:
1797
+ rows = self._datasource.get_page_from_index(0, self._datasource.total_count())
1798
+ self._tree.event_generate("<<TableViewExportAll>>", data=rows)
1799
+ except Exception:
1800
+ pass
1801
+
1802
+ def _export_selection(self) -> None:
1803
+ try:
1804
+ selected = [self._row_map[iid] for iid in self._tree.selection() if iid in self._row_map]
1805
+ self._tree.event_generate("<<TableViewExportSelection>>", data=selected)
1806
+ except Exception:
1807
+ pass
1808
+
1809
+ def _export_page(self) -> None:
1810
+ try:
1811
+ start_index = self._current_page * self._paging['page_size']
1812
+ rows = self._datasource.get_page_from_index(start_index, self._paging['page_size'])
1813
+ self._tree.event_generate("<<TableViewExportPage>>", data=rows)
1814
+ except Exception:
1815
+ pass
1816
+
1817
+ # ------------------------------------------------------------------ Header click handling
1818
+ def _on_header_click(self, event) -> None:
1819
+ """Handle left-click on headers for sorting."""
1820
+ region = self._tree.identify_region(event.x, event.y)
1821
+ if region != "heading":
1822
+ return
1823
+
1824
+ if self._sorting == 'none':
1825
+ return
1826
+
1827
+ col_id = self._tree.identify_column(event.x) # e.g. "#1"
1828
+ try:
1829
+ display_idx = int(col_id.strip("#")) - 1
1830
+ except Exception:
1831
+ return
1832
+
1833
+ if display_idx < 0 or display_idx >= len(self._display_columns):
1834
+ return
1835
+
1836
+ column_idx = self._display_columns[display_idx]
1837
+ self._on_sort(column_idx)
1838
+
1839
+ def _filter_header_column(self) -> None:
1840
+ """Show filter dialog for the currently selected header column."""
1841
+ col = self._header_menu_col
1842
+ if col is None or col >= len(self._column_keys):
1843
+ return
1844
+ self._show_column_filter_dialog(col)
1845
+
1846
+ def _show_column_filter_dialog(self, column_idx: int) -> None:
1847
+ """Show FilterDialog with distinct values for the column."""
1848
+ from bootstack.dialogs.filterdialog import FilterDialog
1849
+
1850
+ if column_idx >= len(self._column_keys):
1851
+ return
1852
+
1853
+ key = self._column_keys[column_idx]
1854
+ heading_text = self._heading_texts[column_idx] if column_idx < len(self._heading_texts) else key
1855
+
1856
+ # Get distinct values from datasource
1857
+ try:
1858
+ distinct_values = self._datasource.get_distinct_values(key)
1859
+ except Exception:
1860
+ distinct_values = []
1861
+
1862
+ if not distinct_values:
1863
+ return
1864
+
1865
+ empty_text = MessageCatalog.translate("table.empty")
1866
+ # Build items for the filter dialog
1867
+ current_filter = self._column_filters.get(key)
1868
+ items = []
1869
+ for val in distinct_values:
1870
+ display_text = str(val) if val is not None else empty_text
1871
+ selected = current_filter is None or val in current_filter
1872
+ items.append(
1873
+ {
1874
+ "text": display_text,
1875
+ "value": val,
1876
+ "selected": selected
1877
+ })
1878
+
1879
+ # Position dialog below the header
1880
+ col_id = f"#{self._display_columns.index(column_idx) + 1}" if column_idx in self._display_columns else "#1"
1881
+ pos_x = self._tree.winfo_rootx()
1882
+ pos_y = self._tree.winfo_rooty()
1883
+
1884
+ tree_items = self._tree.get_children()
1885
+ if tree_items:
1886
+ bbox = self._tree.bbox(tree_items[0], col_id)
1887
+ if bbox:
1888
+ pos_x = self._tree.winfo_rootx() + bbox[0]
1889
+ pos_y = self._tree.winfo_rooty() + bbox[1] + 2
1890
+
1891
+ dialog = FilterDialog(
1892
+ master=self.winfo_toplevel(),
1893
+ title=MessageCatalog.translate("table.filter_column", heading_text),
1894
+ items=items,
1895
+ allow_search=True,
1896
+ allow_select_all=True,
1897
+ frameless=True
1898
+ )
1899
+
1900
+ result = dialog.show(position=(pos_x, pos_y))
1901
+
1902
+ if result is not None:
1903
+ self._apply_column_filter(key, result, distinct_values)
1904
+
1905
+ def _apply_column_filter(self, key: str, selected_values: list, all_values: list) -> None:
1906
+ """Apply column filter based on selected values."""
1907
+ # If all values selected, clear the filter for this column
1908
+ if set(selected_values) == set(all_values):
1909
+ self._column_filters.pop(key, None)
1910
+ else:
1911
+ self._column_filters[key] = selected_values
1912
+
1913
+ # Build combined WHERE clause from all column filters
1914
+ self._rebuild_filter_where()
1915
+
1916
+ def _rebuild_filter_where(self) -> None:
1917
+ """Rebuild WHERE clause from all active column filters."""
1918
+ clauses = []
1919
+ for key, values in self._column_filters.items():
1920
+ if not values:
1921
+ # No values selected = filter out everything
1922
+ clauses.append("1=0")
1923
+ else:
1924
+ quoted_key = self._quote_col(key)
1925
+ # Build IN clause
1926
+ quoted_values = []
1927
+ for v in values:
1928
+ if v is None:
1929
+ quoted_values.append("NULL")
1930
+ else:
1931
+ escaped = str(v).replace("'", "''")
1932
+ quoted_values.append(f"'{escaped}'")
1933
+ # Handle NULL separately since IN doesn't work with NULL
1934
+ null_check = ""
1935
+ if None in values:
1936
+ quoted_values = [qv for qv in quoted_values if qv != "NULL"]
1937
+ null_check = f" OR {quoted_key} IS NULL"
1938
+ if quoted_values:
1939
+ clauses.append(f"({quoted_key} IN ({','.join(quoted_values)}){null_check})")
1940
+ elif null_check:
1941
+ clauses.append(f"({quoted_key} IS NULL)")
1942
+
1943
+ where = " AND ".join(clauses) if clauses else ""
1944
+ try:
1945
+ self._datasource.set_filter(where)
1946
+ except Exception:
1947
+ pass
1948
+ self._clear_cache()
1949
+ self._load_page(0)
1950
+ self._update_status_labels()
1951
+
1952
+ # ------------------------------------------------------------------ Context dispatch
1953
+ def _on_tree_context(self, event) -> None:
1954
+ if self._context_menus == "none":
1955
+ return
1956
+ region = self._tree.identify_region(event.x, event.y)
1957
+ if region == "heading":
1958
+ if not self._header_context_enabled():
1959
+ return
1960
+ self._on_header_context(event)
1961
+ else:
1962
+ if not self._row_context_enabled():
1963
+ return
1964
+ self._on_row_context(event)
1965
+
1966
+ def _on_selection_event(self, _event=None) -> None:
1967
+ """Forward selection changes to subscribers."""
1968
+ rows = self.selected_rows
1969
+ self.event_generate("<<SelectionChange>>", data={"records": rows, "iids": list(self._tree.selection())})
1970
+
1971
+ def _on_row_click_event(self, event) -> None:
1972
+ region = self._tree.identify_region(event.x, event.y)
1973
+ if region == "heading":
1974
+ return
1975
+ iid = self._tree.identify_row(event.y)
1976
+ if not iid:
1977
+ return
1978
+ rec = self._row_map.get(iid, {})
1979
+ self.event_generate("<<RowClick>>", data={"record": rec, "iid": iid})
1980
+
1981
+ # ------------------------------------------------------------------ Header context menu
1982
+ def _ensure_header_menu(self) -> None:
1983
+ if not self._header_context_enabled():
1984
+ return
1985
+ if self._header_menu:
1986
+ return
1987
+ menu = ContextMenu(master=self, target=self._tree, trigger=None)
1988
+ menu.add_command(text="table.align_left", icon="align-start", command=self._align_header_left)
1989
+ menu.add_command(text="table.align_center", icon="align-center", command=self._align_header_center)
1990
+ menu.add_command(text="table.align_right", icon="align-end", command=self._align_header_right)
1991
+ menu.add_separator()
1992
+ menu.add_command(text="table.move_left", icon="arrow-left", command=self._move_header_left)
1993
+ menu.add_command(text="table.move_right", icon="arrow-right", command=self._move_header_right)
1994
+ menu.add_command(text="table.move_first", icon="arrow-bar-left", command=self._move_header_first)
1995
+ menu.add_command(text="table.move_last", icon="arrow-bar-right", command=self._move_header_last)
1996
+ menu.add_separator()
1997
+ menu.add_command(text="table.hide_column", icon="eye-slash", command=self._hide_header_column)
1998
+ menu.add_command(text="table.show_all", icon="eye", command=self._show_all_columns)
1999
+ if self._allow_grouping:
2000
+ menu.add_separator()
2001
+ menu.add_command(text="table.group_by_column", command=self._group_header_column)
2002
+ menu.add_command(text="table.ungroup_all", command=self._ungroup_all)
2003
+ menu.add_separator()
2004
+ menu.add_command(text="table.reset", icon="arrow-counterclockwise", command=self._reset_table)
2005
+ menu.add_separator()
2006
+ if not self._sorting == 'none':
2007
+ menu.add_command(text="table.clear_sort", icon="x-lg", command=self._clear_sort)
2008
+ self._header_menu = menu
2009
+
2010
+ def _on_header_context(self, event) -> None:
2011
+ if not self._header_context_enabled():
2012
+ return
2013
+ # Only handle header clicks
2014
+ if self._tree.identify_region(event.x, event.y) != "heading":
2015
+ return
2016
+ col_id = self._tree.identify_column(event.x) # e.g. "#1"
2017
+ try:
2018
+ idx = int(col_id.strip("#")) - 1
2019
+ except Exception:
2020
+ return
2021
+ if idx < 0 or idx >= len(self._display_columns):
2022
+ return
2023
+ self._header_menu_col = self._display_columns[idx]
2024
+ self._ensure_header_menu()
2025
+
2026
+ # Try to position at bottom-left of the clicked header
2027
+ pos_x, pos_y = event.x_root, event.y_root
2028
+ items = self._tree.get_children()
2029
+ if items:
2030
+ bbox = self._tree.bbox(items[0], col_id)
2031
+ if bbox:
2032
+ # bbox is relative to the widget; bbox[1] is header height offset
2033
+ pos_x = self._tree.winfo_rootx() + bbox[0]
2034
+ pos_y = self._tree.winfo_rooty() + bbox[1] + 2
2035
+ self._header_menu.show(position=(pos_x, pos_y))
2036
+
2037
+ def _align_header_left(self) -> None:
2038
+ self._set_heading_anchor("w")
2039
+
2040
+ def _align_header_center(self) -> None:
2041
+ self._set_heading_anchor("center")
2042
+
2043
+ def _align_header_right(self) -> None:
2044
+ self._set_heading_anchor("e")
2045
+
2046
+ def _set_heading_anchor(self, anchor: str) -> None:
2047
+ """Align only the header text for the selected column."""
2048
+ col = self._header_menu_col
2049
+ if col is None:
2050
+ return
2051
+ self._tree.heading(col, anchor=anchor)
2052
+ self._tree.column(col, anchor=anchor)
2053
+
2054
+ def _move_header_left(self) -> None:
2055
+ self._move_column(-1)
2056
+
2057
+ def _move_header_right(self) -> None:
2058
+ self._move_column(1)
2059
+
2060
+ def _move_header_first(self) -> None:
2061
+ self._move_column(to_index=0)
2062
+
2063
+ def _move_header_last(self) -> None:
2064
+ self._move_column(to_index=len(self._display_columns) - 1)
2065
+
2066
+ def _move_column(self, delta: int | None = None, to_index: int | None = None) -> None:
2067
+ col = self._header_menu_col
2068
+ if col is None or col not in self._display_columns:
2069
+ return
2070
+ current_pos = self._display_columns.index(col)
2071
+ if to_index is not None:
2072
+ new_pos = max(0, min(len(self._display_columns) - 1, to_index))
2073
+ else:
2074
+ new_pos = current_pos + (delta or 0)
2075
+ new_pos = max(0, min(len(self._display_columns) - 1, new_pos))
2076
+ if new_pos == current_pos:
2077
+ return
2078
+ self._display_columns.pop(current_pos)
2079
+ self._display_columns.insert(new_pos, col)
2080
+ self._tree.configure(displaycolumns=self._display_columns)
2081
+
2082
+ def _hide_header_column(self) -> None:
2083
+ col = self._header_menu_col
2084
+ if col is None or col not in self._display_columns:
2085
+ return
2086
+ self._display_columns.remove(col)
2087
+ if not self._display_columns:
2088
+ self._display_columns = list(range(len(self._heading_texts)))
2089
+ self._tree.configure(displaycolumns=self._display_columns)
2090
+
2091
+ def _show_all_columns(self) -> None:
2092
+ if not self._heading_texts:
2093
+ return
2094
+ self._display_columns = list(range(len(self._heading_texts)))
2095
+ self._tree.configure(displaycolumns=self._display_columns)
2096
+
2097
+ def _show_column_chooser_dialog(self) -> None:
2098
+ """Show a dialog to select which columns are visible."""
2099
+ from bootstack.dialogs.filterdialog import FilterDialog
2100
+
2101
+ if not self._heading_texts:
2102
+ return
2103
+
2104
+ # Build items for the filter dialog
2105
+ items = []
2106
+ for idx, text in enumerate(self._heading_texts):
2107
+ items.append(
2108
+ {
2109
+ "text": text,
2110
+ "value": idx,
2111
+ "selected": idx in self._display_columns
2112
+ })
2113
+
2114
+ # Calculate position: align dialog's top-right to button's bottom-right
2115
+ btn = self._column_chooser_btn
2116
+ btn.update_idletasks()
2117
+ btn_right = btn.winfo_rootx() + btn.winfo_width()
2118
+ btn_bottom = btn.winfo_rooty() + btn.winfo_height()
2119
+ dialog_width = 250 # FilterDialog has fixed width of 250
2120
+ pos_x = btn_right - dialog_width - 2 # 2px west
2121
+ pos_y = btn_bottom + 2 # 2px south
2122
+
2123
+ dialog = FilterDialog(
2124
+ master=self.winfo_toplevel(),
2125
+ title="Columns",
2126
+ items=items,
2127
+ allow_search=False,
2128
+ allow_select_all=True,
2129
+ frameless=True
2130
+ )
2131
+
2132
+ result = dialog.show(position=(pos_x, pos_y))
2133
+
2134
+ if result is not None:
2135
+ # Update display columns based on selection
2136
+ self._display_columns = [idx for idx in result if isinstance(idx, int)]
2137
+ if not self._display_columns:
2138
+ # Ensure at least one column is visible
2139
+ self._display_columns = list(range(len(self._heading_texts)))
2140
+ self._tree.configure(displaycolumns=self._display_columns)
2141
+
2142
+ def _reset_table(self) -> None:
2143
+ # Reset sort, columns visibility/order, and reload first page
2144
+ self._display_columns = list(range(len(self._heading_texts)))
2145
+ self._tree.configure(displaycolumns=self._display_columns)
2146
+ self._clear_sort()
2147
+
2148
+ # ------------------------------------------------------------------ Grouping
2149
+ def _group_header_column(self) -> None:
2150
+ """Group current view by the selected header column."""
2151
+ col = self._header_menu_col
2152
+ if col is None or col >= len(self._column_keys):
2153
+ return
2154
+ key = self._column_keys[col]
2155
+ quoted_key = self._quote_col(key)
2156
+ self._group_by_key = key
2157
+ self._group_parents.clear()
2158
+ # Sort entire datasource by the grouping column so grouping reflects full dataset order
2159
+ try:
2160
+ self._datasource.set_sort(f"{quoted_key} ASC")
2161
+ except Exception:
2162
+ pass
2163
+ self._sort_state = {key: True}
2164
+ self._clear_cache()
2165
+ self._update_heading_icons()
2166
+ # Restart at first page to reflect new ordering
2167
+ self._load_page(0)
2168
+ self._update_status_labels()
2169
+
2170
+ def _ungroup_all(self) -> None:
2171
+ """Return to flat view."""
2172
+ if not self._group_by_key:
2173
+ return
2174
+ self._group_by_key = None
2175
+ self._group_parents.clear()
2176
+ self._apply_group_show_state(False)
2177
+ self._load_page(self._current_page)
2178
+ self._update_status_labels()
2179
+
2180
+ def _apply_group_show_state(self, grouped: bool) -> None:
2181
+ """Toggle tree column visibility when grouping."""
2182
+ if grouped:
2183
+ self._tree.configure(show="tree headings")
2184
+ heading = "Group"
2185
+ try:
2186
+ if self._group_by_key and self._group_by_key in self._column_keys:
2187
+ col_idx = self._column_keys.index(self._group_by_key)
2188
+ heading = self._heading_texts[col_idx] if col_idx < len(self._heading_texts) else heading
2189
+ except Exception:
2190
+ pass
2191
+ self._tree.heading("#0", text=heading, anchor="w")
2192
+ # Fix the group column width so it stays visible even when space is tight
2193
+ self._tree.column("#0", width=200, minwidth=120, anchor="w", stretch=False)
2194
+ try:
2195
+ # Reset horizontal view so the group column is not scrolled out
2196
+ self._tree.xview_moveto(0)
2197
+ except Exception:
2198
+ pass
2199
+ self._rebalance_grouped_widths()
2200
+ else:
2201
+ self._tree.configure(show="headings")
2202
+ # Keep the tree column narrow/inert when unused
2203
+ self._tree.heading("#0", text="")
2204
+ self._tree.column("#0", width=0, minwidth=0, stretch=False)
2205
+ # Restore stretch behavior for data columns based on scroll mode
2206
+ try:
2207
+ stretch_cols = not self._paging['xscroll']
2208
+ for idx in range(len(self._heading_texts)):
2209
+ self._tree.column(idx, stretch=stretch_cols)
2210
+ except Exception:
2211
+ pass
2212
+
2213
+ def _render_flat(self, records: list[dict]) -> None:
2214
+ """Insert records as flat rows."""
2215
+ stripe = self._row_alternation.get('enabled', False) and not self._group_by_key
2216
+ for idx, rec in enumerate(records):
2217
+ values = [rec.get(k, "") for k in self._column_keys]
2218
+ tags = ("altrow",) if stripe and idx % 2 == 1 else ()
2219
+ iid = self._tree.insert("", "end", values=values, tags=tags)
2220
+ self._row_map[iid] = rec
2221
+
2222
+ def _render_grouped(self, records: list[dict]) -> None:
2223
+ """Insert records under parent nodes for the active group."""
2224
+ key = self._group_by_key
2225
+ if not key or key not in self._column_keys:
2226
+ self._render_flat(records)
2227
+ return
2228
+ col_idx = self._column_keys.index(key)
2229
+ heading_text = self._heading_texts[col_idx] if col_idx < len(self._heading_texts) else key
2230
+ groups: OrderedDict[str | None, list[dict]] = OrderedDict()
2231
+ for rec in records:
2232
+ groups.setdefault(rec.get(key), []).append(rec)
2233
+ self._group_parents.clear()
2234
+ for val, items in groups.items():
2235
+ label_val = "(None)" if val is None else str(val)
2236
+ label = f"{heading_text}: {label_val} ({len(items)})"
2237
+ parent_iid = self._tree.insert("", "end", text=label, open=True)
2238
+ self._group_parents[val] = parent_iid
2239
+ for rec in items:
2240
+ values = [rec.get(k, "") for k in self._column_keys]
2241
+ iid = self._tree.insert(parent_iid, "end", values=values)
2242
+ self._row_map[iid] = rec
2243
+
2244
+ def _clear_sort(self) -> None:
2245
+ self._sort_state.clear()
2246
+ self._datasource.set_sort("")
2247
+ self._clear_cache()
2248
+ self._update_heading_icons()
2249
+ self._load_page(0)
2250
+ self._update_status_labels()
2251
+
2252
+
2253
+ # Backwards-compatible alias for the legacy Tableview name
2254
+ Tableview = TableView