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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +106 -14
  3. data/examples/checkbox.rb +89 -0
  4. data/examples/counter.rb +50 -0
  5. data/examples/hello_world.rb +28 -0
  6. data/examples/layout_demo.rb +138 -0
  7. data/examples/modal.rb +76 -0
  8. data/examples/mouse.rb +60 -0
  9. data/examples/octagram_picker.rb +224 -0
  10. data/examples/picker.rb +150 -0
  11. data/examples/progress_bar.rb +90 -0
  12. data/examples/scroll_view.rb +64 -0
  13. data/examples/select.rb +64 -0
  14. data/examples/spinner.rb +66 -0
  15. data/examples/status_bar.rb +65 -0
  16. data/examples/stopwatch.rb +84 -0
  17. data/examples/table.rb +196 -0
  18. data/examples/tabs.rb +112 -0
  19. data/examples/text.rb +101 -0
  20. data/examples/theme_picker.rb +95 -0
  21. data/examples/todo.rb +242 -0
  22. data/lib/thaum/action.rb +30 -0
  23. data/lib/thaum/app.rb +87 -0
  24. data/lib/thaum/color.rb +97 -0
  25. data/lib/thaum/concerns/context_update.rb +40 -0
  26. data/lib/thaum/concerns/focus.rb +53 -0
  27. data/lib/thaum/concerns/layout.rb +349 -0
  28. data/lib/thaum/concerns/modal.rb +102 -0
  29. data/lib/thaum/concerns/tab_navigation.rb +97 -0
  30. data/lib/thaum/dispatch.rb +149 -0
  31. data/lib/thaum/escape_parser.rb +265 -0
  32. data/lib/thaum/event.rb +13 -0
  33. data/lib/thaum/events.rb +28 -0
  34. data/lib/thaum/hit_test.rb +28 -0
  35. data/lib/thaum/input_reader.rb +46 -0
  36. data/lib/thaum/key_event.rb +13 -0
  37. data/lib/thaum/keys.rb +55 -0
  38. data/lib/thaum/minitest.rb +64 -0
  39. data/lib/thaum/octagram.rb +76 -0
  40. data/lib/thaum/painter.rb +49 -0
  41. data/lib/thaum/rect.rb +5 -0
  42. data/lib/thaum/rendering/box_drawing.rb +186 -0
  43. data/lib/thaum/rendering/buffer.rb +84 -0
  44. data/lib/thaum/rendering/canvas.rb +219 -0
  45. data/lib/thaum/rendering/cell.rb +11 -0
  46. data/lib/thaum/rendering/renderer.rb +98 -0
  47. data/lib/thaum/rendering/style.rb +13 -0
  48. data/lib/thaum/run_loop.rb +182 -0
  49. data/lib/thaum/seq.rb +91 -0
  50. data/lib/thaum/sigil.rb +41 -0
  51. data/lib/thaum/sigils/button.rb +47 -0
  52. data/lib/thaum/sigils/checkbox.rb +57 -0
  53. data/lib/thaum/sigils/progress_bar.rb +65 -0
  54. data/lib/thaum/sigils/scroll_view.rb +115 -0
  55. data/lib/thaum/sigils/select.rb +56 -0
  56. data/lib/thaum/sigils/spinner.rb +39 -0
  57. data/lib/thaum/sigils/status_bar.rb +89 -0
  58. data/lib/thaum/sigils/table.rb +156 -0
  59. data/lib/thaum/sigils/tabs.rb +59 -0
  60. data/lib/thaum/sigils/text.rb +22 -0
  61. data/lib/thaum/sigils/text_input.rb +86 -0
  62. data/lib/thaum/terminal.rb +46 -0
  63. data/lib/thaum/themes.rb +267 -0
  64. data/lib/thaum/tree.rb +16 -0
  65. data/lib/thaum/version.rb +1 -1
  66. data/lib/thaum.rb +64 -1
  67. 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