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,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'mouse_events'
4
+
5
+ module Teek
6
+ module UI
7
+ # A handle onto one or more canvas items, addressed by Tk's own
8
+ # tagOrId - either the numeric id a `create` call returns (a single
9
+ # item) or an arbitrary tag string (every item currently carrying it,
10
+ # zero or more). Every method here is exactly that uniform - Tk's own
11
+ # canvas command already treats a tag and an id identically for
12
+ # move/coords/itemconfigure/delete/stacking/scale, so a shared tag is
13
+ # addressable as a group with no separate "group handle" type: get one
14
+ # via a shape-creation method (see {Handle#line} and friends, a
15
+ # single-item handle) or {Handle#tagged} (an existing tag, whatever it
16
+ # currently matches).
17
+ class CanvasItem
18
+ # @return [String] the tagOrId this handle addresses
19
+ attr_reader :tag_or_id
20
+
21
+ # @api private
22
+ def initialize(app, canvas_path, tag_or_id)
23
+ @app = app
24
+ @canvas_path = canvas_path
25
+ @tag_or_id = tag_or_id.to_s
26
+ end
27
+
28
+ # @return [String] the canvas's own path, marked past the point a
29
+ # real Tk path stops applying - an item/tag has no independent Tk
30
+ # path of its own, only the canvas does. +!+ is illegal in a Tk
31
+ # path segment, so handing this to a raw Tk command fails loudly
32
+ # (an "invalid command name" Tcl error) instead of silently
33
+ # misbehaving - the same marked-address shape
34
+ # {MenuEntryAddressing#virtual_path} uses for a menu entry, the
35
+ # other kind of thing with no Tk path of its own.
36
+ def virtual_path
37
+ "#{@canvas_path}!#{@tag_or_id}"
38
+ end
39
+
40
+ # Move relative to the current position.
41
+ # @param dx [Numeric]
42
+ # @param dy [Numeric]
43
+ # @return [self]
44
+ def move(dx, dy)
45
+ @app.command(@canvas_path, :move, @tag_or_id, dx, dy)
46
+ self
47
+ end
48
+
49
+ # @return [Array<Float>] the current coordinate list
50
+ def points
51
+ result = @app.command(@canvas_path, :coords, @tag_or_id)
52
+ @app.split_list(result).map(&:to_f)
53
+ end
54
+ alias_method :coords, :points
55
+
56
+ # Replace the coordinate list outright (as opposed to {#move}'s
57
+ # relative shift).
58
+ # @param new_coords [Array<Numeric>] flat or nested (e.g.
59
+ # +[[x1, y1], [x2, y2]]+) - flattened either way
60
+ # @return [void]
61
+ def points=(new_coords)
62
+ @app.command(@canvas_path, :coords, @tag_or_id, *new_coords.flatten)
63
+ end
64
+ alias_method :coords=, :points=
65
+
66
+ # Mutate several item options at once.
67
+ # @param opts [Hash] item options, e.g. +fill: 'red'+
68
+ # @return [self]
69
+ def configure(**opts)
70
+ @app.command(@canvas_path, :itemconfigure, @tag_or_id, **opts)
71
+ self
72
+ end
73
+
74
+ # Read back a single item option - +item[:fill]+.
75
+ # @param opt [Symbol, String] e.g. +:fill+
76
+ # @return [String]
77
+ def [](opt)
78
+ @app.command(@canvas_path, :itemcget, @tag_or_id, "-#{opt}")
79
+ end
80
+
81
+ # Set a single item option - +item[:fill] = 'red'+. Shorthand for
82
+ # +configure(opt => value)+ when there's only one to change.
83
+ # @param opt [Symbol, String] e.g. +:fill+
84
+ # @param value [Object]
85
+ # @return [Object] +value+
86
+ def []=(opt, value)
87
+ configure(opt => value)
88
+ value
89
+ end
90
+
91
+ # Remove the item(s) from the canvas.
92
+ # @return [nil]
93
+ def delete
94
+ @app.command(@canvas_path, :delete, @tag_or_id)
95
+ nil
96
+ end
97
+
98
+ # Bring to the front of the stacking order (drawn last, on top of
99
+ # everything), or - given +above+ - just in front of that one
100
+ # item/tag instead of all the way to the front.
101
+ # @param above [CanvasItem, String, nil]
102
+ # @return [self]
103
+ def bring_to_front(above = nil)
104
+ args = above ? [resolve(above)] : []
105
+ @app.command(@canvas_path, :raise, @tag_or_id, *args)
106
+ self
107
+ end
108
+ # Tk's own name for {#bring_to_front} - not plain +raise+, which
109
+ # would silently shadow +Kernel#raise+ on every CanvasItem (a bare
110
+ # +raise+ call from inside this class, now or later, would call
111
+ # THIS method instead of actually raising - a real, silent
112
+ # footgun, not just a style concern).
113
+ alias_method :tk_raise, :bring_to_front
114
+
115
+ # Send to the back of the stacking order (drawn first, under
116
+ # everything), or - given +below+ - just behind that one item/tag
117
+ # instead of all the way to the back.
118
+ # @param below [CanvasItem, String, nil]
119
+ # @return [self]
120
+ def send_to_back(below = nil)
121
+ args = below ? [resolve(below)] : []
122
+ @app.command(@canvas_path, :lower, @tag_or_id, *args)
123
+ self
124
+ end
125
+ alias_method :lower, :send_to_back
126
+
127
+ # Scale coordinates relative to a fixed point.
128
+ # @param ox [Numeric] x origin scaling is relative to
129
+ # @param oy [Numeric] y origin scaling is relative to
130
+ # @param sx [Numeric] x scale factor
131
+ # @param sy [Numeric] y scale factor
132
+ # @return [self]
133
+ def scale(ox, oy, sx, sy)
134
+ @app.command(@canvas_path, :scale, @tag_or_id, ox, oy, sx, sy)
135
+ self
136
+ end
137
+
138
+ # @return [Array<Float>, nil] +[x1, y1, x2, y2]+ bounding box, or
139
+ # +nil+ if nothing currently matches {#tag_or_id}
140
+ def bounds
141
+ result = @app.command(@canvas_path, :bbox, @tag_or_id)
142
+ result.empty? ? nil : @app.split_list(result).map(&:to_f)
143
+ end
144
+ alias_method :bbox, :bounds
145
+
146
+ # @return [Boolean] whether any item currently matches {#tag_or_id} -
147
+ # always true for a single-item handle from a creation method
148
+ # (the item exists until {#delete}d), meaningful for a {Handle#tagged}
149
+ # group that may currently match zero items
150
+ def exists?
151
+ !@app.command(@canvas_path, :find, :withtag, @tag_or_id).empty?
152
+ end
153
+
154
+ # Fires on a left click, only when the click lands on this specific
155
+ # item/tag - other items on the same canvas are untouched. Wired
156
+ # immediately, via the canvas's own `bind` subcommand (Tk has no
157
+ # per-item widget path to bind a plain `bind` against) - unlike
158
+ # {Handle}, a CanvasItem only ever exists post-realize, so there's no
159
+ # queue-before-realize phase to worry about here.
160
+ # @yield called with no arguments
161
+ # @return [self]
162
+ def on_click(&block)
163
+ bind_item_event('<Button-1>', block)
164
+ self
165
+ end
166
+
167
+ # Fires on a right click, however the platform spells it - see
168
+ # {MouseEvents::RIGHT_CLICK_EVENTS}. Either handle it yourself with a
169
+ # block, or hand it a `:menu`/`:context_menu` handle to pop up at the
170
+ # click's screen position - not both.
171
+ # @param menu [Handle, nil] a `:menu` or `:context_menu` handle to tk_popup
172
+ # @yield called with no arguments (only when +menu+ isn't given)
173
+ # @return [self]
174
+ # @raise [ArgumentError] if given neither or both, or +menu+ isn't a menu handle
175
+ def on_right_click(menu = nil, &block)
176
+ if menu && block
177
+ raise ArgumentError, "on_right_click takes either a menu handle or a block, not both"
178
+ elsif menu
179
+ unless MouseEvents::MENU_HANDLE_TYPES.include?(menu.type)
180
+ raise ArgumentError, "on_right_click(menu) needs a :menu or :context_menu handle (got a :#{menu.type})"
181
+ end
182
+
183
+ popup = lambda do |root_x, root_y|
184
+ @app.popup_menu(menu.path, x: root_x, y: root_y)
185
+ end
186
+ MouseEvents::RIGHT_CLICK_EVENTS.each { |event| bind_item_event(event, popup, subs: %i[root_x root_y]) }
187
+ elsif block
188
+ MouseEvents::RIGHT_CLICK_EVENTS.each { |event| bind_item_event(event, block) }
189
+ else
190
+ raise ArgumentError, "on_right_click needs either a menu handle or a block"
191
+ end
192
+ self
193
+ end
194
+
195
+ # Fires while dragging this item (left button held down and moving).
196
+ # Delivers Integer x/y already converted through the canvas's own
197
+ # canvasx/canvasy, same as {Handle#on_drag} does when bound to a
198
+ # canvas - callers never have to remember to do that themselves.
199
+ # @yield [x, y] Integer coordinates
200
+ # @return [self]
201
+ def on_drag(&block)
202
+ wrapped = lambda do |raw_x, raw_y|
203
+ x, y = canvas_xy(raw_x, raw_y)
204
+ block.call(x, y)
205
+ end
206
+ bind_item_event('<B1-Motion>', wrapped, subs: %i[x y])
207
+ self
208
+ end
209
+
210
+ # Makes this item movable by mouse drag, with zero coordinate math
211
+ # of your own - press and drag it around the canvas, it follows the
212
+ # pointer. Binds `<Button-1>` (to capture the starting position) and
213
+ # `<B1-Motion>` (to shift {#move} by the delta each tick) on this
214
+ # item/tag, replacing any {#on_click}/{#on_drag} binding already set
215
+ # on it, the same way any two binds on the same item/event replace
216
+ # each other in Tk.
217
+ # @yield [x, y] optional - the item's new pointer-relative position
218
+ # (same Integer canvasx/canvasy coordinates {#on_drag} delivers)
219
+ # after each move, e.g. to react to where it's been dragged to
220
+ # @return [self]
221
+ def draggable(&block)
222
+ last = nil
223
+
224
+ press = lambda do |raw_x, raw_y|
225
+ last = canvas_xy(raw_x, raw_y)
226
+ end
227
+ bind_item_event('<Button-1>', press, subs: %i[x y])
228
+
229
+ drag = lambda do |raw_x, raw_y|
230
+ x, y = canvas_xy(raw_x, raw_y)
231
+ move(x - last[0], y - last[1])
232
+ last = [x, y]
233
+ block.call(x, y) if block
234
+ end
235
+ bind_item_event('<B1-Motion>', drag, subs: %i[x y])
236
+
237
+ self
238
+ end
239
+
240
+ private
241
+
242
+ def canvas_xy(raw_x, raw_y)
243
+ x = @app.command(@canvas_path, :canvasx, raw_x).to_f.round
244
+ y = @app.command(@canvas_path, :canvasy, raw_y).to_f.round
245
+ [x, y]
246
+ end
247
+
248
+ def resolve(item)
249
+ item.is_a?(CanvasItem) ? item.tag_or_id : item.to_s
250
+ end
251
+
252
+ # Canvas items have no widget path of their own, so binding one of
253
+ # their events goes through the canvas's own `bind` subcommand
254
+ # (`$canvas bind tagOrId <event> script`) rather than the generic
255
+ # `bind` Tk command {Teek::App#bind} wraps - a different Tcl command
256
+ # entirely, needing its own callback wiring. {Teek::CanvasBindInterceptor}
257
+ # (registered for the `canvas` widget type) already reconciles the
258
+ # callback this registers when the item's tag/id stops matching
259
+ # anything, same leak-safety {Teek::App#bind} gives ordinary widget
260
+ # events - nothing extra to do here for that. +subs+ reuses
261
+ # {Teek::App::BIND_SUBS} for the same symbol -> %-code vocabulary
262
+ # {Teek::App#bind} already uses, so e.g. +:x+/+:root_x+ mean the same
263
+ # thing here as everywhere else.
264
+ def bind_item_event(event, handler, subs: [])
265
+ tcl_subs = subs.map { |s| Teek::App::BIND_SUBS.fetch(s) }
266
+ @app.command(@canvas_path, :bind, @tag_or_id, event, proc { |*args| handler.call(*args) }, *tcl_subs)
267
+ end
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'handle'
4
+
5
+ module Teek
6
+ module UI
7
+ # A component's own public surface, returned by {WidgetDSL#component} -
8
+ # the disciplined way a parent reaches a child's named widgets without
9
+ # falling back to the flat, global `ui[]` index (which never sees into
10
+ # a component's scope at all - see {WidgetDSL#[]}). Wraps a
11
+ # (document, scope) pair and resolves names exactly the way `ui[]`
12
+ # already does from inside the component's own block, just usable from
13
+ # outside it - the parent addresses children through this facade, not
14
+ # through `@document.find` or a captured {Node}.
15
+ class ComponentHandle
16
+ # @api private
17
+ def initialize(document, scope)
18
+ @document = document
19
+ @scope = scope
20
+ end
21
+
22
+ # @param name [Symbol]
23
+ # @return [Handle, nil] +nil+ if this component never declared
24
+ # +name+ - same nil-on-miss convention as {WidgetDSL#[]}
25
+ def handle(name)
26
+ node = @document.find(name, scope: @scope)
27
+ node && Handle.new(node)
28
+ end
29
+ alias_method :[], :handle
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'node'
4
+ require_relative 'scope'
5
+ require_relative 'event_bus'
6
+
7
+ module Teek
8
+ module UI
9
+ # The build-phase tree: an unattached root {Node} plus a name index
10
+ # (Symbol -> Node). Plain Ruby, no Tk - building and traversing a
11
+ # Document never touches an interpreter, which is what makes the DSL
12
+ # headless-testable.
13
+ #
14
+ # Document only constructs and indexes nodes; it has no opinion on tree
15
+ # shape (which node is whose parent) - the build surface decides that by
16
+ # calling {Node#add_child} itself, so Document stays reusable underneath
17
+ # whatever parent-tracking scheme the builder uses.
18
+ class Document
19
+ # @return [Node] the tree's root - starts with no children
20
+ attr_reader :root
21
+
22
+ def initialize
23
+ @root = Node.new(type: :root, document: self)
24
+ @index = {}
25
+ @next_auto_key = 0
26
+ @used_segments = {}
27
+ @events = EventBus.new
28
+ end
29
+
30
+ # @api private
31
+ #
32
+ # A minimal, always-on, generic build-event hook - {Node#add_child}
33
+ # notifies +:append+; the build stack's own push/pop
34
+ # ({WidgetDSL#push_stack}/{WidgetDSL#pop_stack}) notify +:push+/
35
+ # +:pop+. Document has no idea what (if anything) is listening, or
36
+ # why - it's a plain {EventBus}, same mechanism {Session}'s own
37
+ # public +ui.on+/+ui.emit+ already uses, just private and scoped to
38
+ # build-time instrumentation instead of app events. With nothing
39
+ # subscribed (the overwhelmingly common case), {#notify} costs one
40
+ # hash lookup into an empty list - not something a normal build
41
+ # needs to think about. See {TreeInspector}, the one built-in
42
+ # subscriber.
43
+ # @param event [Symbol]
44
+ # @yield see {EventBus#on}
45
+ # @return [Proc] see {EventBus#on}
46
+ def subscribe(event, &block)
47
+ @events.on(event, &block)
48
+ end
49
+
50
+ # @api private - see {#subscribe}
51
+ # @param event [Symbol]
52
+ # @param args [Array] forwarded to every subscriber
53
+ # @return [void]
54
+ def notify(event, *args)
55
+ @events.emit(event, *args)
56
+ end
57
+
58
+ # Construct a node and register it under its name (if any), scoped
59
+ # to +scope+ - the same name used in two different scopes indexes
60
+ # as two distinct entries, so a component's local +:save+ never
61
+ # collides with another component's (or the top level's) own
62
+ # +:save+. Does NOT attach it to any parent - the caller does that
63
+ # with {Node#add_child}, so Document never needs to know about a
64
+ # current-parent stack.
65
+ #
66
+ # The node's own +name+/+key+ stay bare/unqualified - only this
67
+ # index is scope-aware. A node's real Tk path is already distinct
68
+ # per scope with no help needed here, since it's built from the
69
+ # parent chain ({Realizer#allocate_path}), and two components'
70
+ # subtrees are never siblings of themselves.
71
+ # @param type [Symbol]
72
+ # @param name [Symbol, nil]
73
+ # @param opts [Hash]
74
+ # @param scope [Scope] see {Scope} - defaults to {Scope::TOP_LEVEL}
75
+ # @return [Node]
76
+ # @raise [ArgumentError] if +name+ is already registered within +scope+
77
+ def create(type:, name: nil, opts: {}, scope: Scope::TOP_LEVEL)
78
+ node = Node.new(type: type, name: name, key: generate_key(name), opts: opts, scope: scope, document: self)
79
+ register(scope, node) if name
80
+ node
81
+ end
82
+
83
+ # @param name [Symbol]
84
+ # @param scope [Scope] must be the same {Scope} instance the node
85
+ # was {#create}d with - a name registered inside a scope is never
86
+ # found by a lookup in a different one, or vice versa
87
+ # @return [Node, nil]
88
+ def find(name, scope: Scope::TOP_LEVEL)
89
+ @index[[scope, name]]
90
+ end
91
+ alias_method :[], :find
92
+
93
+ # Depth-first, pre-order traversal of the whole tree from {#root}.
94
+ # @yieldparam node [Node]
95
+ # @return [Enumerator] if no block given
96
+ def each_node(&block)
97
+ root.each(&block)
98
+ end
99
+
100
+ # Every named node, regardless of whether it's actually attached
101
+ # anywhere in the tree - see {Validator}'s orphan check, which is
102
+ # exactly the reason this differs from {#each_node}.
103
+ # @yieldparam name [Symbol]
104
+ # @yieldparam node [Node]
105
+ # @return [Enumerator] if no block given
106
+ def each_named_node(&block)
107
+ return enum_for(:each_named_node) unless block
108
+
109
+ @index.each(&block)
110
+ end
111
+
112
+ # Reverse lookup: given a real Tk path (from an error message, a
113
+ # +winfo+ query, or poking around in a REPL), find which node it
114
+ # belongs to - the counterpart to {#find}'s name-based lookup. Only
115
+ # ever matches a node's own {RealizedNode#path}, never its
116
+ # +arrange_path+ (the scrollbar-wrapper case - the wrapper frame
117
+ # itself has no owning node of its own to return) or a
118
+ # {WidgetType#addressing} strategy's synthesized virtual path (a
119
+ # menu entry has no real Tk path at all - see
120
+ # {MenuEntryAddressing#virtual_path}'s own +!+-marked format, never
121
+ # something Tk itself would hand back from +winfo+ or an error).
122
+ # @param path [String] a real Tk widget path, e.g. +".toolbar.save"+
123
+ # @return [Node, nil]
124
+ def find_by_path(path)
125
+ each_node.find { |node| node.realized&.path == path }
126
+ end
127
+
128
+ # @api private - called by {Realizer#allocate_path}, which gets a
129
+ # fresh instance for every separate realize pass (the initial
130
+ # realize, each {Session#add}, each lazily-{Handle#realize!}d
131
+ # screen) - tracking claims here instead keeps them honest across
132
+ # every one of those passes for this Document's whole lifetime.
133
+ # Two mounts of the same component requesting the same key under
134
+ # the same real parent (e.g. a reusable row/screen, realized more
135
+ # than once - see {WidgetDSL#component}) get distinct, disambiguated
136
+ # segments; the common, non-colliding case keeps its plain segment
137
+ # unchanged.
138
+ # @param parent_path [String]
139
+ # @param segment [String] the requested (not yet disambiguated) segment
140
+ # @return [String] +segment+, or +segment+ suffixed to make it unique
141
+ # under +parent_path+ if this is a repeat
142
+ def claim_path_segment(parent_path, segment)
143
+ seen = (@used_segments[parent_path] ||= Hash.new(0))
144
+ count = seen[segment]
145
+ seen[segment] += 1
146
+ count.zero? ? segment : "#{segment}##{count + 1}"
147
+ end
148
+
149
+ # Removes +node+ from the name index, scoped exactly like
150
+ # {#register} does - a no-op if +node+ was never named (nothing to
151
+ # remove) or already unregistered. Called by {Handle#destroy!} for
152
+ # a destroyed node and every named descendant of its own subtree
153
+ # (Tk destroys descendants recursively, so their names need to
154
+ # stop resolving too), so a later widget can reuse the same name
155
+ # in the same scope, and {#find} correctly reports the name as
156
+ # gone in the meantime.
157
+ # @param node [Node]
158
+ # @return [void]
159
+ def unregister(node)
160
+ return unless node.name
161
+
162
+ @index.delete([node.scope, node.name])
163
+ end
164
+
165
+ private
166
+
167
+ def register(scope, node)
168
+ key = [scope, node.name]
169
+ if @index.key?(key)
170
+ suffix = scope.top_level? ? '' : ' in the same component'
171
+ raise ArgumentError, "duplicate widget name :#{node.name} - already used by a #{@index[key].type} node#{suffix}"
172
+ end
173
+
174
+ @index[key] = node
175
+ end
176
+
177
+ def generate_key(name)
178
+ return name.to_s if name
179
+
180
+ @next_auto_key += 1
181
+ "#anon#{@next_auto_key}"
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teek
4
+ module UI
5
+ # Raised by any genuinely realize-only method (Session#app, dialogs,
6
+ # #clipboard, Handle#path/#configure/#modal/#grab_release, ...) called
7
+ # before its node/session has been realized. These don't queue for
8
+ # later - the whole point of a Tk-free build phase is that nothing is
9
+ # pretending to talk to an interpreter that doesn't exist yet, and
10
+ # each of these either IS the live app/widget by definition or needs
11
+ # one to do anything meaningful (grabbing input, popping a native
12
+ # dialog). In practice this is rarely hit in real code: all of them
13
+ # are normally called from inside an on_* event handler, which only
14
+ # ever runs after realize - see {Session#every}/{Session#after} for
15
+ # the one case that genuinely IS deferrable, and queues instead.
16
+ class NotRealizedError < StandardError
17
+ def initialize(msg = "not realized yet - call this from an on_* event handler (already " \
18
+ "post-realize), or after #run/#run_async/#realize")
19
+ super
20
+ end
21
+ end
22
+
23
+ # Raised by {Validator#validate!} when the build tree has one or more
24
+ # raise-level problems. The message lists every one found, not just the
25
+ # first, so a build can be fixed in one pass instead of a cycle of
26
+ # "run, hit the next cryptic Tcl error, fix, repeat."
27
+ class ValidationError < StandardError; end
28
+
29
+ # Raised when a DSL build method (`ui.button`, `ui.panel`, `ui.raw`,
30
+ # `ui.var`, ...) is called after the build has already realized - the
31
+ # tree is only ever walked into Tk once, at realize, so anything
32
+ # appended to it afterward would just silently never show up. Use
33
+ # {Session#add} instead, which builds and realizes a subtree into the
34
+ # already-running app immediately.
35
+ class ClosedBuilderError < StandardError
36
+ def initialize(msg = "the build has already realized - use session.add(parent_name) { } to add widgets to an already-running app instead")
37
+ super
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teek
4
+ module UI
5
+ # An event a node wants wired up. +target+ is +nil+ to bind on the
6
+ # node's own widget, or a Symbol naming another node's widget - resolved
7
+ # by the realizer's link pass, after every node in the whole tree has
8
+ # already been created, so a target declared later in the build still
9
+ # resolves correctly (the forward-reference case). +subs+ are
10
+ # {Teek::App#bind} substitution codes (e.g. +%i[x y]+) forwarded to the
11
+ # handler when it fires.
12
+ EventBinding = Data.define(:event, :handler, :target, :subs) do
13
+ # @param event [String] a Tk bind event pattern, e.g. +"<Button-1>"+
14
+ # @param handler [#call] called with whatever +subs+ substitutes
15
+ # @param target [Symbol, nil] another node's name, or nil for self
16
+ # @param subs [Array<Symbol, String>] see {Teek::App#bind}
17
+ def initialize(event:, handler:, target: nil, subs: [])
18
+ super
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teek
4
+ module UI
5
+ # In-process publish/subscribe for decoupled app events (+:item_selected+,
6
+ # +:theme_changed+) - not Tk events, complementary to
7
+ # {Handle#on_click}/{Handle#on_key}. Reach for this only when a direct
8
+ # handle or a shared reactive {Var} would couple things that should
9
+ # stay decoupled.
10
+ #
11
+ # Owned by one {Session} (+ui.on+/+ui.emit+/+ui.off+) - two separate
12
+ # {Teek::UI.app} instances in the same process never share a bus. Pure
13
+ # Ruby, no Tk/interpreter involved, so it works before realize too.
14
+ class EventBus
15
+ # @api private
16
+ def initialize
17
+ @listeners = Hash.new { |h, k| h[k] = [] }
18
+ end
19
+
20
+ # Subscribe to a named event.
21
+ # @param event [Symbol]
22
+ # @yield the subscriber, called with whatever {#emit} was given
23
+ # @return [Proc] the block, to pass to a later {#off}
24
+ def on(event, &block)
25
+ @listeners[event] << block
26
+ block
27
+ end
28
+
29
+ # Emit a named event to every current subscriber, in subscription order.
30
+ # @param event [Symbol]
31
+ # @param args [Array] forwarded to each subscriber
32
+ # @param kwargs [Hash] forwarded to each subscriber
33
+ # @return [void]
34
+ def emit(event, *args, **kwargs)
35
+ if kwargs.empty?
36
+ @listeners[event].each { |listener| listener.call(*args) }
37
+ else
38
+ @listeners[event].each { |listener| listener.call(*args, **kwargs) }
39
+ end
40
+ nil
41
+ end
42
+
43
+ # Unsubscribe a specific listener.
44
+ # @param event [Symbol]
45
+ # @param block [Proc] the block {#on} returned
46
+ # @return [void]
47
+ def off(event, block)
48
+ @listeners[event].delete(block)
49
+ nil
50
+ end
51
+ end
52
+ end
53
+ end