thaum 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +106 -14
  3. data/examples/checkbox.rb +89 -0
  4. data/examples/counter.rb +50 -0
  5. data/examples/hello_world.rb +28 -0
  6. data/examples/layout_demo.rb +138 -0
  7. data/examples/modal.rb +76 -0
  8. data/examples/mouse.rb +60 -0
  9. data/examples/octagram_picker.rb +224 -0
  10. data/examples/picker.rb +150 -0
  11. data/examples/progress_bar.rb +90 -0
  12. data/examples/scroll_view.rb +64 -0
  13. data/examples/select.rb +64 -0
  14. data/examples/spinner.rb +66 -0
  15. data/examples/status_bar.rb +65 -0
  16. data/examples/stopwatch.rb +84 -0
  17. data/examples/table.rb +196 -0
  18. data/examples/tabs.rb +112 -0
  19. data/examples/text.rb +101 -0
  20. data/examples/theme_picker.rb +95 -0
  21. data/examples/todo.rb +242 -0
  22. data/lib/thaum/action.rb +30 -0
  23. data/lib/thaum/app.rb +87 -0
  24. data/lib/thaum/color.rb +97 -0
  25. data/lib/thaum/concerns/context_update.rb +40 -0
  26. data/lib/thaum/concerns/focus.rb +53 -0
  27. data/lib/thaum/concerns/layout.rb +349 -0
  28. data/lib/thaum/concerns/modal.rb +102 -0
  29. data/lib/thaum/concerns/tab_navigation.rb +97 -0
  30. data/lib/thaum/dispatch.rb +149 -0
  31. data/lib/thaum/escape_parser.rb +265 -0
  32. data/lib/thaum/event.rb +13 -0
  33. data/lib/thaum/events.rb +28 -0
  34. data/lib/thaum/hit_test.rb +28 -0
  35. data/lib/thaum/input_reader.rb +46 -0
  36. data/lib/thaum/key_event.rb +13 -0
  37. data/lib/thaum/keys.rb +55 -0
  38. data/lib/thaum/minitest.rb +64 -0
  39. data/lib/thaum/octagram.rb +76 -0
  40. data/lib/thaum/painter.rb +49 -0
  41. data/lib/thaum/rect.rb +5 -0
  42. data/lib/thaum/rendering/box_drawing.rb +186 -0
  43. data/lib/thaum/rendering/buffer.rb +84 -0
  44. data/lib/thaum/rendering/canvas.rb +219 -0
  45. data/lib/thaum/rendering/cell.rb +11 -0
  46. data/lib/thaum/rendering/renderer.rb +98 -0
  47. data/lib/thaum/rendering/style.rb +13 -0
  48. data/lib/thaum/run_loop.rb +182 -0
  49. data/lib/thaum/seq.rb +91 -0
  50. data/lib/thaum/sigil.rb +41 -0
  51. data/lib/thaum/sigils/button.rb +47 -0
  52. data/lib/thaum/sigils/checkbox.rb +57 -0
  53. data/lib/thaum/sigils/progress_bar.rb +65 -0
  54. data/lib/thaum/sigils/scroll_view.rb +115 -0
  55. data/lib/thaum/sigils/select.rb +56 -0
  56. data/lib/thaum/sigils/spinner.rb +39 -0
  57. data/lib/thaum/sigils/status_bar.rb +89 -0
  58. data/lib/thaum/sigils/table.rb +156 -0
  59. data/lib/thaum/sigils/tabs.rb +59 -0
  60. data/lib/thaum/sigils/text.rb +22 -0
  61. data/lib/thaum/sigils/text_input.rb +86 -0
  62. data/lib/thaum/terminal.rb +46 -0
  63. data/lib/thaum/themes.rb +267 -0
  64. data/lib/thaum/tree.rb +16 -0
  65. data/lib/thaum/version.rb +1 -1
  66. data/lib/thaum.rb +64 -1
  67. metadata +114 -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,219 @@
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, 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
+ 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
+ else @rect.x + x # :left
193
+ end
194
+ end
195
+
196
+ def wrap_lines(text:, width:)
197
+ return [text] if width <= 0
198
+
199
+ lines = []
200
+ text.each_line(chomp: true) do |para|
201
+ words = para.split
202
+ line = +""
203
+ words.each do |word|
204
+ if line.empty?
205
+ line << word
206
+ elsif line.display_width + 1 + word.display_width <= width
207
+ line << " " << word
208
+ else
209
+ lines << line
210
+ line = +word
211
+ end
212
+ end
213
+ lines << line
214
+ end
215
+ lines.empty? ? [""] : lines
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ module Rendering
5
+ Cell = Data.define(:char, :style) do
6
+ def initialize(char: " ", style: Style.new)
7
+ super
8
+ end
9
+ end
10
+ end
11
+ 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