thaum 0.1.0 → 0.2.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 +4 -4
- data/README.md +106 -14
- data/examples/checkbox.rb +89 -0
- data/examples/counter.rb +50 -0
- data/examples/hello_world.rb +28 -0
- data/examples/layout_demo.rb +138 -0
- data/examples/modal.rb +76 -0
- data/examples/mouse.rb +60 -0
- data/examples/octagram_picker.rb +224 -0
- data/examples/picker.rb +150 -0
- data/examples/progress_bar.rb +90 -0
- data/examples/scroll_view.rb +64 -0
- data/examples/select.rb +64 -0
- data/examples/spinner.rb +66 -0
- data/examples/status_bar.rb +65 -0
- data/examples/stopwatch.rb +84 -0
- data/examples/table.rb +196 -0
- data/examples/tabs.rb +112 -0
- data/examples/text.rb +101 -0
- data/examples/theme_picker.rb +95 -0
- data/examples/todo.rb +242 -0
- data/lib/thaum/action.rb +30 -0
- data/lib/thaum/app.rb +87 -0
- data/lib/thaum/color.rb +97 -0
- data/lib/thaum/concerns/context_update.rb +40 -0
- data/lib/thaum/concerns/focus.rb +53 -0
- data/lib/thaum/concerns/layout.rb +349 -0
- data/lib/thaum/concerns/modal.rb +102 -0
- data/lib/thaum/concerns/tab_navigation.rb +97 -0
- data/lib/thaum/dispatch.rb +149 -0
- data/lib/thaum/escape_parser.rb +265 -0
- data/lib/thaum/event.rb +13 -0
- data/lib/thaum/events.rb +28 -0
- data/lib/thaum/hit_test.rb +28 -0
- data/lib/thaum/input_reader.rb +46 -0
- data/lib/thaum/key_event.rb +13 -0
- data/lib/thaum/keys.rb +55 -0
- data/lib/thaum/minitest.rb +64 -0
- data/lib/thaum/octagram.rb +76 -0
- data/lib/thaum/painter.rb +49 -0
- data/lib/thaum/rect.rb +5 -0
- data/lib/thaum/rendering/box_drawing.rb +186 -0
- data/lib/thaum/rendering/buffer.rb +84 -0
- data/lib/thaum/rendering/canvas.rb +219 -0
- data/lib/thaum/rendering/cell.rb +11 -0
- data/lib/thaum/rendering/renderer.rb +98 -0
- data/lib/thaum/rendering/style.rb +13 -0
- data/lib/thaum/run_loop.rb +182 -0
- data/lib/thaum/seq.rb +91 -0
- data/lib/thaum/sigil.rb +41 -0
- data/lib/thaum/sigils/button.rb +47 -0
- data/lib/thaum/sigils/checkbox.rb +57 -0
- data/lib/thaum/sigils/progress_bar.rb +65 -0
- data/lib/thaum/sigils/scroll_view.rb +115 -0
- data/lib/thaum/sigils/select.rb +56 -0
- data/lib/thaum/sigils/spinner.rb +39 -0
- data/lib/thaum/sigils/status_bar.rb +89 -0
- data/lib/thaum/sigils/table.rb +156 -0
- data/lib/thaum/sigils/tabs.rb +59 -0
- data/lib/thaum/sigils/text.rb +22 -0
- data/lib/thaum/sigils/text_input.rb +86 -0
- data/lib/thaum/terminal.rb +46 -0
- data/lib/thaum/themes.rb +267 -0
- data/lib/thaum/tree.rb +16 -0
- data/lib/thaum/version.rb +1 -1
- data/lib/thaum.rb +64 -1
- metadata +114 -4
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thaum
|
|
4
|
+
module Concerns
|
|
5
|
+
module Layout
|
|
6
|
+
attr_reader :rect, :leaf_sigils, :subtree_leaves, :child_layouts, :subtree_children
|
|
7
|
+
|
|
8
|
+
# Override on any Layout node (including App) to specify a Tab traversal
|
|
9
|
+
# order for that subtree. Return an array of leaf Sigils. nil (default)
|
|
10
|
+
# means "use left-to-right leaf order."
|
|
11
|
+
def focus_order = nil
|
|
12
|
+
|
|
13
|
+
# Called by the run loop (or repartition) to assign geometry and walk the partition tree.
|
|
14
|
+
# Returns the flat list of leaf Sigils in render order.
|
|
15
|
+
def run_partition(rect:, collector: nil)
|
|
16
|
+
@rect = rect
|
|
17
|
+
collector ||= []
|
|
18
|
+
start = collector.size
|
|
19
|
+
@leaf_sigils = collector
|
|
20
|
+
@child_layouts = []
|
|
21
|
+
@subtree_children = []
|
|
22
|
+
# An Octagram may inset the rect its children partition into so its
|
|
23
|
+
# render hook (border, padding) survives. Plain Layout passes through.
|
|
24
|
+
@rect = inset_for_partition(rect)
|
|
25
|
+
partition
|
|
26
|
+
@rect = rect
|
|
27
|
+
@subtree_leaves = collector[start..] || []
|
|
28
|
+
collector
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Override on Octagram (or any Layout) to inset the rect that this
|
|
32
|
+
# node's children partition into. Defaults to identity.
|
|
33
|
+
def inset_for_partition(rect)
|
|
34
|
+
return rect unless respond_to?(:partition_inset)
|
|
35
|
+
|
|
36
|
+
inset = partition_inset || {}
|
|
37
|
+
Rect.new(
|
|
38
|
+
x: rect.x + (inset[:left] || 0),
|
|
39
|
+
y: rect.y + (inset[:top] || 0),
|
|
40
|
+
width: [rect.width - (inset[:left] || 0) - (inset[:right] || 0), 0].max,
|
|
41
|
+
height: [rect.height - (inset[:top] || 0) - (inset[:bottom] || 0), 0].max
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Re-run partition for this node's subtree using its current rect.
|
|
46
|
+
def repartition
|
|
47
|
+
return unless @rect
|
|
48
|
+
|
|
49
|
+
old_leaves = @leaf_sigils || []
|
|
50
|
+
old_octagrams = collect_octagrams
|
|
51
|
+
|
|
52
|
+
new_leaves = []
|
|
53
|
+
run_partition(rect: @rect, collector: new_leaves)
|
|
54
|
+
|
|
55
|
+
new_octagrams = collect_octagrams
|
|
56
|
+
|
|
57
|
+
# Fire on_unmount for removed Sigils and Octagrams.
|
|
58
|
+
(old_leaves - new_leaves).each(&:on_unmount)
|
|
59
|
+
(old_octagrams - new_octagrams).each(&:on_unmount)
|
|
60
|
+
|
|
61
|
+
# Rewire handler parents across the (possibly restructured) subtree so
|
|
62
|
+
# newly-added Octagrams and Sigils see the right chain BEFORE on_mount.
|
|
63
|
+
# Only rewires when we can identify this node's role in the dispatch
|
|
64
|
+
# chain — App (root) or Octagram (its own handler scope).
|
|
65
|
+
app = thaum_app_ref || (is_a?(Octagram) ? @thaum_app : nil)
|
|
66
|
+
wire_handler_parents(handler_parent: self, app: app) if app
|
|
67
|
+
|
|
68
|
+
# Fire on_mount for newly-added Sigils and Octagrams.
|
|
69
|
+
(new_octagrams - old_octagrams).each do |o|
|
|
70
|
+
o.thaum_app = app if app
|
|
71
|
+
o.on_mount
|
|
72
|
+
end
|
|
73
|
+
(new_leaves - old_leaves).each do |s|
|
|
74
|
+
s.thaum_app = app if app
|
|
75
|
+
s.on_mount
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@leaf_sigils = new_leaves
|
|
79
|
+
|
|
80
|
+
# Re-validate focus_order in this subtree after structural change.
|
|
81
|
+
validate_focus_order_tree
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Walk this subtree and collect every Octagram node (excluding self).
|
|
85
|
+
def collect_octagrams
|
|
86
|
+
result = []
|
|
87
|
+
(@subtree_children || []).each do |child|
|
|
88
|
+
if child.is_a?(Octagram)
|
|
89
|
+
result << child
|
|
90
|
+
result.concat(child.collect_octagrams)
|
|
91
|
+
elsif child.respond_to?(:collect_octagrams)
|
|
92
|
+
result.concat(child.collect_octagrams)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
result
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Walk this Layout node and every descendant Layout, raising
|
|
99
|
+
# FocusOrderError if any defines a focus_order that does not exactly
|
|
100
|
+
# cover the focusable leaves in its subtree.
|
|
101
|
+
def validate_focus_order_tree
|
|
102
|
+
validate_focus_order_node
|
|
103
|
+
(@child_layouts || []).each(&:validate_focus_order_tree)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Build the Tab traversal order for this subtree, recursively expanding
|
|
107
|
+
# any nested Layout that itself has focus_order. When no focus_order is
|
|
108
|
+
# defined at any level, this falls through to left-to-right leaf order.
|
|
109
|
+
def effective_focus_order
|
|
110
|
+
order = focus_order
|
|
111
|
+
return order if order
|
|
112
|
+
|
|
113
|
+
result = []
|
|
114
|
+
layout_subtree_in_order.each do |node|
|
|
115
|
+
if node.is_a?(Sigil)
|
|
116
|
+
result << node if node.focusable?
|
|
117
|
+
else
|
|
118
|
+
result.concat(node.effective_focus_order)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
result
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The direct children of this Layout in declaration order — both Sigils
|
|
125
|
+
# and nested Layouts. Built during partition (see place_child).
|
|
126
|
+
def layout_subtree_in_order = @subtree_children || []
|
|
127
|
+
|
|
128
|
+
# Scope units for Tab cycling within THIS focus scope. A "scope" is the
|
|
129
|
+
# App or an Octagram. A unit is either a focusable Sigil (reached through
|
|
130
|
+
# plain Layouts only) or a nested Octagram (which appears as one unit and
|
|
131
|
+
# is itself a scope). Plain Layouts are transparent — their units are
|
|
132
|
+
# flattened into the parent scope.
|
|
133
|
+
def focus_scope_units
|
|
134
|
+
result = []
|
|
135
|
+
(@subtree_children || []).each do |child|
|
|
136
|
+
case child
|
|
137
|
+
when Sigil
|
|
138
|
+
result << child if child.focusable?
|
|
139
|
+
when Octagram
|
|
140
|
+
result << child if child.focusable_descendant?
|
|
141
|
+
else
|
|
142
|
+
result.concat(child.focus_scope_units) if child.respond_to?(:focus_scope_units)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
result
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Recursively descend into this Octagram (or plain Layout) to find its
|
|
149
|
+
# first focusable leaf — used when Tab enters an Octagram unit.
|
|
150
|
+
def first_focusable_leaf
|
|
151
|
+
(@subtree_children || []).each do |child|
|
|
152
|
+
case child
|
|
153
|
+
when Sigil
|
|
154
|
+
return child if child.focusable?
|
|
155
|
+
else
|
|
156
|
+
leaf = child.first_focusable_leaf if child.respond_to?(:first_focusable_leaf)
|
|
157
|
+
return leaf if leaf
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Mirror of first_focusable_leaf for Shift-Tab entry.
|
|
164
|
+
def last_focusable_leaf
|
|
165
|
+
(@subtree_children || []).reverse_each do |child|
|
|
166
|
+
case child
|
|
167
|
+
when Sigil
|
|
168
|
+
return child if child.focusable?
|
|
169
|
+
else
|
|
170
|
+
leaf = child.last_focusable_leaf if child.respond_to?(:last_focusable_leaf)
|
|
171
|
+
return leaf if leaf
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def focusable_descendant?
|
|
178
|
+
!first_focusable_leaf.nil?
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Walk the subtree top-down, wiring each leaf Sigil and each Octagram.
|
|
182
|
+
# Leaves and Octagrams get _handler_parent set to the innermost
|
|
183
|
+
# enclosing Octagram (or the App when there is none). Plain Layout
|
|
184
|
+
# nodes are transparent — their children inherit the outer parent.
|
|
185
|
+
def wire_handler_parents(handler_parent:, app:)
|
|
186
|
+
(@subtree_children || []).each do |child|
|
|
187
|
+
case child
|
|
188
|
+
when Sigil
|
|
189
|
+
child.handler_parent = handler_parent
|
|
190
|
+
child.thaum_app = app
|
|
191
|
+
when Octagram
|
|
192
|
+
child.handler_parent = handler_parent
|
|
193
|
+
child.thaum_app = app
|
|
194
|
+
child.wire_handler_parents(handler_parent: child, app: app)
|
|
195
|
+
else
|
|
196
|
+
child.wire_handler_parents(handler_parent:, app:) if child.respond_to?(:wire_handler_parents)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Override to specify the layout. Must call horizontal/vertical.
|
|
202
|
+
def partition; end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
def horizontal(&) = divide_axis(:cols, &)
|
|
207
|
+
def vertical(&) = divide_axis(:rows, &)
|
|
208
|
+
|
|
209
|
+
def region(**opts, &child_block)
|
|
210
|
+
@divide_frame << { opts: opts, block: child_block }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def divide_axis(axis, &block)
|
|
214
|
+
parent_rect = @rect
|
|
215
|
+
saved_frame = @divide_frame
|
|
216
|
+
@divide_frame = []
|
|
217
|
+
|
|
218
|
+
block.call
|
|
219
|
+
|
|
220
|
+
specs = @divide_frame
|
|
221
|
+
@divide_frame = saved_frame
|
|
222
|
+
|
|
223
|
+
validate_axis_kwargs(axis: axis, specs: specs)
|
|
224
|
+
|
|
225
|
+
calc_rects(parent_rect: parent_rect, axis: axis, specs: specs).each_with_index do |child_rect, i|
|
|
226
|
+
@rect = child_rect
|
|
227
|
+
place_child(child: specs[i][:block].call, rect: child_rect)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
@rect = parent_rect
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def validate_axis_kwargs(axis:, specs:)
|
|
234
|
+
expected, forbidden = axis == :cols ? %i[width height] : %i[height width]
|
|
235
|
+
specs.each do |spec|
|
|
236
|
+
next unless spec[:opts].key?(forbidden)
|
|
237
|
+
|
|
238
|
+
direction = axis == :cols ? "horizontal" : "vertical"
|
|
239
|
+
raise LayoutError,
|
|
240
|
+
"region(#{forbidden}:) is invalid inside #{direction} — use #{expected}: instead"
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def place_child(child:, rect:)
|
|
245
|
+
@subtree_children ||= []
|
|
246
|
+
if child.is_a?(Sigil)
|
|
247
|
+
child.rect = rect
|
|
248
|
+
@leaf_sigils << child
|
|
249
|
+
@subtree_children << child
|
|
250
|
+
elsif child.respond_to?(:run_partition)
|
|
251
|
+
@child_layouts << child
|
|
252
|
+
@subtree_children << child
|
|
253
|
+
child.run_partition(rect: rect, collector: @leaf_sigils)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def validate_focus_order_node
|
|
258
|
+
order = focus_order
|
|
259
|
+
return if order.nil?
|
|
260
|
+
|
|
261
|
+
unless order.is_a?(Array)
|
|
262
|
+
raise FocusOrderError,
|
|
263
|
+
"#{self.class}#focus_order must return an Array, got #{order.class}"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
focusable = (@subtree_leaves || []).select(&:focusable?)
|
|
267
|
+
missing = focusable - order
|
|
268
|
+
extras = order - focusable
|
|
269
|
+
duplicates = order.tally.select { |_, c| c > 1 }.keys
|
|
270
|
+
|
|
271
|
+
return if missing.empty? && extras.empty? && duplicates.empty?
|
|
272
|
+
|
|
273
|
+
msg = "#{self.class}#focus_order is invalid:"
|
|
274
|
+
msg += " missing #{missing.map(&:class).join(', ')};" unless missing.empty?
|
|
275
|
+
msg += " unknown entries #{extras.map(&:class).join(', ')};" unless extras.empty?
|
|
276
|
+
msg += " duplicates #{duplicates.map(&:class).join(', ')};" unless duplicates.empty?
|
|
277
|
+
raise FocusOrderError, msg
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def calc_rects(parent_rect:, axis:, specs:)
|
|
281
|
+
if axis == :cols
|
|
282
|
+
total = parent_rect.width
|
|
283
|
+
fixed_total = specs.sum { |s| integer_size(val: s[:opts][:width], total: total) || 0 }
|
|
284
|
+
fill_count = specs.count { |s| s[:opts][:width] == :fill }
|
|
285
|
+
fill_size = fill_count.positive? ? (total - fixed_total) / fill_count : 0
|
|
286
|
+
leftover = fill_count.positive? ? (total - fixed_total) % fill_count : 0
|
|
287
|
+
|
|
288
|
+
x = parent_rect.x
|
|
289
|
+
specs.each_with_index.map do |spec, i|
|
|
290
|
+
fill_index = specs[0..i].count { |s| s[:opts][:width] == :fill } - 1
|
|
291
|
+
last_fill = fill_index >= 0 && fill_index == fill_count - 1
|
|
292
|
+
|
|
293
|
+
w = resolved_size(val: spec[:opts][:width], total: total, fill_size: fill_size,
|
|
294
|
+
extra: last_fill ? leftover : 0)
|
|
295
|
+
w = apply_min_max(size: w, min: spec[:opts][:min], max: spec[:opts][:max])
|
|
296
|
+
r = Rect.new(x: x, y: parent_rect.y, width: w, height: parent_rect.height)
|
|
297
|
+
x += w
|
|
298
|
+
r
|
|
299
|
+
end
|
|
300
|
+
else
|
|
301
|
+
total = parent_rect.height
|
|
302
|
+
fixed_total = specs.sum { |s| integer_size(val: s[:opts][:height], total: total) || 0 }
|
|
303
|
+
fill_count = specs.count { |s| s[:opts][:height] == :fill }
|
|
304
|
+
fill_size = fill_count.positive? ? (total - fixed_total) / fill_count : 0
|
|
305
|
+
leftover = fill_count.positive? ? (total - fixed_total) % fill_count : 0
|
|
306
|
+
|
|
307
|
+
y = parent_rect.y
|
|
308
|
+
specs.each_with_index.map do |spec, i|
|
|
309
|
+
fill_index = specs[0..i].count { |s| s[:opts][:height] == :fill } - 1
|
|
310
|
+
last_fill = fill_index >= 0 && fill_index == fill_count - 1
|
|
311
|
+
|
|
312
|
+
h = resolved_size(val: spec[:opts][:height], total: total, fill_size: fill_size,
|
|
313
|
+
extra: last_fill ? leftover : 0)
|
|
314
|
+
h = apply_min_max(size: h, min: spec[:opts][:min], max: spec[:opts][:max])
|
|
315
|
+
r = Rect.new(x: parent_rect.x, y: y, width: parent_rect.width, height: h)
|
|
316
|
+
y += h
|
|
317
|
+
r
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def integer_size(val:, total:)
|
|
323
|
+
return val if val.is_a?(Integer)
|
|
324
|
+
return nil if val == :fill
|
|
325
|
+
return val.to_i * total / 100 if val.is_a?(String) && val.end_with?("%")
|
|
326
|
+
|
|
327
|
+
nil
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def resolved_size(val:, total:, fill_size:, extra:)
|
|
331
|
+
return val + 0 if val.is_a?(Integer)
|
|
332
|
+
return fill_size + extra if val == :fill
|
|
333
|
+
return val.to_i * total / 100 if val.is_a?(String) && val.end_with?("%")
|
|
334
|
+
|
|
335
|
+
fill_size + extra # default to fill if unspecified
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def apply_min_max(size:, min:, max:)
|
|
339
|
+
size = [size, min].max if min
|
|
340
|
+
size = [size, max].min if max
|
|
341
|
+
[size, 0].max
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Used by repartition to wire new sigils to the app.
|
|
345
|
+
# overridden in App
|
|
346
|
+
def thaum_app_ref = nil
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thaum
|
|
4
|
+
module Concerns
|
|
5
|
+
module Modal
|
|
6
|
+
# --- Modal ---
|
|
7
|
+
|
|
8
|
+
# Show a modal Sigil as an overlay above the layout tree. width/height
|
|
9
|
+
# are required; x/y are optional terminal-absolute coords — when nil
|
|
10
|
+
# the modal is centered on the current terminal size. Calling show_modal
|
|
11
|
+
# while a modal is already active fires on_blur + on_unmount on the
|
|
12
|
+
# outgoing modal and replaces it. The previously-focused Sigil (from
|
|
13
|
+
# before any modal was shown) is preserved so hide_modal can restore it.
|
|
14
|
+
def show_modal(sigil:, width:, height:, x: nil, y: nil)
|
|
15
|
+
replacing = !@modal_sigil.nil?
|
|
16
|
+
centered = x.nil? && y.nil?
|
|
17
|
+
|
|
18
|
+
if replacing
|
|
19
|
+
Thaum.safe_invoke("#{@modal_sigil.class}#on_blur") { @modal_sigil.on_blur }
|
|
20
|
+
Thaum.safe_invoke("#{@modal_sigil.class}#on_unmount") { @modal_sigil.on_unmount }
|
|
21
|
+
@modal_sigil.thaum_app = nil
|
|
22
|
+
@modal_sigil.handler_parent = nil
|
|
23
|
+
@modal_sigil.rect = nil
|
|
24
|
+
elsif @focused_sigil
|
|
25
|
+
# First modal — save the underlying focused Sigil and fire its on_blur.
|
|
26
|
+
@previous_focus = @focused_sigil
|
|
27
|
+
Thaum.safe_invoke("#{@focused_sigil.class}#on_blur") { @focused_sigil.on_blur }
|
|
28
|
+
@focused_sigil = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
rect = compute_modal_rect(width: width, height: height, x: x, y: y)
|
|
32
|
+
|
|
33
|
+
@modal_sigil = sigil
|
|
34
|
+
@modal_rect = rect
|
|
35
|
+
@modal_centered = centered
|
|
36
|
+
@modal_decl_w = width
|
|
37
|
+
@modal_decl_h = height
|
|
38
|
+
sigil.thaum_app = self
|
|
39
|
+
sigil.handler_parent = self
|
|
40
|
+
sigil.rect = rect
|
|
41
|
+
|
|
42
|
+
Thaum.safe_invoke("#{sigil.class}#on_mount") { sigil.on_mount }
|
|
43
|
+
Thaum.safe_invoke("#{sigil.class}#on_focus") { sigil.on_focus }
|
|
44
|
+
|
|
45
|
+
request_render
|
|
46
|
+
sigil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def hide_modal
|
|
50
|
+
sigil = @modal_sigil
|
|
51
|
+
return unless sigil
|
|
52
|
+
|
|
53
|
+
Thaum.safe_invoke("#{sigil.class}#on_blur") { sigil.on_blur }
|
|
54
|
+
Thaum.safe_invoke("#{sigil.class}#on_unmount") { sigil.on_unmount }
|
|
55
|
+
|
|
56
|
+
@modal_sigil = nil
|
|
57
|
+
@modal_rect = nil
|
|
58
|
+
@modal_centered = false
|
|
59
|
+
@modal_decl_w = nil
|
|
60
|
+
@modal_decl_h = nil
|
|
61
|
+
sigil.thaum_app = nil
|
|
62
|
+
sigil.handler_parent = nil
|
|
63
|
+
sigil.rect = nil
|
|
64
|
+
|
|
65
|
+
restored = @previous_focus
|
|
66
|
+
@previous_focus = nil
|
|
67
|
+
if restored && focusable_and_mounted?(restored)
|
|
68
|
+
@focused_sigil = restored
|
|
69
|
+
Thaum.safe_invoke("#{restored.class}#on_focus") { restored.on_focus }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
request_render
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def modal_active? = !@modal_sigil.nil?
|
|
77
|
+
|
|
78
|
+
# Called by the framework on ResizeEvent after the layout repartitions.
|
|
79
|
+
# Re-centers a default-centered modal on the new terminal dimensions.
|
|
80
|
+
# Modals with explicit x/y stay put.
|
|
81
|
+
def recompute_modal_rect
|
|
82
|
+
return unless @modal_sigil && @modal_centered
|
|
83
|
+
|
|
84
|
+
@modal_rect = compute_modal_rect(width: @modal_decl_w, height: @modal_decl_h, x: nil, y: nil)
|
|
85
|
+
@modal_sigil.rect = @modal_rect
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# Compute the modal's terminal-absolute rect from declared size + optional
|
|
91
|
+
# x/y. When x/y are nil, centers on the App's current rect (set by
|
|
92
|
+
# run_partition to the terminal's dimensions). Overflow is allowed —
|
|
93
|
+
# the Buffer drops out-of-bounds cells at paint time.
|
|
94
|
+
def compute_modal_rect(width:, height:, x:, y:)
|
|
95
|
+
app_rect = @rect || Rect.new(x: 0, y: 0, width: 0, height: 0)
|
|
96
|
+
cx = x.nil? ? app_rect.x + ((app_rect.width - width) / 2) : x
|
|
97
|
+
cy = y.nil? ? app_rect.y + ((app_rect.height - height) / 2) : y
|
|
98
|
+
Rect.new(x: cx, y: cy, width: width, height: height)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thaum
|
|
4
|
+
module Concerns
|
|
5
|
+
module TabNavigation
|
|
6
|
+
# Called by the framework before App#on_key sees a bubbled :tab.
|
|
7
|
+
# Shift-Tab moves focus backward; plain Tab moves forward. The App's
|
|
8
|
+
# on_key then runs with focus already updated (see spec — Tab pipeline).
|
|
9
|
+
#
|
|
10
|
+
# When the focused Sigil is inside an Octagram, cycling is SCOPED to
|
|
11
|
+
# that Octagram's focusable units first. Reaching the boundary bubbles
|
|
12
|
+
# to the parent scope (next outer Octagram, or the App), which treats
|
|
13
|
+
# the inner Octagram as a single unit. The App falls back to the flat
|
|
14
|
+
# focus_next/focus_prev path only when no Octagram boundaries are
|
|
15
|
+
# involved — preserving existing behavior for Octagram-free apps.
|
|
16
|
+
def handle_tab_cycle(event)
|
|
17
|
+
direction = event.shift? ? :prev : :next
|
|
18
|
+
sigil = @focused_sigil
|
|
19
|
+
|
|
20
|
+
if sigil && inside_octagram?(sigil)
|
|
21
|
+
target = scoped_tab_target(sigil: sigil, direction: direction)
|
|
22
|
+
focus(target) if target
|
|
23
|
+
else
|
|
24
|
+
direction == :next ? focus_next : focus_prev
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# True when the Sigil's handler chain crosses at least one Octagram.
|
|
31
|
+
def inside_octagram?(sigil)
|
|
32
|
+
parent = sigil.handler_parent
|
|
33
|
+
while parent
|
|
34
|
+
return true if parent.is_a?(Octagram)
|
|
35
|
+
|
|
36
|
+
parent = parent.respond_to?(:handler_parent) ? parent.handler_parent : nil
|
|
37
|
+
end
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Walk scopes from innermost outward. At each scope, find the unit
|
|
42
|
+
# representing where we currently are (the sigil itself, or the inner
|
|
43
|
+
# Octagram unit we just bubbled out of). Step one unit in `direction`.
|
|
44
|
+
# If stepping would wrap past the scope boundary, bubble to the parent
|
|
45
|
+
# scope. App scope wraps within itself (the final fallback).
|
|
46
|
+
def scoped_tab_target(sigil:, direction:)
|
|
47
|
+
scope = innermost_octagram_for(sigil)
|
|
48
|
+
current_unit = sigil
|
|
49
|
+
|
|
50
|
+
loop do
|
|
51
|
+
units = scope.focus_scope_units
|
|
52
|
+
idx = units.index(current_unit)
|
|
53
|
+
# Defensive: if current_unit isn't in this scope's units, treat as
|
|
54
|
+
# entering from before the first (next) or after the last (prev).
|
|
55
|
+
return resolve_unit(unit: units.first, direction: direction) if idx.nil? && !units.empty?
|
|
56
|
+
|
|
57
|
+
step = direction == :next ? 1 : -1
|
|
58
|
+
new_idx = idx + step
|
|
59
|
+
in_bounds = new_idx >= 0 && new_idx < units.size
|
|
60
|
+
|
|
61
|
+
if in_bounds
|
|
62
|
+
return resolve_unit(unit: units[new_idx], direction: direction)
|
|
63
|
+
elsif scope.equal?(self)
|
|
64
|
+
# At App scope — wrap.
|
|
65
|
+
wrapped = direction == :next ? units.first : units.last
|
|
66
|
+
return resolve_unit(unit: wrapped, direction: direction)
|
|
67
|
+
else
|
|
68
|
+
# Bubble to parent scope; the Octagram itself becomes the unit.
|
|
69
|
+
current_unit = scope
|
|
70
|
+
scope = innermost_octagram_for(scope) || self
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Drill into a unit to get the focusable Sigil to focus. Plain Sigil →
|
|
76
|
+
# itself. Octagram → first (or last) focusable leaf.
|
|
77
|
+
def resolve_unit(unit:, direction:)
|
|
78
|
+
return unit if unit.is_a?(Sigil)
|
|
79
|
+
return nil unless unit.respond_to?(:first_focusable_leaf)
|
|
80
|
+
|
|
81
|
+
direction == :next ? unit.first_focusable_leaf : unit.last_focusable_leaf
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Innermost enclosing Octagram in the handler chain, or nil if none
|
|
85
|
+
# (focused sigil is directly under App with no Octagram between).
|
|
86
|
+
def innermost_octagram_for(node)
|
|
87
|
+
parent = node.handler_parent
|
|
88
|
+
while parent
|
|
89
|
+
return parent if parent.is_a?(Octagram)
|
|
90
|
+
|
|
91
|
+
parent = parent.respond_to?(:handler_parent) ? parent.handler_parent : nil
|
|
92
|
+
end
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thaum
|
|
4
|
+
# Routes an event to the right handler.
|
|
5
|
+
#
|
|
6
|
+
# Two entry points share the same per-event-type case in {#invoke_handler}:
|
|
7
|
+
#
|
|
8
|
+
# {.from_queue} — events popped off the main run-loop queue. Routes by
|
|
9
|
+
# target (modal vs focused Sigil vs App) and returns
|
|
10
|
+
# true when the caller should mark the app dirty.
|
|
11
|
+
# {.from_child} — bubbled events from a Sigil's emit. Routes to the
|
|
12
|
+
# handler parent (next outer Octagram, or the App).
|
|
13
|
+
module Dispatch
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Route one event popped off the main queue. Sets the dirty flag for
|
|
17
|
+
# every routed event that does not opt out (TickEvent and unknown
|
|
18
|
+
# objects are the only opt-outs).
|
|
19
|
+
def from_queue(app:, event:)
|
|
20
|
+
if quit_shortcut?(event)
|
|
21
|
+
app.quit
|
|
22
|
+
return
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
modal = app.modal_sigil
|
|
26
|
+
auto_dirty =
|
|
27
|
+
case event
|
|
28
|
+
when KeyEvent then route_key(app: app, modal: modal, event: event)
|
|
29
|
+
when PasteEvent then route_paste(app: app, modal: modal, event: event)
|
|
30
|
+
when MouseEvent then route_mouse(app: app, modal: modal, event: event)
|
|
31
|
+
when ResizeEvent
|
|
32
|
+
Thaum.safe_invoke("App#on_resize") { app.on_resize(event) }
|
|
33
|
+
true
|
|
34
|
+
when TickEvent
|
|
35
|
+
route_tick(app: app, modal: modal, event: event)
|
|
36
|
+
false
|
|
37
|
+
when Event
|
|
38
|
+
Thaum.safe_invoke("App#on_event") { app.on_event(event) }
|
|
39
|
+
true
|
|
40
|
+
else
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
app.request_render if auto_dirty
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Bubbled from a Sigil emit. Caller (App or Octagram) supplies its own
|
|
48
|
+
# safe_invoke label so the stderr trace identifies the handler.
|
|
49
|
+
def invoke_handler(target:, event:, label:)
|
|
50
|
+
Thaum.safe_invoke(label) do
|
|
51
|
+
case event
|
|
52
|
+
when KeyEvent then target.on_key(event)
|
|
53
|
+
when MouseEvent then target.on_mouse(event)
|
|
54
|
+
when PasteEvent then target.on_paste(event)
|
|
55
|
+
else
|
|
56
|
+
target.on_event(event) if event.is_a?(Event)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def quit_shortcut?(event)
|
|
62
|
+
event.is_a?(KeyEvent) && event.ctrl? && event.key == "c"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def route_key(app:, modal:, event:)
|
|
66
|
+
return handle_modal_key(app: app, modal: modal, event: event) if modal
|
|
67
|
+
|
|
68
|
+
if (focused = app.focused_sigil)
|
|
69
|
+
Thaum.safe_invoke("#{focused.class}#on_key") { focused.on_key(event) }
|
|
70
|
+
else
|
|
71
|
+
app.handle_tab_cycle(event) if event.key == :tab
|
|
72
|
+
Thaum.safe_invoke("App#on_key") { app.on_key(event) }
|
|
73
|
+
end
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def handle_modal_key(app:, modal:, event:)
|
|
78
|
+
# Plain Escape dismisses the modal — handler never sees it.
|
|
79
|
+
if event.key == :escape && !event.ctrl? && !event.alt? && !event.shift?
|
|
80
|
+
app.hide_modal
|
|
81
|
+
return true
|
|
82
|
+
end
|
|
83
|
+
# Tab / Shift-Tab are eaten while a modal is active.
|
|
84
|
+
return false if event.key == :tab
|
|
85
|
+
|
|
86
|
+
Thaum.safe_invoke("#{modal.class}#on_key") { modal.on_key(event) }
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def route_paste(app:, modal:, event:)
|
|
91
|
+
target =
|
|
92
|
+
if modal
|
|
93
|
+
modal
|
|
94
|
+
elsif (focused = app.focused_sigil)
|
|
95
|
+
focused
|
|
96
|
+
else
|
|
97
|
+
app
|
|
98
|
+
end
|
|
99
|
+
label = target.equal?(app) ? "App#on_paste" : "#{target.class}#on_paste"
|
|
100
|
+
Thaum.safe_invoke(label) { target.on_paste(event) }
|
|
101
|
+
true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def route_mouse(app:, modal:, event:)
|
|
105
|
+
if modal
|
|
106
|
+
dispatch_modal_mouse(modal: modal, event: event, rect: app.modal_rect)
|
|
107
|
+
else
|
|
108
|
+
dispatch_mouse_event(app: app, event: event)
|
|
109
|
+
end
|
|
110
|
+
true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def route_tick(app:, modal:, event:)
|
|
114
|
+
Thaum.safe_invoke("App#on_tick") { app.on_tick(event) }
|
|
115
|
+
Tree.walk(app) do |node|
|
|
116
|
+
next unless node.is_a?(Sigil) || node.is_a?(Octagram)
|
|
117
|
+
|
|
118
|
+
Thaum.safe_invoke("#{node.class}#on_tick") { node.on_tick(event) }
|
|
119
|
+
end
|
|
120
|
+
# Modal Sigil ticks last, after the layout tree (per spec).
|
|
121
|
+
Thaum.safe_invoke("#{modal.class}#on_tick") { modal.on_tick(event) } if modal
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Hit-test by absolute coords, set canvas-relative x/y on the event, and
|
|
125
|
+
# dispatch to the hit Sigil's on_mouse. If no Sigil is hit, dispatch to
|
|
126
|
+
# the App's on_mouse with abs_x/abs_y unchanged. On :press, transfer
|
|
127
|
+
# focus to the hit Sigil if focusable.
|
|
128
|
+
def dispatch_mouse_event(app:, event:)
|
|
129
|
+
hit = HitTest.hit(app: app, abs_x: event.abs_x, abs_y: event.abs_y)
|
|
130
|
+
if hit
|
|
131
|
+
r = hit.rect
|
|
132
|
+
localized = event.with(x: event.abs_x - r.x, y: event.abs_y - r.y)
|
|
133
|
+
Thaum.safe_invoke("App#focus") { app.focus(hit) } if event.action == :press && hit.focusable?
|
|
134
|
+
Thaum.safe_invoke("#{hit.class}#on_mouse") { hit.on_mouse(localized) }
|
|
135
|
+
else
|
|
136
|
+
Thaum.safe_invoke("App#on_mouse") { app.on_mouse(event) }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Route a MouseEvent to the modal Sigil, translating absolute coords to
|
|
141
|
+
# rect-relative. Out-of-bounds clicks are eaten.
|
|
142
|
+
def dispatch_modal_mouse(modal:, event:, rect:)
|
|
143
|
+
return unless rect && HitTest.point_in_rect?(x: event.abs_x, y: event.abs_y, rect: rect)
|
|
144
|
+
|
|
145
|
+
local = event.with(x: event.abs_x - rect.x, y: event.abs_y - rect.y)
|
|
146
|
+
Thaum.safe_invoke("#{modal.class}#on_mouse") { modal.on_mouse(local) }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|