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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- 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 +48 -0
- data/lib/thaum/app.rb +87 -0
- data/lib/thaum/color.rb +104 -0
- data/lib/thaum/concerns/context_update.rb +40 -0
- data/lib/thaum/concerns/focus.rb +53 -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 +115 -0
- data/lib/thaum/key_event.rb +13 -0
- data/lib/thaum/keys.rb +55 -0
- data/lib/thaum/layout.rb +347 -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 +221 -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 +115 -4
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
|
data/lib/thaum/layout.rb
ADDED
|
@@ -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
|