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 +4 -4
- data/CHANGELOG.md +24 -0
- data/lib/thaum/action.rb +19 -1
- data/lib/thaum/app.rb +1 -1
- data/lib/thaum/color.rb +8 -1
- data/lib/thaum/input_reader.rb +72 -3
- data/lib/thaum/layout.rb +347 -0
- data/lib/thaum/octagram.rb +1 -1
- data/lib/thaum/rendering/canvas.rb +5 -3
- data/lib/thaum/version.rb +1 -1
- data/lib/thaum.rb +1 -1
- metadata +3 -2
- data/lib/thaum/concerns/layout.rb +0 -349
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2a57f1cd2382c79243a8c7cb1de2294e73c63cc8c1ac5d3aa53550a1804b8e18
|
|
4
|
+
data.tar.gz: 15c723d96a1820f1685ebbfec62f1a8be56f497b77136a8641ebfc12dba9b8d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
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
|
data/lib/thaum/input_reader.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
42
|
-
|
|
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
|
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
|
data/lib/thaum/octagram.rb
CHANGED
|
@@ -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
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/
|
|
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.
|
|
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
|