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
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thaum
|
|
4
|
+
# Segment-bitmap merging for Unicode box-drawing characters.
|
|
5
|
+
#
|
|
6
|
+
# Each known glyph is encoded as four directional segment weights:
|
|
7
|
+
# [up, down, left, right]. Weights are 0 (none), 1 (light), 2 (heavy),
|
|
8
|
+
# 3 (double). When two glyphs are merged the result's weights are the
|
|
9
|
+
# per-direction max of the inputs, looked up back to a canonical glyph.
|
|
10
|
+
#
|
|
11
|
+
# Coverage:
|
|
12
|
+
# - Light family (U+2500-U+2503, U+250C-U+253C, half-stubs)
|
|
13
|
+
# - Heavy family (U+2501, U+2503, U+250F, U+2513, U+2517, U+251B,
|
|
14
|
+
# U+2523, U+252B, U+2533, U+253B, U+254B, U+2578-U+257B)
|
|
15
|
+
# - Mixed light/heavy (U+250D-U+250F minus 250C, U+2511-U+2513 minus
|
|
16
|
+
# 2510, etc. — full U+250C-U+254B region)
|
|
17
|
+
# - Double family (U+2550, U+2551, U+2554, U+2557, U+255A, U+255D,
|
|
18
|
+
# U+2560, U+2563, U+2566, U+2569, U+256C)
|
|
19
|
+
# - Mixed light/double (U+2552, U+2553, U+2555, U+2556, U+2558,
|
|
20
|
+
# U+2559, U+255B, U+255C, U+255E-U+256B)
|
|
21
|
+
# - Rounded corners (U+256D-U+2570) — share segments with non-rounded
|
|
22
|
+
# light corners; merges return the non-rounded canonical glyph.
|
|
23
|
+
#
|
|
24
|
+
# Heavy + double in different directions has no Unicode glyph; merge
|
|
25
|
+
# falls through to "incoming wins."
|
|
26
|
+
module Rendering
|
|
27
|
+
module BoxDrawing
|
|
28
|
+
# char => [up, down, left, right] weights (0=none, 1=light, 2=heavy, 3=double)
|
|
29
|
+
SEGMENTS = {
|
|
30
|
+
# ----- Pure light --------------------------------------------------
|
|
31
|
+
"─" => [0, 0, 1, 1],
|
|
32
|
+
"│" => [1, 1, 0, 0],
|
|
33
|
+
"┌" => [0, 1, 0, 1],
|
|
34
|
+
"┐" => [0, 1, 1, 0],
|
|
35
|
+
"└" => [1, 0, 0, 1],
|
|
36
|
+
"┘" => [1, 0, 1, 0],
|
|
37
|
+
"├" => [1, 1, 0, 1],
|
|
38
|
+
"┤" => [1, 1, 1, 0],
|
|
39
|
+
"┬" => [0, 1, 1, 1],
|
|
40
|
+
"┴" => [1, 0, 1, 1],
|
|
41
|
+
"┼" => [1, 1, 1, 1],
|
|
42
|
+
"╴" => [0, 0, 1, 0],
|
|
43
|
+
"╵" => [1, 0, 0, 0],
|
|
44
|
+
"╶" => [0, 0, 0, 1],
|
|
45
|
+
"╷" => [0, 1, 0, 0],
|
|
46
|
+
# Rounded corners share signatures with the non-rounded corners.
|
|
47
|
+
"╭" => [0, 1, 0, 1],
|
|
48
|
+
"╮" => [0, 1, 1, 0],
|
|
49
|
+
"╰" => [1, 0, 0, 1],
|
|
50
|
+
"╯" => [1, 0, 1, 0],
|
|
51
|
+
|
|
52
|
+
# ----- Pure heavy --------------------------------------------------
|
|
53
|
+
"━" => [0, 0, 2, 2],
|
|
54
|
+
"┃" => [2, 2, 0, 0],
|
|
55
|
+
"┏" => [0, 2, 0, 2],
|
|
56
|
+
"┓" => [0, 2, 2, 0],
|
|
57
|
+
"┗" => [2, 0, 0, 2],
|
|
58
|
+
"┛" => [2, 0, 2, 0],
|
|
59
|
+
"┣" => [2, 2, 0, 2],
|
|
60
|
+
"┫" => [2, 2, 2, 0],
|
|
61
|
+
"┳" => [0, 2, 2, 2],
|
|
62
|
+
"┻" => [2, 0, 2, 2],
|
|
63
|
+
"╋" => [2, 2, 2, 2],
|
|
64
|
+
"╸" => [0, 0, 2, 0],
|
|
65
|
+
"╹" => [2, 0, 0, 0],
|
|
66
|
+
"╺" => [0, 0, 0, 2],
|
|
67
|
+
"╻" => [0, 2, 0, 0],
|
|
68
|
+
|
|
69
|
+
# ----- Mixed light/heavy corners (U+250D-U+251B minus pures) -------
|
|
70
|
+
"┍" => [0, 1, 0, 2], # d light, r heavy
|
|
71
|
+
"┎" => [0, 2, 0, 1], # d heavy, r light
|
|
72
|
+
"┑" => [0, 1, 2, 0], # d light, l heavy
|
|
73
|
+
"┒" => [0, 2, 1, 0], # d heavy, l light
|
|
74
|
+
"┕" => [1, 0, 0, 2], # u light, r heavy
|
|
75
|
+
"┖" => [2, 0, 0, 1], # u heavy, r light
|
|
76
|
+
"┙" => [1, 0, 2, 0], # u light, l heavy
|
|
77
|
+
"┚" => [2, 0, 1, 0], # u heavy, l light
|
|
78
|
+
|
|
79
|
+
# ----- Mixed light/heavy left-tees (U+251D-U+2522) -----------------
|
|
80
|
+
"┝" => [1, 1, 0, 2],
|
|
81
|
+
"┞" => [2, 1, 0, 1],
|
|
82
|
+
"┟" => [1, 2, 0, 1],
|
|
83
|
+
"┠" => [2, 2, 0, 1],
|
|
84
|
+
"┡" => [2, 1, 0, 2],
|
|
85
|
+
"┢" => [1, 2, 0, 2],
|
|
86
|
+
|
|
87
|
+
# ----- Mixed light/heavy right-tees (U+2525-U+252A) ----------------
|
|
88
|
+
"┥" => [1, 1, 2, 0],
|
|
89
|
+
"┦" => [2, 1, 1, 0],
|
|
90
|
+
"┧" => [1, 2, 1, 0],
|
|
91
|
+
"┨" => [2, 2, 1, 0],
|
|
92
|
+
"┩" => [2, 1, 2, 0],
|
|
93
|
+
"┪" => [1, 2, 2, 0],
|
|
94
|
+
|
|
95
|
+
# ----- Mixed light/heavy top-tees (U+252D-U+2532) ------------------
|
|
96
|
+
"┭" => [0, 1, 2, 1],
|
|
97
|
+
"┮" => [0, 1, 1, 2],
|
|
98
|
+
"┯" => [0, 1, 2, 2],
|
|
99
|
+
"┰" => [0, 2, 1, 1],
|
|
100
|
+
"┱" => [0, 2, 2, 1],
|
|
101
|
+
"┲" => [0, 2, 1, 2],
|
|
102
|
+
|
|
103
|
+
# ----- Mixed light/heavy bottom-tees (U+2535-U+253A) ---------------
|
|
104
|
+
"┵" => [1, 0, 2, 1],
|
|
105
|
+
"┶" => [1, 0, 1, 2],
|
|
106
|
+
"┷" => [1, 0, 2, 2],
|
|
107
|
+
"┸" => [2, 0, 1, 1],
|
|
108
|
+
"┹" => [2, 0, 2, 1],
|
|
109
|
+
"┺" => [2, 0, 1, 2],
|
|
110
|
+
|
|
111
|
+
# ----- Mixed light/heavy crosses (U+253D-U+254A) -------------------
|
|
112
|
+
"┽" => [1, 1, 2, 1],
|
|
113
|
+
"┾" => [1, 1, 1, 2],
|
|
114
|
+
"┿" => [1, 1, 2, 2],
|
|
115
|
+
"╀" => [2, 1, 1, 1],
|
|
116
|
+
"╁" => [1, 2, 1, 1],
|
|
117
|
+
"╂" => [2, 2, 1, 1],
|
|
118
|
+
"╃" => [2, 1, 2, 1],
|
|
119
|
+
"╄" => [2, 1, 1, 2],
|
|
120
|
+
"╅" => [1, 2, 2, 1],
|
|
121
|
+
"╆" => [1, 2, 1, 2],
|
|
122
|
+
"╇" => [2, 1, 2, 2],
|
|
123
|
+
"╈" => [1, 2, 2, 2],
|
|
124
|
+
"╉" => [2, 2, 2, 1],
|
|
125
|
+
"╊" => [2, 2, 1, 2],
|
|
126
|
+
|
|
127
|
+
# ----- Pure double -------------------------------------------------
|
|
128
|
+
"═" => [0, 0, 3, 3],
|
|
129
|
+
"║" => [3, 3, 0, 0],
|
|
130
|
+
"╔" => [0, 3, 0, 3],
|
|
131
|
+
"╗" => [0, 3, 3, 0],
|
|
132
|
+
"╚" => [3, 0, 0, 3],
|
|
133
|
+
"╝" => [3, 0, 3, 0],
|
|
134
|
+
"╠" => [3, 3, 0, 3],
|
|
135
|
+
"╣" => [3, 3, 3, 0],
|
|
136
|
+
"╦" => [0, 3, 3, 3],
|
|
137
|
+
"╩" => [3, 0, 3, 3],
|
|
138
|
+
"╬" => [3, 3, 3, 3],
|
|
139
|
+
|
|
140
|
+
# ----- Mixed light/double corners (U+2552-U+2559, U+255B-U+255C) ---
|
|
141
|
+
"╒" => [0, 1, 0, 3], # d light, r double
|
|
142
|
+
"╓" => [0, 3, 0, 1], # d double, r light
|
|
143
|
+
"╕" => [0, 1, 3, 0],
|
|
144
|
+
"╖" => [0, 3, 1, 0],
|
|
145
|
+
"╘" => [1, 0, 0, 3],
|
|
146
|
+
"╙" => [3, 0, 0, 1],
|
|
147
|
+
"╛" => [1, 0, 3, 0],
|
|
148
|
+
"╜" => [3, 0, 1, 0],
|
|
149
|
+
|
|
150
|
+
# ----- Mixed light/double tees -------------------------------------
|
|
151
|
+
"╞" => [1, 1, 0, 3],
|
|
152
|
+
"╟" => [3, 3, 0, 1],
|
|
153
|
+
"╡" => [1, 1, 3, 0],
|
|
154
|
+
"╢" => [3, 3, 1, 0],
|
|
155
|
+
"╤" => [0, 1, 3, 3],
|
|
156
|
+
"╥" => [0, 3, 1, 1],
|
|
157
|
+
"╧" => [1, 0, 3, 3],
|
|
158
|
+
"╨" => [3, 0, 1, 1],
|
|
159
|
+
"╪" => [1, 1, 3, 3],
|
|
160
|
+
"╫" => [3, 3, 1, 1]
|
|
161
|
+
}.freeze
|
|
162
|
+
|
|
163
|
+
# [up, down, left, right] => canonical glyph (non-rounded; light-form
|
|
164
|
+
# preferred where a signature is ambiguous, e.g. the rounded corners).
|
|
165
|
+
GLYPH_FROM_SEGMENTS = SEGMENTS.each_with_object({}) do |(glyph, sig), out|
|
|
166
|
+
# Skip rounded corners — their signatures collide with the non-
|
|
167
|
+
# rounded light corners, which should win as the canonical form.
|
|
168
|
+
next if %w[╭ ╮ ╰ ╯].include?(glyph)
|
|
169
|
+
|
|
170
|
+
out[sig] ||= glyph
|
|
171
|
+
end.freeze
|
|
172
|
+
|
|
173
|
+
# Merge two glyphs. Returns the union glyph if both are known
|
|
174
|
+
# box-drawing chars and the resulting signature has a Unicode glyph;
|
|
175
|
+
# otherwise the incoming char (new-write-wins).
|
|
176
|
+
def self.merge(existing:, incoming:)
|
|
177
|
+
seg_a = SEGMENTS[existing]
|
|
178
|
+
seg_b = SEGMENTS[incoming]
|
|
179
|
+
return incoming unless seg_a && seg_b
|
|
180
|
+
|
|
181
|
+
merged = [seg_a, seg_b].transpose.map(&:max)
|
|
182
|
+
GLYPH_FROM_SEGMENTS[merged] || incoming
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thaum
|
|
4
|
+
# A 2D grid of Cells, addressed by (x:, y:) with (0, 0) at the top-left.
|
|
5
|
+
module Rendering
|
|
6
|
+
class Buffer
|
|
7
|
+
attr_reader :width, :height
|
|
8
|
+
attr_accessor :cursor
|
|
9
|
+
|
|
10
|
+
def initialize(width:, height:)
|
|
11
|
+
@width = width
|
|
12
|
+
@height = height
|
|
13
|
+
@cursor = nil # [x, y] in buffer coords, or nil to hide
|
|
14
|
+
# Stored row-major: @cells[y][x]. Public API takes (x:, y:) per convention,
|
|
15
|
+
# but rows-of-columns makes row iteration and row_text natural.
|
|
16
|
+
@cells = Array.new(height) { Array.new(width) { Cell.new } }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def cell(x:, y:)
|
|
20
|
+
@cells[y][x]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def row_text(y:)
|
|
24
|
+
@cells[y].map(&:char).join
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def set(x:, y:, char:, style: nil)
|
|
28
|
+
return unless cover?(x:, y:)
|
|
29
|
+
|
|
30
|
+
current = @cells[y][x]
|
|
31
|
+
# When both the existing and incoming chars are box-drawing glyphs,
|
|
32
|
+
# merge their segments so adjacent borders form correct junctions.
|
|
33
|
+
# For any other pair, the new char wins (existing behavior).
|
|
34
|
+
resolved = BoxDrawing.merge(existing: current.char, incoming: char)
|
|
35
|
+
@cells[y][x] = current.with(char: resolved, style: style || current.style)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def cover?(x:, y:)
|
|
39
|
+
x >= 0 && x < @width && y >= 0 && y < @height
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# One line per row, plain text — no ANSI sequences. Use for content
|
|
43
|
+
# and layout assertions in snapshot tests.
|
|
44
|
+
def to_text_snapshot
|
|
45
|
+
(0...@height).map { |y| row_text(y: y) }.join("\n")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# One line per row, with inline ANSI style transitions. Each row resets
|
|
49
|
+
# style at the end so rows are independent. Use for visual assertions
|
|
50
|
+
# including color.
|
|
51
|
+
def to_ansi_snapshot
|
|
52
|
+
(0...@height).map { |y| ansi_row(y) }.join("\n")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def ansi_row(y)
|
|
58
|
+
out = +""
|
|
59
|
+
prev = nil
|
|
60
|
+
@cells[y].each do |cell|
|
|
61
|
+
out << style_diff(prev: prev, style: cell.style)
|
|
62
|
+
out << cell.char
|
|
63
|
+
prev = cell.style
|
|
64
|
+
end
|
|
65
|
+
out << Seq::RESET unless prev.nil? || prev.empty?
|
|
66
|
+
out
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def style_diff(prev:, style:)
|
|
70
|
+
return "" if prev == style
|
|
71
|
+
|
|
72
|
+
out = +""
|
|
73
|
+
out << Seq::RESET if prev && !prev.empty?
|
|
74
|
+
out << Seq::BOLD if style.bold
|
|
75
|
+
out << Seq::DIM if style.dim
|
|
76
|
+
out << Seq::ITALIC if style.italic
|
|
77
|
+
out << Seq::UNDERLINE if style.underline
|
|
78
|
+
out << Color.to_escape(style.fg, capability: :truecolor, base: 38) if style.fg
|
|
79
|
+
out << Color.to_escape(style.bg, capability: :truecolor, base: 48) if style.bg
|
|
80
|
+
out
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thaum
|
|
4
|
+
# A rectangular view into a Buffer that translates local coordinates to buffer coordinates.
|
|
5
|
+
module Rendering
|
|
6
|
+
class Canvas
|
|
7
|
+
BORDERS = {
|
|
8
|
+
single: { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" },
|
|
9
|
+
rounded: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" },
|
|
10
|
+
double: { tl: "╔", tr: "╗", bl: "╚", br: "╝", h: "═", v: "║" },
|
|
11
|
+
thick: { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" },
|
|
12
|
+
# Dashed + dotted reuse the light corner glyphs — Unicode does not
|
|
13
|
+
# define dashed corners. The horizontal/vertical runs use 2-dash
|
|
14
|
+
# (dashed) or 4-dash (dotted, denser-looking) light glyphs.
|
|
15
|
+
dashed: { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "╌", v: "╎" },
|
|
16
|
+
dotted: { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "┈", v: "┊" },
|
|
17
|
+
ascii: { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|" }
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :rect
|
|
21
|
+
|
|
22
|
+
def initialize(buffer:, rect:)
|
|
23
|
+
@buffer = buffer
|
|
24
|
+
@rect = rect
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def x = @rect.x
|
|
28
|
+
def y = @rect.y
|
|
29
|
+
def width = @rect.width
|
|
30
|
+
def height = @rect.height
|
|
31
|
+
|
|
32
|
+
def fill(char: " ", fg: nil, bg: nil, x: 0, y: 0, width: @rect.width, height: @rect.height)
|
|
33
|
+
height.times do |dy|
|
|
34
|
+
width.times do |dx|
|
|
35
|
+
cx = @rect.x + x + dx
|
|
36
|
+
cy = @rect.y + y + dy
|
|
37
|
+
@buffer.set(x: cx, y: cy, char: char, style: blend_style(cx: cx, cy: cy, fg: fg, bg: bg))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def text(content:, fg: nil, bg: nil, x: 0, y: 0, align: :left, wrap: :none, **_opts)
|
|
43
|
+
if content.is_a?(Array)
|
|
44
|
+
draw_styled_runs(runs: content, x: x, y: y, align: align)
|
|
45
|
+
else
|
|
46
|
+
draw_string_text(content: content.to_s, x: x, y: y, align: align, wrap: wrap, fg: fg, bg: bg)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def cursor(x:, y:)
|
|
51
|
+
@buffer.cursor = [@rect.x + x, @rect.y + y]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Draws a box around the perimeter and returns the inset canvas (w-2 × h-2)
|
|
55
|
+
# so callers can render contents inside. style is one of BORDERS' keys.
|
|
56
|
+
def border(fg: nil, bg: nil, style: :single)
|
|
57
|
+
chars = BORDERS.fetch(style) { raise ArgumentError, "unknown border style: #{style.inspect}" }
|
|
58
|
+
return inset(1) if @rect.width < 2 || @rect.height < 2
|
|
59
|
+
|
|
60
|
+
draw_border(chars: chars, fg: fg, bg: bg)
|
|
61
|
+
inset(1)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def measure(content:, wrap: :none, width: @rect.width)
|
|
65
|
+
lines = wrap == :none ? [content.to_s] : wrap_lines(text: content.to_s, width: width)
|
|
66
|
+
{ width: lines.map(&:display_width).max || 0, height: lines.size }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def row(n)
|
|
70
|
+
return nil if n.negative? || n >= @rect.height
|
|
71
|
+
|
|
72
|
+
child_canvas(Rect.new(x: @rect.x, y: @rect.y + n, width: @rect.width, height: 1))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# rect is in local coordinates (relative to this canvas's origin).
|
|
76
|
+
def sub(rect:)
|
|
77
|
+
child_canvas(Rect.new(x: @rect.x + rect.x, y: @rect.y + rect.y, width: rect.width, height: rect.height))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def top(n)
|
|
81
|
+
child_canvas(Rect.new(x: @rect.x, y: @rect.y, width: @rect.width, height: [n, @rect.height].min))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def bottom(n)
|
|
85
|
+
child_canvas(Rect.new(x: @rect.x, y: @rect.y + @rect.height - n, width: @rect.width,
|
|
86
|
+
height: [n, @rect.height].min))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def left(n)
|
|
90
|
+
child_canvas(Rect.new(x: @rect.x, y: @rect.y, width: [n, @rect.width].min, height: @rect.height))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def right(n)
|
|
94
|
+
child_canvas(Rect.new(x: @rect.x + @rect.width - n, y: @rect.y, width: [n, @rect.width].min,
|
|
95
|
+
height: @rect.height))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def inset(n_or_opts)
|
|
99
|
+
if n_or_opts.is_a?(Integer)
|
|
100
|
+
n = n_or_opts
|
|
101
|
+
child_canvas(Rect.new(x: @rect.x + n, y: @rect.y + n, width: @rect.width - (n * 2),
|
|
102
|
+
height: @rect.height - (n * 2)))
|
|
103
|
+
else
|
|
104
|
+
t = n_or_opts[:top] || 0
|
|
105
|
+
r = n_or_opts[:right] || 0
|
|
106
|
+
b = n_or_opts[:bottom] || 0
|
|
107
|
+
l = n_or_opts[:left] || 0
|
|
108
|
+
child_canvas(Rect.new(x: @rect.x + l, y: @rect.y + t, width: @rect.width - l - r,
|
|
109
|
+
height: @rect.height - t - b))
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def child_canvas(rect) = Canvas.new(buffer: @buffer, rect: rect)
|
|
116
|
+
|
|
117
|
+
def draw_border(chars:, fg:, bg:)
|
|
118
|
+
w = @rect.width
|
|
119
|
+
h = @rect.height
|
|
120
|
+
top = "#{chars[:tl]}#{chars[:h] * (w - 2)}#{chars[:tr]}"
|
|
121
|
+
bot = "#{chars[:bl]}#{chars[:h] * (w - 2)}#{chars[:br]}"
|
|
122
|
+
text(content: top, y: 0, fg: fg, bg: bg)
|
|
123
|
+
text(content: bot, y: h - 1, fg: fg, bg: bg)
|
|
124
|
+
(1..(h - 2)).each do |row_y|
|
|
125
|
+
text(content: chars[:v], x: 0, y: row_y, fg: fg, bg: bg)
|
|
126
|
+
text(content: chars[:v], x: w - 1, y: row_y, fg: fg, bg: bg)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def draw_string_text(content:, x:, y:, align:, wrap:, fg:, bg:)
|
|
131
|
+
lines = wrap == :none ? [content] : wrap_lines(text: content, width: @rect.width - x)
|
|
132
|
+
right_edge = @rect.x + @rect.width
|
|
133
|
+
|
|
134
|
+
lines.each_with_index do |line, dy|
|
|
135
|
+
row_y = y + dy
|
|
136
|
+
break if row_y >= @rect.height
|
|
137
|
+
|
|
138
|
+
bx = align_offset(line: line, available_width: @rect.width - x, align: align, x: x)
|
|
139
|
+
line.each_char do |char|
|
|
140
|
+
w = char.display_width
|
|
141
|
+
break if bx + w > right_edge
|
|
142
|
+
|
|
143
|
+
cy = @rect.y + row_y
|
|
144
|
+
style = blend_style(cx: bx, cy: cy, fg: fg, bg: bg)
|
|
145
|
+
@buffer.set(x: bx, y: cy, char: char, style: style)
|
|
146
|
+
@buffer.set(x: bx + 1, y: cy, char: "", style: style) if w == 2
|
|
147
|
+
bx += w
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Compose a Style by overlaying explicitly-set fg/bg onto the cell's
|
|
153
|
+
# current style. nil means "inherit." This makes layered draws compose:
|
|
154
|
+
# fill bg, then text fg, keeps bg. Per DECISIONS.md 2026-06-10.
|
|
155
|
+
def blend_style(cx:, cy:, fg:, bg:)
|
|
156
|
+
return nil if fg.nil? && bg.nil?
|
|
157
|
+
return Style.new(fg: fg, bg: bg) unless @buffer.cover?(x: cx, y: cy)
|
|
158
|
+
|
|
159
|
+
existing = @buffer.cell(x: cx, y: cy).style
|
|
160
|
+
existing.with(fg: fg || existing.fg, bg: bg || existing.bg)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def draw_styled_runs(runs:, x:, y:, align:)
|
|
164
|
+
return if y >= @rect.height
|
|
165
|
+
|
|
166
|
+
total_w = runs.sum { |str, _style| str.display_width }
|
|
167
|
+
bx = case align
|
|
168
|
+
when :center then @rect.x + [(@rect.width - total_w) / 2, 0].max
|
|
169
|
+
when :right then @rect.x + [@rect.width - total_w, 0].max
|
|
170
|
+
else @rect.x + x
|
|
171
|
+
end
|
|
172
|
+
row_y = @rect.y + y
|
|
173
|
+
right_edge = @rect.x + @rect.width
|
|
174
|
+
|
|
175
|
+
runs.each do |str, style|
|
|
176
|
+
str.each_char do |char|
|
|
177
|
+
w = char.display_width
|
|
178
|
+
break if bx + w > right_edge
|
|
179
|
+
|
|
180
|
+
@buffer.set(x: bx, y: row_y, char: char, style: style)
|
|
181
|
+
@buffer.set(x: bx + 1, y: row_y, char: "", style: style) if w == 2
|
|
182
|
+
bx += w
|
|
183
|
+
end
|
|
184
|
+
break if bx >= right_edge
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
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.
|
|
191
|
+
case align
|
|
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
|
|
194
|
+
else @rect.x + x # :left
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def wrap_lines(text:, width:)
|
|
199
|
+
return [text] if width <= 0
|
|
200
|
+
|
|
201
|
+
lines = []
|
|
202
|
+
text.each_line(chomp: true) do |para|
|
|
203
|
+
words = para.split
|
|
204
|
+
line = +""
|
|
205
|
+
words.each do |word|
|
|
206
|
+
if line.empty?
|
|
207
|
+
line << word
|
|
208
|
+
elsif line.display_width + 1 + word.display_width <= width
|
|
209
|
+
line << " " << word
|
|
210
|
+
else
|
|
211
|
+
lines << line
|
|
212
|
+
line = +word
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
lines << line
|
|
216
|
+
end
|
|
217
|
+
lines.empty? ? [""] : lines
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thaum
|
|
4
|
+
# Renders a Buffer to a terminal output stream using ANSI escape sequences.
|
|
5
|
+
module Rendering
|
|
6
|
+
class Renderer
|
|
7
|
+
def initialize(output: $stdout, capability: :truecolor)
|
|
8
|
+
@output = output
|
|
9
|
+
@out = +""
|
|
10
|
+
@prev_buffer = nil
|
|
11
|
+
@capability = capability
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render(buffer)
|
|
15
|
+
@out.clear
|
|
16
|
+
full_redraw = @prev_buffer.nil? ||
|
|
17
|
+
@prev_buffer.width != buffer.width ||
|
|
18
|
+
@prev_buffer.height != buffer.height
|
|
19
|
+
prev_style = nil
|
|
20
|
+
any_writes = false
|
|
21
|
+
|
|
22
|
+
buffer.height.times do |y|
|
|
23
|
+
if full_redraw
|
|
24
|
+
first_x = 0
|
|
25
|
+
last_x = buffer.width - 1
|
|
26
|
+
else
|
|
27
|
+
first_x, last_x = dirty_span(buffer: buffer, y: y)
|
|
28
|
+
next unless first_x
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Emit the full span between the first and last dirty cells. Skipping
|
|
32
|
+
# unchanged cells inside the span and jumping the cursor over them
|
|
33
|
+
# would leave their on-screen state untouched, which is only safe if
|
|
34
|
+
# the terminal really mirrors prev_buffer at those positions. When
|
|
35
|
+
# consecutive themes share slot values, that assumption produces the
|
|
36
|
+
# bug where swatches keep stale pixels across transitions.
|
|
37
|
+
@out << Seq.cursor_pos(x: first_x + 1, y: y + 1)
|
|
38
|
+
(first_x..last_x).each do |x|
|
|
39
|
+
cell = buffer.cell(x: x, y: y)
|
|
40
|
+
emit_style(style: cell.style, prev: prev_style)
|
|
41
|
+
@out << cell.char
|
|
42
|
+
prev_style = cell.style
|
|
43
|
+
end
|
|
44
|
+
any_writes = true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@out << Seq::RESET if any_writes
|
|
48
|
+
|
|
49
|
+
emit_cursor(buffer: buffer, full_redraw: full_redraw)
|
|
50
|
+
|
|
51
|
+
@prev_buffer = buffer
|
|
52
|
+
return if @out.empty?
|
|
53
|
+
|
|
54
|
+
@output.write(Seq::SYNC_BEGIN, @out, Seq::SYNC_END)
|
|
55
|
+
@output.flush
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def dirty_span(buffer:, y:)
|
|
61
|
+
first = nil
|
|
62
|
+
last = nil
|
|
63
|
+
buffer.width.times do |x|
|
|
64
|
+
next if buffer.cell(x: x, y: y) == @prev_buffer.cell(x: x, y: y)
|
|
65
|
+
|
|
66
|
+
first ||= x
|
|
67
|
+
last = x
|
|
68
|
+
end
|
|
69
|
+
[first, last]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def emit_cursor(buffer:, full_redraw:)
|
|
73
|
+
prev = @prev_buffer&.cursor
|
|
74
|
+
curr = buffer.cursor
|
|
75
|
+
return unless full_redraw || prev != curr
|
|
76
|
+
|
|
77
|
+
if curr
|
|
78
|
+
@out << Seq.cursor_pos(x: curr[0] + 1, y: curr[1] + 1) << Seq::CURSOR_SHOW
|
|
79
|
+
else
|
|
80
|
+
@out << Seq::CURSOR_HIDE
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def emit_style(style:, prev:)
|
|
85
|
+
return if prev && style == prev
|
|
86
|
+
|
|
87
|
+
@out << Seq::RESET if prev && !prev.empty?
|
|
88
|
+
|
|
89
|
+
@out << Seq::BOLD if style.bold
|
|
90
|
+
@out << Seq::DIM if style.dim
|
|
91
|
+
@out << Seq::ITALIC if style.italic
|
|
92
|
+
@out << Seq::UNDERLINE if style.underline
|
|
93
|
+
@out << Color.to_escape(style.fg, capability: @capability, base: 38) if style.fg
|
|
94
|
+
@out << Color.to_escape(style.bg, capability: @capability, base: 48) if style.bg
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thaum
|
|
4
|
+
module Rendering
|
|
5
|
+
Style = Data.define(:fg, :bg, :bold, :italic, :underline, :dim) do
|
|
6
|
+
def initialize(fg: nil, bg: nil, bold: false, italic: false, underline: false, dim: false)
|
|
7
|
+
super
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def empty? = !fg && !bg && !bold && !italic && !underline && !dim
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|