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,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
|
data/lib/thaum/event.rb
ADDED
data/lib/thaum/events.rb
ADDED
|
@@ -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
|
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
|