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,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teek
4
+ module UI
5
+ # The rich text API for one +ui.text_area+ widget's content - reached
6
+ # via {Handle#text_content}, the same shape {Handle#tagged}/{Handle#line}
7
+ # use to hand back a {CanvasItem}: a small, focused companion object,
8
+ # not a pile of widget-specific methods on {Handle} itself.
9
+ #
10
+ # Indices (every +index+/+from+/+to+/+at+ parameter below) are Tk's own
11
+ # text index syntax, passed through verbatim as Ruby strings -
12
+ # +"1.0"+, +"end"+, +"sel.first"+, +"insert +1 line"+, a mark name,
13
+ # +"@12,34"+ - see the Tk +text+ manual page for the full grammar.
14
+ # This is deliberately NOT wrapped in a new index type - sugar, not a
15
+ # wall. Two Symbol shortcuts cover the common cases: +:end+ and
16
+ # +:cursor+ (the +insert+ mark, renamed to something that doesn't
17
+ # collide with {#insert} the method).
18
+ #
19
+ # Naming: a Tk text "tag" is not an HTML tag - it's a named, reusable
20
+ # set of display properties you apply to ranges, like a CSS class.
21
+ # The primary vocabulary here calls that a "format" (avoiding "style",
22
+ # already taken by ttk's own +style:+ widget option); the Tk-named
23
+ # methods (+tag_configure+, +tag_add+, ...) still work as plain
24
+ # aliases, so 1:1 Tk documentation mapping and Tk-fluent muscle memory
25
+ # both keep working.
26
+ #
27
+ # Every content-mutating method (insert/delete/replace/value=/clear/
28
+ # insert_image) transparently lifts a +-state disabled+ (read-only)
29
+ # widget to +normal+ for the duration of the call and restores it
30
+ # after - Tk itself silently no-ops a mutation against a disabled
31
+ # text widget, which is exactly the kind of Tk wonk this DSL exists
32
+ # to hide. An app author building a read-only log pane never needs to
33
+ # know this footgun exists.
34
+ class TextContent
35
+ INDEX_ALIASES = { end: 'end', cursor: 'insert' }.freeze
36
+
37
+ # @api private
38
+ def initialize(app, path)
39
+ @app = app
40
+ @path = path
41
+ end
42
+
43
+ # -- Content -------------------------------------------------------
44
+
45
+ # @param index [String, Symbol]
46
+ # @param text [String]
47
+ # @param tags [Array<String, Symbol>] format name(s) to apply to
48
+ # the inserted text, same as Tk's own trailing tagList
49
+ # @return [void]
50
+ def insert(index, text, *tags)
51
+ mutate { @app.command(@path, :insert, resolve_index(index), text, *tags) }
52
+ end
53
+
54
+ # @param start [String, Symbol]
55
+ # @param end_ [String, Symbol, nil] a single character at +start+
56
+ # if omitted, the range +[start, end_)+ otherwise
57
+ # @return [String]
58
+ def get(start = '1.0', end_ = 'end')
59
+ @app.command(@path, :get, resolve_index(start), resolve_index(end_))
60
+ end
61
+
62
+ # @param start [String, Symbol]
63
+ # @param end_ [String, Symbol, nil] a single character at +start+
64
+ # if omitted, the range +[start, end_)+ otherwise
65
+ # @return [void]
66
+ def delete(start, end_ = nil)
67
+ mutate { @app.command(@path, :delete, resolve_index(start), *(end_ ? [resolve_index(end_)] : [])) }
68
+ end
69
+
70
+ # Atomic delete-then-insert over +[start, end_)+.
71
+ # @param start [String, Symbol]
72
+ # @param end_ [String, Symbol]
73
+ # @param text [String]
74
+ # @return [void]
75
+ def replace(start, end_, text)
76
+ mutate { @app.command(@path, :replace, resolve_index(start), resolve_index(end_), text) }
77
+ end
78
+
79
+ # @return [String] the whole buffer's text, without the synthetic
80
+ # trailing newline Tk always keeps at +end+
81
+ def value
82
+ get('1.0', 'end-1c')
83
+ end
84
+
85
+ # Replaces the whole buffer's content outright.
86
+ # @param text [String]
87
+ # @return [void]
88
+ def value=(text)
89
+ mutate {
90
+ @app.command(@path, :delete, '1.0', 'end')
91
+ @app.command(@path, :insert, '1.0', text)
92
+ }
93
+ end
94
+
95
+ # Empties the whole buffer.
96
+ # @return [void]
97
+ def clear
98
+ mutate { @app.command(@path, :delete, '1.0', 'end') }
99
+ end
100
+
101
+ # -- Formats (Tk's own "tag") ---------------------------------------
102
+
103
+ # Defines (or redefines) a named format - a reusable set of display
104
+ # properties, applied to text ranges via {#apply_format}.
105
+ # @param name [Symbol, String]
106
+ # @param opts [Hash] Tk text-tag options, e.g. +foreground:+/+font:+/+underline:+
107
+ # @return [void]
108
+ def format(name, **opts)
109
+ @app.command(@path, :tag, :configure, name, **opts)
110
+ end
111
+ alias_method :tag_configure, :format
112
+
113
+ # Applies a previously-{#format}ted name to a range.
114
+ # @param name [Symbol, String]
115
+ # @param from [String, Symbol]
116
+ # @param to [String, Symbol]
117
+ # @return [void]
118
+ def apply_format(name, from, to)
119
+ @app.command(@path, :tag, :add, name, resolve_index(from), resolve_index(to))
120
+ end
121
+ alias_method :tag_add, :apply_format
122
+
123
+ # Removes +name+ from a range - the format definition itself is
124
+ # untouched, still applyable elsewhere; see {#delete_format} to
125
+ # remove the definition entirely.
126
+ # @param name [Symbol, String]
127
+ # @param from [String, Symbol]
128
+ # @param to [String, Symbol]
129
+ # @return [void]
130
+ def clear_format(name, from, to)
131
+ @app.command(@path, :tag, :remove, name, resolve_index(from), resolve_index(to))
132
+ end
133
+ alias_method :tag_remove, :clear_format
134
+
135
+ # Deletes a format's definition entirely, and with it every range
136
+ # it was applied to.
137
+ # @param name [Symbol, String]
138
+ # @return [void]
139
+ def delete_format(name)
140
+ @app.command(@path, :tag, :delete, name)
141
+ end
142
+ alias_method :tag_delete, :delete_format
143
+
144
+ # @param name [Symbol, String]
145
+ # @return [Array<String>] a flat list of index pairs - +[start1,
146
+ # end1, start2, end2, ...]+, one pair per contiguous applied range
147
+ def format_ranges(name)
148
+ @app.split_list(@app.command(@path, :tag, :ranges, name))
149
+ end
150
+ alias_method :tag_ranges, :format_ranges
151
+
152
+ # Fires on a left click anywhere text carrying +name+ is displayed.
153
+ # Wired through +tag bind+ (not a raw +tcl_eval+), so the existing
154
+ # leak-safe reconcile (teek core's +TagBindInterceptor+, already
155
+ # registered for the +text+ widget) releases the callback if +name+
156
+ # stops being applied anywhere - the same leak-safety every other
157
+ # DSL event binding already gets.
158
+ # @param name [Symbol, String]
159
+ # @yield called with no arguments
160
+ # @return [void]
161
+ def on_format_click(name, &block)
162
+ bind_format_event(name, '<Button-1>', block)
163
+ end
164
+ alias_method :on_tag_click, :on_format_click
165
+
166
+ # {#on_format_click}, for an arbitrary Tk event pattern instead of
167
+ # the common left-click case.
168
+ # @param name [Symbol, String]
169
+ # @param event [String] a Tk bind event pattern, e.g. +"Double-Button-1"+
170
+ # @yield called with no arguments
171
+ # @return [void]
172
+ def on_format(name, event, &block)
173
+ bind_format_event(name, resolve_event(event), block)
174
+ end
175
+ alias_method :on_tag, :on_format
176
+
177
+ # -- Markers (Tk's own "mark") ---------------------------------------
178
+
179
+ # A marker is a named, floating position in the text that moves
180
+ # with edits around it - a bookmark, not a range.
181
+ # @param name [Symbol, String]
182
+ # @param at [String, Symbol] where to place it
183
+ # @return [void]
184
+ def add_marker(name, at:)
185
+ @app.command(@path, :mark, :set, name, resolve_index(at))
186
+ end
187
+ alias_method :mark_set, :add_marker
188
+
189
+ # @param name [Symbol, String]
190
+ # @return [void]
191
+ def remove_marker(name)
192
+ @app.command(@path, :mark, :unset, name)
193
+ end
194
+ alias_method :mark_unset, :remove_marker
195
+
196
+ # @return [Array<String>] every marker currently defined, including
197
+ # the built-in +insert+/+current+ ones
198
+ def markers
199
+ @app.split_list(@app.command(@path, :mark, :names))
200
+ end
201
+ alias_method :mark_names, :markers
202
+
203
+ # Which way +name+ drifts when text is inserted exactly at it -
204
+ # an advanced, rarely-needed Tk concept, so this stays under its Tk
205
+ # name only (no friendlier alias).
206
+ # @param name [Symbol, String]
207
+ # @param direction [String, Symbol, nil] +:left+/+:right+ to set;
208
+ # omit to just read the current gravity
209
+ # @return [String] +"left"+ or +"right"+
210
+ def mark_gravity(name, direction = nil)
211
+ if direction
212
+ @app.command(@path, :mark, :gravity, name, direction)
213
+ else
214
+ @app.command(@path, :mark, :gravity, name)
215
+ end
216
+ end
217
+
218
+ # -- Search ----------------------------------------------------------
219
+
220
+ # @param pattern [String]
221
+ # @param from [String, Symbol] where to start searching
222
+ # @param to [String, Symbol] the search boundary - with
223
+ # +backwards: true+ this is the earliest index the search may
224
+ # reach, same as plain Tk +search+
225
+ # @param backwards [Boolean]
226
+ # @param regexp [Boolean] treat +pattern+ as a regular expression
227
+ # @param nocase [Boolean]
228
+ # @return [String, nil] the matching index, or +nil+ if not found
229
+ def search(pattern, from: 'insert', to: 'end', backwards: false, regexp: false, nocase: false)
230
+ args = []
231
+ args << '-backward' if backwards
232
+ args << '-regexp' if regexp
233
+ args << '-nocase' if nocase
234
+ args << '--'
235
+ result = @app.command(@path, :search, *args, pattern, resolve_index(from), resolve_index(to))
236
+ result.empty? ? nil : result
237
+ end
238
+
239
+ # -- View / cursor / state --------------------------------------------
240
+
241
+ # Scrolls the view so +index+ is visible.
242
+ # @param index [String, Symbol]
243
+ # @return [void]
244
+ def scroll_to(index)
245
+ @app.command(@path, :see, resolve_index(index))
246
+ end
247
+ alias_method :see, :scroll_to
248
+
249
+ # Resolves any index expression to its canonical +"line.char"+ form.
250
+ # @param spec [String, Symbol]
251
+ # @return [String]
252
+ def index(spec)
253
+ @app.command(@path, :index, resolve_index(spec))
254
+ end
255
+
256
+ # @return [String] the text cursor's current position (the +insert+
257
+ # mark), as +"line.char"+
258
+ def cursor
259
+ index('insert')
260
+ end
261
+
262
+ # Moves the text cursor.
263
+ # @param spec [String, Symbol]
264
+ # @return [void]
265
+ def cursor=(spec)
266
+ add_marker('insert', at: spec)
267
+ end
268
+
269
+ # @return [Boolean] whether this widget currently rejects direct
270
+ # typing/edits (its Tk +-state+ is +disabled+) - {#insert}/
271
+ # {#delete}/etc still work regardless, temporarily lifting this
272
+ # for their own duration (see the class docs)
273
+ def read_only
274
+ @app.command(@path, :cget, '-state') == 'disabled'
275
+ end
276
+
277
+ # @param value [Boolean]
278
+ # @return [void]
279
+ def read_only=(value)
280
+ @app.command(@path, :configure, state: value ? :disabled : :normal)
281
+ end
282
+
283
+ # -- Embedded images ---------------------------------------------------
284
+
285
+ # Embeds an image inline in the text flow at +index+.
286
+ # @param index [String, Symbol]
287
+ # @param image [Image, Teek::Photo] anything whose +#to_s+ is a Tcl
288
+ # image name - a DSL {Image} or a raw {Teek::Photo} both work
289
+ # @return [void]
290
+ def insert_image(index, image:)
291
+ mutate { @app.command(@path, :image, :create, resolve_index(index), image: image) }
292
+ end
293
+ alias_method :image_create, :insert_image
294
+
295
+ private
296
+
297
+ def resolve_index(spec)
298
+ INDEX_ALIASES.fetch(spec, spec).to_s
299
+ end
300
+
301
+ def resolve_event(event)
302
+ event.start_with?('<') ? event : "<#{event}>"
303
+ end
304
+
305
+ def bind_format_event(name, event, handler)
306
+ @app.command(@path, :tag, :bind, name, event, proc { |*args| handler.call(*args) })
307
+ end
308
+
309
+ # See the class docs - every content-mutating method routes through
310
+ # here so a read-only (+-state disabled+) widget never silently
311
+ # swallows the mutation.
312
+ def mutate
313
+ was_read_only = read_only
314
+ self.read_only = false if was_read_only
315
+ yield
316
+ ensure
317
+ self.read_only = true if was_read_only
318
+ end
319
+ end
320
+ end
321
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teek
4
+ module UI
5
+ # Build-phase debug tooling: a readable ASCII tree of a {Document}'s
6
+ # current shape, and (opt-in) a step-by-step trace of how it got there.
7
+ # Deliberately its own class, not methods on {Document}/{Node} - those
8
+ # stay focused on constructing/indexing/parent-tracking the retained
9
+ # tree; this is purely an outside observer of it, built the same way an
10
+ # app author's own tooling would be (see {Document#subscribe}).
11
+ class TreeInspector
12
+ # One recorded moment of tree assembly - see {#log}.
13
+ # @!attribute action
14
+ # @return [Symbol] +:push+/+:pop+ (the build stack, see
15
+ # {WidgetDSL#current_path}) or +:append+ (a node added via
16
+ # {Node#add_child})
17
+ # @!attribute node
18
+ # @return [Node] the node pushed/popped/appended
19
+ # @!attribute path
20
+ # @return [String] for +:push+/+:pop+, the full ancestry breadcrumb
21
+ # this node had while on top of the stack; for +:append+, its new
22
+ # parent's own {Node#display_name}
23
+ Event = Data.define(:action, :node, :path) do
24
+ def to_s
25
+ case action
26
+ when :push then "-> #{path}"
27
+ when :pop then "<- #{path}"
28
+ when :append then "+ #{node.display_name} (under #{path})"
29
+ end
30
+ end
31
+ end
32
+
33
+ # @param document [Document]
34
+ # @param trace [Boolean] subscribe to +document+'s own internal
35
+ # build-event hook immediately, recording every stack push/pop and
36
+ # node append from this point on - see {#log}. +false+ (the
37
+ # default) costs nothing beyond this object's own existence:
38
+ # +document+ never learns whether a TreeInspector is watching or
39
+ # not, on or off.
40
+ def initialize(document, trace: false)
41
+ @document = document
42
+ @log = []
43
+ subscribe! if trace
44
+ end
45
+
46
+ # A readable ASCII tree of the document's CURRENT shape - type,
47
+ # name, and (where present) +opts[:text]+/+opts[:label]+, box-drawn
48
+ # by nesting. Reflects whatever the tree looks like right now; call
49
+ # it again after more building to see the updated shape.
50
+ # @return [String]
51
+ # @example
52
+ # root
53
+ # └─ column
54
+ # ├─ label "Title"
55
+ # ├─ row
56
+ # │ ├─ button "OK"
57
+ # │ └─ button "Cancel"
58
+ # └─ label "Footer"
59
+ def to_s
60
+ lines = [@document.root.display_name]
61
+ append_tree_lines(@document.root, '', lines)
62
+ lines.join("\n")
63
+ end
64
+
65
+ # {#to_s}, straight to stdout.
66
+ # @return [void]
67
+ def print_tree
68
+ puts to_s
69
+ end
70
+
71
+ # @return [Array<Event>] every push/pop/append recorded so far, in
72
+ # order - always empty unless constructed with +trace: true+
73
+ attr_reader :log
74
+
75
+ private
76
+
77
+ def subscribe!
78
+ @document.subscribe(:push) { |node, path| @log << Event.new(action: :push, node: node, path: path) }
79
+ @document.subscribe(:pop) { |node, path| @log << Event.new(action: :pop, node: node, path: path) }
80
+ @document.subscribe(:append) { |parent, child| @log << Event.new(action: :append, node: child, path: parent.display_name) }
81
+ end
82
+
83
+ def append_tree_lines(node, prefix, lines)
84
+ node.children.each_with_index do |child, i|
85
+ last = i == node.children.length - 1
86
+ lines << "#{prefix}#{last ? '└─ ' : '├─ '}#{tree_label(child)}"
87
+ append_tree_lines(child, prefix + (last ? ' ' : '│ '), lines)
88
+ end
89
+ end
90
+
91
+ def tree_label(node)
92
+ text = node.opts[:text] || node.opts[:label]
93
+ text ? "#{node.display_name} #{text.to_s.inspect}" : node.display_name
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+ require_relative 'widget_validators'
5
+ require_relative 'grid_validator'
6
+ require_relative 'tab_validator'
7
+ require_relative 'pane_validator'
8
+ require_relative 'overlay_validator'
9
+
10
+ module Teek
11
+ module UI
12
+ # Walks a {Document} before realize and collects ALL problems, so a
13
+ # broken build can be fixed in one pass instead of a cycle of "run, hit
14
+ # the next cryptic Tcl error, fix, repeat." Headless - no interpreter
15
+ # needed, since it only ever inspects the tree.
16
+ #
17
+ # Two severities: problems that are "definitely broken" raise (folded
18
+ # into one {ValidationError} listing every one found); problems that
19
+ # are "probably a mistake" warn via +Kernel#warn+ by default, or raise
20
+ # too under +strict: true+.
21
+ #
22
+ # This class runs the checks that span the whole tree or relate
23
+ # arbitrary nodes to each other (dangling event targets, orphans). A
24
+ # specific widget/container's own contract (a grid's children all need
25
+ # cells, a tab's parent must be a ui.tabs, ...) lives in its own
26
+ # {WidgetValidators}-registered validator instead (see
27
+ # {GridValidator}/{TabValidator}/{PaneValidator}/{OverlayValidator}), dispatched by node
28
+ # type the same way {Teek::CommandInterceptors} dispatches by widget
29
+ # type. One depth-first walk covers both the document-level checks
30
+ # below and every registered widget validator.
31
+ #
32
+ # A {WidgetType} descriptor's own +validator:+ (see {WidgetTypes})
33
+ # needs no separate handling here at all - {WidgetTypes.register}
34
+ # forwards it into {WidgetValidators} directly, so it's dispatched
35
+ # through the exact same +WidgetValidators.for_type+ call every other
36
+ # validator already goes through.
37
+ #
38
+ # @note "Mixed pack+grid geometry in one container" (the classic Tk-hangs
39
+ # hazard) isn't checked here because, within the pure DSL layout path,
40
+ # it can't happen: {Realizer#arrange_children} picks exactly one
41
+ # arrangement strategy per container, from that container's own node
42
+ # type, and every container realizes into its own dedicated Tk master
43
+ # - a guarantee locked down by a realizer test (test_realizer.rb)
44
+ # asserting no master ever receives calls from more than one manager,
45
+ # so a future refactor that shares/flattens frames fails a test
46
+ # instead of shipping the hazard silently.
47
+ #
48
+ # That guarantee only covers what the DSL itself can construct,
49
+ # though - it says nothing about the escape hatch. +ui.raw+,
50
+ # +session.app.command+, and a live handle's own +app.command+ calls
51
+ # are opaque Procs the tree-walking validator can't see inside, so a
52
+ # raw +pack+/+grid+ call from one of those can still target a master
53
+ # the DSL already manages - that's the one real, unguardable vector,
54
+ # not "direct Node/Document manipulation" (which is what the narrower
55
+ # checks in {GridValidator}/{TabValidator}/{PaneValidator} actually
56
+ # guard against). If it happens, Tk itself is a synchronous backstop:
57
+ # it refuses a second geometry manager on an already-managed master
58
+ # with an immediate, clear +Teek::TclError+ ("cannot use geometry
59
+ # manager X inside Y") rather than the classic silent hang - but the
60
+ # DSL has no way to stop the mistake up front, so avoid mixing raw
61
+ # geometry calls onto a DSL-managed master (see the README's escape
62
+ # hatch section).
63
+ class Validator
64
+ # @param document [Document]
65
+ # @param strict [Boolean] promote warn-level problems (currently just
66
+ # orphans) to raise-level too
67
+ # @raise [ValidationError]
68
+ def self.validate!(document, strict: false)
69
+ new(document, strict: strict).validate!
70
+ end
71
+
72
+ # @api private
73
+ def initialize(document, strict: false)
74
+ @document = document
75
+ @strict = strict
76
+ @errors = []
77
+ @warnings = []
78
+ @reachable = {}
79
+ end
80
+
81
+ # @return [void]
82
+ def validate!
83
+ walk(@document.root, nil)
84
+ check_orphans
85
+
86
+ @warnings.each { |message| warn "teek-ui: #{message}" }
87
+ raise ValidationError, @errors.join("\n") if @errors.any?
88
+ end
89
+
90
+ private
91
+
92
+ # The single tree traversal every check below rides along on - marks
93
+ # each node reachable (for {#check_orphans}), dispatches to every
94
+ # {WidgetValidators}-registered validator for the node's own type,
95
+ # and runs {GridValidator#check_stray_cell}/{OverlayValidator#check_stray_overlay}
96
+ # (see their own comments for why those can't be type-dispatched)
97
+ # plus the dangling-event-target check, all of which genuinely span
98
+ # arbitrary node types.
99
+ def walk(node, parent)
100
+ @reachable[node] = true
101
+
102
+ GridValidator.check_stray_cell(node, parent, @errors)
103
+ OverlayValidator.check_stray_overlay(node, parent, @errors)
104
+ WidgetValidators.for_type(node.type).each { |validator| validator.call(node, parent, @document, @errors) }
105
+ check_dangling_event_targets(node)
106
+
107
+ node.children.each { |child| walk(child, node) }
108
+ end
109
+
110
+ def check_dangling_event_targets(node)
111
+ node.events.each do |binding|
112
+ next unless binding.target
113
+ next if @document.find(binding.target, scope: node.scope)
114
+
115
+ @errors << "#{WidgetValidators.describe(node)}'s event binding targets :#{binding.target}, " \
116
+ "but no widget with that name exists"
117
+ end
118
+ end
119
+
120
+ def check_orphans
121
+ @document.each_named_node do |_name, node|
122
+ next if @reachable[node]
123
+
124
+ message = "#{WidgetValidators.describe(node)} is declared but never placed in the layout - " \
125
+ "it will exist but never realize/show"
126
+ @strict ? @errors << message : @warnings << message
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+
5
+ module Teek
6
+ module UI
7
+ # A reactive Tcl variable, wrapped for Ruby - Tk's own native
8
+ # -textvariable/-variable machinery, done properly instead of the
9
+ # hand-rolled "VAR_NAME constant + manual set_variable/get_variable"
10
+ # pattern this replaces. Widgets bound to the same Var stay in sync with
11
+ # each other for free (that part is entirely Tk's doing); this class
12
+ # adds typed Ruby access and an on_change callback on top.
13
+ #
14
+ # Its Tcl variable name is allocated at build time (see {WidgetDSL#var})
15
+ # - a plain string, no interpreter needed - so widgets can capture it as
16
+ # a `-variable`/`-textvariable` option before realize even happens. The
17
+ # variable itself, its initial value, and its change trace only become
18
+ # real at #realize.
19
+ class Var
20
+ # @return [String] the Tcl variable name
21
+ attr_reader :name
22
+
23
+ # @api private
24
+ def initialize(name, initial)
25
+ @name = name
26
+ @initial = initial
27
+ @on_change_callbacks = []
28
+ @app = nil
29
+ end
30
+
31
+ # The current value, coerced to match the initial value's type
32
+ # (Integer/Float/Boolean pass through typed; anything else is a String).
33
+ # @raise [NotRealizedError] before realize
34
+ def value
35
+ raise_unless_realized!
36
+ coerce(@app.get_variable(@name))
37
+ end
38
+
39
+ # @raise [NotRealizedError] before realize
40
+ def value=(new_value)
41
+ raise_unless_realized!
42
+ @app.set_variable(@name, to_tcl(new_value))
43
+ end
44
+
45
+ # Register a callback fired whenever the value changes, regardless of
46
+ # whether Ruby (#value=) or a bound widget caused it. Queues
47
+ # regardless of build/realize phase - there's only ever one
48
+ # underlying Tcl trace per Var, wired once at realize, so callbacks
49
+ # added later just join the same list.
50
+ # @yield [value] the new, coerced value
51
+ # @return [self]
52
+ def on_change(&block)
53
+ @on_change_callbacks << block
54
+ self
55
+ end
56
+
57
+ # Create the backing Tcl variable, set its initial value, and wire the
58
+ # change trace. Called once by {Session#realize}, before the widget
59
+ # tree realizes, so bound widgets display the initial value from the
60
+ # moment they're created rather than starting blank.
61
+ # @api private
62
+ def realize(app)
63
+ @app = app
64
+ @app.set_variable(@name, to_tcl(@initial))
65
+ cb_id = @app.register_callback(proc { |*| notify_change })
66
+ @app.tcl_eval("trace add variable #{@name} write {ruby_callback #{cb_id}}")
67
+ end
68
+
69
+ private
70
+
71
+ def notify_change
72
+ current = coerce(@app.get_variable(@name))
73
+ @on_change_callbacks.each { |callback| callback.call(current) }
74
+ end
75
+
76
+ def to_tcl(v)
77
+ case v
78
+ when true then '1'
79
+ when false then '0'
80
+ else v.to_s
81
+ end
82
+ end
83
+
84
+ def coerce(raw)
85
+ case @initial
86
+ when Integer then raw.to_i
87
+ when Float then raw.to_f
88
+ when true, false then raw == '1'
89
+ else raw
90
+ end
91
+ end
92
+
93
+ def raise_unless_realized!
94
+ raise NotRealizedError unless @app
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teek
4
+ module UI
5
+ VERSION = "0.1.0"
6
+ end
7
+ end