thaum 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: edf87796b15b377e1fb9630d63a89c488c3ef127afe5f86b00748ffe40ea6bc2
4
- data.tar.gz: 4a3b2b5883f17d2a3050dc9ca0e07802c722757b4f27516ea92b73eafea53b44
3
+ metadata.gz: 2a57f1cd2382c79243a8c7cb1de2294e73c63cc8c1ac5d3aa53550a1804b8e18
4
+ data.tar.gz: 15c723d96a1820f1685ebbfec62f1a8be56f497b77136a8641ebfc12dba9b8d3
5
5
  SHA512:
6
- metadata.gz: cd4dac7b5e97854e8347f382101690d15216450e64de3626ac561629a763811ce6557ece95968f41cf6dfc3bd3c44357df68592dfaf16f83d3fa9b88596824c2
7
- data.tar.gz: d7855d4542eb89480e215f8dd42acd9c15e444db41a9fe83acc7f1cb9f6b0d4baa02803352454767f4ce3fa8f6894063a6f29f5395db031e750d069497dbe34e
6
+ metadata.gz: a745ca6c770a9ed3fe5bacf49bbf5e0e857e3c7f343717e6383dfbf79fe88b82db5071f8fbbc58ef5c3f303d0b69a4dcb97e018bfadb50474a2fa12e0a225d41
7
+ data.tar.gz: 3d0645793d58cb962df5dcbb00f561fcb7dcb41a63b6045d026821f81a36a88ae31e3f13bede63a2572a4e22201f9707c85a129d0f0a9ca7d20b4ecec95dd2ea
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.1] - 2026-06-17
10
+
11
+ ### Fixed
12
+
13
+ - `InputReader#stop` no longer blocks ~1s on quit; the reader thread is interrupted instead of left to time out.
14
+ - Escape sequences split across read boundaries are now coalesced instead of being mis-parsed as a stray Escape plus garbage keys. `InputReader#read_chunk` now extends the read whenever a chunk ends mid-sequence (CSI/SS3/SGR-mouse or a bare ESC), bounded by a timeout and an extend cap.
15
+ - Centered/right-aligned wrapped text drawn at an x-offset is now positioned against the offset content area instead of the full canvas width.
16
+
17
+ ### Added
18
+
19
+ - Honor the `NO_COLOR` environment variable — when set and non-empty, color output is disabled.
20
+
21
+ ### Changed
22
+
23
+ - Moved `Thaum::Concerns::Layout` back to `Thaum::Layout`. Includes in `App`, `Octagram`, and downstream code should reference the top-level module.
24
+ - `Thaum::Action` raises a clear `Thaum::Error` when a background method is called outside a running app, instead of a `NoMethodError` on nil.
data/lib/thaum/action.rb CHANGED
@@ -1,6 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Thaum
4
+ # Mixin that turns a plain class into a background "action".
5
+ #
6
+ # Including this module promotes every *public* instance method into a
7
+ # class method (see {ClassMethods#method_added}). Calling that class method
8
+ # schedules the work on the run loop's background thread pool:
9
+ #
10
+ # - A fresh instance is built with an argless `new`, so Actions must not
11
+ # require constructor arguments.
12
+ # - The method runs fire-and-forget; its return value is discarded.
13
+ # - Use {#emit} to push results back to the run loop as events.
14
+ # - Calling a promoted method outside a running Thaum app (no pool) raises
15
+ # {Thaum::Error}.
4
16
  module Action
5
17
  class << self
6
18
  attr_accessor :queue, :pool
@@ -22,7 +34,13 @@ module Thaum
22
34
  return if singleton_class.method_defined?(name, false)
23
35
 
24
36
  define_singleton_method(name) do |*args, **kwargs|
25
- Thaum::Action.pool.post { new.public_send(name, *args, **kwargs) }
37
+ pool = Thaum::Action.pool
38
+ unless pool
39
+ raise Thaum::Error,
40
+ "Thaum::Action method called outside a running Thaum app (no thread pool available)"
41
+ end
42
+
43
+ pool.post { new.public_send(name, *args, **kwargs) }
26
44
  end
27
45
  end
28
46
  end
data/lib/thaum/app.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Thaum
4
4
  module App
5
- include Concerns::Layout
5
+ include Layout
6
6
  include Concerns::Focus
7
7
  include Concerns::ContextUpdate
8
8
  include Concerns::Modal
data/lib/thaum/color.rb CHANGED
@@ -28,6 +28,8 @@ module Thaum
28
28
  }.freeze
29
29
 
30
30
  def self.detect(env)
31
+ return :none if no_color?(env)
32
+
31
33
  colorterm = env["COLORTERM"]
32
34
  term = env["TERM"]
33
35
  return :truecolor if %w[truecolor 24bit].include?(colorterm)
@@ -37,6 +39,11 @@ module Thaum
37
39
  :ansi
38
40
  end
39
41
 
42
+ def self.no_color?(env)
43
+ value = env["NO_COLOR"]
44
+ !value.nil? && !value.empty?
45
+ end
46
+
40
47
  def self.to_escape(color, capability:, base:)
41
48
  return "" if capability == :none
42
49
  return "" if color.nil?
@@ -92,6 +99,6 @@ module Thaum
92
99
  end.first
93
100
  end
94
101
 
95
- private_class_method :named_escape, :hex_escape, :hex_to_256, :hex_to_ansi # rubocop:disable Naming/VariableNumber
102
+ private_class_method :no_color?, :named_escape, :hex_escape, :hex_to_256, :hex_to_ansi # rubocop:disable Naming/VariableNumber
96
103
  end
97
104
  end
@@ -4,6 +4,14 @@ module Thaum
4
4
  # Reads raw bytes from an input stream in a background thread and pushes KeyEvents onto a queue.
5
5
  class InputReader
6
6
  ESCAPE_TIMEOUT = 0.05 # seconds to wait after a bare \e before treating it as Escape
7
+ MAX_ESCAPE_EXTENDS = 4 # cap extend reads so malformed input can't hang the reader
8
+
9
+ ESC = "\e"
10
+ CSI_INTRO = 0x5b # '[' — Control Sequence Introducer
11
+ SS3_INTRO = 0x4f # 'O' — Single Shift 3
12
+ SGR_MOUSE_MARKER = 0x3c # '<' — SGR mouse introducer
13
+ CSI_FINAL_RANGE = 0x40..0x7e # any byte in this range terminates a CSI
14
+ SGR_MOUSE_FINAL = [0x4d, 0x6d].freeze # 'M' / 'm' terminate an SGR mouse sequence
7
15
 
8
16
  def initialize(input:, queue:, parser: EscapeParser.new)
9
17
  @input = input
@@ -17,7 +25,13 @@ module Thaum
17
25
  end
18
26
 
19
27
  def stop
20
- @thread&.join(1)
28
+ return unless @thread
29
+
30
+ # Give the thread a moment to finish on its own (e.g. input already closed).
31
+ # If it's still blocked in readpartial, interrupt it so stop doesn't wait out
32
+ # the full join timeout (~1s) on every quit.
33
+ @thread.kill unless @thread.join(0.1)
34
+ @thread.join(1)
21
35
  @thread = nil
22
36
  end
23
37
 
@@ -38,9 +52,64 @@ module Thaum
38
52
 
39
53
  def read_chunk
40
54
  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)
55
+ # The chunk may end in the middle of an escape sequence (a bare ESC, or
56
+ # an in-progress CSI/SS3/mouse sequence whose final byte hasn't arrived).
57
+ # Extend the read while that's the case and more bytes are available,
58
+ # bounded by MAX_ESCAPE_EXTENDS so malformed input can't hang the reader.
59
+ # A genuinely-bare ESC keypress resolves to :escape once wait_readable
60
+ # times out (no more bytes) and we return what we have.
61
+ extends = 0
62
+ while pending_escape?(bytes) && extends < MAX_ESCAPE_EXTENDS && @input.wait_readable(ESCAPE_TIMEOUT)
63
+ bytes += @input.readpartial(1024)
64
+ extends += 1
65
+ end
43
66
  bytes
44
67
  end
68
+
69
+ # True when `bytes` ends with an incomplete escape sequence — a trailing
70
+ # ESC that the parser cannot yet dispatch as a complete sequence. Covers
71
+ # the three forms the parser recognizes: CSI (\e[ … final byte 0x40–0x7e,
72
+ # with SGR mouse \e[< … terminated by M/m), SS3 (\eO needs one more byte),
73
+ # and a lone trailing ESC. A complete bracketed-paste START (\e[200~) is
74
+ # NOT pending — paste accumulation is handled statefully by the parser.
75
+ def pending_escape?(bytes)
76
+ esc = bytes.byterindex(ESC)
77
+ return false if esc.nil?
78
+
79
+ # A complete bracketed-paste START (\e[200~) is treated as a normal,
80
+ # dispatchable CSI here (its '~' is a CSI final byte), so it is NOT
81
+ # pending — the parser takes over paste accumulation statefully. A
82
+ # *partial* marker (e.g. \e[2 / \e[200) is an incomplete CSI and stays
83
+ # pending so we keep reading until the '~' arrives.
84
+ tail = bytes.byteslice(esc, bytes.bytesize - esc)
85
+ incomplete_escape_tail?(tail)
86
+ end
87
+
88
+ # `tail` starts at an ESC byte. Returns true if it is NOT yet a complete,
89
+ # dispatchable escape sequence.
90
+ def incomplete_escape_tail?(tail)
91
+ return true if tail.bytesize == 1 # lone trailing ESC
92
+
93
+ case tail.getbyte(1)
94
+ when CSI_INTRO then incomplete_csi_tail?(tail)
95
+ when SS3_INTRO then tail.bytesize < 3 # \eO needs exactly one more byte
96
+ else false # \e + ground byte (Alt+key) is complete in two bytes
97
+ end
98
+ end
99
+
100
+ # `tail` begins with \e[. SGR mouse (\e[<) terminates on M/m; a plain CSI
101
+ # terminates on any byte in 0x40–0x7e. Incomplete until that final byte.
102
+ def incomplete_csi_tail?(tail)
103
+ return true if tail.bytesize == 2 # just "\e[" so far
104
+
105
+ sgr = tail.getbyte(2) == SGR_MOUSE_MARKER
106
+ body_start = sgr ? 3 : 2
107
+ body = tail.byteslice(body_start, tail.bytesize - body_start)
108
+ body.each_byte.none? { |b| csi_final?(b, sgr: sgr) }
109
+ end
110
+
111
+ def csi_final?(byte, sgr:)
112
+ sgr ? SGR_MOUSE_FINAL.include?(byte) : CSI_FINAL_RANGE.cover?(byte)
113
+ end
45
114
  end
46
115
  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
@@ -9,7 +9,7 @@ module Thaum
9
9
  #
10
10
  # See DECISIONS.md (2026-06-02) for the naming rationale.
11
11
  module Octagram
12
- include Concerns::Layout
12
+ include Layout
13
13
 
14
14
  attr_accessor :thaum_app, :handler_parent
15
15
 
@@ -135,7 +135,7 @@ module Thaum
135
135
  row_y = y + dy
136
136
  break if row_y >= @rect.height
137
137
 
138
- bx = align_offset(line: line, available_width: @rect.width, align: align, x: x)
138
+ bx = align_offset(line: line, available_width: @rect.width - x, align: align, x: x)
139
139
  line.each_char do |char|
140
140
  w = char.display_width
141
141
  break if bx + w > right_edge
@@ -186,9 +186,11 @@ module Thaum
186
186
  end
187
187
 
188
188
  def align_offset(line:, available_width:, align:, x:)
189
+ # Origin of the content area is @rect.x + x; alignment is relative to
190
+ # available_width (the offset content area), so center/right add x too.
189
191
  case align
190
- when :center then @rect.x + [(available_width - line.display_width) / 2, 0].max
191
- when :right then @rect.x + [available_width - line.display_width, 0].max
192
+ when :center then @rect.x + x + [(available_width - line.display_width) / 2, 0].max
193
+ when :right then @rect.x + x + [available_width - line.display_width, 0].max
192
194
  else @rect.x + x # :left
193
195
  end
194
196
  end
data/lib/thaum/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Thaum
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/thaum.rb CHANGED
@@ -22,7 +22,7 @@ require_relative "thaum/input_reader"
22
22
  require_relative "thaum/rendering/renderer"
23
23
  require_relative "thaum/themes"
24
24
  require_relative "thaum/sigil"
25
- require_relative "thaum/concerns/layout"
25
+ require_relative "thaum/layout"
26
26
  require_relative "thaum/octagram"
27
27
  require_relative "thaum/concerns/focus"
28
28
  require_relative "thaum/concerns/context_update"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: thaum
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tad Thorley
@@ -61,6 +61,7 @@ executables: []
61
61
  extensions: []
62
62
  extra_rdoc_files: []
63
63
  files:
64
+ - CHANGELOG.md
64
65
  - LICENSE.txt
65
66
  - README.md
66
67
  - Rakefile
@@ -89,7 +90,6 @@ files:
89
90
  - lib/thaum/color.rb
90
91
  - lib/thaum/concerns/context_update.rb
91
92
  - lib/thaum/concerns/focus.rb
92
- - lib/thaum/concerns/layout.rb
93
93
  - lib/thaum/concerns/modal.rb
94
94
  - lib/thaum/concerns/tab_navigation.rb
95
95
  - lib/thaum/dispatch.rb
@@ -100,6 +100,7 @@ files:
100
100
  - lib/thaum/input_reader.rb
101
101
  - lib/thaum/key_event.rb
102
102
  - lib/thaum/keys.rb
103
+ - lib/thaum/layout.rb
103
104
  - lib/thaum/minitest.rb
104
105
  - lib/thaum/octagram.rb
105
106
  - lib/thaum/painter.rb
@@ -1,349 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Thaum
4
- module Concerns
5
- module Layout
6
- attr_reader :rect, :leaf_sigils, :subtree_leaves, :child_layouts, :subtree_children
7
-
8
- # Override on any Layout node (including App) to specify a Tab traversal
9
- # order for that subtree. Return an array of leaf Sigils. nil (default)
10
- # means "use left-to-right leaf order."
11
- def focus_order = nil
12
-
13
- # Called by the run loop (or repartition) to assign geometry and walk the partition tree.
14
- # Returns the flat list of leaf Sigils in render order.
15
- def run_partition(rect:, collector: nil)
16
- @rect = rect
17
- collector ||= []
18
- start = collector.size
19
- @leaf_sigils = collector
20
- @child_layouts = []
21
- @subtree_children = []
22
- # An Octagram may inset the rect its children partition into so its
23
- # render hook (border, padding) survives. Plain Layout passes through.
24
- @rect = inset_for_partition(rect)
25
- partition
26
- @rect = rect
27
- @subtree_leaves = collector[start..] || []
28
- collector
29
- end
30
-
31
- # Override on Octagram (or any Layout) to inset the rect that this
32
- # node's children partition into. Defaults to identity.
33
- def inset_for_partition(rect)
34
- return rect unless respond_to?(:partition_inset)
35
-
36
- inset = partition_inset || {}
37
- Rect.new(
38
- x: rect.x + (inset[:left] || 0),
39
- y: rect.y + (inset[:top] || 0),
40
- width: [rect.width - (inset[:left] || 0) - (inset[:right] || 0), 0].max,
41
- height: [rect.height - (inset[:top] || 0) - (inset[:bottom] || 0), 0].max
42
- )
43
- end
44
-
45
- # Re-run partition for this node's subtree using its current rect.
46
- def repartition
47
- return unless @rect
48
-
49
- old_leaves = @leaf_sigils || []
50
- old_octagrams = collect_octagrams
51
-
52
- new_leaves = []
53
- run_partition(rect: @rect, collector: new_leaves)
54
-
55
- new_octagrams = collect_octagrams
56
-
57
- # Fire on_unmount for removed Sigils and Octagrams.
58
- (old_leaves - new_leaves).each(&:on_unmount)
59
- (old_octagrams - new_octagrams).each(&:on_unmount)
60
-
61
- # Rewire handler parents across the (possibly restructured) subtree so
62
- # newly-added Octagrams and Sigils see the right chain BEFORE on_mount.
63
- # Only rewires when we can identify this node's role in the dispatch
64
- # chain — App (root) or Octagram (its own handler scope).
65
- app = thaum_app_ref || (is_a?(Octagram) ? @thaum_app : nil)
66
- wire_handler_parents(handler_parent: self, app: app) if app
67
-
68
- # Fire on_mount for newly-added Sigils and Octagrams.
69
- (new_octagrams - old_octagrams).each do |o|
70
- o.thaum_app = app if app
71
- o.on_mount
72
- end
73
- (new_leaves - old_leaves).each do |s|
74
- s.thaum_app = app if app
75
- s.on_mount
76
- end
77
-
78
- @leaf_sigils = new_leaves
79
-
80
- # Re-validate focus_order in this subtree after structural change.
81
- validate_focus_order_tree
82
- end
83
-
84
- # Walk this subtree and collect every Octagram node (excluding self).
85
- def collect_octagrams
86
- result = []
87
- (@subtree_children || []).each do |child|
88
- if child.is_a?(Octagram)
89
- result << child
90
- result.concat(child.collect_octagrams)
91
- elsif child.respond_to?(:collect_octagrams)
92
- result.concat(child.collect_octagrams)
93
- end
94
- end
95
- result
96
- end
97
-
98
- # Walk this Layout node and every descendant Layout, raising
99
- # FocusOrderError if any defines a focus_order that does not exactly
100
- # cover the focusable leaves in its subtree.
101
- def validate_focus_order_tree
102
- validate_focus_order_node
103
- (@child_layouts || []).each(&:validate_focus_order_tree)
104
- end
105
-
106
- # Build the Tab traversal order for this subtree, recursively expanding
107
- # any nested Layout that itself has focus_order. When no focus_order is
108
- # defined at any level, this falls through to left-to-right leaf order.
109
- def effective_focus_order
110
- order = focus_order
111
- return order if order
112
-
113
- result = []
114
- layout_subtree_in_order.each do |node|
115
- if node.is_a?(Sigil)
116
- result << node if node.focusable?
117
- else
118
- result.concat(node.effective_focus_order)
119
- end
120
- end
121
- result
122
- end
123
-
124
- # The direct children of this Layout in declaration order — both Sigils
125
- # and nested Layouts. Built during partition (see place_child).
126
- def layout_subtree_in_order = @subtree_children || []
127
-
128
- # Scope units for Tab cycling within THIS focus scope. A "scope" is the
129
- # App or an Octagram. A unit is either a focusable Sigil (reached through
130
- # plain Layouts only) or a nested Octagram (which appears as one unit and
131
- # is itself a scope). Plain Layouts are transparent — their units are
132
- # flattened into the parent scope.
133
- def focus_scope_units
134
- result = []
135
- (@subtree_children || []).each do |child|
136
- case child
137
- when Sigil
138
- result << child if child.focusable?
139
- when Octagram
140
- result << child if child.focusable_descendant?
141
- else
142
- result.concat(child.focus_scope_units) if child.respond_to?(:focus_scope_units)
143
- end
144
- end
145
- result
146
- end
147
-
148
- # Recursively descend into this Octagram (or plain Layout) to find its
149
- # first focusable leaf — used when Tab enters an Octagram unit.
150
- def first_focusable_leaf
151
- (@subtree_children || []).each do |child|
152
- case child
153
- when Sigil
154
- return child if child.focusable?
155
- else
156
- leaf = child.first_focusable_leaf if child.respond_to?(:first_focusable_leaf)
157
- return leaf if leaf
158
- end
159
- end
160
- nil
161
- end
162
-
163
- # Mirror of first_focusable_leaf for Shift-Tab entry.
164
- def last_focusable_leaf
165
- (@subtree_children || []).reverse_each do |child|
166
- case child
167
- when Sigil
168
- return child if child.focusable?
169
- else
170
- leaf = child.last_focusable_leaf if child.respond_to?(:last_focusable_leaf)
171
- return leaf if leaf
172
- end
173
- end
174
- nil
175
- end
176
-
177
- def focusable_descendant?
178
- !first_focusable_leaf.nil?
179
- end
180
-
181
- # Walk the subtree top-down, wiring each leaf Sigil and each Octagram.
182
- # Leaves and Octagrams get _handler_parent set to the innermost
183
- # enclosing Octagram (or the App when there is none). Plain Layout
184
- # nodes are transparent — their children inherit the outer parent.
185
- def wire_handler_parents(handler_parent:, app:)
186
- (@subtree_children || []).each do |child|
187
- case child
188
- when Sigil
189
- child.handler_parent = handler_parent
190
- child.thaum_app = app
191
- when Octagram
192
- child.handler_parent = handler_parent
193
- child.thaum_app = app
194
- child.wire_handler_parents(handler_parent: child, app: app)
195
- else
196
- child.wire_handler_parents(handler_parent:, app:) if child.respond_to?(:wire_handler_parents)
197
- end
198
- end
199
- end
200
-
201
- # Override to specify the layout. Must call horizontal/vertical.
202
- def partition; end
203
-
204
- private
205
-
206
- def horizontal(&) = divide_axis(:cols, &)
207
- def vertical(&) = divide_axis(:rows, &)
208
-
209
- def region(**opts, &child_block)
210
- @divide_frame << { opts: opts, block: child_block }
211
- end
212
-
213
- def divide_axis(axis, &block)
214
- parent_rect = @rect
215
- saved_frame = @divide_frame
216
- @divide_frame = []
217
-
218
- block.call
219
-
220
- specs = @divide_frame
221
- @divide_frame = saved_frame
222
-
223
- validate_axis_kwargs(axis: axis, specs: specs)
224
-
225
- calc_rects(parent_rect: parent_rect, axis: axis, specs: specs).each_with_index do |child_rect, i|
226
- @rect = child_rect
227
- place_child(child: specs[i][:block].call, rect: child_rect)
228
- end
229
-
230
- @rect = parent_rect
231
- end
232
-
233
- def validate_axis_kwargs(axis:, specs:)
234
- expected, forbidden = axis == :cols ? %i[width height] : %i[height width]
235
- specs.each do |spec|
236
- next unless spec[:opts].key?(forbidden)
237
-
238
- direction = axis == :cols ? "horizontal" : "vertical"
239
- raise LayoutError,
240
- "region(#{forbidden}:) is invalid inside #{direction} — use #{expected}: instead"
241
- end
242
- end
243
-
244
- def place_child(child:, rect:)
245
- @subtree_children ||= []
246
- if child.is_a?(Sigil)
247
- child.rect = rect
248
- @leaf_sigils << child
249
- @subtree_children << child
250
- elsif child.respond_to?(:run_partition)
251
- @child_layouts << child
252
- @subtree_children << child
253
- child.run_partition(rect: rect, collector: @leaf_sigils)
254
- end
255
- end
256
-
257
- def validate_focus_order_node
258
- order = focus_order
259
- return if order.nil?
260
-
261
- unless order.is_a?(Array)
262
- raise FocusOrderError,
263
- "#{self.class}#focus_order must return an Array, got #{order.class}"
264
- end
265
-
266
- focusable = (@subtree_leaves || []).select(&:focusable?)
267
- missing = focusable - order
268
- extras = order - focusable
269
- duplicates = order.tally.select { |_, c| c > 1 }.keys
270
-
271
- return if missing.empty? && extras.empty? && duplicates.empty?
272
-
273
- msg = "#{self.class}#focus_order is invalid:"
274
- msg += " missing #{missing.map(&:class).join(', ')};" unless missing.empty?
275
- msg += " unknown entries #{extras.map(&:class).join(', ')};" unless extras.empty?
276
- msg += " duplicates #{duplicates.map(&:class).join(', ')};" unless duplicates.empty?
277
- raise FocusOrderError, msg
278
- end
279
-
280
- def calc_rects(parent_rect:, axis:, specs:)
281
- if axis == :cols
282
- total = parent_rect.width
283
- fixed_total = specs.sum { |s| integer_size(val: s[:opts][:width], total: total) || 0 }
284
- fill_count = specs.count { |s| s[:opts][:width] == :fill }
285
- fill_size = fill_count.positive? ? (total - fixed_total) / fill_count : 0
286
- leftover = fill_count.positive? ? (total - fixed_total) % fill_count : 0
287
-
288
- x = parent_rect.x
289
- specs.each_with_index.map do |spec, i|
290
- fill_index = specs[0..i].count { |s| s[:opts][:width] == :fill } - 1
291
- last_fill = fill_index >= 0 && fill_index == fill_count - 1
292
-
293
- w = resolved_size(val: spec[:opts][:width], total: total, fill_size: fill_size,
294
- extra: last_fill ? leftover : 0)
295
- w = apply_min_max(size: w, min: spec[:opts][:min], max: spec[:opts][:max])
296
- r = Rect.new(x: x, y: parent_rect.y, width: w, height: parent_rect.height)
297
- x += w
298
- r
299
- end
300
- else
301
- total = parent_rect.height
302
- fixed_total = specs.sum { |s| integer_size(val: s[:opts][:height], total: total) || 0 }
303
- fill_count = specs.count { |s| s[:opts][:height] == :fill }
304
- fill_size = fill_count.positive? ? (total - fixed_total) / fill_count : 0
305
- leftover = fill_count.positive? ? (total - fixed_total) % fill_count : 0
306
-
307
- y = parent_rect.y
308
- specs.each_with_index.map do |spec, i|
309
- fill_index = specs[0..i].count { |s| s[:opts][:height] == :fill } - 1
310
- last_fill = fill_index >= 0 && fill_index == fill_count - 1
311
-
312
- h = resolved_size(val: spec[:opts][:height], total: total, fill_size: fill_size,
313
- extra: last_fill ? leftover : 0)
314
- h = apply_min_max(size: h, min: spec[:opts][:min], max: spec[:opts][:max])
315
- r = Rect.new(x: parent_rect.x, y: y, width: parent_rect.width, height: h)
316
- y += h
317
- r
318
- end
319
- end
320
- end
321
-
322
- def integer_size(val:, total:)
323
- return val if val.is_a?(Integer)
324
- return nil if val == :fill
325
- return val.to_i * total / 100 if val.is_a?(String) && val.end_with?("%")
326
-
327
- nil
328
- end
329
-
330
- def resolved_size(val:, total:, fill_size:, extra:)
331
- return val + 0 if val.is_a?(Integer)
332
- return fill_size + extra if val == :fill
333
- return val.to_i * total / 100 if val.is_a?(String) && val.end_with?("%")
334
-
335
- fill_size + extra # default to fill if unspecified
336
- end
337
-
338
- def apply_min_max(size:, min:, max:)
339
- size = [size, min].max if min
340
- size = [size, max].min if max
341
- [size, 0].max
342
- end
343
-
344
- # Used by repartition to wire new sigils to the app.
345
- # overridden in App
346
- def thaum_app_ref = nil
347
- end
348
- end
349
- end