ratatat 1.0.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.
- checksums.yaml +7 -0
- data/README.md +201 -0
- data/examples/log_tailer.rb +460 -0
- data/lib/ratatat/ansi_backend.rb +175 -0
- data/lib/ratatat/app.rb +342 -0
- data/lib/ratatat/binding.rb +74 -0
- data/lib/ratatat/buffer.rb +238 -0
- data/lib/ratatat/cell.rb +166 -0
- data/lib/ratatat/color.rb +191 -0
- data/lib/ratatat/css_parser.rb +192 -0
- data/lib/ratatat/dom_query.rb +124 -0
- data/lib/ratatat/driver.rb +200 -0
- data/lib/ratatat/input.rb +208 -0
- data/lib/ratatat/message.rb +147 -0
- data/lib/ratatat/reactive.rb +79 -0
- data/lib/ratatat/styles.rb +293 -0
- data/lib/ratatat/terminal.rb +168 -0
- data/lib/ratatat/version.rb +3 -0
- data/lib/ratatat/widget.rb +337 -0
- data/lib/ratatat/widgets/button.rb +43 -0
- data/lib/ratatat/widgets/checkbox.rb +68 -0
- data/lib/ratatat/widgets/container.rb +50 -0
- data/lib/ratatat/widgets/data_table.rb +123 -0
- data/lib/ratatat/widgets/grid.rb +40 -0
- data/lib/ratatat/widgets/horizontal.rb +52 -0
- data/lib/ratatat/widgets/log.rb +97 -0
- data/lib/ratatat/widgets/modal.rb +161 -0
- data/lib/ratatat/widgets/progress_bar.rb +80 -0
- data/lib/ratatat/widgets/radio_set.rb +91 -0
- data/lib/ratatat/widgets/scrollable_container.rb +88 -0
- data/lib/ratatat/widgets/select.rb +100 -0
- data/lib/ratatat/widgets/sparkline.rb +61 -0
- data/lib/ratatat/widgets/spinner.rb +93 -0
- data/lib/ratatat/widgets/static.rb +23 -0
- data/lib/ratatat/widgets/tabbed_content.rb +114 -0
- data/lib/ratatat/widgets/text_area.rb +183 -0
- data/lib/ratatat/widgets/text_input.rb +143 -0
- data/lib/ratatat/widgets/toast.rb +55 -0
- data/lib/ratatat/widgets/tooltip.rb +66 -0
- data/lib/ratatat/widgets/tree.rb +172 -0
- data/lib/ratatat/widgets/vertical.rb +52 -0
- data/lib/ratatat.rb +51 -0
- metadata +142 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module Ratatat
|
|
7
|
+
# A 2D grid of cells representing a terminal screen or region.
|
|
8
|
+
# Supports efficient diffing to minimize terminal I/O.
|
|
9
|
+
class Buffer
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { returns(Integer) }
|
|
13
|
+
attr_reader :width, :height
|
|
14
|
+
|
|
15
|
+
sig { returns(T::Array[Cell]) }
|
|
16
|
+
attr_reader :cells
|
|
17
|
+
|
|
18
|
+
sig { params(width: Integer, height: Integer).void }
|
|
19
|
+
def initialize(width, height)
|
|
20
|
+
@width = width
|
|
21
|
+
@height = height
|
|
22
|
+
@cells = T.let(Array.new(width * height) { Cell::EMPTY }, T::Array[Cell])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Get cell at (x, y) coordinates
|
|
26
|
+
sig { params(x: Integer, y: Integer).returns(T.nilable(Cell)) }
|
|
27
|
+
def get(x, y)
|
|
28
|
+
return nil unless in_bounds?(x, y)
|
|
29
|
+
|
|
30
|
+
@cells[index_of(x, y)]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Alias for get
|
|
34
|
+
sig { params(x: Integer, y: Integer).returns(T.nilable(Cell)) }
|
|
35
|
+
def [](x, y)
|
|
36
|
+
get(x, y)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Set cell at (x, y) coordinates
|
|
40
|
+
sig { params(x: Integer, y: Integer, cell: Cell).void }
|
|
41
|
+
def set(x, y, cell)
|
|
42
|
+
return unless in_bounds?(x, y)
|
|
43
|
+
|
|
44
|
+
@cells[index_of(x, y)] = cell
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Alias for set
|
|
48
|
+
sig { params(x: Integer, y: Integer, cell: Cell).void }
|
|
49
|
+
def []=(x, y, cell)
|
|
50
|
+
set(x, y, cell)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Convert (x, y) to linear index
|
|
54
|
+
sig { params(x: Integer, y: Integer).returns(Integer) }
|
|
55
|
+
def index_of(x, y)
|
|
56
|
+
y * @width + x
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Convert linear index to (x, y)
|
|
60
|
+
sig { params(index: Integer).returns([Integer, Integer]) }
|
|
61
|
+
def pos_of(index)
|
|
62
|
+
[index % @width, index / @width]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Check if coordinates are within bounds
|
|
66
|
+
sig { params(x: Integer, y: Integer).returns(T::Boolean) }
|
|
67
|
+
def in_bounds?(x, y)
|
|
68
|
+
x >= 0 && x < @width && y >= 0 && y < @height
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Write a string at position with optional styling
|
|
72
|
+
sig do
|
|
73
|
+
params(
|
|
74
|
+
x: Integer,
|
|
75
|
+
y: Integer,
|
|
76
|
+
text: String,
|
|
77
|
+
fg: Color::AnyColor,
|
|
78
|
+
bg: Color::AnyColor,
|
|
79
|
+
modifiers: T::Set[Modifier]
|
|
80
|
+
).void
|
|
81
|
+
end
|
|
82
|
+
def put_string(x, y, text, fg: Color::Named::Reset, bg: Color::Named::Reset, modifiers: Set.new)
|
|
83
|
+
return unless in_bounds?(x, y)
|
|
84
|
+
|
|
85
|
+
col = x
|
|
86
|
+
text.each_grapheme_cluster do |grapheme|
|
|
87
|
+
break unless col < @width
|
|
88
|
+
|
|
89
|
+
cell = Cell.new(symbol: grapheme, fg: fg, bg: bg, modifiers: modifiers)
|
|
90
|
+
set(col, y, cell)
|
|
91
|
+
|
|
92
|
+
# Handle wide characters
|
|
93
|
+
char_width = cell.width
|
|
94
|
+
col += 1
|
|
95
|
+
|
|
96
|
+
# Mark continuation cells for wide chars
|
|
97
|
+
if char_width > 1 && col < @width
|
|
98
|
+
cont_cell = Cell.new(symbol: "", fg: fg, bg: bg, modifiers: modifiers)
|
|
99
|
+
set(col, y, cont_cell)
|
|
100
|
+
col += 1
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Clear buffer to empty cells
|
|
106
|
+
sig { void }
|
|
107
|
+
def clear
|
|
108
|
+
@cells = Array.new(@width * @height) { Cell::EMPTY }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Resize buffer, preserving content where possible
|
|
112
|
+
sig { params(new_width: Integer, new_height: Integer).void }
|
|
113
|
+
def resize(new_width, new_height)
|
|
114
|
+
return if new_width == @width && new_height == @height
|
|
115
|
+
|
|
116
|
+
new_cells = Array.new(new_width * new_height) { Cell::EMPTY }
|
|
117
|
+
|
|
118
|
+
# Copy existing content
|
|
119
|
+
[height, new_height].min.times do |y|
|
|
120
|
+
[width, new_width].min.times do |x|
|
|
121
|
+
old_idx = y * @width + x
|
|
122
|
+
new_idx = y * new_width + x
|
|
123
|
+
new_cells[new_idx] = T.must(@cells[old_idx])
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
@width = new_width
|
|
128
|
+
@height = new_height
|
|
129
|
+
@cells = new_cells
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Compute diff between this buffer (previous) and another (current).
|
|
133
|
+
# Returns array of [x, y, cell] for cells that changed.
|
|
134
|
+
#
|
|
135
|
+
# This implements Ratatui's diffing algorithm with multi-width char handling.
|
|
136
|
+
# Optimized hot path - avoids method calls and type checks in inner loop.
|
|
137
|
+
sig { params(other: Buffer).returns(T::Array[[Integer, Integer, Cell]]) }
|
|
138
|
+
def diff(other)
|
|
139
|
+
raise ArgumentError, "Buffer size mismatch" unless @width == other.width && @height == other.height
|
|
140
|
+
|
|
141
|
+
updates = []
|
|
142
|
+
invalidated = 0 # Cells invalidated by previous wide char changing
|
|
143
|
+
to_skip = 0 # Cells to skip (continuation of current wide char)
|
|
144
|
+
|
|
145
|
+
# Cache for inner loop
|
|
146
|
+
prev_cells = @cells
|
|
147
|
+
curr_cells = other.cells
|
|
148
|
+
width = @width
|
|
149
|
+
total = prev_cells.length
|
|
150
|
+
|
|
151
|
+
x = 0
|
|
152
|
+
y = 0
|
|
153
|
+
i = 0
|
|
154
|
+
|
|
155
|
+
while i < total
|
|
156
|
+
prev_cell = prev_cells[i]
|
|
157
|
+
curr_cell = curr_cells[i]
|
|
158
|
+
|
|
159
|
+
# Fast path: check if we should emit this cell
|
|
160
|
+
# Skip if: marked to skip, is continuation cell, or unchanged and not invalidated
|
|
161
|
+
unless curr_cell.skip || to_skip > 0
|
|
162
|
+
# Inline visually_equal? check for speed
|
|
163
|
+
prev_sym = prev_cell.symbol
|
|
164
|
+
curr_sym = curr_cell.symbol
|
|
165
|
+
prev_sym = " " if prev_sym.empty?
|
|
166
|
+
curr_sym = " " if curr_sym.empty?
|
|
167
|
+
|
|
168
|
+
changed = invalidated > 0 ||
|
|
169
|
+
prev_sym != curr_sym ||
|
|
170
|
+
prev_cell.fg != curr_cell.fg ||
|
|
171
|
+
prev_cell.bg != curr_cell.bg ||
|
|
172
|
+
prev_cell.modifiers != curr_cell.modifiers
|
|
173
|
+
|
|
174
|
+
updates << [x, y, curr_cell] if changed
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Calculate widths for wide char tracking
|
|
178
|
+
# Fast path: ASCII chars (ord < 128) always have width 1
|
|
179
|
+
curr_sym = curr_cell.symbol
|
|
180
|
+
prev_sym = prev_cell.symbol
|
|
181
|
+
curr_ord = curr_sym.empty? ? 32 : curr_sym.ord
|
|
182
|
+
prev_ord = prev_sym.empty? ? 32 : prev_sym.ord
|
|
183
|
+
|
|
184
|
+
if curr_ord < 128 && prev_ord < 128
|
|
185
|
+
# ASCII fast path - both width 1, no wide char handling needed
|
|
186
|
+
to_skip = 0
|
|
187
|
+
invalidated = invalidated > 0 ? invalidated - 1 : 0
|
|
188
|
+
else
|
|
189
|
+
# Wide char path - need actual width calculation
|
|
190
|
+
curr_width = curr_cell.width
|
|
191
|
+
prev_width = prev_cell.width
|
|
192
|
+
|
|
193
|
+
# Track cells to skip (continuation of current wide char)
|
|
194
|
+
to_skip = curr_width > 1 ? curr_width - 1 : 0
|
|
195
|
+
|
|
196
|
+
# Track invalidated cells (when a wide char changes width)
|
|
197
|
+
affected = curr_width > prev_width ? curr_width : prev_width
|
|
198
|
+
invalidated = invalidated > affected ? invalidated - 1 : affected - 1
|
|
199
|
+
invalidated = 0 if invalidated < 0
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Update position (faster than pos_of)
|
|
203
|
+
x += 1
|
|
204
|
+
if x >= width
|
|
205
|
+
x = 0
|
|
206
|
+
y += 1
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
i += 1
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
updates
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Debug: render buffer to string (for testing)
|
|
216
|
+
sig { returns(String) }
|
|
217
|
+
def to_text
|
|
218
|
+
lines = T.let([], T::Array[String])
|
|
219
|
+
@height.times do |y|
|
|
220
|
+
line = +""
|
|
221
|
+
@width.times do |x|
|
|
222
|
+
cell = get(x, y)
|
|
223
|
+
next unless cell
|
|
224
|
+
|
|
225
|
+
sym = cell.normalized_symbol
|
|
226
|
+
line << (sym.empty? ? " " : sym)
|
|
227
|
+
end
|
|
228
|
+
lines << line.rstrip
|
|
229
|
+
end
|
|
230
|
+
lines.join("\n")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
sig { returns(String) }
|
|
234
|
+
def to_s
|
|
235
|
+
"Buffer(#{@width}x#{@height})"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
data/lib/ratatat/cell.rb
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module Ratatat
|
|
7
|
+
# Text modifiers as a T::Enum for type safety
|
|
8
|
+
class Modifier < T::Enum
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
enums do
|
|
12
|
+
None = new
|
|
13
|
+
Bold = new
|
|
14
|
+
Dim = new
|
|
15
|
+
Italic = new
|
|
16
|
+
Underline = new
|
|
17
|
+
Blink = new
|
|
18
|
+
Reverse = new
|
|
19
|
+
Hidden = new
|
|
20
|
+
Strikethrough = new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# ANSI codes for enabling modifiers
|
|
24
|
+
ENABLE_CODES = T.let({
|
|
25
|
+
Bold => 1,
|
|
26
|
+
Dim => 2,
|
|
27
|
+
Italic => 3,
|
|
28
|
+
Underline => 4,
|
|
29
|
+
Blink => 5,
|
|
30
|
+
Reverse => 7,
|
|
31
|
+
Hidden => 8,
|
|
32
|
+
Strikethrough => 9
|
|
33
|
+
}.freeze, T::Hash[Modifier, Integer])
|
|
34
|
+
|
|
35
|
+
# ANSI codes for disabling modifiers
|
|
36
|
+
DISABLE_CODES = T.let({
|
|
37
|
+
Bold => 22,
|
|
38
|
+
Dim => 22,
|
|
39
|
+
Italic => 23,
|
|
40
|
+
Underline => 24,
|
|
41
|
+
Blink => 25,
|
|
42
|
+
Reverse => 27,
|
|
43
|
+
Hidden => 28,
|
|
44
|
+
Strikethrough => 29
|
|
45
|
+
}.freeze, T::Hash[Modifier, Integer])
|
|
46
|
+
|
|
47
|
+
sig { returns(T.nilable(Integer)) }
|
|
48
|
+
def enable_code
|
|
49
|
+
ENABLE_CODES[self]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
sig { returns(T.nilable(Integer)) }
|
|
53
|
+
def disable_code
|
|
54
|
+
DISABLE_CODES[self]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A single cell in the terminal buffer.
|
|
59
|
+
# Immutable struct containing symbol, colors, and modifiers.
|
|
60
|
+
class Cell < T::Struct
|
|
61
|
+
extend T::Sig
|
|
62
|
+
|
|
63
|
+
const :symbol, String, default: " "
|
|
64
|
+
const :fg, Color::AnyColor, default: Color::Named::Reset
|
|
65
|
+
const :bg, Color::AnyColor, default: Color::Named::Reset
|
|
66
|
+
const :modifiers, T::Set[Modifier], default: Set.new
|
|
67
|
+
const :skip, T::Boolean, default: false
|
|
68
|
+
|
|
69
|
+
# Empty cell constant
|
|
70
|
+
EMPTY = T.let(new, Cell)
|
|
71
|
+
|
|
72
|
+
# Display width of this cell (1 for most chars, 2 for CJK/emoji)
|
|
73
|
+
# Cached for performance
|
|
74
|
+
sig { returns(Integer) }
|
|
75
|
+
def width
|
|
76
|
+
@_width ||= compute_width
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
sig { returns(Integer) }
|
|
82
|
+
def compute_width
|
|
83
|
+
return 1 if symbol.empty?
|
|
84
|
+
|
|
85
|
+
# Fast path for ASCII
|
|
86
|
+
ord = symbol.ord
|
|
87
|
+
return 1 if ord < 128
|
|
88
|
+
|
|
89
|
+
# Use unicode-display_width if available
|
|
90
|
+
if defined?(Unicode::DisplayWidth)
|
|
91
|
+
Unicode::DisplayWidth.of(symbol)
|
|
92
|
+
else
|
|
93
|
+
wide_char?(ord) ? 2 : 1
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
public
|
|
98
|
+
|
|
99
|
+
# Normalize symbol for comparison (empty -> space)
|
|
100
|
+
sig { returns(String) }
|
|
101
|
+
def normalized_symbol
|
|
102
|
+
symbol.empty? ? " " : symbol
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Check if cell has a modifier
|
|
106
|
+
sig { params(mod: Modifier).returns(T::Boolean) }
|
|
107
|
+
def has_modifier?(mod)
|
|
108
|
+
modifiers.include?(mod)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
sig { returns(T::Boolean) }
|
|
112
|
+
def bold? = has_modifier?(Modifier::Bold)
|
|
113
|
+
|
|
114
|
+
sig { returns(T::Boolean) }
|
|
115
|
+
def italic? = has_modifier?(Modifier::Italic)
|
|
116
|
+
|
|
117
|
+
sig { returns(T::Boolean) }
|
|
118
|
+
def underline? = has_modifier?(Modifier::Underline)
|
|
119
|
+
|
|
120
|
+
sig { returns(T::Boolean) }
|
|
121
|
+
def dim? = has_modifier?(Modifier::Dim)
|
|
122
|
+
|
|
123
|
+
sig { returns(T::Boolean) }
|
|
124
|
+
def reverse? = has_modifier?(Modifier::Reverse)
|
|
125
|
+
|
|
126
|
+
# Create a new cell with updated attributes
|
|
127
|
+
sig { params(attrs: T.untyped).returns(Cell) }
|
|
128
|
+
def with(**attrs)
|
|
129
|
+
Cell.new(
|
|
130
|
+
symbol: attrs.fetch(:symbol, symbol),
|
|
131
|
+
fg: attrs.fetch(:fg, fg),
|
|
132
|
+
bg: attrs.fetch(:bg, bg),
|
|
133
|
+
modifiers: attrs.fetch(:modifiers, modifiers),
|
|
134
|
+
skip: attrs.fetch(:skip, skip)
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Visual equality (for diffing)
|
|
139
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
140
|
+
def visually_equal?(other)
|
|
141
|
+
return false unless other.is_a?(Cell)
|
|
142
|
+
|
|
143
|
+
normalized_symbol == other.normalized_symbol &&
|
|
144
|
+
fg == other.fg &&
|
|
145
|
+
bg == other.bg &&
|
|
146
|
+
modifiers == other.modifiers
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# Simple check for wide characters (CJK ranges, emoji)
|
|
152
|
+
sig { params(codepoint: Integer).returns(T::Boolean) }
|
|
153
|
+
def wide_char?(codepoint)
|
|
154
|
+
# CJK Unified Ideographs
|
|
155
|
+
(codepoint >= 0x4E00 && codepoint <= 0x9FFF) ||
|
|
156
|
+
# CJK Extension A
|
|
157
|
+
(codepoint >= 0x3400 && codepoint <= 0x4DBF) ||
|
|
158
|
+
# Hangul Syllables
|
|
159
|
+
(codepoint >= 0xAC00 && codepoint <= 0xD7AF) ||
|
|
160
|
+
# Fullwidth Forms
|
|
161
|
+
(codepoint >= 0xFF00 && codepoint <= 0xFFEF) ||
|
|
162
|
+
# Emoji
|
|
163
|
+
(codepoint >= 0x1F300 && codepoint <= 0x1F9FF)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module Ratatat
|
|
7
|
+
# Color types for terminal cells.
|
|
8
|
+
# Supports named ANSI colors, 256-color palette, and true color (RGB).
|
|
9
|
+
module Color
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
# Union type for any color
|
|
13
|
+
AnyColor = T.type_alias { T.any(Named, Indexed, Rgb) }
|
|
14
|
+
|
|
15
|
+
# Named ANSI colors (16 standard colors + reset)
|
|
16
|
+
class Named < T::Enum
|
|
17
|
+
extend T::Sig
|
|
18
|
+
|
|
19
|
+
enums do
|
|
20
|
+
# Reset to terminal default
|
|
21
|
+
Reset = new(:reset)
|
|
22
|
+
|
|
23
|
+
# Standard 8 colors (codes 0-7)
|
|
24
|
+
Black = new(:black)
|
|
25
|
+
Red = new(:red)
|
|
26
|
+
Green = new(:green)
|
|
27
|
+
Yellow = new(:yellow)
|
|
28
|
+
Blue = new(:blue)
|
|
29
|
+
Magenta = new(:magenta)
|
|
30
|
+
Cyan = new(:cyan)
|
|
31
|
+
White = new(:white)
|
|
32
|
+
|
|
33
|
+
# Bright variants (codes 8-15)
|
|
34
|
+
BrightBlack = new(:bright_black)
|
|
35
|
+
BrightRed = new(:bright_red)
|
|
36
|
+
BrightGreen = new(:bright_green)
|
|
37
|
+
BrightYellow = new(:bright_yellow)
|
|
38
|
+
BrightBlue = new(:bright_blue)
|
|
39
|
+
BrightMagenta = new(:bright_magenta)
|
|
40
|
+
BrightCyan = new(:bright_cyan)
|
|
41
|
+
BrightWhite = new(:bright_white)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# ANSI foreground code lookup
|
|
45
|
+
FG_CODES = T.let({
|
|
46
|
+
reset: 39,
|
|
47
|
+
black: 30, red: 31, green: 32, yellow: 33,
|
|
48
|
+
blue: 34, magenta: 35, cyan: 36, white: 37,
|
|
49
|
+
bright_black: 90, bright_red: 91, bright_green: 92, bright_yellow: 93,
|
|
50
|
+
bright_blue: 94, bright_magenta: 95, bright_cyan: 96, bright_white: 97
|
|
51
|
+
}.freeze, T::Hash[Symbol, Integer])
|
|
52
|
+
|
|
53
|
+
# ANSI background code lookup
|
|
54
|
+
BG_CODES = T.let({
|
|
55
|
+
reset: 49,
|
|
56
|
+
black: 40, red: 41, green: 42, yellow: 43,
|
|
57
|
+
blue: 44, magenta: 45, cyan: 46, white: 47,
|
|
58
|
+
bright_black: 100, bright_red: 101, bright_green: 102, bright_yellow: 103,
|
|
59
|
+
bright_blue: 104, bright_magenta: 105, bright_cyan: 106, bright_white: 107
|
|
60
|
+
}.freeze, T::Hash[Symbol, Integer])
|
|
61
|
+
|
|
62
|
+
sig { returns(Integer) }
|
|
63
|
+
def fg_code
|
|
64
|
+
T.must(FG_CODES[serialize])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
sig { returns(Integer) }
|
|
68
|
+
def bg_code
|
|
69
|
+
T.must(BG_CODES[serialize])
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# 256-color palette (0-255)
|
|
74
|
+
class Indexed
|
|
75
|
+
extend T::Sig
|
|
76
|
+
|
|
77
|
+
sig { returns(Integer) }
|
|
78
|
+
attr_reader :index
|
|
79
|
+
|
|
80
|
+
sig { params(index: Integer).void }
|
|
81
|
+
def initialize(index:)
|
|
82
|
+
raise ArgumentError, "Color index must be 0-255" unless index.between?(0, 255)
|
|
83
|
+
|
|
84
|
+
@index = index
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
88
|
+
def ==(other)
|
|
89
|
+
other.is_a?(Indexed) && @index == other.index
|
|
90
|
+
end
|
|
91
|
+
alias eql? ==
|
|
92
|
+
|
|
93
|
+
sig { returns(Integer) }
|
|
94
|
+
def hash
|
|
95
|
+
@index.hash
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
sig { returns(String) }
|
|
99
|
+
def fg_code
|
|
100
|
+
"38;5;#{index}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
sig { returns(String) }
|
|
104
|
+
def bg_code
|
|
105
|
+
"48;5;#{index}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# True color (24-bit RGB)
|
|
110
|
+
class Rgb
|
|
111
|
+
extend T::Sig
|
|
112
|
+
|
|
113
|
+
sig { returns(Integer) }
|
|
114
|
+
attr_reader :r, :g, :b
|
|
115
|
+
|
|
116
|
+
sig { params(r: Integer, g: Integer, b: Integer).void }
|
|
117
|
+
def initialize(r:, g:, b:)
|
|
118
|
+
raise ArgumentError, "Red must be 0-255" unless r.between?(0, 255)
|
|
119
|
+
raise ArgumentError, "Green must be 0-255" unless g.between?(0, 255)
|
|
120
|
+
raise ArgumentError, "Blue must be 0-255" unless b.between?(0, 255)
|
|
121
|
+
|
|
122
|
+
@r = r
|
|
123
|
+
@g = g
|
|
124
|
+
@b = b
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
128
|
+
def ==(other)
|
|
129
|
+
other.is_a?(Rgb) && @r == other.r && @g == other.g && @b == other.b
|
|
130
|
+
end
|
|
131
|
+
alias eql? ==
|
|
132
|
+
|
|
133
|
+
sig { returns(Integer) }
|
|
134
|
+
def hash
|
|
135
|
+
[@r, @g, @b].hash
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
sig { returns(String) }
|
|
139
|
+
def fg_code
|
|
140
|
+
"38;2;#{r};#{g};#{b}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
sig { returns(String) }
|
|
144
|
+
def bg_code
|
|
145
|
+
"48;2;#{r};#{g};#{b}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
class << self
|
|
150
|
+
extend T::Sig
|
|
151
|
+
|
|
152
|
+
# Create RGB color from hex string
|
|
153
|
+
# Accepts: "#ff8000", "ff8000", "#f80", "f80"
|
|
154
|
+
sig { params(str: String).returns(Rgb) }
|
|
155
|
+
def hex(str)
|
|
156
|
+
str = str.delete_prefix("#")
|
|
157
|
+
r, g, b = case str.length
|
|
158
|
+
when 3
|
|
159
|
+
[str[0] * 2, str[1] * 2, str[2] * 2]
|
|
160
|
+
when 6
|
|
161
|
+
[str[0, 2], str[2, 2], str[4, 2]]
|
|
162
|
+
else
|
|
163
|
+
raise ArgumentError, "Invalid hex color: #{str}"
|
|
164
|
+
end
|
|
165
|
+
Rgb.new(r: T.must(r).to_i(16), g: T.must(g).to_i(16), b: T.must(b).to_i(16))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Generate ANSI escape code for foreground
|
|
169
|
+
sig { params(color: AnyColor).returns(String) }
|
|
170
|
+
def fg_ansi(color)
|
|
171
|
+
case color
|
|
172
|
+
when Named then color.fg_code.to_s
|
|
173
|
+
when Indexed then color.fg_code
|
|
174
|
+
when Rgb then color.fg_code
|
|
175
|
+
else T.absurd(color)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Generate ANSI escape code for background
|
|
180
|
+
sig { params(color: AnyColor).returns(String) }
|
|
181
|
+
def bg_ansi(color)
|
|
182
|
+
case color
|
|
183
|
+
when Named then color.bg_code.to_s
|
|
184
|
+
when Indexed then color.bg_code
|
|
185
|
+
when Rgb then color.bg_code
|
|
186
|
+
else T.absurd(color)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|