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,1026 @@
1
+ from tkinter import Toplevel
2
+
3
+ from typing_extensions import Unpack
4
+
5
+ from typing import Any
6
+
7
+ from bootstack.events import ChangeEvent
8
+ from bootstack.widgets._core.options import (
9
+ cluster_records,
10
+ normalize_options,
11
+ option_display,
12
+ option_is_icon_only,
13
+ record_to_dict,
14
+ )
15
+ from bootstack.widgets._impl.primitives.button import Button
16
+ from bootstack.widgets._impl.primitives.frame import Frame
17
+ from bootstack.widgets._impl.primitives.label import Label
18
+ from bootstack.widgets._impl.primitives.separator import Separator
19
+ from bootstack.widgets._impl.composites.scrollview import ScrollView
20
+ from bootstack.widgets._impl.composites.field import Field, FieldOptions
21
+ from bootstack.widgets._impl.mixins import configure_delegate
22
+ from bootstack.widgets.types import Master, Option
23
+
24
+
25
+ class SelectBox(Field):
26
+ """Dropdown-like field widget built on top of Field.
27
+
28
+ Renders a field with a suffix button that opens a popup list of available
29
+ items. Selecting an item updates the field value and emits `<<Change>>`.
30
+ """
31
+
32
+ _POPUP_MAX_HEIGHT = 200 # px — popup never exceeds this, but shrinks to fit fewer items
33
+
34
+ def __init__(
35
+ self,
36
+ master: Master = None,
37
+ value: Any = None,
38
+ items: list[Option] = None,
39
+ label: str = None,
40
+ message: str = None,
41
+ allow_custom_values: bool = False,
42
+ strict_value: bool = False,
43
+ show_dropdown_button: bool = True,
44
+ dropdown_button_icon: str = None,
45
+ enable_search: bool = False,
46
+ group_by: str = None,
47
+ max_visible_items: int = None,
48
+ **kwargs: Unpack[FieldOptions]
49
+ ):
50
+ """Args:
51
+ master: Parent widget. If None, uses the default root window.
52
+ value: Initial selected value (value-space). Should match one of the
53
+ options unless `allow_custom_values` is set.
54
+ items: Options to present in the popup — each a string,
55
+ `(text, value)` tuple, or `{'text', 'value'}` dict. The popup
56
+ and field display each option's text; the selected value is
57
+ emitted in value-space. A dict option may also carry `'icon'`
58
+ (rendered beside the row label) and `'disabled'` (when `True` the
59
+ row is dimmed and cannot be chosen).
60
+ label: Optional label text shown above the field.
61
+ message: Optional helper/error message shown below the field.
62
+ allow_custom_values: If True, the entry is editable so users can type
63
+ arbitrary values in addition to choosing from the list.
64
+ show_dropdown_button: If True (default), the dropdown button is shown. This option is
65
+ ignored if custom values are allowed.
66
+ dropdown_button_icon: The icon to display on the dropdown button.
67
+ enable_search: If True, allows typing in the entry to filter the popup list.
68
+ When combined with allow_custom_values=False, the first filtered item is selected
69
+ when the popup closes. With allow_custom_values=True, any typed value is kept.
70
+ group_by: Name of an option field to cluster the popup rows under
71
+ non-selectable group headers (e.g. `'category'`). The field is
72
+ read from each option's flat record, so it may be any carried bag
73
+ key (or `'text'`/`'value'`). Groups appear in first-appearance
74
+ order; options missing the field render headerless. Grouping is
75
+ presentational only — `value`/`selection` are unaffected. None
76
+ (default) renders a flat list.
77
+ max_visible_items: Approximate number of option rows the popup shows
78
+ before it scrolls (height is `max_visible_items * row_height`).
79
+ Group headers and separators consume some of that budget, so the
80
+ count is approximate. None (default) uses the built-in cap.
81
+
82
+ Other Parameters:
83
+ allow_blank: If True, empty input is allowed.
84
+ accent: Accent token for styling the focus ring and active border.
85
+ value_format: ICU format pattern for parsing/formatting.
86
+ font: Font for text display.
87
+ foreground: Text color.
88
+ initial_focus: If True, widget receives focus when created.
89
+ justify: Text justification ('left', 'center', 'right').
90
+ show_message: If True, displays message text below the field.
91
+ padding: Padding around the entry widget.
92
+ state: The widget state ('normal', 'disabled', 'readonly').
93
+ textvariable: Tkinter Variable to link with the entry text.
94
+ See [tkinter Variables](https://docs.python.org/3/library/tkinter.html#tkinter-variables).
95
+ textsignal: Signal object for reactive text updates.
96
+ width: Width of the entry in characters.
97
+ required: If True, field cannot be empty.
98
+ """
99
+ # Localization mode for option display (entry face + popup rows). None
100
+ # defers to the app's localize_mode; False keeps the raw labels.
101
+ self._localize = kwargs.pop('localize', None)
102
+ # Normalize options once. The maps only decouple an option's *display
103
+ # text* from its *value*; the entry keeps owning the raw value (so
104
+ # `value_format` parsing, e.g. TimeField's time objects, is preserved).
105
+ # When localization is active the display text is the translated label,
106
+ # so a plain option is treated like a decoupled one (display != value).
107
+ self._records = normalize_options(items)
108
+ self._allow_custom_values = allow_custom_values
109
+ self._strict_value = strict_value
110
+ self._rebuild_option_maps()
111
+
112
+ # Forward an explicit localize mode to Field so the label/message follow
113
+ # it too; when unset, Field keeps its own 'auto' default.
114
+ if self._localize is not None:
115
+ kwargs['localize'] = self._localize
116
+
117
+ # Seed the entry with the display form of the initial value.
118
+ super().__init__(master, value=self._resolve_display(value), label=label, message=message, **kwargs)
119
+
120
+ # Re-translate the displayed text + option maps on a live locale switch.
121
+ self.winfo_toplevel().bind('<<LocaleChanged>>', self._on_locale_changed_refresh, add='+')
122
+
123
+ self._search_enabled = enable_search
124
+ self._group_by = group_by
125
+ self._max_visible_items = max_visible_items
126
+ self._popup_open = False
127
+ self._popup_state = None
128
+ self._dropdown_button_icon = dropdown_button_icon or 'chevron-down'
129
+ self._popup_frame = None
130
+ self._popup_inner = None
131
+ self._item_labels = []
132
+ self._group_headers = []
133
+
134
+ # Add dropdown button if needed
135
+ if allow_custom_values or show_dropdown_button:
136
+ self.insert_addon(
137
+ Button,
138
+ position="after",
139
+ name="dropdown",
140
+ icon=self._dropdown_button_icon,
141
+ icon_only=True,
142
+ command=self._on_dropdown_click
143
+ )
144
+
145
+ # Configure entry state based on search and custom value settings
146
+ if allow_custom_values or enable_search:
147
+ self.entry_widget.state(['!readonly'])
148
+ else:
149
+ # Set entry to readonly but keep dropdown button enabled
150
+ self.entry_widget.state(['readonly'])
151
+ self.after_idle(self._bind_readonly_selection_on_click)
152
+
153
+ # ----- Option normalization + value<->text mapping -----
154
+
155
+ def _effective_localize(self, item_mode: Any = None) -> Any:
156
+ """Resolve the active localize mode, deferring to the app when unset.
157
+
158
+ A per-option `item_mode` (its `localize` bag key) wins; then the
159
+ widget-level mode; then the app's `localize_mode`.
160
+ """
161
+ if item_mode is not None:
162
+ return item_mode
163
+ if self._localize is not None:
164
+ return self._localize
165
+ try:
166
+ from bootstack._runtime.app import get_app_settings
167
+ return get_app_settings().localize_mode
168
+ except Exception:
169
+ return 'auto'
170
+
171
+ def _display_text(self, raw: str, item_mode: Any = None) -> str:
172
+ """Map a raw option label to its display text under the active mode.
173
+
174
+ Returns the label unchanged when localization is off or no translation
175
+ is registered; otherwise the catalog translation.
176
+ """
177
+ if not raw:
178
+ return raw
179
+ if self._effective_localize(item_mode) is False:
180
+ return raw
181
+ try:
182
+ from bootstack.i18n import MessageCatalog
183
+ return MessageCatalog.translate(raw) or raw
184
+ except Exception:
185
+ return raw
186
+
187
+ def _record_display(self, rec) -> str:
188
+ """The display text for one option record (honoring its localize key)."""
189
+ return self._display_text(rec.text, rec.extras.get('localize'))
190
+
191
+ def _rebuild_option_maps(self) -> None:
192
+ """Rebuild displaytext<->value lookups from the current records.
193
+
194
+ Maps are keyed on each option's *display* text (the translated label
195
+ when localization is active), so the entry's shown text round-trips to
196
+ the option value. Duplicate display texts keep the first value;
197
+ duplicate values keep the first text. Popup selection resolves by the
198
+ chosen row's record, so duplicate texts with distinct values still
199
+ select correctly from the list — only the `value` setter (which keys on
200
+ value) is affected by duplicates.
201
+ """
202
+ self._value_by_text: dict[str, Any] = {}
203
+ self._text_by_value: dict[Any, str] = {}
204
+ for rec in self._records:
205
+ display = self._record_display(rec)
206
+ self._value_by_text.setdefault(display, rec.value)
207
+ try:
208
+ self._text_by_value.setdefault(rec.value, display)
209
+ except TypeError:
210
+ pass # unhashable value — no reverse map; popup selection still works
211
+
212
+ def _on_locale_changed_refresh(self, _event=None) -> None:
213
+ """Re-translate the displayed label on a live locale change.
214
+
215
+ Maps the currently-shown text back to its option value under the OLD
216
+ maps, rebuilds the maps with the new translations, then re-displays that
217
+ option's new label — only when it actually changed. A custom/typed value
218
+ (one not backed by an option, e.g. a `TimeEntry` interval) is left
219
+ untouched, so nothing is reformatted or re-emitted for it.
220
+ """
221
+ current_text = self.entry_widget.get()
222
+ value = self._value_by_text.get(current_text) if current_text else None
223
+ self._rebuild_option_maps()
224
+ if value is None:
225
+ return
226
+ new_display = self._text_by_value.get(value, current_text)
227
+ if new_display != current_text:
228
+ self._set_display_text_silently(new_display)
229
+
230
+ def _set_display_text_silently(self, text: str) -> None:
231
+ """Replace the entry's displayed text without emitting `<<Change>>`."""
232
+ is_readonly = self.entry_widget.instate(['readonly'])
233
+ if is_readonly:
234
+ self.entry_widget.state(['!readonly'])
235
+ # Field's value setter is programmatic (no emit); display-only here.
236
+ Field.value.fset(self, text)
237
+ if is_readonly:
238
+ self.entry_widget.state(['readonly'])
239
+
240
+ def _resolve_display(self, value: Any) -> Any:
241
+ """Map a value-space value to what the entry should hold.
242
+
243
+ The entry owns the raw value <-> display text mapping (via
244
+ `value_format`); the option map only kicks in to decouple an option's
245
+ display text from its value:
246
+
247
+ - None/empty -> the value itself (the entry clears).
248
+ - A decoupled option (its text differs from its value) -> the option's
249
+ display text, which the entry shows verbatim.
250
+ - A known plain option, a custom value, or a typed value (e.g. a
251
+ `datetime.time`) -> the value itself, so the entry's `value_format`
252
+ parses/formats it.
253
+ - An unknown value, when `strict_value` is set and custom values are
254
+ off -> raises `ValueError`.
255
+ """
256
+ if value is None or value == "":
257
+ return value
258
+ try:
259
+ if value in self._text_by_value:
260
+ text = self._text_by_value[value]
261
+ return text if text != value else value
262
+ except TypeError:
263
+ pass
264
+ if self._strict_value and not self._allow_custom_values:
265
+ raise ValueError(f"{value!r} is not one of the options")
266
+ return value
267
+
268
+ def _on_dropdown_click(self):
269
+ """Handle dropdown button click by focusing entry then showing popup."""
270
+ self.entry_widget.focus_set()
271
+ self._show_selection_options()
272
+
273
+ def _bind_readonly_selection_on_click(self):
274
+ """Bind entry click to show popup when in readonly mode."""
275
+ self.entry_widget.configure(cursor="hand2")
276
+ self.entry_widget.bind('<Button-1>', lambda _: self.after_idle(self._show_selection_options), add='+')
277
+
278
+ def _show_selection_options(self):
279
+ """Create and display the popup list of selectable items."""
280
+ if not self._records or self._popup_open:
281
+ return
282
+ if self.entry_widget.instate(['disabled']):
283
+ return
284
+
285
+ self._popup_open = True
286
+ self.update_idletasks()
287
+
288
+ # Create popup toplevel
289
+ toplevel = self._create_popup_toplevel()
290
+
291
+ # Create tracking variables for popup state
292
+ popup_state = {
293
+ 'item_was_selected': False,
294
+ 'popup_closed': False,
295
+ 'first_filtered_item': None,
296
+ 'entry_focus_handler': None,
297
+ 'key_bindings': [],
298
+ # Set once the rows exist — grouping can reorder them, so the
299
+ # initial highlight is computed against render order (see below).
300
+ 'highlighted_index': 0,
301
+ }
302
+
303
+ # Setup close handler
304
+ def close_popup(event=None):
305
+ self._close_popup(toplevel, popup_state)
306
+
307
+ self._popup_state = popup_state
308
+
309
+ # Create popup content frame with items
310
+ self._popup_frame = self._create_popup_frame(toplevel, popup_state)
311
+ popup_state['highlighted_index'] = self._initial_highlight_index()
312
+
313
+ # Shrink popup to fit content (still withdrawn — resize is invisible)
314
+ self._fit_popup_height(toplevel)
315
+
316
+ # Setup event bindings based on mode
317
+ self._setup_popup_bindings(toplevel, popup_state, close_popup)
318
+
319
+ # Show popup and set focus
320
+ toplevel.deiconify()
321
+ toplevel.lift()
322
+
323
+ # Scroll to the selected item. Two after_idle calls are needed: the first
324
+ # lets Tkinter process the deiconify/pack events; the second runs once the
325
+ # canvas has finished computing button positions so winfo_y() is accurate.
326
+ idx = popup_state['highlighted_index']
327
+ self.after_idle(lambda: self.after_idle(
328
+ lambda: self._update_highlight(popup_state, idx)
329
+ ))
330
+
331
+ if self._search_enabled:
332
+ self.entry_widget.focus_force()
333
+ self.entry_widget.icursor('end')
334
+ else:
335
+ toplevel.focus_force()
336
+
337
+ def _compute_popup_position(self, height: int) -> tuple[int, int, int]:
338
+ """Return (x, y, width) for a popup of the given height.
339
+
340
+ Flips above the entry when there isn't enough room below — matches
341
+ Tk's combobox PlacePopdown behavior.
342
+ """
343
+ x = self.winfo_rootx() + (1 if self._search_enabled else 3)
344
+ gap_below = 8 if self._search_enabled else 5
345
+ gap_above = 4 if self._search_enabled else 1
346
+ entry_top = self.entry_widget.winfo_rooty()
347
+ entry_bottom = entry_top + self.entry_widget.winfo_height()
348
+ # Clamp to a sane minimum so opening the popup before the field has been
349
+ # laid out (winfo_width() == 1) can't produce a negative geometry.
350
+ width = max(self.winfo_width() - (2 if self._search_enabled else 6), 1)
351
+ screen_h = self.winfo_screenheight()
352
+ if entry_bottom + gap_below + height > screen_h and entry_top - gap_above - height >= 0:
353
+ y = entry_top - gap_above - height
354
+ else:
355
+ y = entry_bottom + gap_below
356
+ return x, y, width
357
+
358
+ def _popup_max_height(self, item_h: int = None) -> int:
359
+ """Maximum popup height in px before it scrolls.
360
+
361
+ `max_visible_items` (when set) caps the popup at roughly that many option
362
+ rows — `n * row_height` plus a little chrome overhead. Before any row
363
+ exists a nominal row height is assumed; `_fit_popup_height` recomputes
364
+ with the measured height. Falls back to the built-in `_POPUP_MAX_HEIGHT`.
365
+ """
366
+ if self._max_visible_items:
367
+ return self._max_visible_items * (item_h or 30) + 8
368
+ return self._POPUP_MAX_HEIGHT
369
+
370
+ def _create_popup_toplevel(self):
371
+ """Create the popup toplevel window (withdrawn, provisional max-height size)."""
372
+ max_h = self._popup_max_height()
373
+ x, y, width = self._compute_popup_position(max_h)
374
+
375
+ # The base_window.py warning about overrideredirect on Aqua applies
376
+ # to full app windows that combine it with grab/transient semantics;
377
+ # this popup uses neither (it dismisses via a root <Button-1>
378
+ # binding), so overrideredirect is safe here and matches Tk's own
379
+ # ttk::combobox::PlacePopdown approach for Mac popdowns.
380
+ toplevel = Toplevel(self)
381
+ toplevel.withdraw()
382
+ toplevel.overrideredirect(True)
383
+ toplevel.attributes('-topmost', True)
384
+ toplevel.minsize(width, 0)
385
+ toplevel.maxsize(width * 2, max_h)
386
+ toplevel.geometry(f"{width}x{max_h}+{x}+{y}")
387
+ return toplevel
388
+
389
+ def _fit_popup_height(self, toplevel):
390
+ """Shrink the popup to fit its content, up to the max popup height.
391
+
392
+ Called after the popup frame is built but before deiconify, so the
393
+ window is still withdrawn and the resize is invisible.
394
+ """
395
+ toplevel.update_idletasks()
396
+
397
+ item_h = self._item_labels[0].winfo_reqheight() if self._item_labels else 32
398
+ # Measure the packed content directly so group headers (which are not in
399
+ # _item_labels) are included; fall back to a per-row estimate.
400
+ if self._popup_inner is not None and self._popup_inner.winfo_exists():
401
+ self._popup_inner.update_idletasks()
402
+ content_h = self._popup_inner.winfo_reqheight() + 8
403
+ else:
404
+ content_h = len(self._item_labels) * item_h + 8
405
+ # 8px overhead: outer_frame border (2px each side) + padding (3px each side)
406
+ cap = self._popup_max_height(item_h)
407
+ actual_h = max(min(content_h, cap), item_h)
408
+
409
+ x, y, width = self._compute_popup_position(actual_h)
410
+ toplevel.maxsize(width * 2, max(cap, actual_h))
411
+ toplevel.geometry(f"{width}x{actual_h}+{x}+{y}")
412
+
413
+ def _create_popup_frame(self, toplevel, popup_state):
414
+ """Create popup frame with scrollable item list."""
415
+ # Outer frame with border and padding — match the entry's input
416
+ # surface so the popup fill blends with the field background.
417
+ outer_frame = Frame(toplevel, padding=3, show_border=True, surface='content')
418
+ outer_frame.pack(fill='both', expand=True)
419
+
420
+ # Create scrollview inside the outer frame
421
+ scrollview = ScrollView(
422
+ outer_frame,
423
+ scroll_direction='vertical',
424
+ scrollbar_visibility='always',
425
+ scrollbar_variant='thin',
426
+ surface='content',
427
+ )
428
+ scrollview.pack(fill='both', expand=True)
429
+
430
+ # Create inner frame for items
431
+ inner_frame = Frame(scrollview)
432
+ scrollview.add(inner_frame)
433
+ self._popup_inner = inner_frame
434
+
435
+ # Make inner frame fill the canvas width
436
+ def on_canvas_configure(event):
437
+ scrollview.canvas.itemconfig(scrollview._window_id, width=event.width - 2)
438
+ scrollview.canvas.bind('<Configure>', on_canvas_configure, add='+')
439
+
440
+ # Expand scrollregion to include vertical padding so content doesn't clip borders
441
+ def on_inner_frame_configure(event):
442
+ bbox = scrollview.canvas.bbox('all')
443
+ if bbox:
444
+ x0, y0, x1, y1 = bbox
445
+ padding_y = 1
446
+ scrollview.canvas.configure(scrollregion=(x0, y0 - padding_y, x1, y1 + padding_y))
447
+ inner_frame.bind('<Configure>', on_inner_frame_configure, add='+')
448
+
449
+ self._item_labels = []
450
+ self._group_headers = []
451
+ self._popup_rows = []
452
+ current_text = self.entry_widget.get()
453
+
454
+ # Get accent from Field's _accent attribute, fallback to primary if None
455
+ accent = getattr(self, '_accent', None) or 'primary'
456
+
457
+ def pack_row(row, **kw):
458
+ # Pack a popup row and remember its pack options, so search re-packs
459
+ # (which forget then re-pack) preserve per-row spacing.
460
+ kw.setdefault('fill', 'x')
461
+ row._pack_kw = kw
462
+ row.pack(**kw)
463
+ self._popup_rows.append(row)
464
+
465
+ # Cluster rows by the grouping field (a no-op single bucket when
466
+ # group_by is None). A named group gets a bold header, preceded by a
467
+ # separator except at the very top. Option buttons keep their
468
+ # render-order slot in _item_labels so the existing nav/search/highlight
469
+ # indexing is unchanged. Headers and separators are non-selectable.
470
+ i = 0
471
+ for group_label, recs in cluster_records(self._records, self._group_by):
472
+ header_widgets = []
473
+ if group_label is not None:
474
+ if self._popup_rows: # not the first row — divide from above
475
+ sep = Separator(inner_frame, orient='horizontal')
476
+ sep._is_separator = True
477
+ pack_row(sep, pady=(5, 2))
478
+ header_widgets.append(sep)
479
+ header = self._create_group_header(inner_frame, group_label)
480
+ pack_row(header)
481
+ header_widgets.append(header)
482
+ group_buttons = []
483
+
484
+ for rec in recs:
485
+ icon, disabled = option_display(rec)
486
+ # Translate the row label once (honoring a per-option localize
487
+ # key); pass localize=False so the Button shows it verbatim and
488
+ # the entry/search/value all agree on the same display text.
489
+ display = self._record_display(rec)
490
+ btn_kwargs = {}
491
+ if icon is not None:
492
+ btn_kwargs['icon'] = icon
493
+ if option_is_icon_only(rec):
494
+ btn_kwargs['icon_only'] = True
495
+ btn = Button(
496
+ inner_frame,
497
+ text=display,
498
+ localize=False,
499
+ accent=accent,
500
+ variant='selectbox_item',
501
+ takefocus=False,
502
+ command=lambda v=rec.value: self._on_item_click(v, toplevel, popup_state),
503
+ **btn_kwargs,
504
+ )
505
+ pack_row(btn)
506
+
507
+ # Store the option's value (for selection) and display text (for
508
+ # filtering — search matches what the user sees).
509
+ btn._item_value = rec.value
510
+ btn._item_text = display
511
+ btn._item_index = i
512
+ btn._item_disabled = disabled
513
+ # When highlighted, scroll to reveal the group's header/separator
514
+ # above the button (not just the button) so the section heading
515
+ # stays on screen.
516
+ btn._reveal_top = header_widgets[0] if header_widgets else btn
517
+ i += 1
518
+
519
+ # A disabled option is dimmed and cannot be chosen — clicking it
520
+ # does nothing (ttk blocks the command) and it is skipped by the
521
+ # keyboard navigation and search auto-select below.
522
+ if disabled:
523
+ btn.state(['disabled'])
524
+ # Apply selected state if this row matches the displayed text
525
+ if display == current_text:
526
+ btn.state(['selected'])
527
+
528
+ self._item_labels.append(btn)
529
+ group_buttons.append(btn)
530
+
531
+ if header_widgets:
532
+ self._group_headers.append((header_widgets, group_buttons))
533
+
534
+ return scrollview
535
+
536
+ def _repack_popup_rows(self, is_visible):
537
+ """Re-pack popup rows in canonical render order, hiding filtered ones.
538
+
539
+ Forgets every row then re-packs the visible ones in their original
540
+ order, so a row that reappears after being filtered out lands back in
541
+ its correct slot (rather than at the end, which a bare `pack()` of a
542
+ previously-forgotten widget would do). The forget/pack pass is coalesced
543
+ at idle, so there is no visible flicker.
544
+
545
+ Args:
546
+ is_visible: Predicate called with each row widget; truthy keeps it.
547
+ """
548
+ for row in self._popup_rows:
549
+ row.pack_forget()
550
+ seen_content = False
551
+ for row in self._popup_rows:
552
+ if not is_visible(row):
553
+ continue
554
+ is_sep = getattr(row, '_is_separator', False)
555
+ if is_sep and not seen_content:
556
+ continue # a divider with nothing visible above it — drop it
557
+ row.pack(**getattr(row, '_pack_kw', {'fill': 'x'}))
558
+ seen_content = seen_content or not is_sep
559
+
560
+ def _create_group_header(self, parent, text):
561
+ """Create a non-selectable group-header label for the popup.
562
+
563
+ Rendered in the option rows' font, bold, and verbatim — the group value
564
+ is never transformed, so `selection`/`group_by` keep the original text.
565
+ """
566
+ return Label(parent, text=str(text), font='body[bold]', anchor='w', padding=(8, 4, 4, 3))
567
+
568
+ def _initial_highlight_index(self) -> int:
569
+ """Render-order index of the row to highlight when the popup opens.
570
+
571
+ The currently-selected option when it is present and enabled, otherwise
572
+ the first enabled option. Computed against `_item_labels` (render order)
573
+ so it stays correct when grouping reorders rows.
574
+ """
575
+ selected = self.value
576
+ first_enabled = None
577
+ for idx, btn in enumerate(self._item_labels):
578
+ if getattr(btn, '_item_disabled', False):
579
+ continue
580
+ if first_enabled is None:
581
+ first_enabled = idx
582
+ if btn._item_value == selected:
583
+ return idx
584
+ return first_enabled if first_enabled is not None else 0
585
+
586
+ def _on_item_click(self, value, toplevel, popup_state):
587
+ """Handle click on item."""
588
+ popup_state['item_was_selected'] = True
589
+ self._set_selected_value(value, toplevel, popup_state)
590
+
591
+ def _first_enabled_index(self) -> int:
592
+ """Index of the first non-disabled option, or 0 if all are disabled."""
593
+ return next(
594
+ (i for i, rec in enumerate(self._records) if not option_display(rec)[1]),
595
+ 0,
596
+ )
597
+
598
+ def _step_highlight(self, popup_state, step):
599
+ """Move the highlight by `step`, skipping disabled rows.
600
+
601
+ Stops at the first non-disabled visible row in the given direction;
602
+ keeps the current highlight when the edge is reached.
603
+ """
604
+ visible_buttons = [btn for btn in self._item_labels if btn.winfo_manager()]
605
+ if not visible_buttons:
606
+ return
607
+ idx = popup_state['highlighted_index']
608
+ n = len(visible_buttons)
609
+ while True:
610
+ idx += step
611
+ if idx < 0 or idx >= n:
612
+ return # reached the edge — keep the current highlight
613
+ if not getattr(visible_buttons[idx], '_item_disabled', False):
614
+ self._update_highlight(popup_state, idx)
615
+ return
616
+
617
+ def _update_highlight(self, popup_state, new_index):
618
+ """Update the highlighted item in the popup."""
619
+ visible_buttons = [btn for btn in self._item_labels if btn.winfo_manager()]
620
+ if not visible_buttons:
621
+ return
622
+
623
+ # Clamp index to valid range
624
+ new_index = max(0, min(new_index, len(visible_buttons) - 1))
625
+ old_index = popup_state['highlighted_index']
626
+
627
+ # Remove highlight from old button
628
+ if 0 <= old_index < len(visible_buttons):
629
+ visible_buttons[old_index].state(['!selected'])
630
+
631
+ # Add highlight to new button
632
+ visible_buttons[new_index].state(['selected'])
633
+ popup_state['highlighted_index'] = new_index
634
+
635
+ # Scroll to make highlighted item visible
636
+ btn = visible_buttons[new_index]
637
+ if self._popup_frame and self._popup_frame.winfo_exists():
638
+ self._popup_frame.canvas.update_idletasks()
639
+ # Get button position relative to canvas
640
+ btn_y = btn.winfo_y()
641
+ btn_height = btn.winfo_height()
642
+ canvas_height = self._popup_frame.canvas.winfo_height()
643
+
644
+ # Scroll if needed
645
+ scroll_top = self._popup_frame.canvas.canvasy(0)
646
+ scroll_bottom = scroll_top + canvas_height
647
+
648
+ # When scrolling up, reveal the group's header/separator above the
649
+ # button (not just the button) so the section heading stays visible.
650
+ reveal = getattr(btn, '_reveal_top', None) or btn
651
+ reveal_y = reveal.winfo_y()
652
+
653
+ if reveal_y < scroll_top:
654
+ self._popup_frame.canvas.yview_moveto(reveal_y / self._popup_frame.canvas.bbox('all')[3])
655
+ elif btn_y + btn_height > scroll_bottom:
656
+ target = (btn_y + btn_height - canvas_height) / self._popup_frame.canvas.bbox('all')[3]
657
+ self._popup_frame.canvas.yview_moveto(target)
658
+
659
+ def _setup_popup_bindings(self, toplevel, popup_state, close_popup):
660
+ """Setup all event bindings for the popup."""
661
+ # Escape always closes
662
+ toplevel.bind("<Escape>", close_popup)
663
+
664
+ # Arrow key navigation (skips disabled rows)
665
+ def on_arrow_down(event):
666
+ self._step_highlight(popup_state, 1)
667
+ return 'break'
668
+
669
+ def on_arrow_up(event):
670
+ self._step_highlight(popup_state, -1)
671
+ return 'break'
672
+
673
+ def on_enter(event):
674
+ visible_buttons = [btn for btn in self._item_labels if btn.winfo_manager()]
675
+ idx = popup_state['highlighted_index']
676
+ if 0 <= idx < len(visible_buttons):
677
+ btn = visible_buttons[idx]
678
+ if getattr(btn, '_item_disabled', False):
679
+ return 'break' # a disabled row can't be chosen
680
+ popup_state['item_was_selected'] = True
681
+ self._set_selected_value(btn._item_value, toplevel, popup_state)
682
+ return 'break'
683
+
684
+ toplevel.bind("<Down>", on_arrow_down)
685
+ toplevel.bind("<Up>", on_arrow_up)
686
+ toplevel.bind("<Return>", on_enter)
687
+
688
+ # Also bind to entry widget for search mode
689
+ if self._search_enabled:
690
+ self._setup_search_bindings(toplevel, popup_state, close_popup)
691
+ entry_down = self.entry_widget.bind('<Down>', on_arrow_down, add='+')
692
+ entry_up = self.entry_widget.bind('<Up>', on_arrow_up, add='+')
693
+ entry_enter = self.entry_widget.bind('<Return>', on_enter, add='+')
694
+ popup_state['key_bindings'].append(('<Down>', entry_down))
695
+ popup_state['key_bindings'].append(('<Up>', entry_up))
696
+ popup_state['key_bindings'].append(('<Return>', entry_enter))
697
+ else:
698
+ toplevel.bind("<FocusOut>", close_popup)
699
+
700
+ # Initial highlight state is applied after deiconify (see _show_selection_options)
701
+
702
+ def _apply_search_filter(self, popup_state):
703
+ """Filter the popup rows to those matching the entry text.
704
+
705
+ Shows option buttons whose display text contains the (case-insensitive)
706
+ search text; a group's header and separator show only while the group
707
+ has a visible option. The first enabled match drives the auto-select
708
+ target and the reset highlight. A no-op when the text is unchanged or the
709
+ popup is gone.
710
+ """
711
+ if popup_state['popup_closed'] or not self._popup_frame or not self._popup_frame.winfo_exists():
712
+ return
713
+
714
+ search_text = self.entry_widget.get().lower()
715
+
716
+ # Skip if search text hasn't changed (e.g., arrow key release)
717
+ if search_text == popup_state['last_search_text']:
718
+ return
719
+ popup_state['last_search_text'] = search_text
720
+
721
+ # Decide which option buttons match — on the visible TEXT. The first
722
+ # ENABLED match drives auto-select + the reset highlight.
723
+ match = {}
724
+ first_visible = None
725
+ for btn in self._item_labels:
726
+ ok = search_text in btn._item_text.lower()
727
+ match[btn] = ok
728
+ if ok and first_visible is None and not getattr(btn, '_item_disabled', False):
729
+ first_visible = btn
730
+
731
+ # A group's header + separator show only while it has a visible option.
732
+ header_visible = {}
733
+ for header_widgets, group_buttons in self._group_headers:
734
+ vis = any(match.get(b, False) for b in group_buttons)
735
+ for w in header_widgets:
736
+ header_visible[w] = vis
737
+ self._repack_popup_rows(
738
+ lambda row: match.get(row, header_visible.get(row, True))
739
+ )
740
+
741
+ # Track first filtered item for auto-select (value-space)
742
+ popup_state['first_filtered_item'] = first_visible._item_value if first_visible else None
743
+
744
+ # Reset highlight to the first enabled visible item
745
+ visible_buttons = [b for b in self._item_labels if b.winfo_manager()]
746
+ target = next(
747
+ (i for i, b in enumerate(visible_buttons)
748
+ if not getattr(b, '_item_disabled', False)),
749
+ 0,
750
+ )
751
+ self._update_highlight(popup_state, target)
752
+
753
+ def _setup_search_bindings(self, toplevel, popup_state, close_popup):
754
+ """Setup search-specific event bindings."""
755
+ # Initialize first filtered item (stored in value-space); skip disabled
756
+ # so an auto-select on close never lands on a non-selectable option.
757
+ enabled = [rec for rec in self._records if not option_display(rec)[1]]
758
+ if enabled:
759
+ popup_state['first_filtered_item'] = enabled[0].value
760
+ popup_state['last_search_text'] = self.entry_widget.get().lower()
761
+
762
+ # Bind KeyRelease for filtering (delegates to the testable method)
763
+ keyrelease_binding = self.entry_widget.bind(
764
+ '<KeyRelease>', lambda e: self._apply_search_filter(popup_state), add='+'
765
+ )
766
+ popup_state['key_bindings'].append(('<KeyRelease>', keyrelease_binding))
767
+
768
+ # Bind Tab to select highlighted item
769
+ def on_tab(event):
770
+ if popup_state['popup_closed']:
771
+ return
772
+ visible_buttons = [btn for btn in self._item_labels if btn.winfo_manager()]
773
+ idx = popup_state['highlighted_index']
774
+ if 0 <= idx < len(visible_buttons):
775
+ btn = visible_buttons[idx]
776
+ if getattr(btn, '_item_disabled', False):
777
+ return 'break' # a disabled row can't be chosen
778
+ popup_state['item_was_selected'] = True
779
+ self._set_selected_value(btn._item_value, toplevel, popup_state)
780
+ return 'break'
781
+
782
+ tab_binding = self.entry_widget.bind('<Tab>', on_tab)
783
+ popup_state['key_bindings'].append(('<Tab>', tab_binding))
784
+
785
+ # Setup click-outside detection
786
+ def on_root_click(event):
787
+ x, y = event.x_root, event.y_root
788
+
789
+ # Check if click is inside entry widget
790
+ ex, ey = self.entry_widget.winfo_rootx(), self.entry_widget.winfo_rooty()
791
+ ew, eh = self.entry_widget.winfo_width(), self.entry_widget.winfo_height()
792
+ if ex <= x <= ex + ew and ey <= y <= ey + eh:
793
+ return
794
+
795
+ # Check if click is inside toplevel
796
+ if toplevel.winfo_exists():
797
+ tx, ty = toplevel.winfo_rootx(), toplevel.winfo_rooty()
798
+ tw, th = toplevel.winfo_width(), toplevel.winfo_height()
799
+ if tx <= x <= tx + tw and ty <= y <= ty + th:
800
+ return
801
+
802
+ close_popup()
803
+
804
+ def bind_click():
805
+ if popup_state['popup_closed']:
806
+ return
807
+ root = self.winfo_toplevel()
808
+ bind_id = root.bind('<Button-1>', on_root_click, add='+')
809
+ popup_state['entry_focus_handler'] = bind_id
810
+
811
+ self.after(100, bind_click)
812
+
813
+ def _close_popup(self, toplevel, popup_state):
814
+ """Close the popup and cleanup bindings."""
815
+ if popup_state['popup_closed']:
816
+ return
817
+
818
+ popup_state['popup_closed'] = True
819
+ self._popup_open = False
820
+
821
+ # Unbind handlers
822
+ if popup_state['entry_focus_handler'] is not None and self._search_enabled:
823
+ root = self.winfo_toplevel()
824
+ root.unbind('<Button-1>', popup_state['entry_focus_handler'])
825
+
826
+ # Unbind key bindings
827
+ for sequence, funcid in popup_state['key_bindings']:
828
+ self.entry_widget.unbind(sequence, funcid)
829
+
830
+ # Destroy toplevel
831
+ if toplevel.winfo_exists():
832
+ toplevel.destroy()
833
+
834
+ # Clean up popup references
835
+ self._popup_frame = None
836
+ self._popup_inner = None
837
+ self._item_labels = []
838
+ self._group_headers = []
839
+ self._popup_rows = []
840
+ self._popup_state = None
841
+
842
+ # Handle value selection for search mode without custom values
843
+ if self._search_enabled and not self._allow_custom_values:
844
+ if not popup_state['item_was_selected']:
845
+ if popup_state['first_filtered_item'] is not None:
846
+ self._last_selected_value = popup_state['first_filtered_item']
847
+ self.value = popup_state['first_filtered_item']
848
+
849
+ def _set_selected_value(self, selected_value, toplevel, popup_state):
850
+ """Set the selected value and close the popup."""
851
+ if selected_value is None:
852
+ return
853
+
854
+ self._last_selected_value = selected_value
855
+ self.value = selected_value
856
+ self._close_popup(toplevel, popup_state)
857
+
858
+ @configure_delegate('items')
859
+ def _delegate_items(self, value: list[Option] = None):
860
+ """Get the normalized option records, or set new options."""
861
+ if value is None:
862
+ return list(self._records)
863
+ else:
864
+ self._records = normalize_options(value)
865
+ self._rebuild_option_maps()
866
+ # Reconcile the displayed selection: if its text is no longer an
867
+ # option (and custom values aren't allowed), clear it.
868
+ if self.entry_widget.get() not in self._value_by_text and not self._allow_custom_values:
869
+ self.value = None
870
+ return None
871
+
872
+ @configure_delegate('allow_custom_values')
873
+ def _delegate_allow_custom_values(self, value: bool = None):
874
+ """Get or set whether free-form text entry is allowed."""
875
+ if value is None:
876
+ return self._allow_custom_values
877
+ else:
878
+ self._allow_custom_values = value
879
+ if value or self._search_enabled:
880
+ self.entry_widget.state(['!readonly'])
881
+ else:
882
+ self.readonly(True)
883
+ return None
884
+
885
+ @configure_delegate('enable_search')
886
+ def _delegate_enable_search(self, value: bool = None):
887
+ """Get or set whether search filtering is enabled."""
888
+ if value is None:
889
+ return self._search_enabled
890
+ else:
891
+ self._search_enabled = value
892
+ if value or self._allow_custom_values:
893
+ self.entry_widget.state(['!readonly'])
894
+ else:
895
+ self.readonly(True)
896
+ return None
897
+
898
+ @configure_delegate('group_by')
899
+ def _delegate_group_by(self, value=None):
900
+ """Get or set the option field the popup clusters rows under.
901
+
902
+ The change takes effect the next time the popup opens (it is rebuilt on
903
+ each open). Following the configure-delegate convention, `None` reads the
904
+ current field; disable grouping at runtime by setting it to `''`.
905
+ """
906
+ if value is None:
907
+ return self._group_by
908
+ self._group_by = value or None
909
+ return None
910
+
911
+ @configure_delegate('max_visible_items')
912
+ def _delegate_max_visible_items(self, value=None):
913
+ """Get or set the approximate visible-row cap before the popup scrolls.
914
+
915
+ The change takes effect the next time the popup opens. Following the
916
+ configure-delegate convention, `None` reads the current value; set it to
917
+ `0` to restore the built-in default cap.
918
+ """
919
+ if value is None:
920
+ return self._max_visible_items
921
+ self._max_visible_items = value or None
922
+ return None
923
+
924
+ @configure_delegate('value')
925
+ def _delegate_value(self, value=None):
926
+ if value is None:
927
+ return self.value
928
+ self.value = value
929
+ return None
930
+
931
+ @property
932
+ def selected_index(self) -> int:
933
+ """Get or set the selected index.
934
+
935
+ Returns -1 if nothing is selected (the displayed text matches no
936
+ option). Setting to -1 or None clears the selection.
937
+ """
938
+ text = self.entry_widget.get()
939
+ for i, rec in enumerate(self._records):
940
+ if self._record_display(rec) == text:
941
+ return i
942
+ return -1
943
+
944
+ @selected_index.setter
945
+ def selected_index(self, index):
946
+ if index is None or index == -1:
947
+ self.value = None
948
+ elif 0 <= index < len(self._records):
949
+ self.value = self._records[index].value
950
+ else:
951
+ raise IndexError(f"index {index} out of range for {len(self._records)} options")
952
+
953
+ @property
954
+ def text(self) -> str:
955
+ """The current display text shown in the field (the formatted string)."""
956
+ return self.entry_widget.get()
957
+
958
+ @property
959
+ def selection(self) -> dict | None:
960
+ """The selected option as a full record dict (the data bag), or None.
961
+
962
+ Returns the matching option's `{text, value, ...extras}`; None when
963
+ nothing is selected or the current value is a custom/off-list one (not
964
+ one of the options).
965
+ """
966
+ value = self.value
967
+ if value is None:
968
+ return None
969
+ for rec in self._records:
970
+ if rec.value == value:
971
+ return record_to_dict(rec)
972
+ return None
973
+
974
+ @property
975
+ def value(self):
976
+ """The selected value, or None when the field is empty.
977
+
978
+ For a decoupled option (its display text differs from its value) this
979
+ returns the option's value. Otherwise it returns the entry's own raw
980
+ value, so `value_format` parsing — e.g. TimeField's `datetime.time` —
981
+ is preserved.
982
+ """
983
+ text = self.entry_widget.get()
984
+ if text == "":
985
+ return None
986
+ if text in self._value_by_text and self._value_by_text[text] != text:
987
+ return self._value_by_text[text]
988
+ return Field.value.fget(self)
989
+
990
+ @value.setter
991
+ def value(self, value):
992
+ """Select `value`, updating the displayed text and emitting `<<Change>>`.
993
+
994
+ A decoupled option shows its text; a plain/typed/custom value is handed
995
+ to the entry, which owns the raw value <-> text formatting. An unknown
996
+ value raises `ValueError` when `strict_value` is set and custom values
997
+ are off.
998
+ """
999
+ prev_value = self.value
1000
+ display = self._resolve_display(value)
1001
+ is_readonly = self.entry_widget.instate(['readonly'])
1002
+ if is_readonly:
1003
+ self.entry_widget.state(['!readonly'])
1004
+ if display is None or display == "":
1005
+ # The entry part's value() setter doesn't reliably clear the field;
1006
+ # empty it directly.
1007
+ self.entry_widget.delete(0, 'end')
1008
+ else:
1009
+ # The entry owns raw<->text; setting its value is programmatic (no emit).
1010
+ Field.value.fset(self, display)
1011
+ if is_readonly:
1012
+ self.entry_widget.state(['readonly'])
1013
+ new_value = self.value
1014
+ if new_value != prev_value:
1015
+ self.entry_widget._prev_changed_value = new_value
1016
+ if not getattr(self, "_suppress_changed_event", False):
1017
+ self.entry_widget.event_generate(
1018
+ '<<Change>>',
1019
+ data=ChangeEvent(
1020
+ value=new_value,
1021
+ prev_value=prev_value,
1022
+ text=self.entry_widget.get(),
1023
+ ),
1024
+ when="tail"
1025
+ )
1026
+