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,1507 @@
1
+ """ListView widget for displaying large lists with virtual scrolling."""
2
+
3
+ from tkinter import TclError
4
+ from typing import Protocol, Any, Callable, Literal, runtime_checkable
5
+
6
+ from bootstack.widgets.composites.list.listitem import ListItem
7
+ from bootstack.widgets.primitives.frame import Frame
8
+ from bootstack.widgets.primitives.scrollbar import Scrollbar
9
+ from bootstack.widgets.mixins import configure_delegate
10
+
11
+ # Constants
12
+ VISIBLE_ROWS = 20
13
+ ROW_HEIGHT = 40
14
+ OVERSCAN_ROWS = 2
15
+ EMPTY = {"__empty__": True, "id": "__empty__"}
16
+
17
+
18
+ @runtime_checkable
19
+ class DataSourceProtocol(Protocol):
20
+ """Protocol for data sources used by ListView.
21
+
22
+ Implementations provide paging, selection, and CRUD operations for records.
23
+ """
24
+
25
+ def total_count(self) -> int:
26
+ """Return total number of records.
27
+
28
+ Returns:
29
+ Total record count.
30
+ """
31
+ ...
32
+
33
+ def get_page_from_index(self, start: int, count: int) -> list[dict]:
34
+ """Get a page of records starting at an index.
35
+
36
+ Args:
37
+ start: Zero-based index for the first record.
38
+ count: Maximum number of records to return.
39
+
40
+ Returns:
41
+ List of record dictionaries.
42
+ """
43
+ ...
44
+
45
+ def is_selected(self, record_id: Any) -> bool:
46
+ """Check if a record is selected.
47
+
48
+ Args:
49
+ record_id: Record identifier to check.
50
+
51
+ Returns:
52
+ True if the record is selected.
53
+ """
54
+ ...
55
+
56
+ def select_record(self, record_id: Any) -> None:
57
+ """Select a record.
58
+
59
+ Args:
60
+ record_id: Record identifier to select.
61
+ """
62
+ ...
63
+
64
+ def deselect_record(self, record_id: Any) -> None:
65
+ """Deselect a record.
66
+
67
+ Args:
68
+ record_id: Record identifier to deselect.
69
+ """
70
+ ...
71
+
72
+ def deselect_all(self) -> None:
73
+ """Deselect all records."""
74
+ ...
75
+
76
+ def get_selected(self) -> list[Any]:
77
+ """Get all selected record IDs.
78
+
79
+ Returns:
80
+ List of selected record identifiers.
81
+ """
82
+ ...
83
+
84
+ def delete_record(self, record_id: Any) -> None:
85
+ """Delete a record.
86
+
87
+ Args:
88
+ record_id: Record identifier to delete.
89
+ """
90
+ ...
91
+
92
+ def create_record(self, data: dict) -> Any:
93
+ """Create a new record and return its ID.
94
+
95
+ Args:
96
+ data: Record data to insert.
97
+
98
+ Returns:
99
+ The new record identifier.
100
+ """
101
+ ...
102
+
103
+ def update_record(self, record_id: Any, data: dict) -> bool:
104
+ """Update a record.
105
+
106
+ Args:
107
+ record_id: Record identifier to update.
108
+ data: Record data to merge into the existing record.
109
+
110
+ Returns:
111
+ True if the record was updated.
112
+ """
113
+ ...
114
+
115
+ def reload(self) -> None:
116
+ """Reload data from the data source."""
117
+ ...
118
+
119
+ def move_record(self, record_id: Any, target_index: int) -> bool:
120
+ """Move a record to a new position.
121
+
122
+ Args:
123
+ record_id: Record identifier to move.
124
+ target_index: Zero-based index to move the record to.
125
+
126
+ Returns:
127
+ True if the record was moved.
128
+ """
129
+ ...
130
+
131
+
132
+ class MemoryDataSource:
133
+ """In-memory data source implementation for ListView.
134
+
135
+ Stores records in a list and tracks selected record IDs.
136
+ """
137
+
138
+ def __init__(self):
139
+ """Initialize an empty data source."""
140
+ self._data: list[dict] = []
141
+ self._selected_ids: set = set()
142
+ self._id_index: dict[Any, int] = {} # Maps record ID to index for O(1) lookups
143
+
144
+ def set_data(self, data: list) -> 'MemoryDataSource':
145
+ """Set the data and return self for chaining.
146
+
147
+ Args:
148
+ data: List of dicts or primitive values to convert to records.
149
+
150
+ Returns:
151
+ This instance for chaining.
152
+ """
153
+ self._data = []
154
+ self._id_index = {}
155
+ for i, item in enumerate(data or []):
156
+ if isinstance(item, dict):
157
+ if 'id' not in item:
158
+ item['id'] = i
159
+ self._data.append(item)
160
+ self._id_index[item['id']] = i
161
+ else:
162
+ # Convert primitives to dict
163
+ record = {'id': i, 'value': str(item)}
164
+ self._data.append(record)
165
+ self._id_index[i] = i
166
+
167
+ return self
168
+
169
+ def total_count(self) -> int:
170
+ """Return total number of records.
171
+
172
+ Returns:
173
+ Total record count.
174
+ """
175
+ return len(self._data)
176
+
177
+ def get_page_from_index(self, start: int, count: int) -> list[dict]:
178
+ """Get a page of records starting at an index.
179
+
180
+ Args:
181
+ start: Zero-based index for the first record.
182
+ count: Maximum number of records to return.
183
+
184
+ Returns:
185
+ List of record dictionaries.
186
+ """
187
+ end = min(start + count, len(self._data))
188
+ return self._data[start:end]
189
+
190
+ def is_selected(self, record_id: Any) -> bool:
191
+ """Check if a record is selected.
192
+
193
+ Args:
194
+ record_id: Record identifier to check.
195
+
196
+ Returns:
197
+ True if the record is selected.
198
+ """
199
+ return record_id in self._selected_ids
200
+
201
+ def select_record(self, record_id: Any) -> None:
202
+ """Select a record.
203
+
204
+ Args:
205
+ record_id: Record identifier to select.
206
+ """
207
+ self._selected_ids.add(record_id)
208
+
209
+ def deselect_record(self, record_id: Any) -> None:
210
+ """Deselect a record.
211
+
212
+ Args:
213
+ record_id: Record identifier to deselect.
214
+ """
215
+ self._selected_ids.discard(record_id)
216
+
217
+ def deselect_all(self) -> None:
218
+ """Deselect all records."""
219
+ self._selected_ids.clear()
220
+
221
+ def get_selected(self) -> list[Any]:
222
+ """Get all selected record IDs.
223
+
224
+ Returns:
225
+ List of selected record identifiers.
226
+ """
227
+ return list(self._selected_ids)
228
+
229
+ def delete_record(self, record_id: Any) -> None:
230
+ """Delete a record.
231
+
232
+ Args:
233
+ record_id: Record identifier to delete.
234
+ """
235
+ # Use index for O(1) lookup
236
+ index = self._id_index.get(record_id)
237
+ if index is not None:
238
+ # Remove from data
239
+ del self._data[index]
240
+ # Remove from index
241
+ del self._id_index[record_id]
242
+ # Rebuild index for all records after the deleted one
243
+ for i in range(index, len(self._data)):
244
+ self._id_index[self._data[i]['id']] = i
245
+ self._selected_ids.discard(record_id)
246
+
247
+ def create_record(self, data: dict) -> Any:
248
+ """Create a new record and return its ID.
249
+
250
+ Args:
251
+ data: Record data to insert.
252
+
253
+ Returns:
254
+ The new record identifier.
255
+ """
256
+ max_id = max((r.get('id', 0) for r in self._data), default=0)
257
+ new_id = max_id + 1 if isinstance(max_id, int) else len(self._data)
258
+ data['id'] = new_id
259
+ new_index = len(self._data)
260
+ self._data.append(data)
261
+ self._id_index[new_id] = new_index
262
+ return new_id
263
+
264
+ def update_record(self, record_id: Any, data: dict) -> bool:
265
+ """Update a record.
266
+
267
+ Args:
268
+ record_id: Record identifier to update.
269
+ data: Record data to merge into the existing record.
270
+
271
+ Returns:
272
+ True if the record was updated.
273
+ """
274
+ # Use index for O(1) lookup
275
+ index = self._id_index.get(record_id)
276
+ if index is not None:
277
+ self._data[index].update(data)
278
+ return True
279
+ return False
280
+
281
+ def reload(self) -> None:
282
+ """Reload data from source.
283
+
284
+ This is a no-op for the in-memory data source.
285
+ """
286
+ pass
287
+
288
+ def move_record(self, record_id: Any, target_index: int) -> bool:
289
+ """Move a record to a new position.
290
+
291
+ Args:
292
+ record_id: Record identifier to move.
293
+ target_index: Zero-based index to move the record to.
294
+
295
+ Returns:
296
+ True if the record was moved.
297
+ """
298
+ if not self._data:
299
+ return False
300
+
301
+ # Use index for O(1) lookup
302
+ source_index = self._id_index.get(record_id)
303
+ if source_index is None:
304
+ return False
305
+
306
+ clamped_target = max(0, min(target_index, len(self._data) - 1))
307
+ if source_index == clamped_target:
308
+ return False
309
+
310
+ # Move the record
311
+ record = self._data.pop(source_index)
312
+ if clamped_target > source_index:
313
+ clamped_target -= 1
314
+ self._data.insert(clamped_target, record)
315
+
316
+ # Rebuild index for affected range
317
+ start = min(source_index, clamped_target)
318
+ end = max(source_index, clamped_target) + 1
319
+ for i in range(start, end):
320
+ self._id_index[self._data[i]['id']] = i
321
+
322
+ return True
323
+
324
+
325
+ class ListView(Frame):
326
+ """A virtual scrolling list widget for efficiently displaying large datasets.
327
+
328
+ ListView uses virtual scrolling to render only visible items, allowing it to
329
+ handle thousands of records efficiently. It supports multiple selection modes,
330
+ item deletion, drag and drop, and custom styling.
331
+
332
+ The widget works with either a simple list/dict data or a custom DataSource
333
+ implementation for more complex scenarios (database, API, etc.).
334
+
335
+ !!! note "Events"
336
+ - `<<SelectionChange>>`: Fired when selection state changes. `event.data = None` (use `get_selected()` to get current selection)
337
+ - `<<ItemDelete>>`: Fired when an item is deleted. `event.data = {'record': dict}`
338
+ - `<<ItemDeleteFail>>`: Fired when item deletion fails. `event.data = {'record': dict, 'error': str}`
339
+ - `<<ItemInsert>>`: Fired when a new item is inserted. `event.data = {'record': dict}`
340
+ - `<<ItemUpdate>>`: Fired when an item is updated. `event.data = {'record': dict}`
341
+ - `<<ItemClick>>`: Fired when an item is clicked. `event.data = {'record': dict}`
342
+ - `<<ItemDragStart>>`: Fired when a drag begins. `event.data = {'record': dict, 'index': int}`
343
+ - `<<ItemDrag>>`: Fired when an item is being dragged. `event.data = {'source_index': int, 'target_index': int, 'x': int, 'y': int}`
344
+ - `<<ItemDragEnd>>`: Fired when a drag ends. `event.data = {'moved': bool, 'source_index': int, 'target_index': int}`
345
+ """
346
+
347
+ def __init__(
348
+ self,
349
+ master=None,
350
+ items: list = None,
351
+ datasource: DataSourceProtocol = None,
352
+ row_factory: Callable = None,
353
+ selection_mode: Literal['none', 'single', 'multi'] = 'none',
354
+ show_selection_controls: bool = False,
355
+ show_chevron: bool = False,
356
+ enable_removing: bool = False,
357
+ enable_dragging: bool = False,
358
+ striped: bool = False,
359
+ striped_background: str = 'background[+1]',
360
+ show_separator: bool = True,
361
+ scrollbar_visibility: Literal['always', 'never'] = 'always',
362
+ enable_focus: bool = True,
363
+ enable_hover: bool = True,
364
+ focus_color: str = None,
365
+ selected_background: str = 'primary',
366
+ select_on_click: bool = None,
367
+ density: Literal['default', 'compact'] = 'default',
368
+ **kwargs
369
+ ):
370
+ """Initialize a ListView widget.
371
+
372
+ Args:
373
+ master: Parent widget.
374
+ items: List of items or dicts to display (alternative to `datasource`).
375
+ datasource: DataSource implementation for data access.
376
+ row_factory: Callable that creates custom `ListItem` widgets.
377
+ selection_mode: Selection mode (`none`, `single`, `multi`).
378
+ show_selection_controls: Show checkboxes/radio buttons for selection.
379
+ show_chevron: Show chevron indicators on items.
380
+ enable_removing: Allow items to be removed; shows remove button on items.
381
+ enable_dragging: Allow row dragging; shows drag handle on items.
382
+ striped: Whether to show alternating row colors.
383
+ striped_background: The background color for striped rows.
384
+ show_separator: Show separator line between items.
385
+ scrollbar_visibility: Scrollbar visibility - 'always' to show scrollbar,
386
+ 'never' to hide (mousewheel only). Defaults to 'always'.
387
+ enable_focus: Whether items can receive keyboard focus.
388
+ enable_hover: Whether items show hover state.
389
+ focus_color: Color for the focus indicator.
390
+ selected_background: Background color for selected items.
391
+ select_on_click: Whether clicking an item selects it. Defaults to True when
392
+ selection_mode is 'single' or 'multi', False otherwise. Can be explicitly
393
+ set to override the default behavior.
394
+ density: Visual density ('default' or 'compact'). Defaults to 'default'.
395
+ **kwargs: Additional keyword arguments forwarded to `Frame`.
396
+ """
397
+ super().__init__(master, variant='container', ttk_class='ListView.TFrame', **kwargs)
398
+
399
+ # Cache the windowing system so scroll bindings can dispatch
400
+ # platform-correctly: Aqua/Win send <MouseWheel>, X11 sends
401
+ # <Button-4>/<Button-5>.
402
+ self.winsys = self.tk.call('tk', 'windowingsystem')
403
+
404
+ # Configuration
405
+ self._selection_mode = selection_mode
406
+ self._show_selection_controls = show_selection_controls
407
+ self._show_chevron = show_chevron
408
+ self._enable_removing = enable_removing
409
+ self._enable_dragging = enable_dragging
410
+ self._show_separator = show_separator
411
+ self._scrollbar_visibility = scrollbar_visibility
412
+ self._select_on_click = select_on_click
413
+ self._enable_focus = enable_focus
414
+ self._enable_hover = enable_hover
415
+ self._striped = striped
416
+ self._striped_background = striped_background
417
+ self._focus_color = focus_color
418
+ self._selected_background = selected_background
419
+ self._density = density
420
+
421
+
422
+ # Data source
423
+ if datasource:
424
+ self._datasource = datasource
425
+ elif items:
426
+ self._datasource = MemoryDataSource().set_data(items)
427
+ else:
428
+ self._datasource = MemoryDataSource().set_data([])
429
+
430
+ # Virtual scrolling state
431
+ self._start_index = 0
432
+ self._prev_start_index = 0
433
+ self._visible_rows = VISIBLE_ROWS
434
+ self._row_height = ROW_HEIGHT
435
+ self._page_size = VISIBLE_ROWS + OVERSCAN_ROWS
436
+ self._rows: list[ListItem] = []
437
+ self._focused_record_id = None
438
+ self._drag_state: dict | None = None
439
+ self._drag_indicator: Frame | None = None
440
+ self._drag_scroll_counter = 0
441
+ self._mousewheel_bound_widgets: set = set() # Track bound widgets to avoid cycles
442
+
443
+ # Row factory
444
+ self._row_factory = row_factory or self._default_row_factory
445
+
446
+ # Create container frame for list items
447
+ self._container = Frame(self, variant='container', ttk_class='ListView.TFrame')
448
+ self._container.pack(side='left', fill='both', expand=True)
449
+
450
+ # Create scrollbar
451
+ self._scrollbar = Scrollbar(self, orient='vertical', command=self._on_scroll)
452
+ if self._scrollbar_visibility == 'always':
453
+ self._scrollbar.pack(side='right', fill='y')
454
+
455
+ # Create row pool
456
+ self._ensure_row_pool(self._page_size)
457
+
458
+ # Bind events
459
+ self.bind('<Configure>', self._on_resize, add='+')
460
+ self._bind_scroll_events(self)
461
+ self._bind_scroll_events(self._container)
462
+
463
+ # Bind ListItem events
464
+ self._container.bind('<<ItemSelecting>>', self._on_item_selecting, add='+')
465
+ self._container.bind('<<ItemRemoving>>', self._on_item_removing, add='+')
466
+ self._container.bind('<<ItemFocus>>', self._on_item_focused, add='+')
467
+ self._container.bind('<<ItemClick>>', self._on_item_click, add='+')
468
+ self._container.bind('<<ItemDragStart>>', self._on_item_drag_start, add='+')
469
+ self._container.bind('<<ItemDrag>>', self._on_item_dragging, add='+')
470
+ self._container.bind('<<ItemDragEnd>>', self._on_item_drag_end, add='+')
471
+
472
+ # Bind keyboard navigation
473
+ self.bind('<Down>', self._on_arrow_down, add='+')
474
+ self.bind('<Up>', self._on_arrow_up, add='+')
475
+ self._container.bind('<Down>', self._on_arrow_down, add='+')
476
+ self._container.bind('<Up>', self._on_arrow_up, add='+')
477
+
478
+ # Initial update
479
+ self.after(10, self._remeasure_and_relayout)
480
+
481
+ @configure_delegate('selection_mode')
482
+ def _delegate_selection_mode(self, value=None):
483
+ """Get or set the selection mode.
484
+
485
+ Args:
486
+ value: If provided, sets the selection mode to 'none', 'single', or 'multi'.
487
+ If None, returns the current selection mode.
488
+
489
+ Returns:
490
+ Current selection mode when called without arguments.
491
+ """
492
+ if value is None:
493
+ return self._selection_mode
494
+ else:
495
+ self._selection_mode = value
496
+ # Recreate row pool to apply new selection mode
497
+ self._ensure_row_pool(self._page_size)
498
+ self._update_rows()
499
+ return None
500
+
501
+ @configure_delegate('scrollbar_visibility')
502
+ def _delegate_scrollbar_visibility(self, value=None):
503
+ """Get or set scrollbar visibility.
504
+
505
+ Args:
506
+ value: If provided ('always' or 'never'), shows or hides the scrollbar.
507
+ If None, returns current visibility setting.
508
+
509
+ Returns:
510
+ Current scrollbar_visibility value when called without arguments.
511
+ """
512
+ if value is None:
513
+ return self._scrollbar_visibility
514
+ else:
515
+ old_value = self._scrollbar_visibility
516
+ self._scrollbar_visibility = value
517
+ if old_value != self._scrollbar_visibility:
518
+ if self._scrollbar_visibility == 'always':
519
+ self._scrollbar.pack(side='right', fill='y')
520
+ else:
521
+ self._scrollbar.pack_forget()
522
+ return None
523
+
524
+ @configure_delegate('striped')
525
+ def _delegate_striped(self, value=None):
526
+ """Get or set striped mode.
527
+
528
+ Args:
529
+ value: If provided, enables or disables striped rows.
530
+ If None, returns the current mode.
531
+
532
+ Returns:
533
+ Current striped value when called without arguments.
534
+ """
535
+ if value is None:
536
+ return self._striped
537
+ else:
538
+ self._striped = bool(value)
539
+ # Reapply surface colors to all rows
540
+ for i, row in enumerate(self._rows):
541
+ self._apply_widget_surface(row, i)
542
+ return None
543
+
544
+ @configure_delegate('striped_background')
545
+ def _delegate_striped_background(self, value=None):
546
+ """Get or set striped row background color.
547
+
548
+ Args:
549
+ value: If provided, sets the striped row background color.
550
+ If None, returns the current color.
551
+
552
+ Returns:
553
+ Current striped_background when called without arguments.
554
+ """
555
+ if value is None:
556
+ return self._striped_background
557
+ else:
558
+ self._striped_background = value
559
+ # Reapply surface colors to all rows
560
+ for i, row in enumerate(self._rows):
561
+ self._apply_widget_surface(row, i)
562
+ return None
563
+
564
+ @staticmethod
565
+ def _default_row_factory(master, **kwargs):
566
+ """Create a default `ListItem`.
567
+
568
+ Args:
569
+ master: Parent widget.
570
+ **kwargs: Keyword arguments for `ListItem`.
571
+
572
+ Returns:
573
+ A new `ListItem` instance.
574
+ """
575
+ return ListItem(master, **kwargs)
576
+
577
+ def _ensure_row_pool(self, needed: int):
578
+ """Create/destroy `ListItem` widgets to match pool size.
579
+
580
+ Args:
581
+ needed: Desired number of row widgets.
582
+ """
583
+ while len(self._rows) < needed:
584
+ # Build kwargs for row factory (using item-level names)
585
+ row_kwargs = dict(
586
+ selection_mode=self._selection_mode,
587
+ show_selection_controls=self._show_selection_controls,
588
+ show_chevron=self._show_chevron,
589
+ removable=self._enable_removing,
590
+ draggable=self._enable_dragging,
591
+ show_separator=self._show_separator,
592
+ focusable=self._enable_focus,
593
+ hoverable=self._enable_hover,
594
+ focus_color=self._focus_color,
595
+ selected_background=self._selected_background,
596
+ density=self._density
597
+ )
598
+
599
+ # Only pass select_on_click if explicitly set
600
+ if self._select_on_click is not None:
601
+ row_kwargs['select_on_click'] = self._select_on_click
602
+
603
+ row = self._row_factory(self._container, **row_kwargs)
604
+ row.pack(fill='x')
605
+ self._rows.append(row)
606
+
607
+ # Bind keyboard navigation to each row and its children
608
+ self._bind_arrow_keys_recursive(row)
609
+
610
+ # Apply surface color once based on widget position
611
+ widget_index = len(self._rows) - 1
612
+ self._apply_widget_surface(row, widget_index)
613
+
614
+ while len(self._rows) > needed:
615
+ row = self._rows.pop()
616
+ row.pack_forget()
617
+ try:
618
+ row.destroy()
619
+ except TclError:
620
+ pass
621
+
622
+ def _clamp_indices(self):
623
+ """Ensure `self._start_index` is within valid range."""
624
+ total = self._datasource.total_count()
625
+ max_start = max(0, total - self._visible_rows)
626
+ self._start_index = max(0, min(self._start_index, max_start))
627
+
628
+ def _update_rows(self):
629
+ """Update visible rows with current data using row recycling for efficiency."""
630
+ self._clamp_indices()
631
+
632
+ # Calculate scroll distance to determine if we can use recycling
633
+ scroll_distance = self._start_index - self._prev_start_index
634
+ can_recycle = abs(scroll_distance) <= 3 and scroll_distance != 0
635
+
636
+ if can_recycle:
637
+ # Use row recycling for small scrolls
638
+ self._recycle_rows(scroll_distance)
639
+ else:
640
+ # Full update for large scrolls or initial render
641
+ self._full_update_rows()
642
+
643
+ # Remember current position for next scroll
644
+ self._prev_start_index = self._start_index
645
+
646
+ # Update scrollbar
647
+ total = max(1, self._datasource.total_count())
648
+ first = self._start_index / total
649
+ last = min(1.0, (self._start_index + self._visible_rows) / total)
650
+ self._scrollbar.set(first, last)
651
+
652
+ def _recycle_rows(self, scroll_distance: int):
653
+ """Recycle rows by moving them from one end to the other.
654
+
655
+ Args:
656
+ scroll_distance: Positive for scrolling down, negative for scrolling up.
657
+ """
658
+ if scroll_distance > 0:
659
+ # Scrolling down: move top rows to bottom
660
+ for _ in range(scroll_distance):
661
+ if not self._rows:
662
+ break
663
+
664
+ # Remove top row
665
+ top_row = self._rows.pop(0)
666
+
667
+ # Calculate new data index
668
+ data_index = self._start_index + len(self._rows)
669
+
670
+ # Update data BEFORE moving widget to prevent focus tracking widget
671
+ self._update_single_row(top_row, data_index)
672
+
673
+ # Move to bottom
674
+ top_row.pack_forget()
675
+ top_row.pack(side='top', fill='x')
676
+ self._rows.append(top_row)
677
+
678
+ elif scroll_distance < 0:
679
+ # Scrolling up: move bottom rows to top
680
+ for _ in range(abs(scroll_distance)):
681
+ if not self._rows:
682
+ break
683
+
684
+ # Remove bottom row
685
+ bottom_row = self._rows.pop()
686
+
687
+ # Calculate new data index
688
+ data_index = self._start_index
689
+
690
+ # Update data BEFORE moving widget to prevent focus tracking widget
691
+ self._update_single_row(bottom_row, data_index)
692
+
693
+ # Move to top
694
+ bottom_row.pack_forget()
695
+ if self._rows:
696
+ bottom_row.pack(side='top', fill='x', before=self._rows[0])
697
+ else:
698
+ bottom_row.pack(side='top', fill='x')
699
+ self._rows.insert(0, bottom_row)
700
+
701
+ def _full_update_rows(self):
702
+ """Perform a full update of all visible rows."""
703
+ page_data = self._datasource.get_page_from_index(self._start_index, self._page_size)
704
+
705
+ for i, row in enumerate(self._rows):
706
+ data_index = self._start_index + i
707
+ if i < len(page_data):
708
+ self._update_single_row(row, data_index, page_data[i])
709
+ else:
710
+ row.update_data(EMPTY)
711
+
712
+ def _update_single_row(self, row: ListItem, data_index: int, record: dict = None):
713
+ """Update a single row widget with data at the given index.
714
+
715
+ Args:
716
+ row: The ListItem widget to update.
717
+ data_index: The data index to fetch and display.
718
+ record: Optional pre-fetched record data. If None, will fetch from datasource.
719
+ """
720
+ if record is None:
721
+ # Fetch the record from datasource
722
+ page_data = self._datasource.get_page_from_index(data_index, 1)
723
+ if not page_data:
724
+ row.update_data(EMPTY)
725
+ # Bind mousewheel after update to ensure all child widgets exist
726
+ self._bind_mousewheel_recursive(row)
727
+ return
728
+ record = page_data[0]
729
+
730
+ record = record.copy()
731
+ record_id = record.get('id')
732
+
733
+ # Add selection state
734
+ if record_id is not None:
735
+ try:
736
+ record['selected'] = self._datasource.is_selected(record_id)
737
+ except Exception:
738
+ record['selected'] = False
739
+ record['focused'] = (record_id == self._focused_record_id)
740
+ else:
741
+ record['selected'] = False
742
+ record['focused'] = False
743
+
744
+ # Add index
745
+ record['item_index'] = data_index
746
+
747
+ # Update the row
748
+ row.update_data(record)
749
+
750
+ # Bind mousewheel after update to ensure all child widgets exist
751
+ self._bind_mousewheel_recursive(row)
752
+
753
+ def _on_scroll(self, *args):
754
+ """Handle scrollbar movement.
755
+
756
+ Args:
757
+ *args: Tkinter scrollbar arguments.
758
+ """
759
+ if args[0] == 'moveto':
760
+ fraction = float(args[1])
761
+ total = self._datasource.total_count()
762
+ max_start = max(0, total - self._visible_rows)
763
+ self._start_index = int(round(fraction * max_start))
764
+ elif args[0] == 'scroll':
765
+ amount = int(args[1])
766
+ unit = args[2]
767
+ # Use smaller step size for smoother scrolling
768
+ step = max(1, self._visible_rows // 2) if unit == 'pages' else 1
769
+ self._start_index += amount * step
770
+
771
+ self._clamp_indices()
772
+ self._update_rows()
773
+
774
+ def _bind_mousewheel_recursive(self, widget):
775
+ """Recursively bind mousewheel event to a widget and all its children.
776
+
777
+ Only binds if the widget hasn't been bound already to avoid duplicate bindings.
778
+
779
+ Args:
780
+ widget: The widget to bind mousewheel event to.
781
+ """
782
+ # Use widget string representation as identifier
783
+ widget_id = str(widget)
784
+
785
+ # Only bind if we haven't already bound this widget
786
+ if widget_id not in self._mousewheel_bound_widgets:
787
+ self._bind_scroll_events(widget)
788
+ self._mousewheel_bound_widgets.add(widget_id)
789
+
790
+ try:
791
+ for child in widget.winfo_children():
792
+ self._bind_mousewheel_recursive(child)
793
+ except Exception:
794
+ pass
795
+
796
+ def _bind_arrow_keys_recursive(self, widget):
797
+ """Recursively bind arrow key events to a widget and all its children.
798
+
799
+ Args:
800
+ widget: The widget to bind arrow key events to.
801
+ """
802
+ widget.bind('<Down>', self._on_arrow_down, add='+')
803
+ widget.bind('<Up>', self._on_arrow_up, add='+')
804
+
805
+ try:
806
+ for child in widget.winfo_children():
807
+ self._bind_arrow_keys_recursive(child)
808
+ except Exception:
809
+ pass
810
+
811
+ def _bind_scroll_events(self, widget) -> None:
812
+ """Bind the platform-correct scroll-wheel events to `widget`.
813
+
814
+ On Aqua/Win the event is `<MouseWheel>` with `event.delta`
815
+ carrying direction and magnitude. On X11 there's no MouseWheel —
816
+ scroll up is `<Button-4>` and scroll down is `<Button-5>`.
817
+ """
818
+ if self.winsys.lower() == 'x11':
819
+ widget.bind('<Button-4>', self._on_mousewheel, add='+')
820
+ widget.bind('<Button-5>', self._on_mousewheel, add='+')
821
+ else:
822
+ widget.bind('<MouseWheel>', self._on_mousewheel, add='+')
823
+
824
+ def _on_mousewheel(self, event):
825
+ """Handle mouse wheel scrolling.
826
+
827
+ Args:
828
+ event: Tkinter mouse wheel event.
829
+ """
830
+ # Check if mouse is over this widget
831
+ widget_under_mouse = self.winfo_containing(event.x_root, event.y_root)
832
+ if widget_under_mouse is None:
833
+ return
834
+
835
+ # Check if the widget under mouse is a child of this ListView
836
+ current = widget_under_mouse
837
+ is_child = False
838
+ while current is not None:
839
+ if current == self:
840
+ is_child = True
841
+ break
842
+ try:
843
+ current = current.master
844
+ except AttributeError:
845
+ break
846
+
847
+ if not is_child:
848
+ return
849
+
850
+ # Resolve scroll direction per platform: X11 carries it in event.num
851
+ # (4=up, 5=down) and has no event.delta; Aqua/Win use event.delta.
852
+ if self.winsys.lower() == 'x11':
853
+ delta = -1 if getattr(event, 'num', 0) == 4 else 1
854
+ else:
855
+ delta = -1 if event.delta > 0 else 1
856
+ self._start_index += delta
857
+ self._clamp_indices()
858
+ self._update_rows()
859
+
860
+ def _on_resize(self, event):
861
+ """Handle widget resize and recalculate visible rows.
862
+
863
+ Args:
864
+ event: Tkinter configure event.
865
+ """
866
+ if event.widget == self:
867
+ self.after_idle(self._remeasure_and_relayout)
868
+
869
+ def _remeasure_and_relayout(self):
870
+ """Measure row height, then recompute sizes and repaint."""
871
+ if not self._rows:
872
+ return
873
+
874
+ # Measure actual widget height
875
+ rh = self._rows[0].winfo_height()
876
+ if rh <= 1:
877
+ rh = self._rows[0].winfo_reqheight()
878
+
879
+ if rh and rh != self._row_height:
880
+ self._row_height = rh
881
+
882
+ # Calculate how many rows fit
883
+ container_height = self._container.winfo_height()
884
+ if container_height > 0:
885
+ visible = max(1, container_height // max(1, self._row_height))
886
+ page_size = visible + OVERSCAN_ROWS
887
+
888
+ if visible != self._visible_rows or page_size != self._page_size:
889
+ self._visible_rows = visible
890
+ self._page_size = page_size
891
+ self._ensure_row_pool(self._page_size)
892
+
893
+ self._clamp_indices()
894
+ self._update_rows()
895
+
896
+ def _on_item_selecting(self, event: Any):
897
+ """Handle item selection event from `ListItem`.
898
+
899
+ Args:
900
+ event: Event with `data` for the item being selected.
901
+ """
902
+ record_id = event.data.get('id')
903
+ if record_id is not None and record_id != '__empty__':
904
+ if self._selection_mode == 'single':
905
+ self._datasource.deselect_all()
906
+ self._datasource.select_record(record_id)
907
+ elif self._selection_mode == 'multi':
908
+ if self._datasource.is_selected(record_id):
909
+ self._datasource.deselect_record(record_id)
910
+ else:
911
+ self._datasource.select_record(record_id)
912
+
913
+ self._update_rows()
914
+ self.event_generate('<<SelectionChange>>')
915
+
916
+ def _on_item_removing(self, event: Any):
917
+ """Handle item remove event from `ListItem`.
918
+
919
+ Args:
920
+ event: Event with `data` for the item being removed.
921
+ """
922
+ record_id = event.data.get('id')
923
+ if record_id is not None and record_id != '__empty__':
924
+ try:
925
+ self._datasource.delete_record(record_id)
926
+ self._update_rows()
927
+ self.event_generate('<<ItemDelete>>')
928
+ except Exception as e:
929
+ self.event_generate('<<ItemDeleteFail>>')
930
+
931
+ def _on_item_focused(self, event: Any):
932
+ """Handle item focus event from `ListItem`.
933
+
934
+ Args:
935
+ event: Event with `data` for the item being focused.
936
+ """
937
+ record_id = event.data.get('id')
938
+ if record_id is not None and record_id != '__empty__':
939
+ self._focused_record_id = record_id
940
+ self._update_rows()
941
+
942
+ def _get_focused_index(self) -> int:
943
+ """Get the data index of the currently focused item.
944
+
945
+ Returns:
946
+ The index of the focused item, or -1 if none is focused.
947
+ """
948
+ if self._focused_record_id is None:
949
+ return -1
950
+
951
+ # Search visible rows for the focused record
952
+ for i, row in enumerate(self._rows):
953
+ if hasattr(row, '_data') and row._data.get('id') == self._focused_record_id:
954
+ return self._start_index + i
955
+
956
+ return -1
957
+
958
+ def _focus_item_at_index(self, index: int) -> None:
959
+ """Focus the item at the given data index.
960
+
961
+ Args:
962
+ index: The data index of the item to focus.
963
+ """
964
+ total = self._datasource.total_count()
965
+ if total == 0 or index < 0 or index >= total:
966
+ return
967
+
968
+ # Scroll if needed to make the item visible
969
+ if index < self._start_index:
970
+ self._start_index = index
971
+ self._clamp_indices()
972
+ self._update_rows()
973
+ elif index >= self._start_index + len(self._rows):
974
+ self._start_index = index - len(self._rows) + 1
975
+ self._clamp_indices()
976
+ self._update_rows()
977
+
978
+ # Get the record at the index
979
+ page_data = self._datasource.get_page_from_index(index, 1)
980
+ if page_data:
981
+ record_id = page_data[0].get('id')
982
+ if record_id is not None:
983
+ self._focused_record_id = record_id
984
+ self._update_rows()
985
+
986
+ # Focus the visible row widget
987
+ visual_index = index - self._start_index
988
+ if 0 <= visual_index < len(self._rows):
989
+ self._rows[visual_index].focus_set()
990
+
991
+ def _on_arrow_down(self, event) -> str:
992
+ """Handle arrow down key for keyboard navigation.
993
+
994
+ Args:
995
+ event: Tkinter key event.
996
+
997
+ Returns:
998
+ 'break' to prevent default handling.
999
+ """
1000
+ total = self._datasource.total_count()
1001
+ if total == 0:
1002
+ return 'break'
1003
+
1004
+ current_index = self._get_focused_index()
1005
+ if current_index < 0:
1006
+ # No item focused, focus the first visible item
1007
+ next_index = self._start_index
1008
+ else:
1009
+ # Move to next item
1010
+ next_index = min(current_index + 1, total - 1)
1011
+
1012
+ self._focus_item_at_index(next_index)
1013
+ return 'break'
1014
+
1015
+ def _on_arrow_up(self, event) -> str:
1016
+ """Handle arrow up key for keyboard navigation.
1017
+
1018
+ Args:
1019
+ event: Tkinter key event.
1020
+
1021
+ Returns:
1022
+ 'break' to prevent default handling.
1023
+ """
1024
+ total = self._datasource.total_count()
1025
+ if total == 0:
1026
+ return 'break'
1027
+
1028
+ current_index = self._get_focused_index()
1029
+ if current_index < 0:
1030
+ # No item focused, focus the last visible item
1031
+ next_index = min(self._start_index + len(self._rows) - 1, total - 1)
1032
+ else:
1033
+ # Move to previous item
1034
+ next_index = max(current_index - 1, 0)
1035
+
1036
+ self._focus_item_at_index(next_index)
1037
+ return 'break'
1038
+
1039
+ def _on_item_click(self, event: Any):
1040
+ """Handle item click event from `ListItem`.
1041
+
1042
+ Args:
1043
+ event: Event with `data` for the clicked item.
1044
+ """
1045
+ # Fetch fresh state from datasource to ensure accurate selection/focus state
1046
+ record_id = event.data.get('id')
1047
+ if record_id is not None and record_id != '__empty__':
1048
+ item_index = event.data.get('item_index')
1049
+ if item_index is not None:
1050
+ page_data = self._datasource.get_page_from_index(item_index, 1)
1051
+ if page_data:
1052
+ record = page_data[0].copy()
1053
+ record['selected'] = self._datasource.is_selected(record_id)
1054
+ record['focused'] = (record_id == self._focused_record_id)
1055
+ record['item_index'] = item_index
1056
+ self.event_generate('<<ItemClick>>', data=record)
1057
+ return
1058
+
1059
+ # Fallback to original data if we can't fetch fresh state
1060
+ self.event_generate('<<ItemClick>>', data=event.data)
1061
+
1062
+ def _apply_widget_surface(self, row: ListItem, widget_index: int) -> None:
1063
+ """Apply surface color to a row widget based on its position in the pool.
1064
+
1065
+ This is called once when a widget is created. The color is based on the
1066
+ widget's position (not the data index), creating a stable alternating
1067
+ pattern during scrolling without needing to recalculate colors.
1068
+
1069
+ Args:
1070
+ row: The ListItem widget to color.
1071
+ widget_index: The position of this widget in the row pool (0-based).
1072
+ """
1073
+ base_surface = getattr(self, "_surface", "background")
1074
+ if not self._striped:
1075
+ surface = base_surface
1076
+ else:
1077
+ # Apply striped background to odd rows
1078
+ is_odd = (widget_index % 2) == 1
1079
+ surface = self._striped_background if is_odd else base_surface
1080
+
1081
+ if hasattr(row, "set_surface"):
1082
+ row.set_surface(surface)
1083
+
1084
+ def _on_item_drag_start(self, event: Any):
1085
+ """Handle item drag start event from `ListItem`.
1086
+
1087
+ Args:
1088
+ event: Event with `data` for the dragged item.
1089
+ """
1090
+ record_id = event.data.get('id')
1091
+ source_index = event.data.get('source_index')
1092
+ if record_id is None or record_id == '__empty__' or source_index is None:
1093
+ return
1094
+
1095
+ self._drag_state = dict(
1096
+ record_id=record_id,
1097
+ source_index=source_index,
1098
+ target_index=source_index,
1099
+ record_data=dict(event.data),
1100
+ )
1101
+ self._drag_scroll_counter = 0
1102
+ self._show_drag_indicator()
1103
+ self._update_drag_indicator_position(source_index)
1104
+ self.event_generate('<<ItemDragStart>>', data=event.data)
1105
+
1106
+ def _on_item_dragging(self, event: Any):
1107
+ """Handle item dragging event from `ListItem`.
1108
+
1109
+ Args:
1110
+ event: Event with `data` for the dragged item.
1111
+ """
1112
+ if not self._drag_state:
1113
+ return
1114
+
1115
+ y_current = event.data.get('y_current')
1116
+ target_index = self._get_drop_index(y_current)
1117
+ self._drag_state['target_index'] = target_index
1118
+ self._auto_scroll_for_drag(y_current)
1119
+ self._update_drag_indicator_position(target_index)
1120
+ payload = dict(self._drag_state.get('record_data', {}))
1121
+ payload.update(
1122
+ dict(
1123
+ source_index=self._drag_state.get('source_index'),
1124
+ target_index=target_index,
1125
+ y_current=y_current,
1126
+ )
1127
+ )
1128
+ self.event_generate('<<ItemDrag>>', data=payload)
1129
+
1130
+ def _on_item_drag_end(self, event: Any):
1131
+ """Handle item drag end event from `ListItem`.
1132
+
1133
+ Args:
1134
+ event: Event with `data` for the dragged item.
1135
+ """
1136
+ if not self._drag_state:
1137
+ return
1138
+
1139
+ self._hide_drag_indicator()
1140
+
1141
+ record_id = self._drag_state.get('record_id')
1142
+ target_index = self._drag_state.get('target_index')
1143
+ moved = self._move_record(record_id, target_index)
1144
+ if moved:
1145
+ self._update_rows()
1146
+
1147
+ payload = dict(self._drag_state.get('record_data', {}))
1148
+ payload.update(
1149
+ dict(
1150
+ source_index=self._drag_state.get('source_index'),
1151
+ target_index=target_index,
1152
+ y_end=event.data.get('y_end'),
1153
+ y_start=event.data.get('y_start'),
1154
+ )
1155
+ )
1156
+ payload['target_index'] = target_index
1157
+ payload['moved'] = moved
1158
+ self.event_generate('<<ItemDragEnd>>', data=payload)
1159
+ self._drag_state = None
1160
+
1161
+ def _get_drop_index(self, y_root: int | None) -> int:
1162
+ """Calculate the drop index for a drag operation.
1163
+
1164
+ Args:
1165
+ y_root: Screen Y coordinate.
1166
+
1167
+ Returns:
1168
+ Zero-based index for the drop position.
1169
+ """
1170
+ total = self._datasource.total_count()
1171
+ if total <= 0:
1172
+ return 0
1173
+
1174
+ if y_root is None:
1175
+ return max(0, min(self._start_index, total - 1))
1176
+
1177
+ container_top = self._container.winfo_rooty()
1178
+ container_height = self._container.winfo_height()
1179
+ if container_height <= 0:
1180
+ return max(0, min(self._start_index, total - 1))
1181
+
1182
+ y_local = y_root - container_top
1183
+ y_local = max(0, min(y_local, container_height - 1))
1184
+ offset = int(y_local // max(1, self._row_height))
1185
+ target = self._start_index + offset
1186
+ return max(0, min(target, total - 1))
1187
+
1188
+ def _auto_scroll_for_drag(self, y_root: int | None) -> None:
1189
+ """Auto-scroll while dragging near the list edges.
1190
+
1191
+ Args:
1192
+ y_root: Screen Y coordinate.
1193
+ """
1194
+ if y_root is None:
1195
+ return
1196
+
1197
+ container_top = self._container.winfo_rooty()
1198
+ container_height = self._container.winfo_height()
1199
+ if container_height <= 0:
1200
+ return
1201
+
1202
+ scroll_zone_height = max(10, int(container_height * 0.2))
1203
+ container_bottom = container_top + container_height
1204
+ self._drag_scroll_counter += 1
1205
+ should_scroll = self._drag_scroll_counter % 8 == 0
1206
+ if should_scroll:
1207
+ if y_root < container_top + scroll_zone_height:
1208
+ self._start_index -= 1
1209
+ elif y_root > container_bottom - scroll_zone_height:
1210
+ self._start_index += 1
1211
+ else:
1212
+ return
1213
+ else:
1214
+ return
1215
+
1216
+ self._clamp_indices()
1217
+ self._update_rows()
1218
+
1219
+ def _move_record(self, record_id: Any, target_index: int | None) -> bool:
1220
+ """Move a record in the data source if supported.
1221
+
1222
+ Args:
1223
+ record_id: Record identifier to move.
1224
+ target_index: Target index to move the record to.
1225
+
1226
+ Returns:
1227
+ True if the record was moved.
1228
+ """
1229
+ if record_id is None or target_index is None:
1230
+ return False
1231
+
1232
+ mover = getattr(self._datasource, 'move_record', None)
1233
+ if callable(mover):
1234
+ return bool(mover(record_id, target_index))
1235
+
1236
+ # Fallback for simple in-memory lists
1237
+ try:
1238
+ total = self._datasource.total_count()
1239
+ all_records = self._datasource.get_page_from_index(0, total)
1240
+ source_index = None
1241
+ for i, record in enumerate(all_records):
1242
+ if record.get('id') == record_id:
1243
+ source_index = i
1244
+ break
1245
+ if source_index is None:
1246
+ return False
1247
+ clamped_target = max(0, min(target_index, len(all_records) - 1))
1248
+ if source_index == clamped_target:
1249
+ return False
1250
+ record = all_records.pop(source_index)
1251
+ if clamped_target > source_index:
1252
+ clamped_target -= 1
1253
+ all_records.insert(clamped_target, record)
1254
+ setter = getattr(self._datasource, 'set_data', None)
1255
+ if callable(setter):
1256
+ setter(all_records)
1257
+ return True
1258
+ except Exception:
1259
+ return False
1260
+
1261
+ return False
1262
+
1263
+ def _show_drag_indicator(self) -> None:
1264
+ """Create and show the drag drop indicator line."""
1265
+ if self._drag_indicator is None:
1266
+ self._drag_indicator = Frame(self._container, accent=self._selected_background)
1267
+
1268
+ def _update_drag_indicator_position(self, target_index: int) -> None:
1269
+ """Update the drag indicator to show drop location."""
1270
+ if self._drag_indicator is None:
1271
+ return
1272
+
1273
+ try:
1274
+ visual_index = target_index - self._start_index
1275
+ if 0 <= visual_index < len(self._rows):
1276
+ y_pos = visual_index * max(1, self._row_height)
1277
+ self._drag_indicator.place(
1278
+ x=0,
1279
+ y=y_pos,
1280
+ width=self._container.winfo_width(),
1281
+ height=3,
1282
+ )
1283
+ self._drag_indicator.lift()
1284
+ else:
1285
+ self._drag_indicator.place_forget()
1286
+ except Exception:
1287
+ pass
1288
+
1289
+ def _hide_drag_indicator(self) -> None:
1290
+ """Hide and destroy the drag indicator."""
1291
+ if self._drag_indicator is not None:
1292
+ try:
1293
+ self._drag_indicator.place_forget()
1294
+ self._drag_indicator.destroy()
1295
+ except Exception:
1296
+ pass
1297
+ self._drag_indicator = None
1298
+
1299
+ # Public API
1300
+
1301
+ def reload(self):
1302
+ """Reload data from the datasource and refresh the display.
1303
+
1304
+ Calls the datasource's `reload()` method and updates all visible rows
1305
+ with the refreshed data. Useful when the underlying data has changed
1306
+ externally.
1307
+ """
1308
+ self._datasource.reload()
1309
+ self._update_rows()
1310
+
1311
+ def get_selected(self) -> list:
1312
+ """Get list of selected record IDs.
1313
+
1314
+ Returns:
1315
+ List of record IDs that are currently selected. Empty list if
1316
+ no items are selected.
1317
+
1318
+ Examples:
1319
+ >>> selected = listview.get_selected()
1320
+ >>> print(f"Selected {len(selected)} items")
1321
+ """
1322
+ return self._datasource.get_selected()
1323
+
1324
+ def select_all(self):
1325
+ """Select all items in the list.
1326
+
1327
+ Only works when selection_mode is 'multi'. Generates a
1328
+ <<SelectionChanged>> event after completion.
1329
+
1330
+ Note:
1331
+ For large datasets, this may be slow as it loads all records.
1332
+ """
1333
+ if self._selection_mode == 'multi':
1334
+ total = self._datasource.total_count()
1335
+ all_records = self._datasource.get_page_from_index(0, total)
1336
+ for record in all_records:
1337
+ record_id = record.get('id')
1338
+ if record_id:
1339
+ self._datasource.select_record(record_id)
1340
+ self._update_rows()
1341
+ self.event_generate('<<SelectionChange>>')
1342
+
1343
+ def clear_selection(self):
1344
+ """Clear all item selections.
1345
+
1346
+ Deselects all items and generates a <<SelectionChange>> event.
1347
+ """
1348
+ self._datasource.deselect_all()
1349
+ self._update_rows()
1350
+ self.event_generate('<<SelectionChange>>')
1351
+
1352
+ def scroll_to_top(self):
1353
+ """Scroll to the beginning of the list.
1354
+
1355
+ Instantly scrolls to show the first item in the list.
1356
+ """
1357
+ self._start_index = 0
1358
+ self._update_rows()
1359
+
1360
+ def scroll_to_bottom(self):
1361
+ """Scroll to the end of the list.
1362
+
1363
+ Instantly scrolls to show the last items in the list.
1364
+ """
1365
+ total = self._datasource.total_count()
1366
+ self._start_index = max(0, total - self._visible_rows)
1367
+ self._update_rows()
1368
+
1369
+ def insert_item(self, data: dict):
1370
+ """Insert a new item into the list.
1371
+
1372
+ Args:
1373
+ data: Dictionary containing the item data. An 'id' will be
1374
+ auto-generated if not provided.
1375
+
1376
+ Note:
1377
+ Generates a <<ItemInserted>> event after the item is added.
1378
+
1379
+ Examples:
1380
+ >>> listview.insert_item({
1381
+ ... 'title': 'New Item',
1382
+ ... 'text': 'Description'
1383
+ ... })
1384
+ """
1385
+ self._datasource.create_record(data)
1386
+ self._update_rows()
1387
+ self.event_generate('<<ItemInsert>>')
1388
+
1389
+ def update_item(self, record_id: Any, data: dict):
1390
+ """Update an existing item's data.
1391
+
1392
+ Args:
1393
+ record_id: The ID of the record to update.
1394
+ data: Dictionary of fields to update. Will be merged with
1395
+ existing record data.
1396
+
1397
+ Note:
1398
+ Generates a <<ItemUpdated>> event if the update succeeds.
1399
+
1400
+ Examples:
1401
+ >>> listview.update_item(42, {'title': 'Updated Title'})
1402
+ """
1403
+ if self._datasource.update_record(record_id, data):
1404
+ self._update_rows()
1405
+ self.event_generate('<<ItemUpdate>>')
1406
+
1407
+ def delete_item(self, record_id: Any):
1408
+ """Delete an item from the list.
1409
+
1410
+ Args:
1411
+ record_id: The ID of the record to delete.
1412
+
1413
+ Note:
1414
+ Generates a <<ItemDeleted>> event after deletion.
1415
+
1416
+ Examples:
1417
+ >>> listview.delete_item(42)
1418
+ """
1419
+ self._datasource.delete_record(record_id)
1420
+ self._update_rows()
1421
+ self.event_generate('<<ItemDelete>>')
1422
+
1423
+ def get_datasource(self) -> DataSourceProtocol:
1424
+ """Get the underlying datasource.
1425
+
1426
+ Returns:
1427
+ The DataSource instance managing the list's data.
1428
+
1429
+ Examples:
1430
+ >>> ds = listview.get_datasource()
1431
+ >>> count = ds.total_count()
1432
+ """
1433
+ return self._datasource
1434
+
1435
+ # Event handler API
1436
+
1437
+ def on_selection_changed(self, callback: Callable) -> str:
1438
+ """Bind to `<<SelectionChange>>`. Callback receives `event.data = None` (use `get_selected()` to get current selection)."""
1439
+ return self.bind('<<SelectionChange>>', callback, add='+')
1440
+
1441
+ def off_selection_changed(self, bind_id: str | None = None) -> None:
1442
+ """Unbind from `<<SelectionChange>>`."""
1443
+ self.unbind('<<SelectionChange>>', bind_id)
1444
+
1445
+ def on_item_delete(self, callback: Callable) -> str:
1446
+ """Bind to `<<ItemDelete>>`. Callback receives `event.data = {'record': dict}`."""
1447
+ return self.bind('<<ItemDelete>>', callback, add='+')
1448
+
1449
+ def off_item_delete(self, bind_id: str | None = None) -> None:
1450
+ """Unbind from `<<ItemDelete>>`."""
1451
+ self.unbind('<<ItemDelete>>', bind_id)
1452
+
1453
+ def on_item_delete_fail(self, callback: Callable) -> str:
1454
+ """Bind to `<<ItemDeleteFail>>`. Callback receives `event.data = {'record': dict, 'error': str}`."""
1455
+ return self.bind('<<ItemDeleteFail>>', callback, add='+')
1456
+
1457
+ def off_item_delete_fail(self, bind_id: str | None = None) -> None:
1458
+ """Unbind from `<<ItemDeleteFail>>`."""
1459
+ self.unbind('<<ItemDeleteFail>>', bind_id)
1460
+
1461
+ def on_item_insert(self, callback: Callable) -> str:
1462
+ """Bind to `<<ItemInsert>>`. Callback receives `event.data = {'record': dict}`."""
1463
+ return self.bind('<<ItemInsert>>', callback, add='+')
1464
+
1465
+ def off_item_insert(self, bind_id: str | None = None) -> None:
1466
+ """Unbind from `<<ItemInsert>>`."""
1467
+ self.unbind('<<ItemInsert>>', bind_id)
1468
+
1469
+ def on_item_update(self, callback: Callable) -> str:
1470
+ """Bind to `<<ItemUpdate>>`. Callback receives `event.data = {'record': dict}`."""
1471
+ return self.bind('<<ItemUpdate>>', callback, add='+')
1472
+
1473
+ def off_item_update(self, bind_id: str | None = None) -> None:
1474
+ """Unbind from `<<ItemUpdate>>`."""
1475
+ self.unbind('<<ItemUpdate>>', bind_id)
1476
+
1477
+ def on_item_click(self, callback: Callable) -> str:
1478
+ """Bind to `<<ItemClick>>`. Callback receives `event.data = {'record': dict}`."""
1479
+ return self.bind('<<ItemClick>>', callback, add='+')
1480
+
1481
+ def off_item_click(self, bind_id: str | None = None) -> None:
1482
+ """Unbind from `<<ItemClick>>`."""
1483
+ self.unbind('<<ItemClick>>', bind_id)
1484
+
1485
+ def on_item_drag_start(self, callback: Callable) -> str:
1486
+ """Bind to `<<ItemDragStart>>`. Callback receives `event.data = {'record': dict, 'index': int}`."""
1487
+ return self.bind('<<ItemDragStart>>', callback, add='+')
1488
+
1489
+ def off_item_drag_start(self, bind_id: str | None = None) -> None:
1490
+ """Unbind from `<<ItemDragStart>>`."""
1491
+ self.unbind('<<ItemDragStart>>', bind_id)
1492
+
1493
+ def on_item_drag(self, callback: Callable) -> str:
1494
+ """Bind to `<<ItemDrag>>`. Callback receives `event.data = {'source_index': int, 'target_index': int, 'x': int, 'y': int}`."""
1495
+ return self.bind('<<ItemDrag>>', callback, add='+')
1496
+
1497
+ def off_item_drag(self, bind_id: str | None = None) -> None:
1498
+ """Unbind from `<<ItemDrag>>`."""
1499
+ self.unbind('<<ItemDrag>>', bind_id)
1500
+
1501
+ def on_item_drag_end(self, callback: Callable) -> str:
1502
+ """Bind to `<<ItemDragEnd>>`. Callback receives `event.data = {'moved': bool, 'source_index': int, 'target_index': int}`."""
1503
+ return self.bind('<<ItemDragEnd>>', callback, add='+')
1504
+
1505
+ def off_item_drag_end(self, bind_id: str | None = None) -> None:
1506
+ """Unbind from `<<ItemDragEnd>>`."""
1507
+ self.unbind('<<ItemDragEnd>>', bind_id)