thaum 0.1.0 → 0.2.1

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +106 -14
  4. data/examples/checkbox.rb +89 -0
  5. data/examples/counter.rb +50 -0
  6. data/examples/hello_world.rb +28 -0
  7. data/examples/layout_demo.rb +138 -0
  8. data/examples/modal.rb +76 -0
  9. data/examples/mouse.rb +60 -0
  10. data/examples/octagram_picker.rb +224 -0
  11. data/examples/picker.rb +150 -0
  12. data/examples/progress_bar.rb +90 -0
  13. data/examples/scroll_view.rb +64 -0
  14. data/examples/select.rb +64 -0
  15. data/examples/spinner.rb +66 -0
  16. data/examples/status_bar.rb +65 -0
  17. data/examples/stopwatch.rb +84 -0
  18. data/examples/table.rb +196 -0
  19. data/examples/tabs.rb +112 -0
  20. data/examples/text.rb +101 -0
  21. data/examples/theme_picker.rb +95 -0
  22. data/examples/todo.rb +242 -0
  23. data/lib/thaum/action.rb +48 -0
  24. data/lib/thaum/app.rb +87 -0
  25. data/lib/thaum/color.rb +104 -0
  26. data/lib/thaum/concerns/context_update.rb +40 -0
  27. data/lib/thaum/concerns/focus.rb +53 -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 +115 -0
  36. data/lib/thaum/key_event.rb +13 -0
  37. data/lib/thaum/keys.rb +55 -0
  38. data/lib/thaum/layout.rb +347 -0
  39. data/lib/thaum/minitest.rb +64 -0
  40. data/lib/thaum/octagram.rb +76 -0
  41. data/lib/thaum/painter.rb +49 -0
  42. data/lib/thaum/rect.rb +5 -0
  43. data/lib/thaum/rendering/box_drawing.rb +186 -0
  44. data/lib/thaum/rendering/buffer.rb +84 -0
  45. data/lib/thaum/rendering/canvas.rb +221 -0
  46. data/lib/thaum/rendering/cell.rb +11 -0
  47. data/lib/thaum/rendering/renderer.rb +98 -0
  48. data/lib/thaum/rendering/style.rb +13 -0
  49. data/lib/thaum/run_loop.rb +182 -0
  50. data/lib/thaum/seq.rb +91 -0
  51. data/lib/thaum/sigil.rb +41 -0
  52. data/lib/thaum/sigils/button.rb +47 -0
  53. data/lib/thaum/sigils/checkbox.rb +57 -0
  54. data/lib/thaum/sigils/progress_bar.rb +65 -0
  55. data/lib/thaum/sigils/scroll_view.rb +115 -0
  56. data/lib/thaum/sigils/select.rb +56 -0
  57. data/lib/thaum/sigils/spinner.rb +39 -0
  58. data/lib/thaum/sigils/status_bar.rb +89 -0
  59. data/lib/thaum/sigils/table.rb +156 -0
  60. data/lib/thaum/sigils/tabs.rb +59 -0
  61. data/lib/thaum/sigils/text.rb +22 -0
  62. data/lib/thaum/sigils/text_input.rb +86 -0
  63. data/lib/thaum/terminal.rb +46 -0
  64. data/lib/thaum/themes.rb +267 -0
  65. data/lib/thaum/tree.rb +16 -0
  66. data/lib/thaum/version.rb +1 -1
  67. data/lib/thaum.rb +64 -1
  68. metadata +115 -4
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ KeyEvent = Event.define(:key, :ctrl, :alt, :shift) do
5
+ def initialize(key:, ctrl: false, alt: false, shift: false)
6
+ super
7
+ end
8
+
9
+ def ctrl? = ctrl
10
+ def alt? = alt
11
+ def shift? = shift
12
+ end
13
+ end
data/lib/thaum/keys.rb ADDED
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ module Keys
5
+ # Control Sequence Introducer (CSI) sequences: \e[ + final byte
6
+ # e.g. up arrow → \e[A
7
+ CSI = {
8
+ Seq::CSI_UP[-1] => :up,
9
+ Seq::CSI_DOWN[-1] => :down,
10
+ Seq::CSI_RIGHT[-1] => :right,
11
+ Seq::CSI_LEFT[-1] => :left,
12
+ Seq::CSI_HOME[-1] => :home,
13
+ Seq::CSI_END[-1] => :end
14
+ }.freeze
15
+
16
+ # Control Sequence Introducer (CSI) tilde sequences: \e[ + number + ~
17
+ # e.g. delete → \e[3~ (the number identifies the key, ~ is always the final byte)
18
+ TILDE = {
19
+ Seq::TILDE_HOME[2..-2].to_i => :home,
20
+ Seq::TILDE_INSERT[2..-2].to_i => :insert,
21
+ Seq::TILDE_DELETE[2..-2].to_i => :delete,
22
+ Seq::TILDE_END[2..-2].to_i => :end,
23
+ Seq::TILDE_PAGE_UP[2..-2].to_i => :page_up,
24
+ Seq::TILDE_PAGE_DOWN[2..-2].to_i => :page_down,
25
+ Seq::TILDE_F1[2..-2].to_i => :f1,
26
+ Seq::TILDE_F2[2..-2].to_i => :f2,
27
+ Seq::TILDE_F3[2..-2].to_i => :f3,
28
+ Seq::TILDE_F4[2..-2].to_i => :f4,
29
+ Seq::TILDE_F5[2..-2].to_i => :f5,
30
+ Seq::TILDE_F6[2..-2].to_i => :f6,
31
+ Seq::TILDE_F7[2..-2].to_i => :f7,
32
+ Seq::TILDE_F8[2..-2].to_i => :f8,
33
+ Seq::TILDE_F9[2..-2].to_i => :f9,
34
+ Seq::TILDE_F10[2..-2].to_i => :f10,
35
+ Seq::TILDE_F11[2..-2].to_i => :f11,
36
+ Seq::TILDE_F12[2..-2].to_i => :f12
37
+ }.freeze
38
+
39
+ # Single Shift 3 (SS3) sequences: \eO + final byte
40
+ # Older VT100 encoding for function and arrow keys; many terminals still emit
41
+ # these for F1-F4 and arrows in application cursor mode.
42
+ SS3 = {
43
+ Seq::SS3_F1[-1] => :f1,
44
+ Seq::SS3_F2[-1] => :f2,
45
+ Seq::SS3_F3[-1] => :f3,
46
+ Seq::SS3_F4[-1] => :f4,
47
+ Seq::SS3_HOME[-1] => :home,
48
+ Seq::SS3_END[-1] => :end,
49
+ Seq::SS3_UP[-1] => :up,
50
+ Seq::SS3_DOWN[-1] => :down,
51
+ Seq::SS3_RIGHT[-1] => :right,
52
+ Seq::SS3_LEFT[-1] => :left
53
+ }.freeze
54
+ end
55
+ end
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ module Layout
5
+ attr_reader :rect, :leaf_sigils, :subtree_leaves, :child_layouts, :subtree_children
6
+
7
+ # Override on any Layout node (including App) to specify a Tab traversal
8
+ # order for that subtree. Return an array of leaf Sigils. nil (default)
9
+ # means "use left-to-right leaf order."
10
+ def focus_order = nil
11
+
12
+ # Called by the run loop (or repartition) to assign geometry and walk the partition tree.
13
+ # Returns the flat list of leaf Sigils in render order.
14
+ def run_partition(rect:, collector: nil)
15
+ @rect = rect
16
+ collector ||= []
17
+ start = collector.size
18
+ @leaf_sigils = collector
19
+ @child_layouts = []
20
+ @subtree_children = []
21
+ # An Octagram may inset the rect its children partition into so its
22
+ # render hook (border, padding) survives. Plain Layout passes through.
23
+ @rect = inset_for_partition(rect)
24
+ partition
25
+ @rect = rect
26
+ @subtree_leaves = collector[start..] || []
27
+ collector
28
+ end
29
+
30
+ # Override on Octagram (or any Layout) to inset the rect that this
31
+ # node's children partition into. Defaults to identity.
32
+ def inset_for_partition(rect)
33
+ return rect unless respond_to?(:partition_inset)
34
+
35
+ inset = partition_inset || {}
36
+ Rect.new(
37
+ x: rect.x + (inset[:left] || 0),
38
+ y: rect.y + (inset[:top] || 0),
39
+ width: [rect.width - (inset[:left] || 0) - (inset[:right] || 0), 0].max,
40
+ height: [rect.height - (inset[:top] || 0) - (inset[:bottom] || 0), 0].max
41
+ )
42
+ end
43
+
44
+ # Re-run partition for this node's subtree using its current rect.
45
+ def repartition
46
+ return unless @rect
47
+
48
+ old_leaves = @leaf_sigils || []
49
+ old_octagrams = collect_octagrams
50
+
51
+ new_leaves = []
52
+ run_partition(rect: @rect, collector: new_leaves)
53
+
54
+ new_octagrams = collect_octagrams
55
+
56
+ # Fire on_unmount for removed Sigils and Octagrams.
57
+ (old_leaves - new_leaves).each(&:on_unmount)
58
+ (old_octagrams - new_octagrams).each(&:on_unmount)
59
+
60
+ # Rewire handler parents across the (possibly restructured) subtree so
61
+ # newly-added Octagrams and Sigils see the right chain BEFORE on_mount.
62
+ # Only rewires when we can identify this node's role in the dispatch
63
+ # chain — App (root) or Octagram (its own handler scope).
64
+ app = thaum_app_ref || (is_a?(Octagram) ? @thaum_app : nil)
65
+ wire_handler_parents(handler_parent: self, app: app) if app
66
+
67
+ # Fire on_mount for newly-added Sigils and Octagrams.
68
+ (new_octagrams - old_octagrams).each do |o|
69
+ o.thaum_app = app if app
70
+ o.on_mount
71
+ end
72
+ (new_leaves - old_leaves).each do |s|
73
+ s.thaum_app = app if app
74
+ s.on_mount
75
+ end
76
+
77
+ @leaf_sigils = new_leaves
78
+
79
+ # Re-validate focus_order in this subtree after structural change.
80
+ validate_focus_order_tree
81
+ end
82
+
83
+ # Walk this subtree and collect every Octagram node (excluding self).
84
+ def collect_octagrams
85
+ result = []
86
+ (@subtree_children || []).each do |child|
87
+ if child.is_a?(Octagram)
88
+ result << child
89
+ result.concat(child.collect_octagrams)
90
+ elsif child.respond_to?(:collect_octagrams)
91
+ result.concat(child.collect_octagrams)
92
+ end
93
+ end
94
+ result
95
+ end
96
+
97
+ # Walk this Layout node and every descendant Layout, raising
98
+ # FocusOrderError if any defines a focus_order that does not exactly
99
+ # cover the focusable leaves in its subtree.
100
+ def validate_focus_order_tree
101
+ validate_focus_order_node
102
+ (@child_layouts || []).each(&:validate_focus_order_tree)
103
+ end
104
+
105
+ # Build the Tab traversal order for this subtree, recursively expanding
106
+ # any nested Layout that itself has focus_order. When no focus_order is
107
+ # defined at any level, this falls through to left-to-right leaf order.
108
+ def effective_focus_order
109
+ order = focus_order
110
+ return order if order
111
+
112
+ result = []
113
+ layout_subtree_in_order.each do |node|
114
+ if node.is_a?(Sigil)
115
+ result << node if node.focusable?
116
+ else
117
+ result.concat(node.effective_focus_order)
118
+ end
119
+ end
120
+ result
121
+ end
122
+
123
+ # The direct children of this Layout in declaration order — both Sigils
124
+ # and nested Layouts. Built during partition (see place_child).
125
+ def layout_subtree_in_order = @subtree_children || []
126
+
127
+ # Scope units for Tab cycling within THIS focus scope. A "scope" is the
128
+ # App or an Octagram. A unit is either a focusable Sigil (reached through
129
+ # plain Layouts only) or a nested Octagram (which appears as one unit and
130
+ # is itself a scope). Plain Layouts are transparent — their units are
131
+ # flattened into the parent scope.
132
+ def focus_scope_units
133
+ result = []
134
+ (@subtree_children || []).each do |child|
135
+ case child
136
+ when Sigil
137
+ result << child if child.focusable?
138
+ when Octagram
139
+ result << child if child.focusable_descendant?
140
+ else
141
+ result.concat(child.focus_scope_units) if child.respond_to?(:focus_scope_units)
142
+ end
143
+ end
144
+ result
145
+ end
146
+
147
+ # Recursively descend into this Octagram (or plain Layout) to find its
148
+ # first focusable leaf — used when Tab enters an Octagram unit.
149
+ def first_focusable_leaf
150
+ (@subtree_children || []).each do |child|
151
+ case child
152
+ when Sigil
153
+ return child if child.focusable?
154
+ else
155
+ leaf = child.first_focusable_leaf if child.respond_to?(:first_focusable_leaf)
156
+ return leaf if leaf
157
+ end
158
+ end
159
+ nil
160
+ end
161
+
162
+ # Mirror of first_focusable_leaf for Shift-Tab entry.
163
+ def last_focusable_leaf
164
+ (@subtree_children || []).reverse_each do |child|
165
+ case child
166
+ when Sigil
167
+ return child if child.focusable?
168
+ else
169
+ leaf = child.last_focusable_leaf if child.respond_to?(:last_focusable_leaf)
170
+ return leaf if leaf
171
+ end
172
+ end
173
+ nil
174
+ end
175
+
176
+ def focusable_descendant?
177
+ !first_focusable_leaf.nil?
178
+ end
179
+
180
+ # Walk the subtree top-down, wiring each leaf Sigil and each Octagram.
181
+ # Leaves and Octagrams get _handler_parent set to the innermost
182
+ # enclosing Octagram (or the App when there is none). Plain Layout
183
+ # nodes are transparent — their children inherit the outer parent.
184
+ def wire_handler_parents(handler_parent:, app:)
185
+ (@subtree_children || []).each do |child|
186
+ case child
187
+ when Sigil
188
+ child.handler_parent = handler_parent
189
+ child.thaum_app = app
190
+ when Octagram
191
+ child.handler_parent = handler_parent
192
+ child.thaum_app = app
193
+ child.wire_handler_parents(handler_parent: child, app: app)
194
+ else
195
+ child.wire_handler_parents(handler_parent:, app:) if child.respond_to?(:wire_handler_parents)
196
+ end
197
+ end
198
+ end
199
+
200
+ # Override to specify the layout. Must call horizontal/vertical.
201
+ def partition; end
202
+
203
+ private
204
+
205
+ def horizontal(&) = divide_axis(:cols, &)
206
+ def vertical(&) = divide_axis(:rows, &)
207
+
208
+ def region(**opts, &child_block)
209
+ @divide_frame << { opts: opts, block: child_block }
210
+ end
211
+
212
+ def divide_axis(axis, &block)
213
+ parent_rect = @rect
214
+ saved_frame = @divide_frame
215
+ @divide_frame = []
216
+
217
+ block.call
218
+
219
+ specs = @divide_frame
220
+ @divide_frame = saved_frame
221
+
222
+ validate_axis_kwargs(axis: axis, specs: specs)
223
+
224
+ calc_rects(parent_rect: parent_rect, axis: axis, specs: specs).each_with_index do |child_rect, i|
225
+ @rect = child_rect
226
+ place_child(child: specs[i][:block].call, rect: child_rect)
227
+ end
228
+
229
+ @rect = parent_rect
230
+ end
231
+
232
+ def validate_axis_kwargs(axis:, specs:)
233
+ expected, forbidden = axis == :cols ? %i[width height] : %i[height width]
234
+ specs.each do |spec|
235
+ next unless spec[:opts].key?(forbidden)
236
+
237
+ direction = axis == :cols ? "horizontal" : "vertical"
238
+ raise LayoutError,
239
+ "region(#{forbidden}:) is invalid inside #{direction} — use #{expected}: instead"
240
+ end
241
+ end
242
+
243
+ def place_child(child:, rect:)
244
+ @subtree_children ||= []
245
+ if child.is_a?(Sigil)
246
+ child.rect = rect
247
+ @leaf_sigils << child
248
+ @subtree_children << child
249
+ elsif child.respond_to?(:run_partition)
250
+ @child_layouts << child
251
+ @subtree_children << child
252
+ child.run_partition(rect: rect, collector: @leaf_sigils)
253
+ end
254
+ end
255
+
256
+ def validate_focus_order_node
257
+ order = focus_order
258
+ return if order.nil?
259
+
260
+ unless order.is_a?(Array)
261
+ raise FocusOrderError,
262
+ "#{self.class}#focus_order must return an Array, got #{order.class}"
263
+ end
264
+
265
+ focusable = (@subtree_leaves || []).select(&:focusable?)
266
+ missing = focusable - order
267
+ extras = order - focusable
268
+ duplicates = order.tally.select { |_, c| c > 1 }.keys
269
+
270
+ return if missing.empty? && extras.empty? && duplicates.empty?
271
+
272
+ msg = "#{self.class}#focus_order is invalid:"
273
+ msg += " missing #{missing.map(&:class).join(', ')};" unless missing.empty?
274
+ msg += " unknown entries #{extras.map(&:class).join(', ')};" unless extras.empty?
275
+ msg += " duplicates #{duplicates.map(&:class).join(', ')};" unless duplicates.empty?
276
+ raise FocusOrderError, msg
277
+ end
278
+
279
+ def calc_rects(parent_rect:, axis:, specs:)
280
+ if axis == :cols
281
+ total = parent_rect.width
282
+ fixed_total = specs.sum { |s| integer_size(val: s[:opts][:width], total: total) || 0 }
283
+ fill_count = specs.count { |s| s[:opts][:width] == :fill }
284
+ fill_size = fill_count.positive? ? (total - fixed_total) / fill_count : 0
285
+ leftover = fill_count.positive? ? (total - fixed_total) % fill_count : 0
286
+
287
+ x = parent_rect.x
288
+ specs.each_with_index.map do |spec, i|
289
+ fill_index = specs[0..i].count { |s| s[:opts][:width] == :fill } - 1
290
+ last_fill = fill_index >= 0 && fill_index == fill_count - 1
291
+
292
+ w = resolved_size(val: spec[:opts][:width], total: total, fill_size: fill_size,
293
+ extra: last_fill ? leftover : 0)
294
+ w = apply_min_max(size: w, min: spec[:opts][:min], max: spec[:opts][:max])
295
+ r = Rect.new(x: x, y: parent_rect.y, width: w, height: parent_rect.height)
296
+ x += w
297
+ r
298
+ end
299
+ else
300
+ total = parent_rect.height
301
+ fixed_total = specs.sum { |s| integer_size(val: s[:opts][:height], total: total) || 0 }
302
+ fill_count = specs.count { |s| s[:opts][:height] == :fill }
303
+ fill_size = fill_count.positive? ? (total - fixed_total) / fill_count : 0
304
+ leftover = fill_count.positive? ? (total - fixed_total) % fill_count : 0
305
+
306
+ y = parent_rect.y
307
+ specs.each_with_index.map do |spec, i|
308
+ fill_index = specs[0..i].count { |s| s[:opts][:height] == :fill } - 1
309
+ last_fill = fill_index >= 0 && fill_index == fill_count - 1
310
+
311
+ h = resolved_size(val: spec[:opts][:height], total: total, fill_size: fill_size,
312
+ extra: last_fill ? leftover : 0)
313
+ h = apply_min_max(size: h, min: spec[:opts][:min], max: spec[:opts][:max])
314
+ r = Rect.new(x: parent_rect.x, y: y, width: parent_rect.width, height: h)
315
+ y += h
316
+ r
317
+ end
318
+ end
319
+ end
320
+
321
+ def integer_size(val:, total:)
322
+ return val if val.is_a?(Integer)
323
+ return nil if val == :fill
324
+ return val.to_i * total / 100 if val.is_a?(String) && val.end_with?("%")
325
+
326
+ nil
327
+ end
328
+
329
+ def resolved_size(val:, total:, fill_size:, extra:)
330
+ return val + 0 if val.is_a?(Integer)
331
+ return fill_size + extra if val == :fill
332
+ return val.to_i * total / 100 if val.is_a?(String) && val.end_with?("%")
333
+
334
+ fill_size + extra # default to fill if unspecified
335
+ end
336
+
337
+ def apply_min_max(size:, min:, max:)
338
+ size = [size, min].max if min
339
+ size = [size, max].min if max
340
+ [size, 0].max
341
+ end
342
+
343
+ # Used by repartition to wire new sigils to the app.
344
+ # overridden in App
345
+ def thaum_app_ref = nil
346
+ end
347
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional Minitest integration for Thaum snapshot testing.
4
+ #
5
+ # require "thaum/minitest"
6
+ #
7
+ # Adds assert_snapshot to every Minitest::Test. Snapshots are stored under
8
+ # test/snapshots/<name>.txt (plain text) or .ans (ANSI). On the first run
9
+ # the fixture is written and the assertion passes; subsequent runs compare
10
+ # byte-for-byte. Setting UPDATE_SNAPSHOTS=1 rewrites the fixture in place.
11
+
12
+ require "fileutils"
13
+ require "minitest/assertions"
14
+
15
+ module Thaum
16
+ module SnapshotMatcher
17
+ SNAPSHOT_ROOT_CANDIDATES = %w[test/snapshots spec/snapshots].freeze
18
+ ANSI_INDICATOR = "\e["
19
+
20
+ def assert_snapshot(actual:, name:)
21
+ path = Snapshot.path_for(name: name, actual: actual)
22
+ Snapshot.compare(test: self, actual: actual, path: path, name: name)
23
+ end
24
+
25
+ module_function
26
+
27
+ def update_mode? = ENV["UPDATE_SNAPSHOTS"] == "1"
28
+ end
29
+
30
+ # Internal helpers — not part of the public API.
31
+ module Snapshot
32
+ module_function
33
+
34
+ def path_for(name:, actual:)
35
+ ext = actual.include?(SnapshotMatcher::ANSI_INDICATOR) ? "ans" : "txt"
36
+ root = SnapshotMatcher::SNAPSHOT_ROOT_CANDIDATES.find { |d| Dir.exist?(d) } || "test/snapshots"
37
+ File.join(root, "#{name}.#{ext}")
38
+ end
39
+
40
+ def compare(test:, actual:, path:, name:)
41
+ missing = !File.exist?(path)
42
+ if missing || SnapshotMatcher.update_mode?
43
+ write(path: path, actual: actual)
44
+ warn "[Thaum] wrote new snapshot #{name} (#{path})" if missing
45
+ test.assert(true)
46
+ return
47
+ end
48
+
49
+ expected = File.read(path)
50
+ test.assert_equal(
51
+ expected, actual,
52
+ "Snapshot \"#{name}\" mismatch (#{path}). " \
53
+ "Run with UPDATE_SNAPSHOTS=1 to rewrite if the new output is correct."
54
+ )
55
+ end
56
+
57
+ def write(path:, actual:)
58
+ FileUtils.mkdir_p(File.dirname(path))
59
+ File.write(path, actual)
60
+ end
61
+ end
62
+ end
63
+
64
+ Minitest::Test.include(Thaum::SnapshotMatcher) if defined?(Minitest::Test)
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ # A composite container — a distributable component that owns both
5
+ # layout (Layout DSL) and behavior (Sigil-like handlers). Sits between
6
+ # its child Sigils and the App in the dispatch chain: when a focused
7
+ # child Sigil emits, the innermost enclosing Octagram's handler runs
8
+ # first, and propagates upward only if it calls emit.
9
+ #
10
+ # See DECISIONS.md (2026-06-02) for the naming rationale.
11
+ module Octagram
12
+ include Layout
13
+
14
+ attr_accessor :thaum_app, :handler_parent
15
+
16
+ # Marker so the framework's tree-walks can distinguish Octagrams
17
+ # from plain Layouts (which don't participate in event dispatch).
18
+ def octagram? = true
19
+
20
+ # Optional background — drawn before the Octagram's child sigils.
21
+ # Override to paint a frame, border, or fill behind the children.
22
+ def render(canvas:, theme:); end
23
+
24
+ # Override to inset the rect the Octagram's children partition into,
25
+ # so the render hook above is not overwritten by child rendering.
26
+ # Return a Hash with any of :top, :bottom, :left, :right keys (each
27
+ # an Integer; missing keys default to 0). For a 1-cell border on all
28
+ # sides, return { top: 1, bottom: 1, left: 1, right: 1 }.
29
+ def partition_inset = nil
30
+
31
+ # Handlers — same shape as Sigil. Defaults propagate to the handler
32
+ # parent (the next outer Octagram, or the App).
33
+ def on_key(event) = emit(event)
34
+ def on_mouse(event) = emit(event)
35
+ def on_paste(event) = emit(event)
36
+ def on_event(event) = emit(event)
37
+
38
+ # Lifecycle — overridable.
39
+ def on_mount; end
40
+ def on_unmount; end
41
+ def on_update(context); end
42
+ def on_tick(event); end
43
+
44
+ # Propagate an event to this Octagram's handler parent. Mirrors
45
+ # Sigil#emit: drops framework-internal events and respects the
46
+ # emit-from-on_update guard.
47
+ def emit(event)
48
+ app = @thaum_app or return
49
+ raise Thaum::EmitFromUpdateError, "emit called from on_update" if app.in_on_update
50
+
51
+ if event.is_a?(Thaum::TickEvent) || event.is_a?(Thaum::ResizeEvent)
52
+ warn "[Thaum] dropping #{event.class} from #{self.class}: " \
53
+ "framework-internal events cannot be emitted from Sigils or Actions"
54
+ return
55
+ end
56
+
57
+ (@handler_parent || app).dispatch_from_child(event)
58
+ end
59
+
60
+ # Called by a child Sigil (or nested Octagram) when it emits.
61
+ def dispatch_from_child(event)
62
+ Dispatch.invoke_handler(target: self, event: event, label: "#{self.class}##{handler_name_for(event)}")
63
+ end
64
+
65
+ private
66
+
67
+ def handler_name_for(event)
68
+ case event
69
+ when KeyEvent then "on_key"
70
+ when MouseEvent then "on_mouse"
71
+ when PasteEvent then "on_paste"
72
+ else "on_event"
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ # Paints the App + modal tree into a fresh Buffer and hands it to the
5
+ # Renderer. Called once per dirty frame from the run loop.
6
+ module Painter
7
+ module_function
8
+
9
+ def paint(app:, renderer:, cols:, rows:)
10
+ theme = app.theme
11
+ buffer = Rendering::Buffer.new(width: cols, height: rows)
12
+ paint_node(node: app, buffer: buffer, theme: theme)
13
+ paint_modal(app: app, buffer: buffer, theme: theme)
14
+ renderer.render(buffer)
15
+ end
16
+
17
+ # Paint the modal Sigil into a Canvas built from its rect. The Buffer
18
+ # silently drops out-of-bounds cells, so an overflowing or fully off-
19
+ # screen modal clips naturally.
20
+ def paint_modal(app:, buffer:, theme:)
21
+ sigil = app.modal_sigil or return
22
+ rect = app.modal_rect or return
23
+ return if rect.width <= 0 || rect.height <= 0
24
+
25
+ canvas = Rendering::Canvas.new(buffer: buffer, rect: rect)
26
+ Thaum.safe_invoke("#{sigil.class}#render") { sigil.render(canvas: canvas, theme: theme) }
27
+ end
28
+
29
+ # Recursive draw walk. Octagrams render their background first (so
30
+ # children draw on top of it). Plain Layout nodes are pass-through.
31
+ # Leaf Sigils render into their own rect.
32
+ def paint_node(node:, buffer:, theme:)
33
+ if node.is_a?(Octagram) && node.rect
34
+ canvas = Rendering::Canvas.new(buffer: buffer, rect: node.rect)
35
+ Thaum.safe_invoke("#{node.class}#render") { node.render(canvas: canvas, theme: theme) }
36
+ end
37
+
38
+ (node.subtree_children || []).each do |child|
39
+ if child.is_a?(Sigil)
40
+ r = child.rect or next
41
+ canvas = Rendering::Canvas.new(buffer: buffer, rect: r)
42
+ Thaum.safe_invoke("#{child.class}#render") { child.render(canvas: canvas, theme: theme) }
43
+ elsif child.respond_to?(:subtree_children)
44
+ paint_node(node: child, buffer: buffer, theme: theme)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
data/lib/thaum/rect.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ Rect = Data.define(:x, :y, :width, :height)
5
+ end