bootstack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (471) hide show
  1. bootstack/__init__.py +157 -0
  2. bootstack/__main__.py +5 -0
  3. bootstack/_core/__init__.py +21 -0
  4. bootstack/_core/capabilities/__init__.py +45 -0
  5. bootstack/_core/capabilities/after.py +103 -0
  6. bootstack/_core/capabilities/bind.py +192 -0
  7. bootstack/_core/capabilities/bindtags.py +112 -0
  8. bootstack/_core/capabilities/busy.py +72 -0
  9. bootstack/_core/capabilities/clipboard.py +89 -0
  10. bootstack/_core/capabilities/focus.py +118 -0
  11. bootstack/_core/capabilities/grab.py +65 -0
  12. bootstack/_core/capabilities/grid.py +211 -0
  13. bootstack/_core/capabilities/localization.py +231 -0
  14. bootstack/_core/capabilities/pack.py +140 -0
  15. bootstack/_core/capabilities/place.py +113 -0
  16. bootstack/_core/capabilities/selection.py +136 -0
  17. bootstack/_core/capabilities/signals.py +244 -0
  18. bootstack/_core/capabilities/winfo.py +315 -0
  19. bootstack/_core/colorutils.py +234 -0
  20. bootstack/_core/exceptions.py +25 -0
  21. bootstack/_core/images.py +463 -0
  22. bootstack/_core/mixins/__init__.py +1 -0
  23. bootstack/_core/mixins/ttk_state.py +35 -0
  24. bootstack/_core/mixins/widget.py +132 -0
  25. bootstack/_core/paths.py +49 -0
  26. bootstack/_core/publisher.py +149 -0
  27. bootstack/_core/variables.py +62 -0
  28. bootstack/_runtime/__init__.py +3 -0
  29. bootstack/_runtime/app.py +930 -0
  30. bootstack/_runtime/base_window.py +945 -0
  31. bootstack/_runtime/events.py +399 -0
  32. bootstack/_runtime/shortcuts.py +496 -0
  33. bootstack/_runtime/tk_patch.py +43 -0
  34. bootstack/_runtime/toplevel.py +276 -0
  35. bootstack/_runtime/utility.py +457 -0
  36. bootstack/_runtime/visual_focus.py +240 -0
  37. bootstack/_runtime/window_utilities.py +1123 -0
  38. bootstack/assets/__init__.py +21 -0
  39. bootstack/assets/bootstack.ico +0 -0
  40. bootstack/assets/bootstack.png +0 -0
  41. bootstack/assets/elements/__init__.py +0 -0
  42. bootstack/assets/elements/badge-pill.png +0 -0
  43. bootstack/assets/elements/badge-square.png +0 -0
  44. bootstack/assets/elements/button-compact.png +0 -0
  45. bootstack/assets/elements/button-default.png +0 -0
  46. bootstack/assets/elements/buttongroup-after-h-compact.png +0 -0
  47. bootstack/assets/elements/buttongroup-after-h-default.png +0 -0
  48. bootstack/assets/elements/buttongroup-after-v-compact.png +0 -0
  49. bootstack/assets/elements/buttongroup-after-v-default.png +0 -0
  50. bootstack/assets/elements/buttongroup-before-h-compact.png +0 -0
  51. bootstack/assets/elements/buttongroup-before-h-default.png +0 -0
  52. bootstack/assets/elements/buttongroup-before-v-compact.png +0 -0
  53. bootstack/assets/elements/buttongroup-before-v-default.png +0 -0
  54. bootstack/assets/elements/buttongroup-center-h-compact.png +0 -0
  55. bootstack/assets/elements/buttongroup-center-h-default.png +0 -0
  56. bootstack/assets/elements/buttongroup-center-v-compact.png +0 -0
  57. bootstack/assets/elements/buttongroup-center-v-default.png +0 -0
  58. bootstack/assets/elements/card.png +0 -0
  59. bootstack/assets/elements/checkbox-checked.png +0 -0
  60. bootstack/assets/elements/checkbox-indeterminate.png +0 -0
  61. bootstack/assets/elements/checkbox-unchecked.png +0 -0
  62. bootstack/assets/elements/field.png +0 -0
  63. bootstack/assets/elements/input-addon-compact.png +0 -0
  64. bootstack/assets/elements/input-addon-default.png +0 -0
  65. bootstack/assets/elements/input-compact.png +0 -0
  66. bootstack/assets/elements/input-default.png +0 -0
  67. bootstack/assets/elements/list-item-separated.png +0 -0
  68. bootstack/assets/elements/list-item.png +0 -0
  69. bootstack/assets/elements/listrow-compact.png +0 -0
  70. bootstack/assets/elements/listrow-default.png +0 -0
  71. bootstack/assets/elements/manifest.toml +361 -0
  72. bootstack/assets/elements/menu-item.png +0 -0
  73. bootstack/assets/elements/navitem-compact.png +0 -0
  74. bootstack/assets/elements/navitem-default.png +0 -0
  75. bootstack/assets/elements/progressbar-h-compact.png +0 -0
  76. bootstack/assets/elements/progressbar-h-default.png +0 -0
  77. bootstack/assets/elements/progressbar-v-compact.png +0 -0
  78. bootstack/assets/elements/progressbar-v-default.png +0 -0
  79. bootstack/assets/elements/radiobutton.png +0 -0
  80. bootstack/assets/elements/scrollbar-horizontal.png +0 -0
  81. bootstack/assets/elements/scrollbar-vertical.png +0 -0
  82. bootstack/assets/elements/slider-handle.png +0 -0
  83. bootstack/assets/elements/slider-track-h.png +0 -0
  84. bootstack/assets/elements/slider-track-v.png +0 -0
  85. bootstack/assets/elements/switch-off.png +0 -0
  86. bootstack/assets/elements/switch-on.png +0 -0
  87. bootstack/assets/elements/tab-h.png +0 -0
  88. bootstack/assets/elements/tab-v.png +0 -0
  89. bootstack/assets/icons/bootstrap.ttf +0 -0
  90. bootstack/assets/icons/glyphmap.json +2080 -0
  91. bootstack/assets/icons/icon_metrics.json +12470 -0
  92. bootstack/assets/locales/ar/LC_MESSAGES/bootstack.mo +0 -0
  93. bootstack/assets/locales/ar/LC_MESSAGES/bootstack.po +856 -0
  94. bootstack/assets/locales/bg/LC_MESSAGES/bootstack.mo +0 -0
  95. bootstack/assets/locales/bg/LC_MESSAGES/bootstack.po +878 -0
  96. bootstack/assets/locales/cs/LC_MESSAGES/bootstack.mo +0 -0
  97. bootstack/assets/locales/cs/LC_MESSAGES/bootstack.po +856 -0
  98. bootstack/assets/locales/da/LC_MESSAGES/bootstack.mo +0 -0
  99. bootstack/assets/locales/da/LC_MESSAGES/bootstack.po +856 -0
  100. bootstack/assets/locales/de/LC_MESSAGES/bootstack.mo +0 -0
  101. bootstack/assets/locales/de/LC_MESSAGES/bootstack.po +856 -0
  102. bootstack/assets/locales/en/LC_MESSAGES/bootstack.mo +0 -0
  103. bootstack/assets/locales/en/LC_MESSAGES/bootstack.po +878 -0
  104. bootstack/assets/locales/es/LC_MESSAGES/bootstack.mo +0 -0
  105. bootstack/assets/locales/es/LC_MESSAGES/bootstack.po +856 -0
  106. bootstack/assets/locales/fr/LC_MESSAGES/bootstack.mo +0 -0
  107. bootstack/assets/locales/fr/LC_MESSAGES/bootstack.po +856 -0
  108. bootstack/assets/locales/he/LC_MESSAGES/bootstack.mo +0 -0
  109. bootstack/assets/locales/he/LC_MESSAGES/bootstack.po +854 -0
  110. bootstack/assets/locales/hi/LC_MESSAGES/bootstack.mo +0 -0
  111. bootstack/assets/locales/hi/LC_MESSAGES/bootstack.po +845 -0
  112. bootstack/assets/locales/it/LC_MESSAGES/bootstack.mo +0 -0
  113. bootstack/assets/locales/it/LC_MESSAGES/bootstack.po +844 -0
  114. bootstack/assets/locales/ja/LC_MESSAGES/bootstack.mo +0 -0
  115. bootstack/assets/locales/ja/LC_MESSAGES/bootstack.po +917 -0
  116. bootstack/assets/locales/ko/LC_MESSAGES/bootstack.mo +0 -0
  117. bootstack/assets/locales/ko/LC_MESSAGES/bootstack.po +845 -0
  118. bootstack/assets/locales/nb/LC_MESSAGES/bootstack.mo +0 -0
  119. bootstack/assets/locales/nb/LC_MESSAGES/bootstack.po +844 -0
  120. bootstack/assets/locales/nl/LC_MESSAGES/bootstack.mo +0 -0
  121. bootstack/assets/locales/nl/LC_MESSAGES/bootstack.po +844 -0
  122. bootstack/assets/locales/pl/LC_MESSAGES/bootstack.mo +0 -0
  123. bootstack/assets/locales/pl/LC_MESSAGES/bootstack.po +845 -0
  124. bootstack/assets/locales/pt/LC_MESSAGES/bootstack.mo +0 -0
  125. bootstack/assets/locales/pt/LC_MESSAGES/bootstack.po +845 -0
  126. bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.mo +0 -0
  127. bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.po +845 -0
  128. bootstack/assets/locales/sl/LC_MESSAGES/bootstack.mo +0 -0
  129. bootstack/assets/locales/sl/LC_MESSAGES/bootstack.po +845 -0
  130. bootstack/assets/locales/sv/LC_MESSAGES/bootstack.mo +0 -0
  131. bootstack/assets/locales/sv/LC_MESSAGES/bootstack.po +845 -0
  132. bootstack/assets/locales/tr/LC_MESSAGES/bootstack.mo +0 -0
  133. bootstack/assets/locales/tr/LC_MESSAGES/bootstack.po +845 -0
  134. bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.mo +0 -0
  135. bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.po +845 -0
  136. bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.mo +0 -0
  137. bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.po +845 -0
  138. bootstack/cli/__init__.py +133 -0
  139. bootstack/cli/__main__.py +6 -0
  140. bootstack/cli/add.py +395 -0
  141. bootstack/cli/appicon.py +285 -0
  142. bootstack/cli/build.py +115 -0
  143. bootstack/cli/config.py +313 -0
  144. bootstack/cli/demo.py +564 -0
  145. bootstack/cli/dev.py +153 -0
  146. bootstack/cli/doctor.py +195 -0
  147. bootstack/cli/icons.py +98 -0
  148. bootstack/cli/promote.py +120 -0
  149. bootstack/cli/pyinstaller.py +268 -0
  150. bootstack/cli/run.py +95 -0
  151. bootstack/cli/start.py +117 -0
  152. bootstack/cli/templates/__init__.py +931 -0
  153. bootstack/clipboard.py +48 -0
  154. bootstack/constants.py +318 -0
  155. bootstack/data/README.md +615 -0
  156. bootstack/data/__init__.py +78 -0
  157. bootstack/data/_observable.py +276 -0
  158. bootstack/data/base.py +780 -0
  159. bootstack/data/file_source.py +367 -0
  160. bootstack/data/memory_source.py +450 -0
  161. bootstack/data/query.py +367 -0
  162. bootstack/data/readers.py +289 -0
  163. bootstack/data/sqlite_source.py +869 -0
  164. bootstack/data/types.py +354 -0
  165. bootstack/data/writers.py +232 -0
  166. bootstack/dev/__init__.py +36 -0
  167. bootstack/dev/_capture.py +141 -0
  168. bootstack/dev/_env.py +43 -0
  169. bootstack/dev/_registry.py +140 -0
  170. bootstack/dev/_reloader.py +351 -0
  171. bootstack/dev/_reset.py +50 -0
  172. bootstack/dev/_watcher.py +91 -0
  173. bootstack/dialogs/__init__.py +948 -0
  174. bootstack/dialogs/_impl/__init__.py +47 -0
  175. bootstack/dialogs/_impl/colorchooser.py +588 -0
  176. bootstack/dialogs/_impl/datedialog.py +450 -0
  177. bootstack/dialogs/_impl/dialog.py +594 -0
  178. bootstack/dialogs/_impl/filterdialog.py +358 -0
  179. bootstack/dialogs/_impl/fontdialog.py +364 -0
  180. bootstack/dialogs/_impl/formdialog.py +564 -0
  181. bootstack/dialogs/_impl/message.py +486 -0
  182. bootstack/dialogs/_impl/query.py +570 -0
  183. bootstack/errors.py +67 -0
  184. bootstack/events/__init__.py +111 -0
  185. bootstack/events/_event.py +135 -0
  186. bootstack/events/_payloads.py +539 -0
  187. bootstack/events/_subscription.py +38 -0
  188. bootstack/i18n/README.md +77 -0
  189. bootstack/i18n/__init__.py +23 -0
  190. bootstack/i18n/catalog.py +121 -0
  191. bootstack/i18n/intl_format.py +584 -0
  192. bootstack/i18n/msgcat.py +425 -0
  193. bootstack/i18n/specs.py +156 -0
  194. bootstack/images.py +563 -0
  195. bootstack/py.typed +1 -0
  196. bootstack/scheduling/__init__.py +11 -0
  197. bootstack/scheduling/_schedule.py +218 -0
  198. bootstack/shortcuts.py +21 -0
  199. bootstack/signals/README.md +98 -0
  200. bootstack/signals/__init__.py +10 -0
  201. bootstack/signals/integration.py +100 -0
  202. bootstack/signals/signal.py +353 -0
  203. bootstack/signals/types.py +6 -0
  204. bootstack/store.py +286 -0
  205. bootstack/streams/__init__.py +12 -0
  206. bootstack/streams/_stream.py +321 -0
  207. bootstack/style/__init__.py +38 -0
  208. bootstack/style/builders/__init__.py +51 -0
  209. bootstack/style/builders/badge.py +46 -0
  210. bootstack/style/builders/button.py +339 -0
  211. bootstack/style/builders/buttongroup.py +311 -0
  212. bootstack/style/builders/calendar.py +271 -0
  213. bootstack/style/builders/checkbutton.py +110 -0
  214. bootstack/style/builders/combobox.py +113 -0
  215. bootstack/style/builders/contextmenu.py +268 -0
  216. bootstack/style/builders/entry.py +82 -0
  217. bootstack/style/builders/expander.py +148 -0
  218. bootstack/style/builders/field.py +335 -0
  219. bootstack/style/builders/frame.py +50 -0
  220. bootstack/style/builders/label.py +28 -0
  221. bootstack/style/builders/labelframe.py +34 -0
  222. bootstack/style/builders/listview.py +369 -0
  223. bootstack/style/builders/menubar.py +91 -0
  224. bootstack/style/builders/menubutton.py +359 -0
  225. bootstack/style/builders/panedwindow.py +25 -0
  226. bootstack/style/builders/progressbar.py +67 -0
  227. bootstack/style/builders/radiobutton.py +99 -0
  228. bootstack/style/builders/scale.py +64 -0
  229. bootstack/style/builders/scrollbar.py +225 -0
  230. bootstack/style/builders/separator.py +49 -0
  231. bootstack/style/builders/sidenav.py +643 -0
  232. bootstack/style/builders/sizegrip.py +15 -0
  233. bootstack/style/builders/spinbox.py +119 -0
  234. bootstack/style/builders/switch.py +70 -0
  235. bootstack/style/builders/tabitem.py +204 -0
  236. bootstack/style/builders/togglegroup.py +294 -0
  237. bootstack/style/builders/toolbutton.py +275 -0
  238. bootstack/style/builders/tooltip.py +26 -0
  239. bootstack/style/builders/treeview.py +193 -0
  240. bootstack/style/builders/utils.py +455 -0
  241. bootstack/style/builders_tk/__init__.py +16 -0
  242. bootstack/style/builders_tk/defaults.py +229 -0
  243. bootstack/style/element.py +173 -0
  244. bootstack/style/fonts.py +123 -0
  245. bootstack/style/style.py +609 -0
  246. bootstack/style/style_builder_base.py +716 -0
  247. bootstack/style/style_builder_mixed.py +93 -0
  248. bootstack/style/style_builder_tk.py +109 -0
  249. bootstack/style/style_builder_ttk.py +353 -0
  250. bootstack/style/style_resolver.py +447 -0
  251. bootstack/style/theme.py +245 -0
  252. bootstack/style/theme_provider.py +471 -0
  253. bootstack/style/themes/__init__.py +128 -0
  254. bootstack/style/tk_patch.py +5 -0
  255. bootstack/style/token_maps.py +41 -0
  256. bootstack/style/types.py +32 -0
  257. bootstack/style/typography.py +523 -0
  258. bootstack/style/utility.py +746 -0
  259. bootstack/types.py +39 -0
  260. bootstack/validation/__init__.py +6 -0
  261. bootstack/validation/types.py +15 -0
  262. bootstack/validation/validation_result.py +17 -0
  263. bootstack/validation/validation_rules.py +205 -0
  264. bootstack/widgets/__init__.py +74 -0
  265. bootstack/widgets/_core/__init__.py +31 -0
  266. bootstack/widgets/_core/app_config.py +266 -0
  267. bootstack/widgets/_core/base.py +461 -0
  268. bootstack/widgets/_core/container.py +334 -0
  269. bootstack/widgets/_core/context.py +35 -0
  270. bootstack/widgets/_core/events.py +130 -0
  271. bootstack/widgets/_core/field_mixin.py +353 -0
  272. bootstack/widgets/_core/icon_image_props.py +72 -0
  273. bootstack/widgets/_core/image_binding.py +93 -0
  274. bootstack/widgets/_core/navmodel.py +345 -0
  275. bootstack/widgets/_core/options.py +210 -0
  276. bootstack/widgets/_core/selection_group.py +130 -0
  277. bootstack/widgets/_core/window_controls.py +96 -0
  278. bootstack/widgets/_core/window_menu.py +246 -0
  279. bootstack/widgets/_impl/__init__.py +1 -0
  280. bootstack/widgets/_impl/_internal/__init__.py +0 -0
  281. bootstack/widgets/_impl/_internal/wrapper_base.py +307 -0
  282. bootstack/widgets/_impl/_parts/__init__.py +11 -0
  283. bootstack/widgets/_impl/_parts/numberentry_part.py +385 -0
  284. bootstack/widgets/_impl/_parts/spinnerentry_part.py +434 -0
  285. bootstack/widgets/_impl/_parts/textentry_part.py +406 -0
  286. bootstack/widgets/_impl/composites/__init__.py +33 -0
  287. bootstack/widgets/_impl/composites/_dateutils.py +33 -0
  288. bootstack/widgets/_impl/composites/_image_fit.py +105 -0
  289. bootstack/widgets/_impl/composites/accordion.py +381 -0
  290. bootstack/widgets/_impl/composites/avatar.py +191 -0
  291. bootstack/widgets/_impl/composites/buttongroup.py +371 -0
  292. bootstack/widgets/_impl/composites/calendar.py +972 -0
  293. bootstack/widgets/_impl/composites/carousel.py +567 -0
  294. bootstack/widgets/_impl/composites/chart.py +882 -0
  295. bootstack/widgets/_impl/composites/compositeframe.py +298 -0
  296. bootstack/widgets/_impl/composites/contextmenu.py +1951 -0
  297. bootstack/widgets/_impl/composites/dateentry.py +404 -0
  298. bootstack/widgets/_impl/composites/dropdownbutton.py +325 -0
  299. bootstack/widgets/_impl/composites/expander.py +515 -0
  300. bootstack/widgets/_impl/composites/field.py +670 -0
  301. bootstack/widgets/_impl/composites/form.py +1066 -0
  302. bootstack/widgets/_impl/composites/gallery.py +551 -0
  303. bootstack/widgets/_impl/composites/list/__init__.py +15 -0
  304. bootstack/widgets/_impl/composites/list/listitem.py +802 -0
  305. bootstack/widgets/_impl/composites/list/listview.py +1433 -0
  306. bootstack/widgets/_impl/composites/menu/__init__.py +18 -0
  307. bootstack/widgets/_impl/composites/menu/model.py +358 -0
  308. bootstack/widgets/_impl/composites/menu/render_native.py +134 -0
  309. bootstack/widgets/_impl/composites/menu/render_themed.py +134 -0
  310. bootstack/widgets/_impl/composites/meter.py +860 -0
  311. bootstack/widgets/_impl/composites/numericentry.py +201 -0
  312. bootstack/widgets/_impl/composites/pagestack.py +395 -0
  313. bootstack/widgets/_impl/composites/passwordentry.py +142 -0
  314. bootstack/widgets/_impl/composites/pathentry.py +168 -0
  315. bootstack/widgets/_impl/composites/picture.py +289 -0
  316. bootstack/widgets/_impl/composites/radiogroup.py +511 -0
  317. bootstack/widgets/_impl/composites/scrolledtext.py +375 -0
  318. bootstack/widgets/_impl/composites/scrolledtext.pyi +186 -0
  319. bootstack/widgets/_impl/composites/scrollview.py +764 -0
  320. bootstack/widgets/_impl/composites/selectbox.py +1026 -0
  321. bootstack/widgets/_impl/composites/shell/__init__.py +40 -0
  322. bootstack/widgets/_impl/composites/shell/content_host.py +52 -0
  323. bootstack/widgets/_impl/composites/shell/layout.py +335 -0
  324. bootstack/widgets/_impl/composites/shell/nav_panel.py +345 -0
  325. bootstack/widgets/_impl/composites/shell/providers.py +558 -0
  326. bootstack/widgets/_impl/composites/shell/rail.py +117 -0
  327. bootstack/widgets/_impl/composites/shell/shell.py +581 -0
  328. bootstack/widgets/_impl/composites/shell/workspace.py +273 -0
  329. bootstack/widgets/_impl/composites/sidenav/__init__.py +16 -0
  330. bootstack/widgets/_impl/composites/sidenav/header.py +81 -0
  331. bootstack/widgets/_impl/composites/sidenav/separator.py +44 -0
  332. bootstack/widgets/_impl/composites/slider/__init__.py +7 -0
  333. bootstack/widgets/_impl/composites/slider/_shared.py +195 -0
  334. bootstack/widgets/_impl/composites/slider/rangeslider.py +982 -0
  335. bootstack/widgets/_impl/composites/slider/slider.py +851 -0
  336. bootstack/widgets/_impl/composites/spinnerentry.py +185 -0
  337. bootstack/widgets/_impl/composites/tableview/__init__.py +5 -0
  338. bootstack/widgets/_impl/composites/tableview/tableview.py +3388 -0
  339. bootstack/widgets/_impl/composites/tableview/types.py +169 -0
  340. bootstack/widgets/_impl/composites/tabs/__init__.py +23 -0
  341. bootstack/widgets/_impl/composites/tabs/tabitem.py +389 -0
  342. bootstack/widgets/_impl/composites/tabs/tabs.py +974 -0
  343. bootstack/widgets/_impl/composites/tabs/tabview.py +650 -0
  344. bootstack/widgets/_impl/composites/textarea/__init__.py +26 -0
  345. bootstack/widgets/_impl/composites/textarea/change.py +46 -0
  346. bootstack/widgets/_impl/composites/textarea/codeeditor.py +526 -0
  347. bootstack/widgets/_impl/composites/textarea/core.py +495 -0
  348. bootstack/widgets/_impl/composites/textarea/decoration.py +42 -0
  349. bootstack/widgets/_impl/composites/textarea/diff.py +127 -0
  350. bootstack/widgets/_impl/composites/textarea/extensions/__init__.py +1 -0
  351. bootstack/widgets/_impl/composites/textarea/extensions/bracket_matcher.py +138 -0
  352. bootstack/widgets/_impl/composites/textarea/extensions/indent_guides.py +158 -0
  353. bootstack/widgets/_impl/composites/textarea/extensions/line_numbers.py +138 -0
  354. bootstack/widgets/_impl/composites/textarea/extensions/pygments_highlighter.py +312 -0
  355. bootstack/widgets/_impl/composites/textarea/extensions/smart_indent.py +177 -0
  356. bootstack/widgets/_impl/composites/textarea/filter.py +171 -0
  357. bootstack/widgets/_impl/composites/textarea/search_overlay.py +459 -0
  358. bootstack/widgets/_impl/composites/textarea/sidebar.py +88 -0
  359. bootstack/widgets/_impl/composites/textarea/style_registry.py +178 -0
  360. bootstack/widgets/_impl/composites/textarea/textarea.py +606 -0
  361. bootstack/widgets/_impl/composites/textarea/undo.py +217 -0
  362. bootstack/widgets/_impl/composites/textentry.py +57 -0
  363. bootstack/widgets/_impl/composites/timeentry.py +176 -0
  364. bootstack/widgets/_impl/composites/toast.py +390 -0
  365. bootstack/widgets/_impl/composites/toast_stack.py +156 -0
  366. bootstack/widgets/_impl/composites/togglegroup.py +418 -0
  367. bootstack/widgets/_impl/composites/toolbar.py +608 -0
  368. bootstack/widgets/_impl/composites/tooltip.py +491 -0
  369. bootstack/widgets/_impl/composites/tree/__init__.py +7 -0
  370. bootstack/widgets/_impl/composites/tree/source_binding.py +136 -0
  371. bootstack/widgets/_impl/composites/tree/treeitem.py +393 -0
  372. bootstack/widgets/_impl/composites/tree/treenode.py +174 -0
  373. bootstack/widgets/_impl/composites/tree/treeview.py +841 -0
  374. bootstack/widgets/_impl/mixins/__init__.py +24 -0
  375. bootstack/widgets/_impl/mixins/configure_mixin.py +216 -0
  376. bootstack/widgets/_impl/mixins/entry_mixin.py +134 -0
  377. bootstack/widgets/_impl/mixins/font_mixin.py +368 -0
  378. bootstack/widgets/_impl/mixins/icon_mixin.py +61 -0
  379. bootstack/widgets/_impl/mixins/localization_mixin.py +253 -0
  380. bootstack/widgets/_impl/mixins/signal_mixin.py +268 -0
  381. bootstack/widgets/_impl/mixins/validation_mixin.py +226 -0
  382. bootstack/widgets/_impl/primitives/__init__.py +49 -0
  383. bootstack/widgets/_impl/primitives/_menubutton.py +107 -0
  384. bootstack/widgets/_impl/primitives/badge.py +45 -0
  385. bootstack/widgets/_impl/primitives/button.py +76 -0
  386. bootstack/widgets/_impl/primitives/card.py +45 -0
  387. bootstack/widgets/_impl/primitives/checkbutton.py +124 -0
  388. bootstack/widgets/_impl/primitives/checktoggle.py +62 -0
  389. bootstack/widgets/_impl/primitives/combobox.py +156 -0
  390. bootstack/widgets/_impl/primitives/entry.py +87 -0
  391. bootstack/widgets/_impl/primitives/flexframe.py +448 -0
  392. bootstack/widgets/_impl/primitives/frame.py +185 -0
  393. bootstack/widgets/_impl/primitives/gridframe.py +546 -0
  394. bootstack/widgets/_impl/primitives/label.py +84 -0
  395. bootstack/widgets/_impl/primitives/labelframe.py +54 -0
  396. bootstack/widgets/_impl/primitives/optionmenu.py +387 -0
  397. bootstack/widgets/_impl/primitives/packframe.py +227 -0
  398. bootstack/widgets/_impl/primitives/panedwindow.py +45 -0
  399. bootstack/widgets/_impl/primitives/progressbar.py +83 -0
  400. bootstack/widgets/_impl/primitives/radiobutton.py +115 -0
  401. bootstack/widgets/_impl/primitives/radiotoggle.py +54 -0
  402. bootstack/widgets/_impl/primitives/scrollbar.py +42 -0
  403. bootstack/widgets/_impl/primitives/separator.py +43 -0
  404. bootstack/widgets/_impl/primitives/sizegrip.py +33 -0
  405. bootstack/widgets/_impl/primitives/spinbox.py +95 -0
  406. bootstack/widgets/_impl/primitives/switch.py +44 -0
  407. bootstack/widgets/_impl/primitives/treeview.py +69 -0
  408. bootstack/widgets/app.py +371 -0
  409. bootstack/widgets/appshell.py +1179 -0
  410. bootstack/widgets/avatar.py +140 -0
  411. bootstack/widgets/boolean_controls.py +455 -0
  412. bootstack/widgets/button.py +224 -0
  413. bootstack/widgets/buttongroup.py +239 -0
  414. bootstack/widgets/calendar.py +195 -0
  415. bootstack/widgets/card.py +159 -0
  416. bootstack/widgets/carousel.py +241 -0
  417. bootstack/widgets/chart.py +302 -0
  418. bootstack/widgets/codeeditor.py +675 -0
  419. bootstack/widgets/contextmenu.py +371 -0
  420. bootstack/widgets/datatable.py +688 -0
  421. bootstack/widgets/datefield.py +395 -0
  422. bootstack/widgets/divider.py +60 -0
  423. bootstack/widgets/expander.py +579 -0
  424. bootstack/widgets/form.py +200 -0
  425. bootstack/widgets/gallery.py +250 -0
  426. bootstack/widgets/gauge.py +168 -0
  427. bootstack/widgets/grid.py +121 -0
  428. bootstack/widgets/groupbox.py +162 -0
  429. bootstack/widgets/label.py +229 -0
  430. bootstack/widgets/listview.py +354 -0
  431. bootstack/widgets/menubutton.py +419 -0
  432. bootstack/widgets/numberfield.py +431 -0
  433. bootstack/widgets/pagestack.py +345 -0
  434. bootstack/widgets/passwordfield.py +383 -0
  435. bootstack/widgets/pathfield.py +454 -0
  436. bootstack/widgets/picture.py +251 -0
  437. bootstack/widgets/progressbar.py +106 -0
  438. bootstack/widgets/radio_variants.py +271 -0
  439. bootstack/widgets/radiogroup.py +228 -0
  440. bootstack/widgets/scrollbar.py +64 -0
  441. bootstack/widgets/scrollview.py +186 -0
  442. bootstack/widgets/select.py +302 -0
  443. bootstack/widgets/selectbutton.py +182 -0
  444. bootstack/widgets/sidebar_toggle.py +130 -0
  445. bootstack/widgets/sizegrip.py +42 -0
  446. bootstack/widgets/slider.py +408 -0
  447. bootstack/widgets/spinbox.py +147 -0
  448. bootstack/widgets/spinnerfield.py +413 -0
  449. bootstack/widgets/splash.py +367 -0
  450. bootstack/widgets/splitview.py +482 -0
  451. bootstack/widgets/stacks.py +249 -0
  452. bootstack/widgets/statusbar.py +189 -0
  453. bootstack/widgets/tabs.py +425 -0
  454. bootstack/widgets/textarea.py +459 -0
  455. bootstack/widgets/textfield.py +410 -0
  456. bootstack/widgets/theme_toggle.py +92 -0
  457. bootstack/widgets/timefield.py +340 -0
  458. bootstack/widgets/toast.py +314 -0
  459. bootstack/widgets/togglegroup.py +231 -0
  460. bootstack/widgets/toolbar.py +347 -0
  461. bootstack/widgets/tooltip.py +94 -0
  462. bootstack/widgets/tree.py +648 -0
  463. bootstack/widgets/types.py +305 -0
  464. bootstack/widgets/window.py +307 -0
  465. bootstack-0.1.0.dist-info/METADATA +301 -0
  466. bootstack-0.1.0.dist-info/RECORD +471 -0
  467. bootstack-0.1.0.dist-info/WHEEL +5 -0
  468. bootstack-0.1.0.dist-info/entry_points.txt +2 -0
  469. bootstack-0.1.0.dist-info/licenses/LICENSE +21 -0
  470. bootstack-0.1.0.dist-info/licenses/NOTICE +10 -0
  471. bootstack-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1433 @@
1
+ """ListView widget for displaying large lists with virtual scrolling."""
2
+
3
+ import contextlib
4
+ from tkinter import TclError
5
+ from typing import Any, Callable, Literal
6
+ from typing_extensions import Unpack
7
+
8
+ from bootstack.data import MemoryDataSource, DataSourceProtocol
9
+ from bootstack.widgets._impl.composites.list.listitem import ListItem
10
+ from bootstack.widgets._impl.primitives.frame import Frame, FrameKwargs
11
+ from bootstack.widgets.types import Master, ScrollbarVariant
12
+ from bootstack.widgets._impl.primitives.scrollbar import Scrollbar
13
+ from bootstack.widgets._impl.mixins import configure_delegate
14
+
15
+ # Constants
16
+ VISIBLE_ROWS = 20
17
+ ROW_HEIGHT = 40
18
+ OVERSCAN_ROWS = 2
19
+ EMPTY = {"__empty__": True, "id": "__empty__"}
20
+
21
+
22
+
23
+ class ListView(Frame):
24
+ """A virtual scrolling list widget for efficiently displaying large datasets.
25
+
26
+ ListView uses virtual scrolling to render only visible items, allowing it to
27
+ handle thousands of records efficiently. It supports multiple selection modes,
28
+ item deletion, drag and drop, and custom styling.
29
+
30
+ The widget works with either a simple list/dict data or a custom DataSource
31
+ implementation for more complex scenarios (database, API, etc.).
32
+
33
+ """
34
+
35
+ def __init__(self, master: Master = None,
36
+ items: list = None,
37
+ datasource: DataSourceProtocol = None,
38
+ _row_factory: Callable = None,
39
+ selection_mode: Literal['none', 'single', 'multi'] = 'none',
40
+ show_selection_controls: bool = False,
41
+ show_chevron: bool = False,
42
+ enable_removing: bool = False,
43
+ enable_dragging: bool = False,
44
+ striped: bool = False,
45
+ striped_background: str = 'background[+0.85]',
46
+ show_separator: bool = True,
47
+ scrollbar_visibility: Literal['always', 'never'] = 'always',
48
+ enable_focus: bool = True,
49
+ enable_hover: bool = True,
50
+ select_on_click: bool = None,
51
+ density: Literal['default', 'compact'] = 'default',
52
+ accent_selection: bool = False,
53
+ scrollbar_variant: ScrollbarVariant = 'thin',
54
+ **kwargs: Unpack[FrameKwargs]
55
+ ):
56
+ """Initialize a ListView widget.
57
+
58
+ Args:
59
+ master: Parent widget.
60
+ items: List of items or dicts to display (alternative to `datasource`).
61
+ datasource: DataSource implementation for data access.
62
+ selection_mode: Selection mode (`none`, `single`, `multi`).
63
+ show_selection_controls: Show checkboxes/radio buttons for selection.
64
+ show_chevron: Show chevron indicators on items.
65
+ enable_removing: Allow items to be removed; shows remove button on items.
66
+ enable_dragging: Allow row dragging; shows drag handle on items.
67
+ striped: Whether to show alternating row colors.
68
+ striped_background: The background color for striped rows.
69
+ show_separator: Show separator line between items.
70
+ scrollbar_visibility: Scrollbar visibility - 'always' to show scrollbar,
71
+ 'never' to hide (mousewheel only). Defaults to 'always'.
72
+ enable_focus: Whether items can receive keyboard focus.
73
+ enable_hover: Whether items show hover state.
74
+ select_on_click: Whether clicking an item selects it. Defaults to True when
75
+ selection_mode is 'single' or 'multi', False otherwise. Can be explicitly
76
+ set to override the default behavior.
77
+ density: Visual density ('default' or 'compact'). Defaults to 'default'.
78
+ **kwargs: Additional keyword arguments forwarded to `Frame`.
79
+ """
80
+ # Capture user-provided accent for row propagation and the drag indicator.
81
+ # super().__init__ runs through the style wrapper which sets self._accent
82
+ # to whatever's in kwargs (None if absent), so we re-assert with a 'primary'
83
+ # fallback after super() completes.
84
+ _user_accent = kwargs.get('accent')
85
+
86
+ super().__init__(master, variant='container', ttk_class='ListView.TFrame', **kwargs)
87
+
88
+ self._accent = _user_accent or 'primary'
89
+
90
+ # Cache the windowing system so scroll bindings can dispatch
91
+ # platform-correctly: Aqua/Win send <MouseWheel>, X11 sends
92
+ # <Button-4>/<Button-5>.
93
+ self.winsys = self.tk.call('tk', 'windowingsystem')
94
+
95
+ # Configuration
96
+ self._selection_mode = selection_mode
97
+ self._show_selection_controls = show_selection_controls
98
+ self._show_chevron = show_chevron
99
+ self._enable_removing = enable_removing
100
+ self._enable_dragging = enable_dragging
101
+ self._show_separator = show_separator
102
+ self._scrollbar_visibility = scrollbar_visibility
103
+ self._scrollbar_variant = scrollbar_variant
104
+ self._select_on_click = select_on_click
105
+ self._enable_focus = enable_focus
106
+ self._enable_hover = enable_hover
107
+ self._striped = striped
108
+ self._striped_background = striped_background
109
+ self._density = density
110
+ self._accent_selection = accent_selection
111
+
112
+ # Virtual scrolling state
113
+ self._start_index = 0
114
+ self._prev_start_index = 0
115
+ self._visible_rows = VISIBLE_ROWS
116
+ self._row_height = ROW_HEIGHT
117
+ self._page_size = VISIBLE_ROWS + OVERSCAN_ROWS
118
+
119
+ # Data source
120
+ if datasource:
121
+ self._datasource = datasource
122
+ elif items:
123
+ self._datasource = MemoryDataSource(page_size=self._page_size).load(items)
124
+ else:
125
+ self._datasource = MemoryDataSource(page_size=self._page_size).load([])
126
+
127
+ self._rows: list[ListItem] = []
128
+ self._focused_record_id = None
129
+ self._drag_state: dict | None = None
130
+ self._drag_indicator: Frame | None = None
131
+ self._drag_scroll_counter = 0
132
+ self._mousewheel_bound_widgets: set = set() # Track bound widgets to avoid cycles
133
+
134
+ # Row factory
135
+ self._row_factory = _row_factory or self._default_row_factory
136
+
137
+ # Create the scrollbar first and pack it on the right (with a small left
138
+ # pad so it sits in its own gutter, not flush against the rows); the
139
+ # container then fills the space to its left.
140
+ from bootstack.style.style import get_style
141
+ self._scrollbar_gutter = get_style().style_builder.scale(6)
142
+ sb_surface = getattr(self, '_surface', None)
143
+ sb_kw = {'surface': sb_surface} if sb_surface else {}
144
+ self._scrollbar = Scrollbar(
145
+ self, orient='vertical', command=self._on_scroll,
146
+ variant=self._scrollbar_variant, **sb_kw)
147
+ if self._scrollbar_visibility == 'always':
148
+ self._scrollbar.pack(side='right', fill='y', padx=(self._scrollbar_gutter, 0))
149
+
150
+ # Create container frame for list items (fills the area left of the gutter)
151
+ self._container = Frame(self, variant='container', ttk_class='ListView.TFrame')
152
+ self._container.pack(side='left', fill='both', expand=True)
153
+
154
+ # Create row pool
155
+ self._ensure_row_pool(self._page_size)
156
+
157
+ # Bind events
158
+ self.bind('<Configure>', self._on_resize, add='+')
159
+ # The striped row surface is applied imperatively at pool-creation time,
160
+ # so it must be re-resolved on theme change — gated to on-screen by the
161
+ # Frame base hook (an off-screen list defers its per-row repaint to <Map>)
162
+ # and released on destroy automatically.
163
+ self._bind_scroll_events(self)
164
+ self._bind_scroll_events(self._container)
165
+
166
+ # Bind ListItem events
167
+ self._container.bind('<<ItemSelecting>>', self._on_item_selecting, add='+')
168
+ self._container.bind('<<ItemRemoving>>', self._on_item_removing, add='+')
169
+ self._container.bind('<<ItemFocus>>', self._on_item_focused, add='+')
170
+ self._container.bind('<<ItemClick>>', self._on_item_click, add='+')
171
+ self._container.bind('<<ItemDragStart>>', self._on_item_drag_start, add='+')
172
+ self._container.bind('<<ItemDrag>>', self._on_item_dragging, add='+')
173
+ self._container.bind('<<ItemDragEnd>>', self._on_item_drag_end, add='+')
174
+
175
+ # Bind keyboard navigation
176
+ self.bind('<Down>', self._on_arrow_down, add='+')
177
+ self.bind('<Up>', self._on_arrow_up, add='+')
178
+ self._container.bind('<Down>', self._on_arrow_down, add='+')
179
+ self._container.bind('<Up>', self._on_arrow_up, add='+')
180
+
181
+ # Auto-refresh when the data source changes from the outside (a shared
182
+ # source mutated directly, or a background-thread feed). The hub marshals
183
+ # the callback onto the main thread; mutations this widget makes itself
184
+ # are wrapped in `_silence_source()` so they do not loop back here.
185
+ self._change_sub = None
186
+ on_change = getattr(self._datasource, 'on_change', None)
187
+ if callable(on_change):
188
+ try:
189
+ self._change_sub = on_change(self._on_source_change)
190
+ except Exception:
191
+ self._change_sub = None
192
+ self.bind('<Destroy>', self._on_listview_destroy, add='+')
193
+
194
+ # Initial update
195
+ self.after(10, self._remeasure_and_relayout)
196
+
197
+ def _silence_source(self):
198
+ """Context manager suppressing source change broadcasts for our own writes."""
199
+ silence = getattr(self._datasource, '_silence', None)
200
+ if callable(silence):
201
+ return silence()
202
+ return contextlib.nullcontext()
203
+
204
+ def _on_source_change(self, event: Any = None) -> None:
205
+ """Refresh the visible rows after an external data-source change.
206
+
207
+ Re-measures and relays out (not just `_update_rows`) so the visible-row
208
+ count, the row-widget pool, and the scrollbar all track a changed row
209
+ count — the same path a window resize takes. This also self-corrects the
210
+ pool if the initial measurement ran before the geometry had settled.
211
+ """
212
+ try:
213
+ self._remeasure_and_relayout()
214
+ except Exception:
215
+ pass
216
+
217
+ def _on_listview_destroy(self, event: Any = None) -> None:
218
+ """Cancel the data-source subscription when the widget is destroyed."""
219
+ if event is not None and getattr(event, 'widget', None) is not self:
220
+ return
221
+ # The theme subscription is released by the Frame base hook's own
222
+ # <Destroy> handler (publisher unsubscribe).
223
+ sub = self._change_sub
224
+ self._change_sub = None
225
+ if sub is not None:
226
+ try:
227
+ sub.cancel()
228
+ except Exception:
229
+ pass
230
+
231
+ @configure_delegate('selection_mode')
232
+ def _delegate_selection_mode(self, value=None):
233
+ """Get or set the selection mode.
234
+
235
+ Args:
236
+ value: If provided, sets the selection mode to 'none', 'single', or 'multi'.
237
+ If None, returns the current selection mode.
238
+
239
+ Returns:
240
+ Current selection mode when called without arguments.
241
+ """
242
+ if value is None:
243
+ return self._selection_mode
244
+ else:
245
+ self._selection_mode = value
246
+ # Recreate row pool to apply new selection mode
247
+ self._ensure_row_pool(self._page_size)
248
+ self._update_rows()
249
+ return None
250
+
251
+ @configure_delegate('scrollbar_visibility')
252
+ def _delegate_scrollbar_visibility(self, value=None):
253
+ """Get or set scrollbar visibility.
254
+
255
+ Args:
256
+ value: If provided ('always' or 'never'), shows or hides the scrollbar.
257
+ If None, returns current visibility setting.
258
+
259
+ Returns:
260
+ Current scrollbar_visibility value when called without arguments.
261
+ """
262
+ if value is None:
263
+ return self._scrollbar_visibility
264
+ else:
265
+ old_value = self._scrollbar_visibility
266
+ self._scrollbar_visibility = value
267
+ if old_value != self._scrollbar_visibility:
268
+ if self._scrollbar_visibility == 'always':
269
+ # before=container keeps the scrollbar in its right gutter
270
+ # rather than overlaying the already-packed rows.
271
+ self._scrollbar.pack(side='right', fill='y',
272
+ padx=(self._scrollbar_gutter, 0), before=self._container)
273
+ else:
274
+ self._scrollbar.pack_forget()
275
+ return None
276
+
277
+ @configure_delegate('striped')
278
+ def _delegate_striped(self, value=None):
279
+ """Get or set striped mode.
280
+
281
+ Args:
282
+ value: If provided, enables or disables striped rows.
283
+ If None, returns the current mode.
284
+
285
+ Returns:
286
+ Current striped value when called without arguments.
287
+ """
288
+ if value is None:
289
+ return self._striped
290
+ else:
291
+ self._striped = bool(value)
292
+ # Reapply surface colors to all rows
293
+ for i, row in enumerate(self._rows):
294
+ self._apply_widget_surface(row, i)
295
+ return None
296
+
297
+ @configure_delegate('striped_background')
298
+ def _delegate_striped_background(self, value=None):
299
+ """Get or set striped row background color.
300
+
301
+ Args:
302
+ value: If provided, sets the striped row background color.
303
+ If None, returns the current color.
304
+
305
+ Returns:
306
+ Current striped_background when called without arguments.
307
+ """
308
+ if value is None:
309
+ return self._striped_background
310
+ else:
311
+ self._striped_background = value
312
+ # Reapply surface colors to all rows
313
+ for i, row in enumerate(self._rows):
314
+ self._apply_widget_surface(row, i)
315
+ return None
316
+
317
+ @staticmethod
318
+ def _default_row_factory(master, **kwargs: Unpack[FrameKwargs]):
319
+ """Create a default `ListItem`.
320
+
321
+ Args:
322
+ master: Parent widget.
323
+ **kwargs: Keyword arguments for `ListItem`.
324
+
325
+ Returns:
326
+ A new `ListItem` instance.
327
+ """
328
+ return ListItem(master, **kwargs)
329
+
330
+ def _ensure_row_pool(self, needed: int):
331
+ """Create/destroy `ListItem` widgets to match pool size.
332
+
333
+ Args:
334
+ needed: Desired number of row widgets.
335
+ """
336
+ while len(self._rows) < needed:
337
+ # Build kwargs for row factory (using item-level names)
338
+ row_kwargs = dict(
339
+ selection_mode=self._selection_mode,
340
+ show_selection_controls=self._show_selection_controls,
341
+ show_chevron=self._show_chevron,
342
+ removable=self._enable_removing,
343
+ draggable=self._enable_dragging,
344
+ show_separator=self._show_separator,
345
+ focusable=self._enable_focus,
346
+ hoverable=self._enable_hover,
347
+ accent=self._accent,
348
+ density=self._density,
349
+ accent_selection=self._accent_selection,
350
+ )
351
+
352
+ # When striped AND separated, draw the row divider in the stripe
353
+ # color so the two read as one subtle tone instead of a darker grid
354
+ # of lines competing with the bands.
355
+ if self._striped and self._show_separator:
356
+ row_kwargs['separator_color'] = self._striped_background
357
+
358
+ # Only pass select_on_click if explicitly set
359
+ if self._select_on_click is not None:
360
+ row_kwargs['select_on_click'] = self._select_on_click
361
+
362
+ row = self._row_factory(self._container, **row_kwargs)
363
+ row.pack(fill='x')
364
+ self._rows.append(row)
365
+
366
+ # Bind keyboard navigation to each row and its children
367
+ self._bind_arrow_keys_recursive(row)
368
+
369
+ # Apply surface color once based on widget position
370
+ widget_index = len(self._rows) - 1
371
+ self._apply_widget_surface(row, widget_index)
372
+
373
+ while len(self._rows) > needed:
374
+ row = self._rows.pop()
375
+ row.pack_forget()
376
+ try:
377
+ row.destroy()
378
+ except TclError:
379
+ pass
380
+
381
+ def _clamp_indices(self):
382
+ """Ensure `self._start_index` is within valid range."""
383
+ total = self._datasource.count
384
+ max_start = max(0, total - self._visible_rows)
385
+ self._start_index = max(0, min(self._start_index, max_start))
386
+
387
+ def _update_rows(self):
388
+ """Update visible rows with current data using row recycling for efficiency."""
389
+ self._clamp_indices()
390
+
391
+ # Calculate scroll distance to determine if we can use recycling
392
+ scroll_distance = self._start_index - self._prev_start_index
393
+ can_recycle = abs(scroll_distance) <= 3 and scroll_distance != 0
394
+
395
+ if can_recycle:
396
+ # Use row recycling for small scrolls
397
+ self._recycle_rows(scroll_distance)
398
+ else:
399
+ # Full update for large scrolls or initial render
400
+ self._full_update_rows()
401
+
402
+ # Remember current position for next scroll
403
+ self._prev_start_index = self._start_index
404
+
405
+ # Update scrollbar
406
+ total = max(1, self._datasource.count)
407
+ first = self._start_index / total
408
+ last = min(1.0, (self._start_index + self._visible_rows) / total)
409
+ self._scrollbar.set(first, last)
410
+
411
+ def _recycle_rows(self, scroll_distance: int):
412
+ """Recycle rows by moving them from one end to the other.
413
+
414
+ Args:
415
+ scroll_distance: Positive for scrolling down, negative for scrolling up.
416
+ """
417
+ if scroll_distance > 0:
418
+ # Scrolling down: move top rows to bottom
419
+ for _ in range(scroll_distance):
420
+ if not self._rows:
421
+ break
422
+
423
+ # Remove top row
424
+ top_row = self._rows.pop(0)
425
+
426
+ # Calculate new data index
427
+ data_index = self._start_index + len(self._rows)
428
+
429
+ # Update data BEFORE moving widget to prevent focus tracking widget
430
+ self._update_single_row(top_row, data_index)
431
+
432
+ # Move to bottom
433
+ top_row.pack_forget()
434
+ top_row.pack(side='top', fill='x')
435
+ self._rows.append(top_row)
436
+
437
+ elif scroll_distance < 0:
438
+ # Scrolling up: move bottom rows to top
439
+ for _ in range(abs(scroll_distance)):
440
+ if not self._rows:
441
+ break
442
+
443
+ # Remove bottom row
444
+ bottom_row = self._rows.pop()
445
+
446
+ # Calculate new data index
447
+ data_index = self._start_index
448
+
449
+ # Update data BEFORE moving widget to prevent focus tracking widget
450
+ self._update_single_row(bottom_row, data_index)
451
+
452
+ # Move to top
453
+ bottom_row.pack_forget()
454
+ if self._rows:
455
+ bottom_row.pack(side='top', fill='x', before=self._rows[0])
456
+ else:
457
+ bottom_row.pack(side='top', fill='x')
458
+ self._rows.insert(0, bottom_row)
459
+
460
+ def _full_update_rows(self):
461
+ """Perform a full update of all visible rows."""
462
+ page_data = self._datasource.page_slice(self._start_index, self._page_size)
463
+
464
+ for i, row in enumerate(self._rows):
465
+ data_index = self._start_index + i
466
+ if i < len(page_data):
467
+ self._update_single_row(row, data_index, page_data[i])
468
+ else:
469
+ row.update_data(EMPTY)
470
+
471
+ def _update_single_row(self, row: ListItem, data_index: int, record: dict = None):
472
+ """Update a single row widget with data at the given index.
473
+
474
+ Args:
475
+ row: The ListItem widget to update.
476
+ data_index: The data index to fetch and display.
477
+ record: Optional pre-fetched record data. If None, will fetch from datasource.
478
+ """
479
+ if record is None:
480
+ # Fetch the record from datasource
481
+ page_data = self._datasource.page_slice(data_index, 1)
482
+ if not page_data:
483
+ row.update_data(EMPTY)
484
+ # Bind mousewheel after update to ensure all child widgets exist
485
+ self._bind_mousewheel_recursive(row)
486
+ return
487
+ record = page_data[0]
488
+
489
+ record = record.copy()
490
+ record_id = record.get('id')
491
+
492
+ # Add selection state
493
+ if record_id is not None:
494
+ try:
495
+ record['selected'] = self._datasource.is_selected(record_id)
496
+ except Exception:
497
+ record['selected'] = False
498
+ record['focused'] = (record_id == self._focused_record_id)
499
+ else:
500
+ record['selected'] = False
501
+ record['focused'] = False
502
+
503
+ # Add index
504
+ record['item_index'] = data_index
505
+
506
+ # Update the row
507
+ row.update_data(record)
508
+
509
+ # Bind mousewheel after update to ensure all child widgets exist
510
+ self._bind_mousewheel_recursive(row)
511
+
512
+ def _on_scroll(self, *args):
513
+ """Handle scrollbar movement.
514
+
515
+ Args:
516
+ *args: Tkinter scrollbar arguments.
517
+ """
518
+ if args[0] == 'moveto':
519
+ fraction = float(args[1])
520
+ total = self._datasource.count
521
+ max_start = max(0, total - self._visible_rows)
522
+ self._start_index = int(round(fraction * max_start))
523
+ elif args[0] == 'scroll':
524
+ amount = int(args[1])
525
+ unit = args[2]
526
+ # Use smaller step size for smoother scrolling
527
+ step = max(1, self._visible_rows // 2) if unit == 'pages' else 1
528
+ self._start_index += amount * step
529
+
530
+ self._clamp_indices()
531
+ self._update_rows()
532
+
533
+ def _bind_mousewheel_recursive(self, widget):
534
+ """Recursively bind mousewheel event to a widget and all its children.
535
+
536
+ Only binds if the widget hasn't been bound already to avoid duplicate bindings.
537
+
538
+ Args:
539
+ widget: The widget to bind mousewheel event to.
540
+ """
541
+ # Use widget string representation as identifier
542
+ widget_id = str(widget)
543
+
544
+ # Only bind if we haven't already bound this widget
545
+ if widget_id not in self._mousewheel_bound_widgets:
546
+ self._bind_scroll_events(widget)
547
+ self._mousewheel_bound_widgets.add(widget_id)
548
+
549
+ try:
550
+ for child in widget.winfo_children():
551
+ self._bind_mousewheel_recursive(child)
552
+ except Exception:
553
+ pass
554
+
555
+ def _bind_arrow_keys_recursive(self, widget):
556
+ """Recursively bind arrow key events to a widget and all its children.
557
+
558
+ Args:
559
+ widget: The widget to bind arrow key events to.
560
+ """
561
+ widget.bind('<Down>', self._on_arrow_down, add='+')
562
+ widget.bind('<Up>', self._on_arrow_up, add='+')
563
+
564
+ try:
565
+ for child in widget.winfo_children():
566
+ self._bind_arrow_keys_recursive(child)
567
+ except Exception:
568
+ pass
569
+
570
+ def _bind_scroll_events(self, widget) -> None:
571
+ """Bind the platform-correct scroll-wheel events to `widget`.
572
+
573
+ On Aqua/Win the event is `<MouseWheel>` with `event.delta`
574
+ carrying direction and magnitude. On X11 there's no MouseWheel —
575
+ scroll up is `<Button-4>` and scroll down is `<Button-5>`.
576
+ """
577
+ if self.winsys.lower() == 'x11':
578
+ widget.bind('<Button-4>', self._on_mousewheel, add='+')
579
+ widget.bind('<Button-5>', self._on_mousewheel, add='+')
580
+ else:
581
+ widget.bind('<MouseWheel>', self._on_mousewheel, add='+')
582
+
583
+ def _on_mousewheel(self, event):
584
+ """Handle mouse wheel scrolling.
585
+
586
+ Args:
587
+ event: Tkinter mouse wheel event.
588
+ """
589
+ # Check if mouse is over this widget
590
+ widget_under_mouse = self.winfo_containing(event.x_root, event.y_root)
591
+ if widget_under_mouse is None:
592
+ return
593
+
594
+ # Check if the widget under mouse is a child of this ListView
595
+ current = widget_under_mouse
596
+ is_child = False
597
+ while current is not None:
598
+ if current == self:
599
+ is_child = True
600
+ break
601
+ try:
602
+ current = current.master
603
+ except AttributeError:
604
+ break
605
+
606
+ if not is_child:
607
+ return
608
+
609
+ # Resolve scroll direction per platform: X11 carries it in event.num
610
+ # (4=up, 5=down) and has no event.delta; Aqua/Win use event.delta.
611
+ if self.winsys.lower() == 'x11':
612
+ delta = -1 if getattr(event, 'num', 0) == 4 else 1
613
+ else:
614
+ delta = -1 if event.delta > 0 else 1
615
+ self._start_index += delta
616
+ self._clamp_indices()
617
+ self._update_rows()
618
+
619
+ def _on_resize(self, event):
620
+ """Handle widget resize and recalculate visible rows.
621
+
622
+ Args:
623
+ event: Tkinter configure event.
624
+ """
625
+ if event.widget == self:
626
+ self.after_idle(self._remeasure_and_relayout)
627
+
628
+ def _remeasure_and_relayout(self):
629
+ """Measure row height, then recompute sizes and repaint."""
630
+ if not self._rows:
631
+ return
632
+
633
+ # Measure actual widget height
634
+ rh = self._rows[0].winfo_height()
635
+ if rh <= 1:
636
+ rh = self._rows[0].winfo_reqheight()
637
+
638
+ if rh and rh != self._row_height:
639
+ self._row_height = rh
640
+
641
+ # Calculate how many rows fit
642
+ container_height = self._container.winfo_height()
643
+ if container_height > 0:
644
+ visible = max(1, container_height // max(1, self._row_height))
645
+ page_size = visible + OVERSCAN_ROWS
646
+
647
+ if visible != self._visible_rows or page_size != self._page_size:
648
+ self._visible_rows = visible
649
+ self._page_size = page_size
650
+ self._ensure_row_pool(self._page_size)
651
+
652
+ self._clamp_indices()
653
+ self._update_rows()
654
+
655
+ def _on_item_selecting(self, event: Any):
656
+ """Handle item selection event from `ListItem`.
657
+
658
+ Args:
659
+ event: Event with `data` for the item being selected.
660
+ """
661
+ record_id = event.data.get('id')
662
+ if record_id is not None and record_id != '__empty__':
663
+ with self._silence_source():
664
+ if self._selection_mode == 'single':
665
+ self._datasource.deselect_all()
666
+ self._datasource.select(record_id)
667
+ elif self._selection_mode == 'multi':
668
+ if self._datasource.is_selected(record_id):
669
+ self._datasource.deselect(record_id)
670
+ else:
671
+ self._datasource.select(record_id)
672
+
673
+ self._update_rows()
674
+ self.event_generate('<<SelectionChange>>')
675
+
676
+ def _on_item_removing(self, event: Any):
677
+ """Handle item remove event from `ListItem`.
678
+
679
+ Args:
680
+ event: Event with `data` for the item being removed.
681
+ """
682
+ record_id = event.data.get('id')
683
+ if record_id is not None and record_id != '__empty__':
684
+ try:
685
+ with self._silence_source():
686
+ self._datasource.delete(record_id)
687
+ self._update_rows()
688
+ self.event_generate('<<ItemDelete>>', data=event.data)
689
+ except Exception as exc:
690
+ payload = dict(event.data)
691
+ payload['error'] = str(exc)
692
+ self.event_generate('<<ItemDeleteFail>>', data=payload)
693
+
694
+ def _on_item_focused(self, event: Any):
695
+ """Handle item focus event from `ListItem`.
696
+
697
+ Args:
698
+ event: Event with `data` for the item being focused.
699
+ """
700
+ record_id = event.data.get('id')
701
+ if record_id is not None and record_id != '__empty__':
702
+ self._focused_record_id = record_id
703
+ self._update_rows()
704
+
705
+ def _get_focused_index(self) -> int:
706
+ """Get the data index of the currently focused item.
707
+
708
+ Returns:
709
+ The index of the focused item, or -1 if none is focused.
710
+ """
711
+ if self._focused_record_id is None:
712
+ return -1
713
+
714
+ # Search visible rows for the focused record
715
+ for i, row in enumerate(self._rows):
716
+ if hasattr(row, '_data') and row._data.get('id') == self._focused_record_id:
717
+ return self._start_index + i
718
+
719
+ return -1
720
+
721
+ def _focus_item_at_index(self, index: int) -> None:
722
+ """Focus the item at the given data index.
723
+
724
+ Args:
725
+ index: The data index of the item to focus.
726
+ """
727
+ total = self._datasource.count
728
+ if total == 0 or index < 0 or index >= total:
729
+ return
730
+
731
+ # Scroll if needed to make the item visible
732
+ if index < self._start_index:
733
+ self._start_index = index
734
+ self._clamp_indices()
735
+ self._update_rows()
736
+ elif index >= self._start_index + len(self._rows):
737
+ self._start_index = index - len(self._rows) + 1
738
+ self._clamp_indices()
739
+ self._update_rows()
740
+
741
+ # Get the record at the index
742
+ page_data = self._datasource.page_slice(index, 1)
743
+ if page_data:
744
+ record_id = page_data[0].get('id')
745
+ if record_id is not None:
746
+ self._focused_record_id = record_id
747
+ self._update_rows()
748
+
749
+ # Focus the visible row widget (keyboard nav -> show focus ring)
750
+ visual_index = index - self._start_index
751
+ if 0 <= visual_index < len(self._rows):
752
+ self._rows[visual_index].focus_set(visual_focus=True)
753
+
754
+ def _on_arrow_down(self, event) -> str:
755
+ """Handle arrow down key for keyboard navigation.
756
+
757
+ Args:
758
+ event: Tkinter key event.
759
+
760
+ Returns:
761
+ 'break' to prevent default handling.
762
+ """
763
+ total = self._datasource.count
764
+ if total == 0:
765
+ return 'break'
766
+
767
+ current_index = self._get_focused_index()
768
+ if current_index < 0:
769
+ # No item focused, focus the first visible item
770
+ next_index = self._start_index
771
+ else:
772
+ # Move to next item
773
+ next_index = min(current_index + 1, total - 1)
774
+
775
+ self._focus_item_at_index(next_index)
776
+ return 'break'
777
+
778
+ def _on_arrow_up(self, event) -> str:
779
+ """Handle arrow up key for keyboard navigation.
780
+
781
+ Args:
782
+ event: Tkinter key event.
783
+
784
+ Returns:
785
+ 'break' to prevent default handling.
786
+ """
787
+ total = self._datasource.count
788
+ if total == 0:
789
+ return 'break'
790
+
791
+ current_index = self._get_focused_index()
792
+ if current_index < 0:
793
+ # No item focused, focus the last visible item
794
+ next_index = min(self._start_index + len(self._rows) - 1, total - 1)
795
+ else:
796
+ # Move to previous item
797
+ next_index = max(current_index - 1, 0)
798
+
799
+ self._focus_item_at_index(next_index)
800
+ return 'break'
801
+
802
+ def _on_item_click(self, event: Any):
803
+ """Handle item click event from `ListItem`.
804
+
805
+ Args:
806
+ event: Event with `data` for the clicked item.
807
+ """
808
+ # Fetch fresh state from datasource to ensure accurate selection/focus state
809
+ record_id = event.data.get('id')
810
+ if record_id is not None and record_id != '__empty__':
811
+ item_index = event.data.get('item_index')
812
+ if item_index is not None:
813
+ page_data = self._datasource.page_slice(item_index, 1)
814
+ if page_data:
815
+ record = page_data[0].copy()
816
+ record['selected'] = self._datasource.is_selected(record_id)
817
+ record['focused'] = (record_id == self._focused_record_id)
818
+ record['item_index'] = item_index
819
+ self.event_generate('<<ItemClick>>', data=record)
820
+ return
821
+
822
+ # Fallback to original data if we can't fetch fresh state
823
+ self.event_generate('<<ItemClick>>', data=event.data)
824
+
825
+ def _bs_apply_theme(self, _event: Any = None) -> None:
826
+ """Re-resolve theme-dependent per-row surfaces on a theme change.
827
+
828
+ The striped background is applied imperatively (not via a ttk style), so
829
+ it does not update when the theme rebuilds — re-apply it to every pooled
830
+ row and re-render so the new theme's colors take effect.
831
+ """
832
+ try:
833
+ for i, row in enumerate(self._rows):
834
+ self._apply_widget_surface(row, i)
835
+ self._full_update_rows()
836
+ except Exception:
837
+ pass
838
+
839
+ def _apply_widget_surface(self, row: ListItem, widget_index: int) -> None:
840
+ """Apply surface color to a row widget based on its position in the pool.
841
+
842
+ This is called once when a widget is created. The color is based on the
843
+ widget's position (not the data index), creating a stable alternating
844
+ pattern during scrolling without needing to recalculate colors.
845
+
846
+ Args:
847
+ row: The ListItem widget to color.
848
+ widget_index: The position of this widget in the row pool (0-based).
849
+ """
850
+ base_surface = getattr(self, "_surface", "background")
851
+ if not self._striped:
852
+ surface = base_surface
853
+ else:
854
+ # Apply striped background to odd rows
855
+ is_odd = (widget_index % 2) == 1
856
+ surface = self._striped_background if is_odd else base_surface
857
+
858
+ if hasattr(row, "set_surface"):
859
+ row.set_surface(surface)
860
+
861
+ def _on_item_drag_start(self, event: Any):
862
+ """Handle item drag start event from `ListItem`.
863
+
864
+ Args:
865
+ event: Event with `data` for the dragged item.
866
+ """
867
+ record_id = event.data.get('id')
868
+ source_index = event.data.get('source_index')
869
+ if record_id is None or record_id == '__empty__' or source_index is None:
870
+ return
871
+
872
+ self._drag_state = dict(
873
+ record_id=record_id,
874
+ source_index=source_index,
875
+ target_index=source_index,
876
+ record_data=dict(event.data),
877
+ )
878
+ self._drag_scroll_counter = 0
879
+ self._show_drag_indicator()
880
+ self._update_drag_indicator_position(source_index)
881
+ self.event_generate('<<ItemDragStart>>', data=event.data)
882
+
883
+ def _on_item_dragging(self, event: Any):
884
+ """Handle item dragging event from `ListItem`.
885
+
886
+ Args:
887
+ event: Event with `data` for the dragged item.
888
+ """
889
+ if not self._drag_state:
890
+ return
891
+
892
+ y_current = event.data.get('y_current')
893
+ target_index = self._get_drop_index(y_current)
894
+ self._drag_state['target_index'] = target_index
895
+ self._auto_scroll_for_drag(y_current)
896
+ self._update_drag_indicator_position(target_index)
897
+ payload = dict(self._drag_state.get('record_data', {}))
898
+ payload.update(
899
+ dict(
900
+ source_index=self._drag_state.get('source_index'),
901
+ target_index=target_index,
902
+ y_current=y_current,
903
+ )
904
+ )
905
+ self.event_generate('<<ItemDrag>>', data=payload)
906
+
907
+ def _on_item_drag_end(self, event: Any):
908
+ """Handle item drag end event from `ListItem`.
909
+
910
+ Args:
911
+ event: Event with `data` for the dragged item.
912
+ """
913
+ if not self._drag_state:
914
+ return
915
+
916
+ self._hide_drag_indicator()
917
+
918
+ record_id = self._drag_state.get('record_id')
919
+ target_index = self._drag_state.get('target_index')
920
+ moved = self._move_record(record_id, target_index)
921
+ if moved:
922
+ self._update_rows()
923
+
924
+ payload = dict(self._drag_state.get('record_data', {}))
925
+ payload.update(
926
+ dict(
927
+ source_index=self._drag_state.get('source_index'),
928
+ target_index=target_index,
929
+ y_end=event.data.get('y_end'),
930
+ y_start=event.data.get('y_start'),
931
+ )
932
+ )
933
+ payload['target_index'] = target_index
934
+ payload['moved'] = moved
935
+ self.event_generate('<<ItemDragEnd>>', data=payload)
936
+ self._drag_state = None
937
+
938
+ def _get_drop_index(self, y_root: int | None) -> int:
939
+ """Calculate the drop index for a drag operation.
940
+
941
+ Args:
942
+ y_root: Screen Y coordinate.
943
+
944
+ Returns:
945
+ Zero-based index for the drop position.
946
+ """
947
+ total = self._datasource.count
948
+ if total <= 0:
949
+ return 0
950
+
951
+ if y_root is None:
952
+ return max(0, min(self._start_index, total - 1))
953
+
954
+ container_top = self._container.winfo_rooty()
955
+ container_height = self._container.winfo_height()
956
+ if container_height <= 0:
957
+ return max(0, min(self._start_index, total - 1))
958
+
959
+ y_local = y_root - container_top
960
+ y_local = max(0, min(y_local, container_height - 1))
961
+ offset = int(y_local // max(1, self._row_height))
962
+ target = self._start_index + offset
963
+ return max(0, min(target, total - 1))
964
+
965
+ def _auto_scroll_for_drag(self, y_root: int | None) -> None:
966
+ """Auto-scroll while dragging near the list edges.
967
+
968
+ Args:
969
+ y_root: Screen Y coordinate.
970
+ """
971
+ if y_root is None:
972
+ return
973
+
974
+ container_top = self._container.winfo_rooty()
975
+ container_height = self._container.winfo_height()
976
+ if container_height <= 0:
977
+ return
978
+
979
+ scroll_zone_height = max(10, int(container_height * 0.2))
980
+ container_bottom = container_top + container_height
981
+ self._drag_scroll_counter += 1
982
+ should_scroll = self._drag_scroll_counter % 8 == 0
983
+ if should_scroll:
984
+ if y_root < container_top + scroll_zone_height:
985
+ self._start_index -= 1
986
+ elif y_root > container_bottom - scroll_zone_height:
987
+ self._start_index += 1
988
+ else:
989
+ return
990
+ else:
991
+ return
992
+
993
+ self._clamp_indices()
994
+ self._update_rows()
995
+
996
+ def _move_record(self, record_id: Any, target_index: int | None) -> bool:
997
+ """Move a record in the data source if supported.
998
+
999
+ Args:
1000
+ record_id: Record identifier to move.
1001
+ target_index: Target index to move the record to.
1002
+
1003
+ Returns:
1004
+ True if the record was moved.
1005
+ """
1006
+ if record_id is None or target_index is None:
1007
+ return False
1008
+
1009
+ with self._silence_source():
1010
+ mover = getattr(self._datasource, 'move', None)
1011
+ if callable(mover):
1012
+ return bool(mover(record_id, target_index))
1013
+
1014
+ # Fallback for simple in-memory lists
1015
+ try:
1016
+ total = self._datasource.count
1017
+ all_records = self._datasource.page_slice(0, total)
1018
+ source_index = None
1019
+ for i, record in enumerate(all_records):
1020
+ if record.get('id') == record_id:
1021
+ source_index = i
1022
+ break
1023
+ if source_index is None:
1024
+ return False
1025
+ clamped_target = max(0, min(target_index, len(all_records) - 1))
1026
+ if source_index == clamped_target:
1027
+ return False
1028
+ record = all_records.pop(source_index)
1029
+ if clamped_target > source_index:
1030
+ clamped_target -= 1
1031
+ all_records.insert(clamped_target, record)
1032
+ setter = getattr(self._datasource, 'load', None)
1033
+ if callable(setter):
1034
+ setter(all_records)
1035
+ return True
1036
+ except Exception:
1037
+ return False
1038
+
1039
+ return False
1040
+
1041
+ def _show_drag_indicator(self) -> None:
1042
+ """Create and show the drag drop indicator line."""
1043
+ if self._drag_indicator is None:
1044
+ self._drag_indicator = Frame(self._container, accent=self._accent)
1045
+
1046
+ def _update_drag_indicator_position(self, target_index: int) -> None:
1047
+ """Update the drag indicator to show drop location."""
1048
+ if self._drag_indicator is None:
1049
+ return
1050
+
1051
+ try:
1052
+ visual_index = target_index - self._start_index
1053
+ if 0 <= visual_index < len(self._rows):
1054
+ y_pos = visual_index * max(1, self._row_height)
1055
+ self._drag_indicator.place(
1056
+ x=0,
1057
+ y=y_pos,
1058
+ width=self._container.winfo_width(),
1059
+ height=3,
1060
+ )
1061
+ self._drag_indicator.lift()
1062
+ else:
1063
+ self._drag_indicator.place_forget()
1064
+ except Exception:
1065
+ pass
1066
+
1067
+ def _hide_drag_indicator(self) -> None:
1068
+ """Hide and destroy the drag indicator."""
1069
+ if self._drag_indicator is not None:
1070
+ try:
1071
+ self._drag_indicator.place_forget()
1072
+ self._drag_indicator.destroy()
1073
+ except Exception:
1074
+ pass
1075
+ self._drag_indicator = None
1076
+
1077
+ # Public API
1078
+
1079
+ def reload(self):
1080
+ """Reload data from the datasource and refresh the display.
1081
+
1082
+ Calls the datasource's `reload()` method and updates all visible rows
1083
+ with the refreshed data. Useful when the underlying data has changed
1084
+ externally.
1085
+ """
1086
+ self._datasource.reload()
1087
+ self._update_rows()
1088
+
1089
+ def get_selected(self) -> list[dict]:
1090
+ """Get the currently selected records.
1091
+
1092
+ Returns:
1093
+ List of selected record dictionaries (each with `id` and the
1094
+ record's data). Empty list if no items are selected.
1095
+
1096
+ Examples:
1097
+ >>> selected = listview.get_selected()
1098
+ >>> for record in selected:
1099
+ ... print(record["id"], record.get("title"))
1100
+ """
1101
+ return self._datasource.selected()
1102
+
1103
+ def select_all(self):
1104
+ """Select all items in the list.
1105
+
1106
+ Only works when selection_mode is 'multi'. Generates a
1107
+ <<SelectionChange>> event after completion.
1108
+
1109
+ Note:
1110
+ For large datasets, this may be slow as it loads all records.
1111
+ """
1112
+ if self._selection_mode == 'multi':
1113
+ total = self._datasource.count
1114
+ all_records = self._datasource.page_slice(0, total)
1115
+ with self._silence_source():
1116
+ for record in all_records:
1117
+ record_id = record.get('id')
1118
+ if record_id is not None: # id 0 is valid, don't skip it
1119
+ self._datasource.select(record_id)
1120
+ self._update_rows()
1121
+ self.event_generate('<<SelectionChange>>')
1122
+
1123
+ def clear_selection(self):
1124
+ """Clear all item selections.
1125
+
1126
+ Deselects all items and generates a <<SelectionChange>> event.
1127
+ """
1128
+ with self._silence_source():
1129
+ self._datasource.deselect_all()
1130
+ self._update_rows()
1131
+ self.event_generate('<<SelectionChange>>')
1132
+
1133
+ def select_items(self, ids: list) -> None:
1134
+ """Select items by record `id`.
1135
+
1136
+ In single-selection mode the current selection is replaced; in multi
1137
+ mode the given items are added. No-op when selection is disabled.
1138
+ Generates a <<SelectionChange>> event.
1139
+ """
1140
+ if self._selection_mode == 'none':
1141
+ return
1142
+ with self._silence_source():
1143
+ if self._selection_mode == 'single':
1144
+ self._datasource.deselect_all()
1145
+ for record_id in ids:
1146
+ if record_id is not None: # id 0 is valid, don't skip it
1147
+ self._datasource.select(record_id)
1148
+ self._update_rows()
1149
+ self.event_generate('<<SelectionChange>>')
1150
+
1151
+ def deselect_items(self, ids: list) -> None:
1152
+ """Remove the given items from the selection by record `id`.
1153
+
1154
+ Generates a <<SelectionChange>> event.
1155
+ """
1156
+ with self._silence_source():
1157
+ for record_id in ids:
1158
+ if record_id is not None:
1159
+ self._datasource.deselect(record_id)
1160
+ self._update_rows()
1161
+ self.event_generate('<<SelectionChange>>')
1162
+
1163
+ def scroll_to_top(self):
1164
+ """Scroll to the beginning of the list.
1165
+
1166
+ Instantly scrolls to show the first item in the list.
1167
+ """
1168
+ self._start_index = 0
1169
+ self._update_rows()
1170
+
1171
+ def scroll_to_bottom(self):
1172
+ """Scroll to the end of the list.
1173
+
1174
+ Instantly scrolls to show the last items in the list.
1175
+ """
1176
+ total = self._datasource.count
1177
+ self._start_index = max(0, total - self._visible_rows)
1178
+ self._update_rows()
1179
+
1180
+ def insert_item(self, data: dict):
1181
+ """Insert a new item into the list.
1182
+
1183
+ Args:
1184
+ data: Dictionary containing the item data. An 'id' will be
1185
+ auto-generated if not provided.
1186
+
1187
+ Note:
1188
+ Generates a <<ItemInsert>> event after the item is added.
1189
+ `event.data` is the inserted record dict with `id` populated.
1190
+
1191
+ Examples:
1192
+ >>> listview.insert_item({
1193
+ ... 'title': 'New Item',
1194
+ ... 'text': 'Description'
1195
+ ... })
1196
+ """
1197
+ with self._silence_source():
1198
+ record_id = self._datasource.insert(data)
1199
+ self._update_rows()
1200
+ record = dict(data)
1201
+ record.setdefault('id', record_id)
1202
+ self.event_generate('<<ItemInsert>>', data=record)
1203
+
1204
+ def update_item(self, record_id: Any, data: dict):
1205
+ """Update an existing item's data.
1206
+
1207
+ Args:
1208
+ record_id: The ID of the record to update.
1209
+ data: Dictionary of fields to update. Will be merged with
1210
+ existing record data.
1211
+
1212
+ Note:
1213
+ Generates a <<ItemUpdate>> event if the update succeeds.
1214
+ `event.data` is the patch dict with `id` set to `record_id`.
1215
+
1216
+ Examples:
1217
+ >>> listview.update_item(42, {'title': 'Updated Title'})
1218
+ """
1219
+ with self._silence_source():
1220
+ updated = self._datasource.update(record_id, data)
1221
+ if updated:
1222
+ self._update_rows()
1223
+ record = dict(data)
1224
+ record['id'] = record_id
1225
+ self.event_generate('<<ItemUpdate>>', data=record)
1226
+
1227
+ def delete_item(self, record_id: Any):
1228
+ """Delete an item from the list.
1229
+
1230
+ Args:
1231
+ record_id: The ID of the record to delete.
1232
+
1233
+ Note:
1234
+ Generates a <<ItemDelete>> event after deletion.
1235
+ `event.data = {'id': record_id}`.
1236
+
1237
+ Examples:
1238
+ >>> listview.delete_item(42)
1239
+ """
1240
+ with self._silence_source():
1241
+ self._datasource.delete(record_id)
1242
+ self._update_rows()
1243
+ self.event_generate('<<ItemDelete>>', data={'id': record_id})
1244
+
1245
+ def get_datasource(self) -> DataSourceProtocol:
1246
+ """Get the underlying datasource.
1247
+
1248
+ Returns:
1249
+ The DataSource instance managing the list's data.
1250
+
1251
+ Examples:
1252
+ >>> ds = listview.get_datasource()
1253
+ >>> count = ds.count
1254
+ """
1255
+ return self._datasource
1256
+
1257
+ # Event handler API
1258
+
1259
+ def on_selection_changed(self, callback: Callable) -> str:
1260
+ """Register a callback for `<<SelectionChange>>` events.
1261
+
1262
+ Args:
1263
+ callback: Called when selection changes. `event.data` is None — use
1264
+ `get_selected()` to read the current selection.
1265
+
1266
+ Returns:
1267
+ Bind ID — pass to `off_selection_changed()` to unsubscribe.
1268
+ """
1269
+ return self.bind('<<SelectionChange>>', callback, add='+')
1270
+
1271
+ def off_selection_changed(self, bind_id: str | None = None) -> None:
1272
+ """Unsubscribe from `<<SelectionChange>>`.
1273
+
1274
+ Args:
1275
+ bind_id: ID returned by `on_selection_changed()`. If None, removes all.
1276
+ """
1277
+ self.unbind('<<SelectionChange>>', bind_id)
1278
+
1279
+ def on_item_delete(self, callback: Callable) -> str:
1280
+ """Register a callback for `<<ItemDelete>>` events (fires when an item is removed).
1281
+
1282
+ Args:
1283
+ callback: Receives `event.data` — the deleted record dict with at least `id`.
1284
+
1285
+ Returns:
1286
+ Bind ID — pass to `off_item_delete()` to unsubscribe.
1287
+ """
1288
+ return self.bind('<<ItemDelete>>', callback, add='+')
1289
+
1290
+ def off_item_delete(self, bind_id: str | None = None) -> None:
1291
+ """Unsubscribe from `<<ItemDelete>>`.
1292
+
1293
+ Args:
1294
+ bind_id: ID returned by `on_item_delete()`. If None, removes all.
1295
+ """
1296
+ self.unbind('<<ItemDelete>>', bind_id)
1297
+
1298
+ def on_item_delete_fail(self, callback: Callable) -> str:
1299
+ """Register a callback for `<<ItemDeleteFail>>` events (fires when deletion raises an exception).
1300
+
1301
+ Args:
1302
+ callback: Receives `event.data` — the record dict plus an `error: str` key.
1303
+
1304
+ Returns:
1305
+ Bind ID — pass to `off_item_delete_fail()` to unsubscribe.
1306
+ """
1307
+ return self.bind('<<ItemDeleteFail>>', callback, add='+')
1308
+
1309
+ def off_item_delete_fail(self, bind_id: str | None = None) -> None:
1310
+ """Unsubscribe from `<<ItemDeleteFail>>`.
1311
+
1312
+ Args:
1313
+ bind_id: ID returned by `on_item_delete_fail()`. If None, removes all.
1314
+ """
1315
+ self.unbind('<<ItemDeleteFail>>', bind_id)
1316
+
1317
+ def on_item_insert(self, callback: Callable) -> str:
1318
+ """Register a callback for `<<ItemInsert>>` events (fires when an item is added).
1319
+
1320
+ Args:
1321
+ callback: Receives `event.data` — the inserted record dict with `id` populated.
1322
+
1323
+ Returns:
1324
+ Bind ID — pass to `off_item_insert()` to unsubscribe.
1325
+ """
1326
+ return self.bind('<<ItemInsert>>', callback, add='+')
1327
+
1328
+ def off_item_insert(self, bind_id: str | None = None) -> None:
1329
+ """Unsubscribe from `<<ItemInsert>>`.
1330
+
1331
+ Args:
1332
+ bind_id: ID returned by `on_item_insert()`. If None, removes all.
1333
+ """
1334
+ self.unbind('<<ItemInsert>>', bind_id)
1335
+
1336
+ def on_item_update(self, callback: Callable) -> str:
1337
+ """Register a callback for `<<ItemUpdate>>` events (fires when an item is updated).
1338
+
1339
+ Args:
1340
+ callback: Receives `event.data` — the patch dict with `id` set to the
1341
+ updated record's ID.
1342
+
1343
+ Returns:
1344
+ Bind ID — pass to `off_item_update()` to unsubscribe.
1345
+ """
1346
+ return self.bind('<<ItemUpdate>>', callback, add='+')
1347
+
1348
+ def off_item_update(self, bind_id: str | None = None) -> None:
1349
+ """Unsubscribe from `<<ItemUpdate>>`.
1350
+
1351
+ Args:
1352
+ bind_id: ID returned by `on_item_update()`. If None, removes all.
1353
+ """
1354
+ self.unbind('<<ItemUpdate>>', bind_id)
1355
+
1356
+ def on_item_click(self, callback: Callable) -> str:
1357
+ """Register a callback for `<<ItemClick>>` events (fires when an item is clicked).
1358
+
1359
+ Args:
1360
+ callback: Receives `event.data` — the record dict with `selected`,
1361
+ `focused`, and `item_index` injected.
1362
+
1363
+ Returns:
1364
+ Bind ID — pass to `off_item_click()` to unsubscribe.
1365
+ """
1366
+ return self.bind('<<ItemClick>>', callback, add='+')
1367
+
1368
+ def off_item_click(self, bind_id: str | None = None) -> None:
1369
+ """Unsubscribe from `<<ItemClick>>`.
1370
+
1371
+ Args:
1372
+ bind_id: ID returned by `on_item_click()`. If None, removes all.
1373
+ """
1374
+ self.unbind('<<ItemClick>>', bind_id)
1375
+
1376
+ def on_item_drag_start(self, callback: Callable) -> str:
1377
+ """Register a callback for `<<ItemDragStart>>` events (fires when a drag begins).
1378
+
1379
+ Args:
1380
+ callback: Receives `event.data` — the dragged record dict.
1381
+
1382
+ Returns:
1383
+ Bind ID — pass to `off_item_drag_start()` to unsubscribe.
1384
+ """
1385
+ return self.bind('<<ItemDragStart>>', callback, add='+')
1386
+
1387
+ def off_item_drag_start(self, bind_id: str | None = None) -> None:
1388
+ """Unsubscribe from `<<ItemDragStart>>`.
1389
+
1390
+ Args:
1391
+ bind_id: ID returned by `on_item_drag_start()`. If None, removes all.
1392
+ """
1393
+ self.unbind('<<ItemDragStart>>', bind_id)
1394
+
1395
+ def on_item_drag(self, callback: Callable) -> str:
1396
+ """Register a callback for `<<ItemDrag>>` events (fires during a drag).
1397
+
1398
+ Args:
1399
+ callback: Receives `event.data` — the record dict plus `source_index`,
1400
+ `target_index`, and `y_current`.
1401
+
1402
+ Returns:
1403
+ Bind ID — pass to `off_item_drag()` to unsubscribe.
1404
+ """
1405
+ return self.bind('<<ItemDrag>>', callback, add='+')
1406
+
1407
+ def off_item_drag(self, bind_id: str | None = None) -> None:
1408
+ """Unsubscribe from `<<ItemDrag>>`.
1409
+
1410
+ Args:
1411
+ bind_id: ID returned by `on_item_drag()`. If None, removes all.
1412
+ """
1413
+ self.unbind('<<ItemDrag>>', bind_id)
1414
+
1415
+ def on_item_drag_end(self, callback: Callable) -> str:
1416
+ """Register a callback for `<<ItemDragEnd>>` events (fires when a drag completes).
1417
+
1418
+ Args:
1419
+ callback: Receives `event.data` — the record dict plus `source_index`,
1420
+ `target_index`, `moved`, `y_start`, and `y_end`.
1421
+
1422
+ Returns:
1423
+ Bind ID — pass to `off_item_drag_end()` to unsubscribe.
1424
+ """
1425
+ return self.bind('<<ItemDragEnd>>', callback, add='+')
1426
+
1427
+ def off_item_drag_end(self, bind_id: str | None = None) -> None:
1428
+ """Unsubscribe from `<<ItemDragEnd>>`.
1429
+
1430
+ Args:
1431
+ bind_id: ID returned by `on_item_drag_end()`. If None, removes all.
1432
+ """
1433
+ self.unbind('<<ItemDragEnd>>', bind_id)