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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +41 -0
- data/README.md +452 -0
- data/lib/teek/ui/canvas_item.rb +270 -0
- data/lib/teek/ui/component_handle.rb +32 -0
- data/lib/teek/ui/document.rb +185 -0
- data/lib/teek/ui/errors.rb +41 -0
- data/lib/teek/ui/event_binding.rb +22 -0
- data/lib/teek/ui/event_bus.rb +53 -0
- data/lib/teek/ui/grid_validator.rb +89 -0
- data/lib/teek/ui/handle.rb +585 -0
- data/lib/teek/ui/image.rb +58 -0
- data/lib/teek/ui/keysyms.rb +72 -0
- data/lib/teek/ui/menu_builder.rb +130 -0
- data/lib/teek/ui/menu_entry_addressing.rb +62 -0
- data/lib/teek/ui/modal_stack.rb +99 -0
- data/lib/teek/ui/mouse_events.rb +31 -0
- data/lib/teek/ui/node.rb +141 -0
- data/lib/teek/ui/option_dump_parsing.rb +31 -0
- data/lib/teek/ui/overlay_anchors.rb +29 -0
- data/lib/teek/ui/overlay_validator.rb +33 -0
- data/lib/teek/ui/pane_validator.rb +30 -0
- data/lib/teek/ui/realized_node.rb +25 -0
- data/lib/teek/ui/realizer.rb +577 -0
- data/lib/teek/ui/scope.rb +40 -0
- data/lib/teek/ui/screens.rb +141 -0
- data/lib/teek/ui/session.rb +417 -0
- data/lib/teek/ui/tab_validator.rb +30 -0
- data/lib/teek/ui/text_content.rb +321 -0
- data/lib/teek/ui/tree_inspector.rb +97 -0
- data/lib/teek/ui/validator.rb +131 -0
- data/lib/teek/ui/var.rb +98 -0
- data/lib/teek/ui/version.rb +7 -0
- data/lib/teek/ui/widget_addressing.rb +47 -0
- data/lib/teek/ui/widget_dsl.rb +512 -0
- data/lib/teek/ui/widget_type.rb +242 -0
- data/lib/teek/ui/widget_types/button.rb +7 -0
- data/lib/teek/ui/widget_types/canvas.rb +15 -0
- data/lib/teek/ui/widget_types/checkbox.rb +7 -0
- data/lib/teek/ui/widget_types/column.rb +14 -0
- data/lib/teek/ui/widget_types/context_menu.rb +15 -0
- data/lib/teek/ui/widget_types/divider.rb +10 -0
- data/lib/teek/ui/widget_types/dropdown.rb +7 -0
- data/lib/teek/ui/widget_types/grid.rb +12 -0
- data/lib/teek/ui/widget_types/group.rb +7 -0
- data/lib/teek/ui/widget_types/label.rb +7 -0
- data/lib/teek/ui/widget_types/list.rb +7 -0
- data/lib/teek/ui/widget_types/menu_bar.rb +26 -0
- data/lib/teek/ui/widget_types/menu_checkbox.rb +14 -0
- data/lib/teek/ui/widget_types/menu_item.rb +20 -0
- data/lib/teek/ui/widget_types/menu_radio.rb +14 -0
- data/lib/teek/ui/widget_types/number_box.rb +7 -0
- data/lib/teek/ui/widget_types/pane.rb +37 -0
- data/lib/teek/ui/widget_types/panel.rb +7 -0
- data/lib/teek/ui/widget_types/progress.rb +7 -0
- data/lib/teek/ui/widget_types/radio.rb +7 -0
- data/lib/teek/ui/widget_types/row.rb +14 -0
- data/lib/teek/ui/widget_types/scrollable.rb +17 -0
- data/lib/teek/ui/widget_types/slider.rb +7 -0
- data/lib/teek/ui/widget_types/spacer.rb +14 -0
- data/lib/teek/ui/widget_types/split.rb +17 -0
- data/lib/teek/ui/widget_types/tab.rb +35 -0
- data/lib/teek/ui/widget_types/table.rb +9 -0
- data/lib/teek/ui/widget_types/tabs.rb +10 -0
- data/lib/teek/ui/widget_types/text_area.rb +7 -0
- data/lib/teek/ui/widget_types/text_box.rb +7 -0
- data/lib/teek/ui/widget_types/tree.rb +9 -0
- data/lib/teek/ui/widget_types/window.rb +50 -0
- data/lib/teek/ui/widget_types.rb +121 -0
- data/lib/teek/ui/widget_validators.rb +62 -0
- data/lib/teek/ui.rb +54 -0
- data/teek-ui.gemspec +24 -0
- data/test/support/fake_app.rb +57 -0
- data/test/test_busy.rb +82 -0
- data/test/test_canvas_items.rb +551 -0
- data/test/test_clipboard.rb +87 -0
- data/test/test_close_handling.rb +118 -0
- data/test/test_component.rb +151 -0
- data/test/test_component_realtk.rb +120 -0
- data/test/test_debug_info.rb +158 -0
- data/test/test_document.rb +317 -0
- data/test/test_event_bus.rb +163 -0
- data/test/test_events.rb +164 -0
- data/test/test_fake_app_contract.rb +62 -0
- data/test/test_grid.rb +97 -0
- data/test/test_handle.rb +386 -0
- data/test/test_handle_destroy_realtk.rb +344 -0
- data/test/test_helper.rb +20 -0
- data/test/test_image.rb +24 -0
- data/test/test_image_realtk.rb +146 -0
- data/test/test_incremental_realize.rb +203 -0
- data/test/test_keysyms.rb +76 -0
- data/test/test_layout.rb +185 -0
- data/test/test_lazy_realize.rb +128 -0
- data/test/test_managed_window.rb +304 -0
- data/test/test_menu_dsl.rb +230 -0
- data/test/test_menu_entry_addressing.rb +73 -0
- data/test/test_menu_realize.rb +296 -0
- data/test/test_modal_handle.rb +147 -0
- data/test/test_modal_stack.rb +219 -0
- data/test/test_modal_stack_realtk.rb +131 -0
- data/test/test_native_scrollable.rb +250 -0
- data/test/test_node.rb +193 -0
- data/test/test_overlay.rb +139 -0
- data/test/test_raw.rb +58 -0
- data/test/test_reactive_vars.rb +118 -0
- data/test/test_realizer.rb +264 -0
- data/test/test_scope.rb +38 -0
- data/test/test_screens.rb +260 -0
- data/test/test_screens_realtk.rb +314 -0
- data/test/test_scrollable.rb +185 -0
- data/test/test_scrollable_wheel.rb +172 -0
- data/test/test_scrollbar_auto_hide.rb +127 -0
- data/test/test_split.rb +145 -0
- data/test/test_tabs.rb +201 -0
- data/test/test_text_content.rb +664 -0
- data/test/test_toast.rb +126 -0
- data/test/test_tree_inspector.rb +183 -0
- data/test/test_ui.rb +428 -0
- data/test/test_validator.rb +209 -0
- data/test/test_var.rb +32 -0
- data/test/test_widget_dsl.rb +621 -0
- data/test/test_widget_introspection.rb +126 -0
- data/test/test_widget_types.rb +429 -0
- data/test/test_widget_types_realtk.rb +82 -0
- data/test/test_widget_validators.rb +82 -0
- 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
|