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
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+ require_relative 'option_dump_parsing'
5
+
6
+ module Teek
7
+ module UI
8
+ # @api private
9
+ #
10
+ # The default {WidgetType#addressing} strategy - an ordinary Tk
11
+ # widget with an independent path of its own, driving +#path+/
12
+ # +#configure+ through it directly. See {WidgetType#addressing} for
13
+ # how a type opts into a different strategy (e.g. {MenuEntryAddressing}).
14
+ class WidgetAddressing
15
+ # @api private
16
+ def initialize(node)
17
+ @node = node
18
+ end
19
+
20
+ # @return [String] the real Tk widget path
21
+ # @raise [NotRealizedError] before realize
22
+ def virtual_path
23
+ realized.path
24
+ end
25
+
26
+ # @param opts [Hash]
27
+ # @return [void]
28
+ # @raise [NotRealizedError] before realize
29
+ def configure(**opts)
30
+ realized.app.command(realized.path, :configure, **opts)
31
+ end
32
+
33
+ # @return [Hash{Symbol => String}] every current option/value Tk
34
+ # reports for this widget right now
35
+ # @raise [NotRealizedError] before realize
36
+ def option_dump
37
+ OptionDumpParsing.parse(realized.app, realized.app.command(realized.path, :configure))
38
+ end
39
+
40
+ private
41
+
42
+ def realized
43
+ @node.realized or raise NotRealizedError
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,512 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+ require_relative 'handle'
5
+ require_relative 'component_handle'
6
+ require_relative 'var'
7
+ require_relative 'image'
8
+ require_relative 'menu_builder'
9
+ require_relative 'screens'
10
+ require_relative 'modal_stack'
11
+ require_relative 'widget_types'
12
+ require_relative 'overlay_anchors'
13
+ require_relative 'scope'
14
+
15
+ module Teek
16
+ module UI
17
+ # The build surface: `ui.<widget>` methods that APPEND nodes to the
18
+ # {Document} tree. They never touch Tk - widgets become live only when
19
+ # the realizer runs at realize.
20
+ #
21
+ # Names are deliberately Tk-free (the litmus test: if decoding a name
22
+ # needs Tk knowledge, the name is wrong) - see the design sketch for the
23
+ # full vocabulary rationale.
24
+ #
25
+ # Mixed into {Session} rather than living on a separate accessor, so the
26
+ # DSL reads as `ui.button(...)`, not `ui.widgets.button(...)`.
27
+ #
28
+ # Included classes must provide +@document+ (a {Document}), +@stack+
29
+ # (an Array of {Node}, current-parent stack seeded with
30
+ # +@document.root+), +@scope_stack+ (an Array of {Scope}, current-scope
31
+ # stack seeded with +[Scope::TOP_LEVEL]+ - see {#component}), +@vars+
32
+ # (an Array of {Var}), and +@images+ (an Array of {Image}) - {Session}
33
+ # sets all five up in +initialize+. They must also provide
34
+ # +#build_open?+ (a predicate the tree-mutating methods below check
35
+ # via {#raise_if_closed!} - true before the initial realize and again
36
+ # for the duration of an +#add+ block, false otherwise).
37
+ module WidgetDSL
38
+ # Every widget/container type - leaf or container, plain or special -
39
+ # gets its own `ui.<type>` method(s) here, generated from its own
40
+ # {WidgetType} descriptor (see {WidgetTypes}). {WidgetTypes.on_register}
41
+ # replays every type already registered (every built-in loads before
42
+ # this file does) and keeps firing for any registered later, so a
43
+ # type registered by third-party code lights up here with no edit to
44
+ # this file at all. A type reachable only via a bespoke,
45
+ # hand-written method below (`#tab`/`#pane`/`#split`, `#menu_bar`/
46
+ # `#context_menu`) sets its own descriptor's `dsl:` to a no-op, so
47
+ # this never shadows those with a same-named generic method.
48
+ WidgetTypes.on_register { |widget_type| widget_type.define_dsl_method!(self) }
49
+
50
+ # `box` is a bare alternate spelling of `panel` - same node type, so
51
+ # the realizer only ever has to know about `:panel`.
52
+ def box(name = nil, **opts, &block)
53
+ append_container(:panel, name, opts, &block)
54
+ end
55
+
56
+ # `window` with dialog-appropriate defaults - modal and fixed-size,
57
+ # for the common "small modal window" case (confirmations, pickers).
58
+ # Same underlying node type as `window`, just different defaults for
59
+ # `modal:`/`resizable:` - both still overridable.
60
+ # @return [Handle]
61
+ def dialog(name = nil, modal: true, resizable: false, **opts, &block)
62
+ append_container(:window, name, opts.merge(modal: modal, resizable: resizable), &block)
63
+ end
64
+
65
+ # Look up a named widget declared in the CURRENT scope: at the top
66
+ # level outside any {#component}, that's everything built outside
67
+ # one; inside a component's own block, only that component's own
68
+ # names - a sibling component's (or the top level's) same-named
69
+ # node is never found this way, and vice versa. See {#component}.
70
+ # @param name [Symbol]
71
+ # @return [Handle, nil]
72
+ def [](name)
73
+ node = @document.find(name, scope: current_scope)
74
+ node && Handle.new(node)
75
+ end
76
+
77
+ # The current build-parent ancestry, as a readable breadcrumb (e.g.
78
+ # +"column > row"+) - derived from +@stack+, the one thing only the
79
+ # builder (not the {Document}) knows: which containers are
80
+ # currently open. Useful in a build-time error message (+"X added
81
+ # outside a Y; current parent: #{current_path}"+) or just to orient
82
+ # yourself while poking around mid-build.
83
+ # @return [String] +"(top level)"+ when nothing is currently open
84
+ def current_path
85
+ crumbs = @stack.reject { |node| node.type == :root }.map(&:display_name)
86
+ crumbs.empty? ? '(top level)' : crumbs.join(' > ')
87
+ end
88
+
89
+ # Position the single widget declared in the block at (row, col) in
90
+ # the enclosing `ui.grid`. Only valid directly inside a grid's block.
91
+ # @param row [Integer]
92
+ # @param col [Integer]
93
+ # @param span [Integer] how many columns this cell spans
94
+ # @return [void]
95
+ def cell(row:, col:, span: 1)
96
+ grid_node = current_grid!('cell')
97
+
98
+ before = grid_node.children.length
99
+ yield self if block_given?
100
+ placed = grid_node.children[before..]
101
+
102
+ unless placed.length == 1
103
+ raise ArgumentError, "cell needs exactly one widget declared in its block (got #{placed.length})"
104
+ end
105
+
106
+ node = placed.first
107
+ node.layout = (node.layout || {}).merge(cell: { row: row, col: col, span: span })
108
+ end
109
+
110
+ # Mark which columns/rows of the enclosing `ui.grid` absorb leftover
111
+ # space - the named replacement for `grid columnconfigure -weight`.
112
+ # Only valid directly inside a grid's block.
113
+ # @param columns [Array<Integer>]
114
+ # @param rows [Array<Integer>]
115
+ # @return [void]
116
+ def stretch(columns: [], rows: [])
117
+ grid_node = current_grid!('stretch')
118
+
119
+ grid_node.opts[:stretch_columns] = Array(columns) if columns.any?
120
+ grid_node.opts[:stretch_rows] = Array(rows) if rows.any?
121
+ end
122
+
123
+ # Floats the single widget declared in the block on top of the
124
+ # enclosing `ui.canvas`, positioned at a fixed corner/edge/center
125
+ # anchor via Tk's `place` geometry manager - a "use sparingly"
126
+ # escape valve for the one legitimate absolute-position case (a
127
+ # status readout or button bar layered over canvas content), not a
128
+ # general-purpose layout mode. Stays correctly positioned across a
129
+ # canvas resize with nothing to redo by hand - `place`'s relative
130
+ # coordinates are fractions of the canvas's current size, recomputed
131
+ # live by Tk on every resize. Only valid directly inside a
132
+ # `ui.canvas` block.
133
+ # @param at [Symbol] one of {OverlayAnchors::POSITIONS}'s keys
134
+ # @return [void]
135
+ # @raise [ArgumentError] if declared anywhere other than directly
136
+ # inside ui.canvas, given an unrecognized at:, or its block builds
137
+ # anything other than exactly one widget
138
+ def overlay(at:)
139
+ canvas_node = current_canvas!('overlay')
140
+ unless OverlayAnchors::POSITIONS.key?(at)
141
+ raise ArgumentError, "overlay's at: must be one of #{OverlayAnchors::POSITIONS.keys.join(', ')} (got #{at.inspect})"
142
+ end
143
+
144
+ before = canvas_node.children.length
145
+ yield if block_given?
146
+ placed = canvas_node.children[before..]
147
+
148
+ unless placed.length == 1
149
+ raise ArgumentError, "overlay needs exactly one widget declared in its block (got #{placed.length})"
150
+ end
151
+
152
+ node = placed.first
153
+ node.layout = (node.layout || {}).merge(overlay: { at: at })
154
+ end
155
+
156
+ # One page of an enclosing `ui.tabs`, labeled `label` in the tab bar.
157
+ # Only valid directly inside a `ui.tabs` block; its own block builds
158
+ # the pane's content with the ordinary widget DSL, same as any other
159
+ # container.
160
+ # @param label [String] the tab's title, shown in the tab bar
161
+ # @param name [Symbol, nil] for `ui[:name]` lookup, same as any widget
162
+ # @return [Handle]
163
+ # @raise [ArgumentError] if declared anywhere other than directly inside ui.tabs
164
+ def tab(label, name = nil, **opts, &block)
165
+ current_tabs!('tab')
166
+ append_container(:tab, name, opts.merge(tab_label: label), &block)
167
+ end
168
+
169
+ # Orientation values a `ui.split` accepts - the same plain words Tk's
170
+ # own -orient option uses, so no translation is needed at realize.
171
+ ORIENTATIONS = %i[horizontal vertical].freeze
172
+
173
+ # A draggable split - two or more `#pane`-declared regions, resizable
174
+ # by dragging the sash between them. Maps to `ttk::panedwindow`.
175
+ # @param name [Symbol, nil] for `ui[:name]` lookup, same as any widget
176
+ # @param orientation [Symbol] +:horizontal+ (panes side by side, a
177
+ # vertical sash) or +:vertical+ (panes stacked, a horizontal sash)
178
+ # @yieldparam s [self] build panes with `s.pane { ... }`
179
+ # @return [Handle]
180
+ # @raise [ArgumentError] if orientation isn't :horizontal or :vertical
181
+ def split(name = nil, orientation: :horizontal, **opts, &block)
182
+ unless ORIENTATIONS.include?(orientation)
183
+ raise ArgumentError, "split's orientation must be :horizontal or :vertical (got #{orientation.inspect})"
184
+ end
185
+
186
+ append_container(:split, name, opts.merge(orient: orientation.to_s), &block)
187
+ end
188
+
189
+ # One region of an enclosing `ui.split`. Only valid directly inside a
190
+ # `ui.split` block; its own block builds the pane's content with the
191
+ # ordinary widget DSL, same as any other container.
192
+ # @param name [Symbol, nil] for `ui[:name]` lookup, same as any widget
193
+ # @param weight [Integer, nil] how much of the leftover space this
194
+ # pane absorbs when the split is resized, relative to its sibling
195
+ # panes' weights - unset panes get Tk's own default (0, fixed size
196
+ # until dragged). The same plain word `ttk::panedwindow` itself
197
+ # uses for this.
198
+ # @return [Handle]
199
+ # @raise [ArgumentError] if declared anywhere other than directly inside ui.split
200
+ def pane(name = nil, weight: nil, **opts, &block)
201
+ current_split!('pane')
202
+ opts = weight.nil? ? opts : opts.merge(pane_weight: weight)
203
+ append_container(:pane, name, opts, &block)
204
+ end
205
+
206
+ # Node types a menu_bar is allowed to attach to - the root window
207
+ # itself, or a ui.window toplevel. Attaching a -menu to anything else
208
+ # (a plain frame) isn't a real Tk option, so this fails fast at
209
+ # declaration time rather than surfacing as a cryptic Tcl error later.
210
+ MENU_BAR_HOSTS = %i[root window].freeze
211
+
212
+ # A window's menu bar - the row of top-level dropdowns (File/Edit/...)
213
+ # along its top edge. Valid at the top level of a build or directly
214
+ # inside `ui.window` - attaches to whichever of those it's declared
215
+ # in once realized.
216
+ # @param name [Symbol, nil]
217
+ # @yieldparam mb [MenuBuilder]
218
+ # @return [Handle]
219
+ # @raise [ArgumentError] if declared anywhere other than the top level or directly inside ui.window
220
+ def menu_bar(name = nil, **opts, &block)
221
+ raise_if_closed!
222
+ parent = @stack.last
223
+ unless MENU_BAR_HOSTS.include?(parent.type)
224
+ raise ArgumentError, "menu_bar can only be declared at the top level of a build or directly inside ui.window"
225
+ end
226
+
227
+ node = @document.create(type: :menu_bar, name: name, opts: opts, scope: current_scope)
228
+ parent.add_child(node)
229
+ build_menu_subtree(node, &block)
230
+ Handle.new(node)
231
+ end
232
+
233
+ # A standalone popup menu - built the same declarative way as a
234
+ # menu_bar's dropdowns, but not attached to anything automatically.
235
+ # Wire it to a widget with `handle.on_right_click(this)`.
236
+ # @param name [Symbol, nil]
237
+ # @yieldparam m [MenuBuilder]
238
+ # @return [Handle]
239
+ def context_menu(name = nil, **opts, &block)
240
+ raise_if_closed!
241
+ node = @document.create(type: :context_menu, name: name, opts: opts, scope: current_scope)
242
+ @stack.last.add_child(node)
243
+ build_menu_subtree(node, &block)
244
+ Handle.new(node)
245
+ end
246
+
247
+ # The build-time escape hatch. A widget has no Tk path yet during
248
+ # build, so `app.command(handle.path, ...)` mid-build can't work -
249
+ # `ui.raw` defers the block instead, running it at realize with the
250
+ # live app in scope. It's a closure, so it can still reference sibling
251
+ # widgets by name (`ui[:other].path`) even if they're declared later -
252
+ # by the time any raw block runs, the whole tree has already been
253
+ # realized once over (same forward-reference guarantee event target:
254
+ # gets). For anything after realize, a live {Handle}/`session.app` is
255
+ # the escape hatch instead - see the README for the full split.
256
+ # @yieldparam app [Teek::App]
257
+ # @return [nil]
258
+ def raw(&block)
259
+ raise_if_closed!
260
+ node = @document.create(type: :raw_op, opts: { block: block })
261
+ @stack.last.add_child(node)
262
+ nil
263
+ end
264
+
265
+ # Declare a reactive variable. Its Tcl variable name is allocated now
266
+ # (no interpreter needed - it's just a string); the variable itself
267
+ # only becomes real at realize. Bind it to widgets with `bind:`.
268
+ # @param initial [Object] initial value - its class decides how
269
+ # {Var#value} coerces later (Integer/Float/Boolean typed, else String)
270
+ # @return [Var]
271
+ def var(initial)
272
+ raise_if_closed!
273
+ @var_count = (@var_count || 0) + 1
274
+ v = Var.new("::teek_ui_var_#{@var_count}", initial)
275
+ @vars << v
276
+ v
277
+ end
278
+
279
+ # Declare an image, loaded from a file. Its Tcl image name is
280
+ # allocated now (no interpreter needed - it's just a string); the
281
+ # backing {Teek::Photo} - and the actual file load - only becomes
282
+ # real at realize. Pass it as a widget's `image:` option (or a
283
+ # later `handle.configure(image: ...)`) to display it.
284
+ # @param path [String] path to an image file (any format Tk's own
285
+ # `image create photo -file` accepts - PNG, GIF, ...)
286
+ # @param opts [Hash] forwarded to {Teek::Photo.new} - e.g.
287
+ # +width:+/+height:+/+format:+/+palette:+/+gamma:+
288
+ # @return [Image]
289
+ def image(path, **opts)
290
+ raise_if_closed!
291
+ @image_count = (@image_count || 0) + 1
292
+ img = Image.new("teek_ui_image_#{@image_count}", path, opts)
293
+ @images << img
294
+ img
295
+ end
296
+
297
+ # Opens a fresh {Scope} around the block, so names declared inside
298
+ # it (`ui.button(:save)`, ...) never collide with the same name
299
+ # used elsewhere - in another component, or at the top level - no
300
+ # matter how many components share the same (or no) +label+, since
301
+ # {Scope} identity, not label, is what makes two scopes distinct.
302
+ # Splices its content directly into whatever's currently open -
303
+ # this is scope isolation only, not an extra layer of nesting, so
304
+ # a component built inside `ui.panel(:p) { }` attaches as an
305
+ # ordinary child of +:p+, exactly like any other widget declared
306
+ # right there would. The common 80% case - a plain method that
307
+ # takes +ui+ and appends into whatever's already open
308
+ # (`def toolbar(ui) = ui.row { ... }`) - needs none of this; reach
309
+ # for +#component+ only when scope isolation itself is the point
310
+ # (reuse across files, avoiding name collisions).
311
+ #
312
+ # The returned {ComponentHandle} is the disciplined way for the
313
+ # caller to reach into the component's own named widgets afterward
314
+ # (`screen.handle(:action)`/`screen[:action]`) - the global `ui[]`
315
+ # never sees into a component's scope (see {#[]}), so a component
316
+ # built in one file and mounted from another stays reachable only
317
+ # through the facade it hands back, not by guessing its internal
318
+ # names.
319
+ # @param label [Symbol, String, nil] a human-readable label for
320
+ # error messages/debugging - has no bearing on uniqueness
321
+ # @yieldparam c [self] build the component's content with the
322
+ # ordinary widget DSL, same as any other block
323
+ # @return [ComponentHandle]
324
+ def component(label = nil, &block)
325
+ raise_if_closed!
326
+ scope = Scope.new(label, parent: current_scope)
327
+ @scope_stack.push(scope)
328
+ begin
329
+ block.call(self) if block
330
+ ensure
331
+ @scope_stack.pop
332
+ end
333
+ ComponentHandle.new(@document, scope)
334
+ end
335
+
336
+ # A push/pop stack for content screens - see {Screens}. One stack per
337
+ # build, created on first access.
338
+ # @return [Screens]
339
+ def screens
340
+ @screens ||= Screens.new(document: @document)
341
+ end
342
+
343
+ # A push/pop stack for modal window handles - see {ModalStack}. `nil`
344
+ # until assigned; unlike {#screens} it isn't created automatically,
345
+ # since its callbacks (`on_enter:`/`on_exit:`) are mandatory and
346
+ # app-specific: `ui.modal = Teek::UI::ModalStack.new(on_enter:, on_exit:)`.
347
+ # @return [ModalStack, nil]
348
+ attr_accessor :modal
349
+
350
+ private
351
+
352
+ # The tree is only ever walked into Tk once (at realize) - a node
353
+ # appended afterward, outside an {Session#add} block, would just sit
354
+ # in the tree forever and never show up, with no error to say why.
355
+ # Every tree-mutating build method checks this first instead.
356
+ def raise_if_closed!
357
+ raise ClosedBuilderError unless build_open?
358
+ end
359
+
360
+ # @api private - the ONLY place +@stack+ (the build-parent stack)
361
+ # is pushed. Notifies {Document#notify}'s +:push+ event with the
362
+ # ancestry this node now heads - see {Document#subscribe}.
363
+ def push_stack(node)
364
+ @stack.push(node)
365
+ @document.notify(:push, node, current_path)
366
+ end
367
+
368
+ # @api private - the ONLY place +@stack+ is popped. Symmetric with
369
+ # {#push_stack}: the +:pop+ event's own path is captured BEFORE
370
+ # popping, so it still includes the node being popped, exactly like
371
+ # the +:push+ event for that same node did.
372
+ # @return [Node] the popped node
373
+ def pop_stack
374
+ path = current_path
375
+ node = @stack.pop
376
+ @document.notify(:pop, node, path)
377
+ node
378
+ end
379
+
380
+ def build_menu_subtree(node, &block)
381
+ return unless block
382
+
383
+ push_stack(node)
384
+ begin
385
+ block.call(MenuBuilder.new(@document, @stack))
386
+ ensure
387
+ pop_stack
388
+ end
389
+ end
390
+
391
+ def append_leaf(type, name, opts)
392
+ raise_if_closed!
393
+ validate_scroll!(type, opts)
394
+ opts, layout = extract_dsl_opts(resolve_bind(type, opts))
395
+ node = @document.create(type: type, name: name, opts: opts, scope: current_scope)
396
+ node.layout = layout if layout
397
+ @stack.last.add_child(node)
398
+ Handle.new(node)
399
+ end
400
+
401
+ def current_scope
402
+ @scope_stack.last
403
+ end
404
+
405
+ def resolve_bind(type, opts)
406
+ return opts unless opts.key?(:bind)
407
+
408
+ tk_option = bind_option_for(type) or
409
+ raise ArgumentError, "##{type} doesn't support bind: (no bindable Tk option is mapped for it)"
410
+ opts.reject { |k, _| k == :bind }.merge(tk_option => opts[:bind].name)
411
+ end
412
+
413
+ # A registered type's own `bind_option:` - `nil` (raising below) for
414
+ # anything unregistered or genuinely unsupported.
415
+ def bind_option_for(type)
416
+ WidgetTypes.for_type(type)&.bind_option
417
+ end
418
+
419
+ def validate_scroll!(type, opts)
420
+ return unless opts.key?(:scroll)
421
+ return if natively_scrollable_for?(type)
422
+
423
+ raise ArgumentError, "##{type} doesn't support scroll: (only #{scrollable_type_names.join('/')} do)"
424
+ end
425
+
426
+ # A registered type's own `natively_scrollable?` - `false` for
427
+ # anything unregistered.
428
+ def natively_scrollable_for?(type)
429
+ WidgetTypes.for_type(type)&.natively_scrollable? || false
430
+ end
431
+
432
+ # The full set of types `scroll:` actually works on, for the error
433
+ # message above.
434
+ def scrollable_type_names
435
+ WidgetTypes.each.select(&:natively_scrollable?).map(&:type).sort
436
+ end
437
+
438
+ # `grow:`/`lazy:` are DSL-only intents, not real Tk options - pull
439
+ # them off opts (so neither ever reaches a widget-creation call) and
440
+ # onto the node's own dedicated slots instead: `grow:` becomes part
441
+ # of {Node#layout} (the realizer's flow packing looks for it there),
442
+ # `lazy:` becomes {Node#lazy?} (the realizer's tree walk skips
443
+ # creating this subtree until something explicitly realizes it
444
+ # later - see {Handle#realize!}). A leaf's own `lazy:` return value
445
+ # is simply unused by its caller - only a container has anywhere to
446
+ # put it.
447
+ # @return [Array(Hash, Hash, Boolean)] cleaned opts, layout (or nil), lazy
448
+ def extract_dsl_opts(opts)
449
+ layout = opts.key?(:grow) ? { grow: opts[:grow] } : nil
450
+ lazy = opts.fetch(:lazy, false)
451
+ [opts.reject { |k, _| k == :grow || k == :lazy }, layout, lazy]
452
+ end
453
+
454
+ def current_grid!(method_name)
455
+ grid_node = @stack.last
456
+ unless grid_node.type == :grid
457
+ raise ArgumentError, "##{method_name} can only be used directly inside ui.grid"
458
+ end
459
+
460
+ grid_node
461
+ end
462
+
463
+ def current_tabs!(method_name)
464
+ tabs_node = @stack.last
465
+ unless tabs_node.type == :tabs
466
+ raise ArgumentError, "##{method_name} can only be used directly inside ui.tabs"
467
+ end
468
+
469
+ tabs_node
470
+ end
471
+
472
+ def current_split!(method_name)
473
+ split_node = @stack.last
474
+ unless split_node.type == :split
475
+ raise ArgumentError, "##{method_name} can only be used directly inside ui.split"
476
+ end
477
+
478
+ split_node
479
+ end
480
+
481
+ def current_canvas!(method_name)
482
+ canvas_node = @stack.last
483
+ unless canvas_node.type == :canvas
484
+ raise ArgumentError, "##{method_name} can only be used directly inside ui.canvas"
485
+ end
486
+
487
+ canvas_node
488
+ end
489
+
490
+ def append_container(type, name, opts)
491
+ raise_if_closed!
492
+ validate_scroll!(type, opts)
493
+ opts, layout, lazy = extract_dsl_opts(opts)
494
+ node = @document.create(type: type, name: name, opts: opts, scope: current_scope)
495
+ node.layout = layout if layout
496
+ node.lazy = lazy
497
+ @stack.last.add_child(node)
498
+
499
+ if block_given?
500
+ push_stack(node)
501
+ begin
502
+ yield self
503
+ ensure
504
+ pop_stack
505
+ end
506
+ end
507
+
508
+ Handle.new(node)
509
+ end
510
+ end
511
+ end
512
+ end