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,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
|
data/lib/teek/ui/var.rb
ADDED
|
@@ -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
|