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,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ # Parses raw terminal input bytes into KeyEvent / PasteEvent objects.
5
+ #
6
+ # The parser is stateful: a paste payload that spans multiple parse()
7
+ # calls (chunked reads from the InputReader) is accumulated across calls
8
+ # and emitted as a single PasteEvent once the closing \e[201~ marker
9
+ # arrives. The class-level .parse helper makes a fresh instance for
10
+ # one-shot use.
11
+ class EscapeParser
12
+ PASTE_START = "\e[200~"
13
+ PASTE_END = "\e[201~"
14
+
15
+ # Ground state byte codes
16
+ CR = 0x0d # Carriage return
17
+ LF = 0x0a # Line feed
18
+ TAB = 0x09 # Tab
19
+ DEL = 0x7f # Delete
20
+ BS = 0x08 # Backspace
21
+ CTRL_START = 0x01
22
+ CTRL_END = 0x1a
23
+ PRINTABLE_START = 0x20
24
+ PRINTABLE_END = 0x7e
25
+
26
+ # Escape sequence byte codes
27
+ ESC = 0x1b
28
+ SGR_MOUSE_MARKER = 0x3c # '<' for SGR mouse introducer
29
+ SGR_MOUSE_PRESS = 0x4d # 'M'
30
+ SGR_MOUSE_RELEASE = 0x6d # 'm'
31
+ CSI_FINAL_START = 0x40
32
+ CSI_FINAL_END = 0x7e
33
+ SGR_DIGIT_START = 0x30 # '0'
34
+ SGR_DIGIT_END = 0x39 # '9'
35
+ SGR_SEP = 0x3b # ';'
36
+
37
+ def self.parse(input) = new.parse(input)
38
+
39
+ def initialize
40
+ @paste_buf = nil
41
+ end
42
+
43
+ def parse(input)
44
+ events = []
45
+ i = 0
46
+ while i < input.bytesize
47
+ if @paste_buf
48
+ i = collect_paste(input: input, i: i, events: events)
49
+ elsif input.getbyte(i) == ESC
50
+ i = parse_at_escape(input: input, i: i, events: events)
51
+ else
52
+ event = parse_ground(input.getbyte(i))
53
+ events << event if event
54
+ i += 1
55
+ end
56
+ end
57
+ events
58
+ end
59
+
60
+ private
61
+
62
+ # Inside a paste — look for PASTE_END from position i. Returns the
63
+ # index to resume parsing from (just past PASTE_END, or bytesize if
64
+ # the terminator hasn't arrived yet).
65
+ def collect_paste(input:, i:, events:)
66
+ end_idx = input.byteindex(PASTE_END, i)
67
+ if end_idx
68
+ @paste_buf << input.byteslice(i, end_idx - i)
69
+ events << PasteEvent.new(text: @paste_buf)
70
+ @paste_buf = nil
71
+ end_idx + PASTE_END.bytesize
72
+ else
73
+ @paste_buf << input.byteslice(i, input.bytesize - i)
74
+ input.bytesize
75
+ end
76
+ end
77
+
78
+ # ESC at position i. Either paste-start, paste-end (stray — ignored as
79
+ # a bare :escape), or a normal escape sequence. Returns the next index.
80
+ def parse_at_escape(input:, i:, events:)
81
+ if input.byteslice(i, PASTE_START.bytesize) == PASTE_START
82
+ @paste_buf = +""
83
+ return i + PASTE_START.bytesize
84
+ end
85
+
86
+ event, consumed = parse_escape_at(input: input, i: i)
87
+ events << event if event
88
+ i + consumed
89
+ end
90
+
91
+ # Returns [KeyEvent, bytes_consumed] starting from position i (the ESC byte).
92
+ def parse_escape_at(input:, i:)
93
+ next_pos = i + 1
94
+ return [KeyEvent.new(key: :escape), 1] if next_pos >= input.bytesize
95
+
96
+ case input.getbyte(next_pos)
97
+ when Seq::CSI_UP.getbyte(1) then parse_csi_at(input: input, i: i) # Control Sequence Introducer (CSI): 0x5b [
98
+ when Seq::SS3_UP.getbyte(1) then parse_ss3_at(input: input, i: i) # Single Shift 3 (SS3): 0x4f O
99
+ else
100
+ # Alt + whatever the next ground byte produces
101
+ event = parse_ground(input.getbyte(next_pos))
102
+ if event
103
+ [KeyEvent.new(key: event.key, alt: true), 2]
104
+ else
105
+ [KeyEvent.new(key: :escape), 1]
106
+ end
107
+ end
108
+ end
109
+
110
+ # i points to ESC; input[i+1] == '[' (Control Sequence Introducer).
111
+ def parse_csi_at(input:, i:)
112
+ # SGR mouse: ESC [ < Cb ; Cx ; Cy (M|m)
113
+ return parse_sgr_mouse_at(input: input, i: i) if input.getbyte(i + 2) == SGR_MOUSE_MARKER
114
+
115
+ j = i + 2
116
+ params_start = j
117
+ while j < input.bytesize
118
+ b = input.getbyte(j)
119
+ if b.between?(CSI_FINAL_START, CSI_FINAL_END)
120
+ params = input.byteslice(params_start, j - params_start)
121
+ final = b.chr
122
+ return [decode_csi(params: params, final: final), j - i + 1]
123
+ end
124
+ j += 1
125
+ end
126
+ [KeyEvent.new(key: :escape), 1]
127
+ end
128
+
129
+ # i points to ESC; input[i+1] == '['; input[i+2] == '<'. Scan to the
130
+ # final byte (M or m) and decode an SGR mouse event.
131
+ def parse_sgr_mouse_at(input:, i:)
132
+ j = i + 3
133
+ params_start = j
134
+ while j < input.bytesize
135
+ b = input.getbyte(j)
136
+ if [SGR_MOUSE_PRESS, SGR_MOUSE_RELEASE].include?(b)
137
+ params = input.byteslice(params_start, j - params_start)
138
+ final = b.chr
139
+ # Well-formed SGR sequence — consume it whether or not we emit.
140
+ # decode_sgr_mouse returns nil for events we intentionally drop.
141
+ return [decode_sgr_mouse(params: params, final: final), j - i + 1]
142
+ end
143
+ # SGR mouse params are digits and ';'. Bail out if we see anything else.
144
+ break unless b.between?(SGR_DIGIT_START, SGR_DIGIT_END) || b == SGR_SEP
145
+
146
+ j += 1
147
+ end
148
+ [KeyEvent.new(key: :escape), 1]
149
+ end
150
+
151
+ def decode_sgr_mouse(params:, final:)
152
+ parts = params.split(";")
153
+ return nil if parts.size != 3
154
+
155
+ cb = parts[0].to_i
156
+ cx = parts[1].to_i
157
+ cy = parts[2].to_i
158
+
159
+ shift = cb.anybits?(4)
160
+ alt = cb.anybits?(8)
161
+ ctrl = cb.anybits?(16)
162
+ motion = cb.anybits?(32)
163
+ wheel = cb.anybits?(64)
164
+ btn_bits = cb & 0b11
165
+
166
+ button, action = button_and_action(btn_bits: btn_bits, final: final, wheel: wheel, motion: motion)
167
+ return nil if action == :invalid
168
+
169
+ # SGR coords are 1-based; convert to 0-based.
170
+ ax = cx - 1
171
+ ay = cy - 1
172
+ MouseEvent.new(button: button, action: action, abs_x: ax, abs_y: ay,
173
+ shift: shift, alt: alt, ctrl: ctrl)
174
+ end
175
+
176
+ def button_from_bits(btn_bits)
177
+ case btn_bits
178
+ when 0 then :left
179
+ when 1 then :middle
180
+ when 2 then :right
181
+ end
182
+ end
183
+
184
+ def button_and_action(btn_bits:, final:, wheel:, motion:)
185
+ if wheel
186
+ button = btn_bits.zero? ? :wheel_up : :wheel_down
187
+ [button, :scroll]
188
+ elsif final == "m"
189
+ # Release. SGR encodes the released button in the low bits;
190
+ # value 3 historically means "unknown" — leave button nil.
191
+ [button_from_bits(btn_bits), :release]
192
+ elsif motion
193
+ # Under button-event tracking (1002), motion arrives only while a
194
+ # button is held → :drag. btn_bits == 3 (no button) shouldn't occur;
195
+ # drop it if it does.
196
+ button = button_from_bits(btn_bits)
197
+ button ? [button, :drag] : [nil, :invalid]
198
+ else
199
+ button = button_from_bits(btn_bits)
200
+ return [nil, :invalid] unless button # btn_bits == 3 with no motion bit is unspecified
201
+
202
+ [button, :press]
203
+ end
204
+ end
205
+
206
+ # i points to ESC; input[i+1] == 'O' (Single Shift 3).
207
+ def parse_ss3_at(input:, i:)
208
+ pos = i + 2
209
+ return [KeyEvent.new(key: :escape), 1] if pos >= input.bytesize
210
+
211
+ key = Keys::SS3[input.getbyte(pos).chr]
212
+ key ? [KeyEvent.new(key: key), 3] : [KeyEvent.new(key: :escape), 1]
213
+ end
214
+
215
+ def decode_csi(params:, final:)
216
+ case final
217
+ when Seq::CSI_SHIFT_TAB[-1]
218
+ KeyEvent.new(key: :tab, shift: true)
219
+ when "~"
220
+ parts = params.split(";")
221
+ key = Keys::TILDE[parts[0].to_i]
222
+ return KeyEvent.new(key: :escape) unless key
223
+
224
+ mod = parts[1]&.to_i
225
+ mod ? KeyEvent.new(key: key, **modifier_flags(mod)) : KeyEvent.new(key: key)
226
+ else
227
+ key = Keys::CSI[final]
228
+ return KeyEvent.new(key: :escape) unless key
229
+
230
+ if params.include?(";")
231
+ mod = params.split(";")[1]&.to_i
232
+ mod ? KeyEvent.new(key: key, **modifier_flags(mod)) : KeyEvent.new(key: key)
233
+ else
234
+ KeyEvent.new(key: key)
235
+ end
236
+ end
237
+ end
238
+
239
+ def parse_ground(byte)
240
+ case byte
241
+ when CR, LF
242
+ KeyEvent.new(key: :enter)
243
+ when TAB
244
+ KeyEvent.new(key: :tab)
245
+ when DEL, BS
246
+ KeyEvent.new(key: :backspace)
247
+ when CTRL_START..CTRL_END
248
+ KeyEvent.new(key: (byte + 96).chr, ctrl: true)
249
+ when PRINTABLE_START..PRINTABLE_END
250
+ KeyEvent.new(key: byte.chr)
251
+ end
252
+ end
253
+
254
+ # xterm modifier encoding: param = bitflags + 1
255
+ # bit 0 = shift, bit 1 = alt, bit 2 = ctrl
256
+ def modifier_flags(mod)
257
+ flags = mod - 1
258
+ {
259
+ shift: flags.anybits?(1),
260
+ alt: flags.anybits?(2),
261
+ ctrl: flags.anybits?(4)
262
+ }
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ module Event
5
+ def self.define(*attrs, &block)
6
+ Data.define(*attrs) do
7
+ include Thaum::Event
8
+
9
+ class_eval(&block) if block
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Framework-internal event types pushed onto the main queue by the input
4
+ # reader, signal traps, and the tick timer. KeyEvent has its own file
5
+ # because it carries modifier predicates.
6
+ module Thaum
7
+ PasteEvent = Event.define(:text)
8
+ ResizeEvent = Event.define(:width, :height)
9
+ TickEvent = Event.define(:time, :delta)
10
+
11
+ # SGR mouse event. Actions: :press / :release / :drag / :scroll.
12
+ # Scroll direction is folded into `button` (:wheel_up / :wheel_down).
13
+ # Modifier booleans (shift/alt/ctrl) come from the SGR Cb bits.
14
+ #
15
+ # `x`/`y` are canvas-relative to the receiving Sigil and are set by the
16
+ # dispatcher right before invoking on_mouse. `abs_x`/`abs_y` are the
17
+ # terminal-absolute coordinates and are always populated.
18
+ MouseEvent = Event.define(:button, :action, :x, :y, :abs_x, :abs_y, :shift, :alt, :ctrl) do
19
+ def initialize(button:, action:, abs_x:, abs_y:, x: abs_x, y: abs_y,
20
+ shift: false, alt: false, ctrl: false)
21
+ super
22
+ end
23
+
24
+ def shift? = shift
25
+ def alt? = alt
26
+ def ctrl? = ctrl
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ # Mouse hit testing for the layout tree and modals.
5
+ module HitTest
6
+ module_function
7
+
8
+ # Walk every leaf Sigil with an allocated rect, return the LAST one
9
+ # whose rect contains (abs_x, abs_y). Last-in-render-order wins on
10
+ # overlap, matching the draw walk in Painter.
11
+ def hit(app:, abs_x:, abs_y:)
12
+ hit = nil
13
+ Tree.walk(app) do |node|
14
+ next unless node.is_a?(Sigil)
15
+
16
+ rect = node.rect or next
17
+ next unless point_in_rect?(x: abs_x, y: abs_y, rect: rect)
18
+
19
+ hit = node
20
+ end
21
+ hit
22
+ end
23
+
24
+ def point_in_rect?(x:, y:, rect:)
25
+ x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ # Reads raw bytes from an input stream in a background thread and pushes KeyEvents onto a queue.
5
+ class InputReader
6
+ ESCAPE_TIMEOUT = 0.05 # seconds to wait after a bare \e before treating it as Escape
7
+
8
+ def initialize(input:, queue:, parser: EscapeParser.new)
9
+ @input = input
10
+ @queue = queue
11
+ @parser = parser
12
+ @thread = nil
13
+ end
14
+
15
+ def start
16
+ @thread = Thread.new { run }
17
+ end
18
+
19
+ def stop
20
+ @thread&.join(1)
21
+ @thread = nil
22
+ end
23
+
24
+ def alive?
25
+ @thread&.alive? || false
26
+ end
27
+
28
+ private
29
+
30
+ def run
31
+ loop do
32
+ bytes = read_chunk
33
+ @parser.parse(bytes).each { |event| @queue.push(event) }
34
+ end
35
+ rescue IOError
36
+ # input closed — exit cleanly
37
+ end
38
+
39
+ def read_chunk
40
+ bytes = @input.readpartial(1024)
41
+ # If bytes end with a bare ESC, wait briefly — it may be the start of a sequence.
42
+ bytes += @input.readpartial(1024) if bytes.end_with?("\e") && @input.wait_readable(ESCAPE_TIMEOUT)
43
+ bytes
44
+ end
45
+ end
46
+ end
@@ -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,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 Concerns::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