teek-ui 0.1.0

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 (127) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +41 -0
  3. data/README.md +452 -0
  4. data/lib/teek/ui/canvas_item.rb +270 -0
  5. data/lib/teek/ui/component_handle.rb +32 -0
  6. data/lib/teek/ui/document.rb +185 -0
  7. data/lib/teek/ui/errors.rb +41 -0
  8. data/lib/teek/ui/event_binding.rb +22 -0
  9. data/lib/teek/ui/event_bus.rb +53 -0
  10. data/lib/teek/ui/grid_validator.rb +89 -0
  11. data/lib/teek/ui/handle.rb +585 -0
  12. data/lib/teek/ui/image.rb +58 -0
  13. data/lib/teek/ui/keysyms.rb +72 -0
  14. data/lib/teek/ui/menu_builder.rb +130 -0
  15. data/lib/teek/ui/menu_entry_addressing.rb +62 -0
  16. data/lib/teek/ui/modal_stack.rb +99 -0
  17. data/lib/teek/ui/mouse_events.rb +31 -0
  18. data/lib/teek/ui/node.rb +141 -0
  19. data/lib/teek/ui/option_dump_parsing.rb +31 -0
  20. data/lib/teek/ui/overlay_anchors.rb +29 -0
  21. data/lib/teek/ui/overlay_validator.rb +33 -0
  22. data/lib/teek/ui/pane_validator.rb +30 -0
  23. data/lib/teek/ui/realized_node.rb +25 -0
  24. data/lib/teek/ui/realizer.rb +577 -0
  25. data/lib/teek/ui/scope.rb +40 -0
  26. data/lib/teek/ui/screens.rb +141 -0
  27. data/lib/teek/ui/session.rb +417 -0
  28. data/lib/teek/ui/tab_validator.rb +30 -0
  29. data/lib/teek/ui/text_content.rb +321 -0
  30. data/lib/teek/ui/tree_inspector.rb +97 -0
  31. data/lib/teek/ui/validator.rb +131 -0
  32. data/lib/teek/ui/var.rb +98 -0
  33. data/lib/teek/ui/version.rb +7 -0
  34. data/lib/teek/ui/widget_addressing.rb +47 -0
  35. data/lib/teek/ui/widget_dsl.rb +512 -0
  36. data/lib/teek/ui/widget_type.rb +242 -0
  37. data/lib/teek/ui/widget_types/button.rb +7 -0
  38. data/lib/teek/ui/widget_types/canvas.rb +15 -0
  39. data/lib/teek/ui/widget_types/checkbox.rb +7 -0
  40. data/lib/teek/ui/widget_types/column.rb +14 -0
  41. data/lib/teek/ui/widget_types/context_menu.rb +15 -0
  42. data/lib/teek/ui/widget_types/divider.rb +10 -0
  43. data/lib/teek/ui/widget_types/dropdown.rb +7 -0
  44. data/lib/teek/ui/widget_types/grid.rb +12 -0
  45. data/lib/teek/ui/widget_types/group.rb +7 -0
  46. data/lib/teek/ui/widget_types/label.rb +7 -0
  47. data/lib/teek/ui/widget_types/list.rb +7 -0
  48. data/lib/teek/ui/widget_types/menu_bar.rb +26 -0
  49. data/lib/teek/ui/widget_types/menu_checkbox.rb +14 -0
  50. data/lib/teek/ui/widget_types/menu_item.rb +20 -0
  51. data/lib/teek/ui/widget_types/menu_radio.rb +14 -0
  52. data/lib/teek/ui/widget_types/number_box.rb +7 -0
  53. data/lib/teek/ui/widget_types/pane.rb +37 -0
  54. data/lib/teek/ui/widget_types/panel.rb +7 -0
  55. data/lib/teek/ui/widget_types/progress.rb +7 -0
  56. data/lib/teek/ui/widget_types/radio.rb +7 -0
  57. data/lib/teek/ui/widget_types/row.rb +14 -0
  58. data/lib/teek/ui/widget_types/scrollable.rb +17 -0
  59. data/lib/teek/ui/widget_types/slider.rb +7 -0
  60. data/lib/teek/ui/widget_types/spacer.rb +14 -0
  61. data/lib/teek/ui/widget_types/split.rb +17 -0
  62. data/lib/teek/ui/widget_types/tab.rb +35 -0
  63. data/lib/teek/ui/widget_types/table.rb +9 -0
  64. data/lib/teek/ui/widget_types/tabs.rb +10 -0
  65. data/lib/teek/ui/widget_types/text_area.rb +7 -0
  66. data/lib/teek/ui/widget_types/text_box.rb +7 -0
  67. data/lib/teek/ui/widget_types/tree.rb +9 -0
  68. data/lib/teek/ui/widget_types/window.rb +50 -0
  69. data/lib/teek/ui/widget_types.rb +121 -0
  70. data/lib/teek/ui/widget_validators.rb +62 -0
  71. data/lib/teek/ui.rb +54 -0
  72. data/teek-ui.gemspec +24 -0
  73. data/test/support/fake_app.rb +57 -0
  74. data/test/test_busy.rb +82 -0
  75. data/test/test_canvas_items.rb +551 -0
  76. data/test/test_clipboard.rb +87 -0
  77. data/test/test_close_handling.rb +118 -0
  78. data/test/test_component.rb +151 -0
  79. data/test/test_component_realtk.rb +120 -0
  80. data/test/test_debug_info.rb +158 -0
  81. data/test/test_document.rb +317 -0
  82. data/test/test_event_bus.rb +163 -0
  83. data/test/test_events.rb +164 -0
  84. data/test/test_fake_app_contract.rb +62 -0
  85. data/test/test_grid.rb +97 -0
  86. data/test/test_handle.rb +386 -0
  87. data/test/test_handle_destroy_realtk.rb +344 -0
  88. data/test/test_helper.rb +20 -0
  89. data/test/test_image.rb +24 -0
  90. data/test/test_image_realtk.rb +146 -0
  91. data/test/test_incremental_realize.rb +203 -0
  92. data/test/test_keysyms.rb +76 -0
  93. data/test/test_layout.rb +185 -0
  94. data/test/test_lazy_realize.rb +128 -0
  95. data/test/test_managed_window.rb +304 -0
  96. data/test/test_menu_dsl.rb +230 -0
  97. data/test/test_menu_entry_addressing.rb +73 -0
  98. data/test/test_menu_realize.rb +296 -0
  99. data/test/test_modal_handle.rb +147 -0
  100. data/test/test_modal_stack.rb +219 -0
  101. data/test/test_modal_stack_realtk.rb +131 -0
  102. data/test/test_native_scrollable.rb +250 -0
  103. data/test/test_node.rb +193 -0
  104. data/test/test_overlay.rb +139 -0
  105. data/test/test_raw.rb +58 -0
  106. data/test/test_reactive_vars.rb +118 -0
  107. data/test/test_realizer.rb +264 -0
  108. data/test/test_scope.rb +38 -0
  109. data/test/test_screens.rb +260 -0
  110. data/test/test_screens_realtk.rb +314 -0
  111. data/test/test_scrollable.rb +185 -0
  112. data/test/test_scrollable_wheel.rb +172 -0
  113. data/test/test_scrollbar_auto_hide.rb +127 -0
  114. data/test/test_split.rb +145 -0
  115. data/test/test_tabs.rb +201 -0
  116. data/test/test_text_content.rb +664 -0
  117. data/test/test_toast.rb +126 -0
  118. data/test/test_tree_inspector.rb +183 -0
  119. data/test/test_ui.rb +428 -0
  120. data/test/test_validator.rb +209 -0
  121. data/test/test_var.rb +32 -0
  122. data/test/test_widget_dsl.rb +621 -0
  123. data/test/test_widget_introspection.rb +126 -0
  124. data/test/test_widget_types.rb +429 -0
  125. data/test/test_widget_types_realtk.rb +82 -0
  126. data/test/test_widget_validators.rb +82 -0
  127. metadata +207 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fefa47ab1d5707a468cb30e82b2bd054087275a1cae1b83091c7b06e90d83930
4
+ data.tar.gz: 8bb53367006141f9978243ba5b3fce5376ac638d2f637c655ee631c98ea07275
5
+ SHA512:
6
+ metadata.gz: a572b6d1c0e3d32d269f4f8da080c81f1364d943b9946dc6397f174d6f173cb0acaa91902838bd815c96127f27f06741c64f5ac896623242b733fc74105243db
7
+ data.tar.gz: 127cd45e69d65e2124af7d447aa1023d84b824aaad66ace0ca0dbb33f9c518858fed9079fbc4e1d0ee9f8f20ae6bfe3379979f4d94fce31915437c26fbc7db1b
data/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # Changelog — teek-ui
2
+
3
+ > **Alpha**: teek-ui is early and the API will change between minor versions.
4
+
5
+ All notable changes to teek-ui will be documented in this file. See the README for full usage - this is a feature list, not a tutorial.
6
+
7
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
8
+
9
+ ## [0.1.0] - 2026-07-16
10
+
11
+ - Retained-mode build: `Teek::UI.app { |ui| ... }.run`, Tk-free until realize.
12
+ - Widgets: `ui.<widget>` for every leaf/container type, addressable via `ui[:name]`.
13
+ - Components: `ui.component { }` opens a scope so local names never collide with another component's (or the top level's) same name - splices its content into whatever's already open, not an extra container; plain threaded-builder methods (`def foo(ui) = ...`) need none of this and keep working unchanged. Returns a facade (`.handle(:name)`/`[]`) so a parent can address the component's own named widgets from outside it without reaching through the global `ui[]`. Mountable more than once, including several times directly under one shared parent, with no Tk path collision even when every instance reuses the same local names.
14
+ - Realize/validation: atomic realize; tree-wide validation surfaces every problem at once, including a grid child missing `g.cell(...)`; build methods raise `ClosedBuilderError` once realized, redirecting to `session.add`.
15
+ - Scrolling: native widgets (`list`/`text_area`/`table`/`tree`) auto-attach a scrollbar (`scroll:` opt-out, 3-level default); `canvas` opts in instead; `ui.scrollable` wraps arbitrary content; both auto-hide and support mouse wheel.
16
+ - Layout: `column`/`row` flow containers (`gap:`/`align:`/`pad:`/`grow:`), `ui.grid` for the rest, `cv.overlay(at: anchor)` to float a widget over a `ui.canvas`.
17
+ - Handles: one handle type across both phases - `.path`/`.configure`/`.enable`/`.disable`/`.destroy!` (auto-defers to the next idle point when called from inside a callback, so a widget can safely tear down its own containing window from its own click handler - `defer:` overrides either way).
18
+ - Events: `on_click`/`on_right_click`/`on_drag`/`on_key`, queue before realize, wire immediately after.
19
+ - Close handling: `on_close` on windows, overridable default-destroy.
20
+ - Escape hatch: `session.app` post-realize, `ui.raw { |app| }` pre-realize.
21
+ - Timers: `#every`/`#after` - queue-then-wire like events, declarable inside the build block or after realize.
22
+ - Reactive variables: `ui.var`, `bind:`, `#value`/`#value=`, `#on_change`.
23
+ - Event bus: `ui.on`/`ui.emit`/`ui.off` - in-process pub/sub for decoupled widgets, app-scoped (not a global singleton), works before realize.
24
+ - Menus: `menu_bar`/`menu`/`context_menu`, shared `item`/`separator`/`checkbox`/`radio` vocabulary; named `item`/`checkbox`/`radio` entries are addressable via `ui[:name]`, immune to entry renumbering.
25
+ - Windows: `ui.window`/`ui.dialog` - managed toplevels with `show`/`hide`/`modal`.
26
+ - Tabs: `ui.tabs`/`t.tab(label, name)` - `ttk::notebook`, `on_tab_changed` event.
27
+ - Split panes: `ui.split(name, orientation:)`/`s.pane(name, weight:)` - `ttk::panedwindow` with a draggable sash.
28
+ - Screens: `ui.screens` - push/pop stack for swapping displayed content. A `lazy: true` panel/window isn't realized until first pushed; `.pop` returns the popped screen, and `handle.destroy!` tears it down for good (`ui.screens.pop&.destroy!`).
29
+ - Modal stacking: `ui.modal` - push/pop stack for stacked dialogs, with enter/exit/focus-change callbacks. Same `lazy: true`/`.pop&.destroy!` support as `ui.screens` (`document:` on `ModalStack.new`).
30
+ - Dynamic UIs: `session.add` builds and realizes a subtree into an already-running app.
31
+ - Canvas items: `line`/`ellipse`/`polygon`/`rectangle`/`text`/`arc`/`bitmap` on a canvas handle, each returning a `CanvasItem` - `.move`/`.points`/`.points=`/`.configure`/`[]`/`[]=`/`.delete`/`.bring_to_front`/`.send_to_back`/`.scale`/`.bounds`; `tags:` at creation plus `.tagged(tag)` address a shared group the same way as a single item; `on_click`/`on_right_click`/`on_drag` scoped to that item/tag; `draggable` for drag-to-move with no coordinate math.
32
+ - Images: `ui.image(path)` - queue-then-load like `ui.var`; pass the result as `image:` on a `label`/`button`, swap it later via `configure(image: ...)`. GC-owned via teek core's `Teek::Photo` (`.photo` reaches the live one).
33
+ - Dialogs: `ui.open_file`/`ui.save_file`/`ui.message`/`ui.choose_color`/`ui.choose_dir`, realize-only - thin over teek core's own dialog wrappers.
34
+ - Clipboard: `ui.clipboard.set`/`.get`/`.clear`, realize-only; `text_box`/`text_area` copy/cut/paste already work via Tk's own built-in key bindings, nothing to wire up.
35
+ - Toast: `session.toast(message, duration:)` - transient auto-dismissing notification, replaces rather than stacks.
36
+ - Busy cursor: `session.busy(window:) { }` - thin block wrapper over teek core's `App#busy`.
37
+ - Text content: `handle.text_content` on a `text_area` - insert/get/delete/replace/value/value=/clear; named formats (`format`/`apply_format`/`clear_format`/`delete_format`/`format_ranges`, leak-safe `on_format_click`/`on_format`); markers (`add_marker`/`remove_marker`/`markers`); `search`; `scroll_to`/`cursor`/`cursor=`/`read_only`/`read_only=`; `insert_image`. Every method has a Tk-named alias (`tag_configure`, `mark_set`, `see`, ...).
38
+ - Debug info: `session.debug_info` - live callback counts by kind, for spotting leaks; `run(debug:)`/`run_async(debug:)` print the same summary to stderr.
39
+ - Introspection: `session.find_by_path(path)` - reverse path-to-widget lookup; `handle.events` - live event bindings; `handle.options` - live Tk option dump.
40
+ - Build-phase debugging: `Teek::UI::TreeInspector` - ASCII tree of the current build + opt-in assembly trace; `builder.current_path` - build-parent breadcrumb.
41
+ - Friendly/Tk aliasing: every wrapped Tk concept is reachable by both a friendly name and its original Tk name (`ellipse`/`oval`, `points`/`coords`, `release_focus`/`grab_release`, `shortcut:`/`accelerator:`, ...) - see the README's alias table.
data/README.md ADDED
@@ -0,0 +1,452 @@
1
+ # Teek::UI
2
+
3
+ **The friendly way to build Tk apps in Ruby.** A DSL over [Teek](https://github.com/jamescook/teek) — sugar over Tk's plumbing, not a wall around it.
4
+
5
+ **This is the recommended entry point for building a teek app.** teek-ui depends on teek — installing teek-ui brings teek in automatically — and everything compiles down to plain teek calls, so you can always drop to bare teek when you need to.
6
+
7
+ > **Alpha**: teek-ui is early, but real: widgets, flexbox-style layout, events, reactive vars, menus, canvas, windows/dialogs, rich text, and images all work. Overlay layout isn't built yet.
8
+
9
+ Method and option names are the newcomer-friendly ones; every Tk name still works as an alias (see [Friendly vs. Tk Names](#friendly-vs-tk-names)). Deep detail beyond this guide lives in the API docs.
10
+
11
+ ## Quick Start
12
+
13
+ ```ruby
14
+ require 'teek/ui'
15
+
16
+ Teek::UI.app(title: 'Hello') do |ui|
17
+ # widget/layout DSL calls go here
18
+ end.run
19
+ ```
20
+
21
+ `Teek::UI.app` returns the `Teek::UI::Session` it yields, so `.run` chains right off it.
22
+
23
+ ## Your First App
24
+
25
+ A complete, runnable app using only the core — widgets, layout, an event, and a shared reactive value:
26
+
27
+ ```ruby
28
+ require 'teek/ui'
29
+
30
+ Teek::UI.app(title: 'Greeter') do |ui|
31
+ name = ui.var('') # a shared, reactive value
32
+
33
+ ui.column(gap: 8, pad: 12, align: :stretch) do |c|
34
+ c.label(text: "What's your name?")
35
+ c.text_box(:name_field, bind: name) # two-way bound to `name`
36
+ c.button(:greet, text: 'Greet')
37
+ c.label(:greeting)
38
+ end
39
+
40
+ greet = -> { ui[:greeting].configure(text: "Hello, #{name.value}!") }
41
+ ui[:greet].on_click(&greet) # click the button...
42
+ ui[:name_field].on_key(:enter, &greet) # ...or just press Enter
43
+ end.run
44
+ ```
45
+
46
+ You *describe* the UI in the block; `.run` *builds* it into real Tk widgets and starts the app — like writing HTML, then loading the page. The four sections that follow are the rest of that core; everything past **Going further** is optional.
47
+
48
+ ## Widgets
49
+
50
+ `ui.<widget>` declares a widget in the build tree. A `name` makes it addressable via `ui[:name]` (returns a `Handle`) without holding a reference:
51
+
52
+ ```ruby
53
+ session = Teek::UI.app(title: 'Hello') do |ui|
54
+ ui.panel(:controls) do |p|
55
+ p.text_box(:query)
56
+ p.button(:go, text: 'Go')
57
+ end
58
+ end.run
59
+
60
+ session[:query].configure(width: 40) # after realize
61
+ session[:query].disable # shorthand for configure(state: :disabled)
62
+ ```
63
+
64
+ Paths derive from names (`ui[:go].path` is `.controls.go`), not `.ttkbtn7` junk.
65
+
66
+ Leaf widgets: `text_box`, `text_area`, `label`, `button`, `checkbox`, `radio`, `slider`, `dropdown`, `number_box`, `list`, `table`, `tree`, `progress`, `divider`.
67
+ Containers (take a block): `panel` (alias `box`), `group`, `canvas`, `window`, plus the layout containers below.
68
+
69
+ ## Layout
70
+
71
+ `column`/`row` hide all three Tk geometry managers behind flexbox vocabulary — `pack`/`grid`/`sticky`/`-weight` never appear in app code:
72
+
73
+ ```ruby
74
+ ui.column(:controls, gap: 8, align: :stretch, pad: 5) do |c|
75
+ c.button(:start, text: 'Start')
76
+ c.button(:pause, text: 'Pause')
77
+ c.spacer # flexible gap - pushes what follows down
78
+ c.button(:about, text: 'About')
79
+ end
80
+ ```
81
+
82
+ - `gap:` — space between children. `pad:` — margin around the stack.
83
+ - `align:` — cross-axis: `:start` / `:center` / `:end` / `:stretch`.
84
+ - `grow: true` on any child — it consumes leftover space on the main axis (`spacer` is a child with `grow` baked in).
85
+
86
+ `ui.grid` is for what flow doesn't fit — forms, input tables:
87
+
88
+ ```ruby
89
+ ui.grid(:form, gap: 4) do |g|
90
+ g.cell(row: 0, col: 0) { g.label(text: 'Name:') }
91
+ g.cell(row: 0, col: 1) { g.text_box(:name_field) }
92
+ g.stretch(columns: [1]) # the input column absorbs extra width
93
+ end
94
+ ```
95
+
96
+ `g.cell(row:, col:, span: 1)` positions the widget its block declares; `g.stretch(columns:, rows:)` picks which absorb slack. Both only work inside `ui.grid`.
97
+
98
+ ## Events
99
+
100
+ `on_*` methods wire real Tk events under intent-named methods — no Tk event syntax:
101
+
102
+ ```ruby
103
+ session[:go].on_click { puts 'clicked' }
104
+ session[:go].on_right_click { show_context_menu }
105
+ session[:query].on_key(:enter) { search } # friendly keysym
106
+ session[:query].on_key('Ctrl-s') { save } # Ctrl/Alt/Shift/Cmd, spelled out
107
+ session[:area].on_drag { |x, y| puts "#{x},#{y}" }
108
+ ```
109
+
110
+ Declared before realize they queue and wire automatically; after realize they wire immediately — same method either way. A `ui.window` handle also gets `on_close` (titlebar/Cmd-W/Alt-F4) and `modal`/`release_focus` (input grab for dialogs). See the API docs for the modal grab lifecycle.
111
+
112
+ ## Reactive Variables
113
+
114
+ `ui.var(initial)` wraps a Tcl variable — bind it to more than one widget and they stay in sync for free, no event wiring:
115
+
116
+ ```ruby
117
+ speed = ui.var(5)
118
+ ui.slider(:speed_slider, from: 1, to: 10, bind: speed)
119
+ ui.label(:speed_label, bind: speed) # updates as the slider moves
120
+ speed.on_change { |v| puts "speed is now #{v}" }
121
+ ```
122
+
123
+ `var.value` / `var.value =` read and write directly (typed to the initial value); `on_change` fires on every change from either side. `bind:` works on the single-value widgets (`text_box`/`label`/`dropdown`/`number_box`/`checkbox`/`slider`/`progress`); multi-value widgets raise.
124
+
125
+ ## Talking Between Widgets
126
+
127
+ Four tools, most-direct to most-decoupled — start at the top, move down only when you need the decoupling:
128
+
129
+ | Reach for | When |
130
+ | --- | --- |
131
+ | **A handle** — `ui[:name].configure(...)` | A one-off, direct update: this button updates that label. |
132
+ | **A reactive var** — `ui.var` + `bind:` | Widgets stay in sync with one value automatically. See [Reactive Variables](#reactive-variables). |
133
+ | **A component facade** — `ui.component` + `screen[:name]` | A parent reaches a reusable subtree's widgets without a global name. See [Components](#components). |
134
+ | **The event bus** — `ui.on` / `ui.emit` | Unrelated widgets react and the sender shouldn't know they exist. See [Event Bus](#event-bus). |
135
+
136
+ ---
137
+
138
+ ## Going further
139
+
140
+ Everything above is a complete app. The rest is power you reach for when a screen needs it — read it as needed, not in order.
141
+
142
+ ## Building vs. Realizing
143
+
144
+ Building is Tk-free: the block runs immediately, but no `Teek::App`/interpreter exists until the session is **realized** by `#run`/`#run_async`/`#realize`. That's what makes a build inspectable (`session.document`) with no display — useful for headless testing.
145
+
146
+ A few things only work **after** realize and raise `Teek::UI::NotRealizedError` before it: `session.app`, `modal`/`release_focus`, the standard dialogs, and `ui.clipboard`. In practice this rarely bites, since they're normally called from an `on_*` handler (which only runs post-realize). Events and timers are the exception — they queue before realize and wire themselves, so they read fine inside the build block.
147
+
148
+ Realize validates the tree first: a real problem (dangling event target, two widgets in one grid cell) raises one `Teek::UI::ValidationError` listing everything. An unplaced widget warns; `strict: true` promotes that to a raise.
149
+
150
+ ## Authoring the Build Block
151
+
152
+ The block is plain Ruby run via `.call` — loops, conditionals, and helper methods all work; they just decide which `ui.<widget>` calls run:
153
+
154
+ ```ruby
155
+ Teek::UI.app(title: 'Hello') do |ui|
156
+ ui.column do |c|
157
+ %w[Start Pause Reset].each { |label| c.button(text: label) }
158
+ c.label(text: 'Ready') if some_condition
159
+ end
160
+ end.run
161
+ ```
162
+
163
+ Keep it pure and single-threaded (slow work belongs behind an event handler). Building on an already-realized session raises `ClosedBuilderError` — use `session.add(parent_name) { }` to grow the UI after it's running (see [Dynamic UIs](#dynamic-uis)).
164
+
165
+ ## Components
166
+
167
+ A retained tree is a plain Ruby value, so splitting a big build across files is just splitting a value. Two ways, and most apps only need the first:
168
+
169
+ ```ruby
170
+ # 1. A plain method that takes `ui` and appends into whatever's open:
171
+ def toolbar(ui, on_save:)
172
+ ui.row(gap: 8) { |r| r.button(text: 'Save').on_click { on_save.call } }
173
+ end
174
+
175
+ # 2. `ui.component { }` when you also want name-scope isolation:
176
+ def sidebar(ui)
177
+ ui.component { |c| c.button(:save, text: 'Save') } # this :save can't collide
178
+ end
179
+
180
+ Teek::UI.app(title: 'Editor') do |ui|
181
+ ui.panel(:top) { |p| toolbar(p, on_save: -> { save }) }
182
+ screen = ui.panel(:side) { |p| sidebar(p) }
183
+ screen[:save].on_click { save } # facade: reach the component's own names
184
+ end.run
185
+ ```
186
+
187
+ `ui.component` splices content in place (scope isolation, not a container) and returns a facade — `screen[:name]` reaches its named widgets from outside; the global `ui[:name]` can't see in. It's mountable more than once, each instance getting its own scope and facade. See the API docs for the scoping rules.
188
+
189
+ ## Canvas Items
190
+
191
+ A `canvas` handle draws persistent, addressable shapes — closer to SVG than an HTML5 paint-and-forget `<canvas>`. Every shape method returns a live `CanvasItem`:
192
+
193
+ ```ruby
194
+ board = session[:board] # ui.canvas(:board, width: 400, height: 300)
195
+ ball = board.ellipse(10, 10, 40, 40, fill: 'red', tags: 'movable')
196
+ ball.move(20, 0)
197
+ ball.points = [10, 10, 60, 60] # replace coordinates
198
+ ball[:fill] = 'blue' # read/write one option
199
+ ball.bring_to_front # (send_to_back is the opposite)
200
+ ball.on_click { ball[:fill] = 'green' }
201
+ ball.draggable # drag-to-move, no coordinate math
202
+ ```
203
+
204
+ Shapes: `line`, `ellipse` (Tk: `oval`), `polygon`, `rectangle`, `text`, `arc`, `bitmap` — coordinates flat or nested, plus Tk item options passed straight through. `board.tagged('movable')` addresses every item with that tag as one `CanvasItem`. Items take the same `on_click`/`on_right_click`/`on_drag` as widgets.
205
+
206
+ `overlay` floats ordinary widgets over the canvas at a plain-English anchor (`:top_left`, `:center`, `:bottom_right`, ...), a "use sparingly" absolute-position escape valve:
207
+
208
+ ```ruby
209
+ ui.canvas(:board, width: 400, height: 300) do |cv|
210
+ cv.overlay(at: :top_left) { ui.label(:status, text: 'Ready') }
211
+ end
212
+ ```
213
+
214
+ ## Text Content
215
+
216
+ A `text_area` handle's `text_content` is its full rich-text API — text, named formats, markers, search, embedded images:
217
+
218
+ ```ruby
219
+ log = session[:log].text_content # ui.text_area(:log)
220
+ log.insert(:end, 'started up')
221
+ log.value # => "started up"
222
+
223
+ log.format(:error, foreground: 'red', font: ['Courier', 10, :bold]) # define
224
+ log.apply_format(:error, '3.0', '3.end') # apply to a range
225
+ log.on_format_click(:error) { ... } # leak-safe binding
226
+ log.add_marker(:checkpoint, at: :cursor)
227
+ log.scroll_to(:end)
228
+ log.insert_image(:end, image: logo)
229
+ ```
230
+
231
+ - **Indices** are Tk's own text-index strings passed through (`"1.0"`, `"end"`, `"insert +1 line"`, `"@12,34"`, a marker name); `:end` and `:cursor` are symbol shortcuts.
232
+ - **Content**: `insert`, `get`, `delete`, `replace`, `value`/`value=`, `clear`.
233
+ - **Formats** (Tk "tags", renamed — a reusable named style, like a CSS class): `format`, `apply_format`, `clear_format`, `delete_format`, `format_ranges`, `on_format_click`.
234
+ - **Markers** (floating bookmark positions): `add_marker`, `remove_marker`, `markers`.
235
+ - **Other**: `search`, `scroll_to`, `cursor`/`cursor=`, `read_only`/`read_only=`.
236
+
237
+ Mutating methods transparently work on a `state: :disabled` (read-only) widget — an appending read-only log pane just works with no state juggling. Every friendly name has a Tk alias (`tag_add`, `mark_set`, `see`, ...). Not wrapped (escape-hatch only): embedded live widgets, the undo/redo stack, `dump`, peer widgets.
238
+
239
+ ## Images
240
+
241
+ `ui.image(path)` loads an image for a `label`/`button`'s `image:` — same build-then-realize shape as `ui.var`:
242
+
243
+ ```ruby
244
+ icon = ui.image('assets/logo.png')
245
+ ui.label(:logo, image: icon)
246
+ # later: session[:logo].configure(image: another_icon)
247
+ ```
248
+
249
+ Backed by teek's `Teek::Photo` (GC-owned — the Tk image frees itself, no manual bookkeeping); reach `icon.photo` for pixel-level access.
250
+
251
+ ## Scrolling
252
+
253
+ A bare `list`/`text_area`/`table`/`tree` auto-attaches a scrollbar that appears only on overflow — no `-yscrollcommand` wiring:
254
+
255
+ ```ruby
256
+ ui.list(:log) # already scrolls
257
+ ui.list(:log, scroll: false) # opt out
258
+ ```
259
+
260
+ `canvas` defaults to `scroll: false`. `x:`/`y:` pick the axis. Defaults resolve most-specific-first: widget `scroll:` → `Teek::UI.app(scroll:)` → global `Teek::UI.auto_scroll`. For a scrollbar around *arbitrary* content, wrap it in `ui.scrollable { }`. Mouse-wheel (incl. `Shift`+wheel for horizontal) works on all of it. See the API docs for the full precedence rules.
261
+
262
+ ## Windows
263
+
264
+ `ui.window(title:, geometry:, resizable:, modal:) { }` is a managed toplevel — it handles the wm bookkeeping (title, geometry, transient-to-parent, macOS menubar quirk) and starts **withdrawn** until `.show`:
265
+
266
+ ```ruby
267
+ settings = ui.window(:settings, title: 'Settings', geometry: '400x300') do |w|
268
+ w.button(:close, text: 'Close').on_click { settings.hide }
269
+ end
270
+ ui.button(:open, text: 'Settings...').on_click { settings.show }
271
+ ```
272
+
273
+ `.show` positions near its parent, raises, and (if `modal: true`) grabs input; `.hide` withdraws. `ui.dialog(...)` is `ui.window` with `modal: true, resizable: false` defaults. Plain containers (`panel`/`group`/`canvas`) just stack their children — use `column`/`row`/`grid` for real control.
274
+
275
+ ## Navigation
276
+
277
+ Three ways to swap what's on screen — pick by how separate the new content is:
278
+
279
+ | Reach for | When |
280
+ | --- | --- |
281
+ | **`ui.window` / `ui.dialog`** | A separate top-level window with its own titlebar. See [Windows](#windows). |
282
+ | **`ui.screens`** | Full-content swaps *within one window* — a push/pop stack. |
283
+ | **`ui.modal`** | Stacked modal dialogs where dismissing one re-shows the one beneath. |
284
+
285
+ ## Screens
286
+
287
+ `ui.screens` is a push/pop stack for swapping displayed content — pushing conceals the current screen, popping reverses it. Works against ordinary handles:
288
+
289
+ ```ruby
290
+ ui.panel(:picker) { |p| p.button(:play, text: 'Play').on_click { ui.screens.push(:emu, ui[:emu]) } }
291
+ ui.panel(:emu) { |p| p.button(:back, text: 'Back').on_click { ui.screens.pop } }
292
+ ```
293
+
294
+ `replace_current(handle)` swaps in place; `.current`/`.size`/`.active?` read state. A `lazy: true` container isn't realized until first pushed (avoids building every screen up front); `ui.screens.pop&.destroy!` closes one for good. See the API docs for the candidate-visibility gotcha and warm-conceal behavior.
295
+
296
+ ## Modal Stacking
297
+
298
+ `ui.modal` stacks modal dialogs so one can push another and the previous re-shows on dismiss. Assign it yourself — its `on_enter:`/`on_exit:` hooks are mandatory (e.g. pause/resume what's underneath):
299
+
300
+ ```ruby
301
+ ui.modal = Teek::UI::ModalStack.new(
302
+ on_enter: ->(name) { pause },
303
+ on_exit: -> { resume },
304
+ )
305
+ ui.dialog(:settings) { |d| d.button(:replay).on_click { ui.modal.push(:replay, ui[:replay]) } }
306
+ ```
307
+
308
+ `.push(name, handle)`/`.pop` reveal/conceal like `ui.screens`; push a `modal: true` handle (`ui.dialog` defaults to it) so `.show` grabs input. See the API docs for the `on_enter`/`on_exit`/`on_focus_change` firing rules and the fresh-dialog-per-open pattern.
309
+
310
+ ## Tabs
311
+
312
+ `ui.tabs { }` is a `ttk::notebook`; `t.tab(label, name = nil) { }` declares a page:
313
+
314
+ ```ruby
315
+ ui.tabs(:settings) do |t|
316
+ t.tab('General') { |g| g.checkbox(text: 'Dark mode') }
317
+ t.tab('Advanced', :advanced_tab) { |a| a.label(text: 'Here be dragons') }
318
+ end
319
+ ui[:settings].on_tab_changed { |tab| puts "switched to #{tab}" } # name, or index if unnamed
320
+ ```
321
+
322
+ Tab content is an ordinary subtree; new tabs can be added at runtime via `session.add`.
323
+
324
+ ## Split Panes
325
+
326
+ `ui.split(orientation: :horizontal) { }` is a `ttk::panedwindow`; `s.pane(weight: nil) { }` declares a draggable region:
327
+
328
+ ```ruby
329
+ ui.split(:main, orientation: :horizontal) do |s|
330
+ s.pane(weight: 1) { |a| a.list(:files) }
331
+ s.pane(weight: 3) { |b| b.text_area(:editor) }
332
+ end
333
+ ```
334
+
335
+ `weight:` sets how much slack a pane absorbs relative to its siblings.
336
+
337
+ ## Event Bus
338
+
339
+ `ui.on`/`ui.emit` is in-process publish/subscribe — for widgets that react without holding a reference to whoever caused it:
340
+
341
+ ```ruby
342
+ ui.on(:item_added) { |product| cart_badge.configure(text: "#{count += 1} items") }
343
+ add_button.on_click { ui.emit(:item_added, product) } # no idea who's listening
344
+ ```
345
+
346
+ `ui.off(:item_added, block)` unsubscribes. Each `Teek::UI.app` owns its own bus. See `sample/event_bus_demo.rb`.
347
+
348
+ ## Menus
349
+
350
+ `ui.menu_bar { }` declares a window's menu bar, attaching to whatever window it's in. `.menu(label:)` is one recursive method for every dropdown/cascade/submenu:
351
+
352
+ ```ruby
353
+ ui.menu_bar do |mb|
354
+ mb.menu(label: 'File') do |file|
355
+ file.item(label: 'Open...', shortcut: 'Cmd+O') { open_file }
356
+ file.separator
357
+ file.menu(label: 'Recent') { |r| r.item(label: 'notes.txt') { open_recent } }
358
+ end
359
+ end
360
+ ```
361
+
362
+ Inside a menu, `item`/`separator`/`checkbox`/`radio` build entries (menu entries, not the widgets of the same name). Name one to address it later (`.enable`/`.disable`/`.configure`). `shortcut:` (Tk: `accelerator:`) is display-only — wire the real key with `on_key`. A `ui.context_menu(:name) { }` is a standalone popup — attach it with `on_right_click`.
363
+
364
+ ## Escape Hatch
365
+
366
+ The DSL is sugar, not a wall. **After realize**, `session.app` is the live `Teek::App`. **During build**, use `ui.raw { |app| ... }` — it records the block and runs it at realize with the live app (and can forward-reference widgets by name):
367
+
368
+ ```ruby
369
+ Teek::UI.app(title: 'Hello') do |ui|
370
+ ui.raw { |app| app.command(ui[:later].path, :configure, text: 'Changed') }
371
+ ui.button(:later, text: 'Original') # declared after, still resolves
372
+ end.run
373
+ ```
374
+
375
+ Don't issue a raw `pack`/`grid` against a master the DSL already manages — Tk allows one geometry manager per master and raises a clear `Teek::TclError` if you mix them.
376
+
377
+ ## Dynamic UIs
378
+
379
+ `session.add(parent_name) { }` builds a subtree with the same widget DSL and realizes it immediately under an already-realized widget — for UIs that grow after the window is up:
380
+
381
+ ```ruby
382
+ session = Teek::UI.app(title: 'Hello') { |ui| ui.column(:list) }.run_async
383
+ session.add(:list) { |a| a.button(:item1, text: 'Item 1').on_click { puts 'clicked!' } }
384
+ ```
385
+
386
+ New widgets route through the same leak-cleanup path; `parent_name` must already be realized.
387
+
388
+ ## Timers
389
+
390
+ `#every`/`#after` queue-then-wire like events — declare a tick loop in the build block, next to the UI it drives:
391
+
392
+ ```ruby
393
+ Teek::UI.app(title: 'Hello') do |ui|
394
+ ui.every(1000) { puts 'tick' }
395
+ ui.after(500) { puts 'once' }
396
+ end.run
397
+ ```
398
+
399
+ Before realize they return `nil`; after realize they return the live, `.cancel`-able timer.
400
+
401
+ ## Interactive / REPL Use
402
+
403
+ `#run` blocks the event loop. `#run_async` shows the window and returns; call `ui.app.update` yourself to process events between prompts:
404
+
405
+ ```ruby
406
+ session = Teek::UI.app(title: 'Hello').run_async
407
+ session.app.update
408
+ ```
409
+
410
+ ## Dialogs
411
+
412
+ Standard native dialogs, directly on `ui` (realize-only):
413
+
414
+ ```ruby
415
+ path = ui.open_file(filetypes: [['Images', ['.png', '.jpg']]])
416
+ answer = ui.message(message: 'Delete this?', type: :yesno) # => :yes / :no
417
+ color = ui.choose_color(initial: '#3366ff')
418
+ dir = ui.choose_dir(title: 'Pick a folder')
419
+ ```
420
+
421
+ Each returns `nil` on cancel (`ui.message` returns the pressed button symbol). Also `ui.save_file`.
422
+
423
+ ## Clipboard
424
+
425
+ `ui.clipboard` reads/writes directly (realize-only):
426
+
427
+ ```ruby
428
+ ui.clipboard.set('copied text')
429
+ ui.clipboard.get # => "copied text", or nil if empty
430
+ ```
431
+
432
+ `text_box`/`text_area` already handle Ctrl/Cmd-C/X/V natively.
433
+
434
+ ## Friendly vs. Tk Names
435
+
436
+ The litmus test for every name in this DSL: if decoding it needs Tk knowledge, the name is wrong. Where that meant renaming an underlying Tk concept, the Tk name still works too, as a plain alias - so a Tk man page, a Ruby-Tk migration, or plain muscle memory all still resolve correctly. The friendly name is primary (what the README/examples use); either name works identically everywhere.
437
+
438
+ | Friendly (primary) | Tk name (alias) | Where |
439
+ |---|---|---|
440
+ | `ellipse` | `oval` | canvas shape method |
441
+ | `points` / `points=` | `coords` / `coords=` | `CanvasItem` |
442
+ | `bring_to_front` | `tk_raise`* | `CanvasItem` |
443
+ | `send_to_back` | `lower` | `CanvasItem` |
444
+ | `bounds` | `bbox` | `CanvasItem` |
445
+ | `release_focus` | `grab_release` | `Handle` (window) |
446
+ | `shortcut:` | `accelerator:` | menu `item`/`checkbox`/`radio` |
447
+
448
+ \* Not plain `raise` - that would silently shadow `Kernel#raise` on every `CanvasItem`. `tk_raise` keeps the Tk association without the collision.
449
+
450
+ A few Tk names are kept as-is, deliberately not renamed - either genuinely universal (`configure`, `move`, `scale`, `value`), or Tk-specific enough that a forced rename would obscure more than it clarifies (`relief:`, `highlightthickness:`, `bitmap`, `tagged`).
451
+
452
+ The text widget has its own alias set (in [Text Content](#text-content) above): `format`/`apply_format`/`clear_format`/`delete_format`/`format_ranges`/`on_format_click` (Tk: `tag_*`), `add_marker`/`remove_marker`/`markers` (Tk: `mark_*`), `scroll_to` (Tk: `see`), `insert_image` (Tk: `image_create`).