rubytui 1.2.3
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/LICENSE +21 -0
- data/README.md +574 -0
- data/lib/rubytui/app.rb +174 -0
- data/lib/rubytui/backend.rb +158 -0
- data/lib/rubytui/backends/ansi_backend.rb +391 -0
- data/lib/rubytui/backends/test_backend.rb +223 -0
- data/lib/rubytui/buffer.rb +400 -0
- data/lib/rubytui/cell.rb +55 -0
- data/lib/rubytui/color.rb +153 -0
- data/lib/rubytui/color_mode.rb +203 -0
- data/lib/rubytui/errors.rb +13 -0
- data/lib/rubytui/event.rb +161 -0
- data/lib/rubytui/frame.rb +70 -0
- data/lib/rubytui/input/key.rb +93 -0
- data/lib/rubytui/input/parser.rb +231 -0
- data/lib/rubytui/input/reader.rb +119 -0
- data/lib/rubytui/layout/constraint.rb +83 -0
- data/lib/rubytui/layout/flex.rb +16 -0
- data/lib/rubytui/layout/layout.rb +205 -0
- data/lib/rubytui/modifier.rb +67 -0
- data/lib/rubytui/rect.rb +126 -0
- data/lib/rubytui/stateful_widget.rb +20 -0
- data/lib/rubytui/style.rb +143 -0
- data/lib/rubytui/symbols.rb +88 -0
- data/lib/rubytui/terminal.rb +218 -0
- data/lib/rubytui/text/line.rb +67 -0
- data/lib/rubytui/text/span.rb +34 -0
- data/lib/rubytui/text/text.rb +82 -0
- data/lib/rubytui/unicode.rb +162 -0
- data/lib/rubytui/version.rb +6 -0
- data/lib/rubytui/widget.rb +20 -0
- data/lib/rubytui/widgets/async_image.rb +248 -0
- data/lib/rubytui/widgets/block.rb +260 -0
- data/lib/rubytui/widgets/canvas.rb +248 -0
- data/lib/rubytui/widgets/chart.rb +224 -0
- data/lib/rubytui/widgets/gauge.rb +139 -0
- data/lib/rubytui/widgets/image.rb +330 -0
- data/lib/rubytui/widgets/input_field.rb +245 -0
- data/lib/rubytui/widgets/list.rb +186 -0
- data/lib/rubytui/widgets/paragraph.rb +181 -0
- data/lib/rubytui/widgets/popup.rb +140 -0
- data/lib/rubytui/widgets/scrollbar.rb +175 -0
- data/lib/rubytui/widgets/sparkline.rb +86 -0
- data/lib/rubytui/widgets/table.rb +231 -0
- data/lib/rubytui/widgets/tabs.rb +90 -0
- data/lib/rubytui.rb +389 -0
- metadata +89 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
# 2D grid of styled {Cell}s used for rendering.
|
|
5
|
+
#
|
|
6
|
+
# Buffer is the core rendering target. Widgets write into a Buffer via
|
|
7
|
+
# {#set_string} and {#set_style}. The {Terminal} then diffs two buffers
|
|
8
|
+
# to send only changed cells to the backend.
|
|
9
|
+
#
|
|
10
|
+
# For one-shot CLI output, use {#to_ansi} or {#print} to convert the
|
|
11
|
+
# buffer contents to an ANSI-styled string without needing a Terminal.
|
|
12
|
+
#
|
|
13
|
+
# @example Create and render text
|
|
14
|
+
# buf = Buffer.new(80, 24)
|
|
15
|
+
# buf.set_string(0, 0, "Hello!", Style.new.fg(Color::GREEN).bold)
|
|
16
|
+
# puts buf.to_ansi
|
|
17
|
+
class Buffer
|
|
18
|
+
# @return [Rect] the rectangular bounds of this buffer
|
|
19
|
+
attr_reader :area
|
|
20
|
+
# @return [Hash{Array => Hash{Symbol => Object}}]
|
|
21
|
+
# image placements keyed by [x, y, content_hash] (or [x, y] when no content
|
|
22
|
+
# hash is given), registered via {#set_image} by {Widgets::Image#render}
|
|
23
|
+
attr_reader :image_placements
|
|
24
|
+
|
|
25
|
+
# Create a new Buffer.
|
|
26
|
+
#
|
|
27
|
+
# @overload initialize(width, height)
|
|
28
|
+
# Shorthand that creates a buffer at origin (0, 0).
|
|
29
|
+
# @param width [Integer] buffer width in columns
|
|
30
|
+
# @param height [Integer] buffer height in rows
|
|
31
|
+
#
|
|
32
|
+
# @overload initialize(area)
|
|
33
|
+
# @param area [Rect] the rectangular bounds
|
|
34
|
+
def initialize(area_or_width, height = nil)
|
|
35
|
+
@area = if height
|
|
36
|
+
Rect.new(0, 0, area_or_width, height)
|
|
37
|
+
else
|
|
38
|
+
area_or_width
|
|
39
|
+
end
|
|
40
|
+
@cells = Array.new(@area.area) { Cell.new }
|
|
41
|
+
@image_placements = {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get the cell at position (x, y).
|
|
45
|
+
#
|
|
46
|
+
# @param x [Integer] column position
|
|
47
|
+
# @param y [Integer] row position
|
|
48
|
+
# @return [Cell, nil] the cell, or nil if out of bounds
|
|
49
|
+
def [](x, y)
|
|
50
|
+
idx = index(x, y)
|
|
51
|
+
idx ? @cells[idx] : nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Set the cell at position (x, y).
|
|
55
|
+
#
|
|
56
|
+
# @param x [Integer] column position
|
|
57
|
+
# @param y [Integer] row position
|
|
58
|
+
# @param cell [Cell] the cell to place
|
|
59
|
+
# @return [void]
|
|
60
|
+
def []=(x, y, cell)
|
|
61
|
+
idx = index(x, y)
|
|
62
|
+
@cells[idx] = cell if idx
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @!group Deprecated Unicode utilities — use the Unicode module instead
|
|
66
|
+
|
|
67
|
+
# @deprecated Use {Unicode.string_width} instead.
|
|
68
|
+
# @param string [String] the string to measure
|
|
69
|
+
# @return [Integer] display width in terminal columns
|
|
70
|
+
def self.string_width(string) = Unicode.string_width(string)
|
|
71
|
+
# @deprecated Use {Unicode.cluster_width} instead.
|
|
72
|
+
# @param cluster [String] a grapheme cluster (one or more codepoints)
|
|
73
|
+
# @return [Integer] 0, 1, or 2
|
|
74
|
+
def self.cluster_width(cluster) = Unicode.cluster_width(cluster)
|
|
75
|
+
# @deprecated Use {Unicode.truncate_to_width} instead.
|
|
76
|
+
# @param string [String] the string to truncate
|
|
77
|
+
# @param max_width [Integer] maximum display width in columns
|
|
78
|
+
# @return [String] truncated string
|
|
79
|
+
def self.truncate_to_width(string, max_width) = Unicode.truncate_to_width(string, max_width)
|
|
80
|
+
# @deprecated Use {Unicode.char_width} instead.
|
|
81
|
+
# @param ch [String] a single character
|
|
82
|
+
# @return [Integer] 0 for combining marks, 2 for wide characters, 1 otherwise
|
|
83
|
+
def self.char_width(ch) = Unicode.char_width(ch)
|
|
84
|
+
# @deprecated Use {Unicode.combining_mark?} instead.
|
|
85
|
+
# @param cp [Integer] Unicode codepoint
|
|
86
|
+
# @return [Boolean] true if codepoint is a combining mark (zero-width)
|
|
87
|
+
def self.combining_mark?(cp) = Unicode.combining_mark?(cp)
|
|
88
|
+
# @deprecated Use {Unicode.wide_char?} instead.
|
|
89
|
+
# @param cp [Integer] Unicode codepoint
|
|
90
|
+
# @return [Boolean] true if codepoint is a wide character (2-column)
|
|
91
|
+
def self.wide_char?(cp) = Unicode.wide_char?(cp)
|
|
92
|
+
|
|
93
|
+
# @!endgroup
|
|
94
|
+
|
|
95
|
+
# Write a styled string starting at (x, y).
|
|
96
|
+
#
|
|
97
|
+
# Iterates by grapheme cluster so that combining sequences (Devanagari
|
|
98
|
+
# matras/virama, emoji ZWJ, Latin diacritics) are stored as a single
|
|
99
|
+
# cell symbol. Wide clusters (2-column) mark the next cell as a spacer.
|
|
100
|
+
# Clips at buffer boundaries.
|
|
101
|
+
#
|
|
102
|
+
# @param x [Integer] starting column
|
|
103
|
+
# @param y [Integer] row
|
|
104
|
+
# @param string [String] text to write
|
|
105
|
+
# @param style [Style] style to apply (default: {Style::DEFAULT})
|
|
106
|
+
# @return [Integer] number of terminal columns consumed
|
|
107
|
+
def set_string(x, y, string, style = Style::DEFAULT)
|
|
108
|
+
return 0 unless y >= @area.y && y < @area.bottom
|
|
109
|
+
|
|
110
|
+
count = 0
|
|
111
|
+
col = x
|
|
112
|
+
|
|
113
|
+
string.grapheme_clusters.each do |gc|
|
|
114
|
+
break if col >= @area.right
|
|
115
|
+
|
|
116
|
+
w = Unicode.cluster_width(gc)
|
|
117
|
+
|
|
118
|
+
if col >= @area.x
|
|
119
|
+
idx = index(col, y)
|
|
120
|
+
if idx
|
|
121
|
+
@cells[idx].symbol = gc
|
|
122
|
+
@cells[idx].style = style
|
|
123
|
+
count += w
|
|
124
|
+
|
|
125
|
+
# For wide clusters, mark the next cell as a spacer
|
|
126
|
+
if w == 2 && col + 1 < @area.right
|
|
127
|
+
next_idx = index(col + 1, y)
|
|
128
|
+
if next_idx
|
|
129
|
+
@cells[next_idx].symbol = ""
|
|
130
|
+
@cells[next_idx].style = style
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
col += w
|
|
136
|
+
end
|
|
137
|
+
count
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Apply a style to all cells within the given area.
|
|
141
|
+
#
|
|
142
|
+
# Uses {Style#patch} so the applied style merges with existing cell styles.
|
|
143
|
+
#
|
|
144
|
+
# @param area [Rect] the area to style
|
|
145
|
+
# @param style [Style] the style to apply
|
|
146
|
+
# @return [void]
|
|
147
|
+
def set_style(area, style)
|
|
148
|
+
each_cell_in(area) do |idx|
|
|
149
|
+
@cells[idx].style = @cells[idx].style.patch(style)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Diff this buffer against another, returning changed cells.
|
|
154
|
+
#
|
|
155
|
+
# Used by {Terminal#draw} for minimal screen updates.
|
|
156
|
+
#
|
|
157
|
+
# @param other [Buffer] the previous buffer to diff against
|
|
158
|
+
# @return [Array<Array(Integer, Integer, Cell)>] changed cells as [x, y, cell]
|
|
159
|
+
def diff(other)
|
|
160
|
+
changes = []
|
|
161
|
+
@cells.each_with_index do |cell, i|
|
|
162
|
+
next if cell == other.cells[i]
|
|
163
|
+
|
|
164
|
+
x = @area.x + (i % @area.width)
|
|
165
|
+
y = @area.y + (i / @area.width)
|
|
166
|
+
changes << [x, y, cell]
|
|
167
|
+
end
|
|
168
|
+
changes
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Reset all cells to empty (space character, default style).
|
|
172
|
+
#
|
|
173
|
+
# @return [void]
|
|
174
|
+
def reset!
|
|
175
|
+
reset_cells!
|
|
176
|
+
@image_placements.clear
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Reset cells only, keeping image placements — used by {Terminal} for
|
|
180
|
+
# full repaints, where placements must still diff against the previous
|
|
181
|
+
# frame to detect deletions.
|
|
182
|
+
#
|
|
183
|
+
# @return [void]
|
|
184
|
+
def reset_cells!
|
|
185
|
+
@cells.each(&:reset!)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Register an image placement for Kitty graphics protocol rendering.
|
|
189
|
+
#
|
|
190
|
+
# Called by {Widgets::Image#render}. The placement is stored and later
|
|
191
|
+
# emitted by the backend during {Terminal#draw}.
|
|
192
|
+
#
|
|
193
|
+
# @param x [Integer] column position
|
|
194
|
+
# @param y [Integer] row position
|
|
195
|
+
# @param cols [Integer] display width in columns
|
|
196
|
+
# @param rows [Integer] display height in rows
|
|
197
|
+
# @param data [String] raw image bytes (PNG or RGBA)
|
|
198
|
+
# @param pixel_width [Integer] image width in pixels
|
|
199
|
+
# @param pixel_height [Integer] image height in pixels
|
|
200
|
+
# @param format [Symbol] :png or :rgba
|
|
201
|
+
# @param content_hash [String, nil] hash of image content for change detection
|
|
202
|
+
# @param src_x [Integer, nil] source rectangle X offset in pixels
|
|
203
|
+
# @param src_y [Integer, nil] source rectangle Y offset in pixels
|
|
204
|
+
# @param src_w [Integer, nil] source rectangle width in pixels
|
|
205
|
+
# @param src_h [Integer, nil] source rectangle height in pixels
|
|
206
|
+
# @return [void]
|
|
207
|
+
def set_image(x:, y:, cols:, rows:, data:, pixel_width:, pixel_height:, format:,
|
|
208
|
+
content_hash: nil, src_x: nil, src_y: nil, src_w: nil, src_h: nil)
|
|
209
|
+
# Key by position + content hash to detect when image changes at same position
|
|
210
|
+
key = content_hash ? [x, y, content_hash] : [x, y]
|
|
211
|
+
|
|
212
|
+
placement = {
|
|
213
|
+
x: x, y: y, cols: cols, rows: rows,
|
|
214
|
+
data: data, pixel_width: pixel_width, pixel_height: pixel_height,
|
|
215
|
+
format: format, content_hash: content_hash
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
# Add source rectangle if specified
|
|
219
|
+
if src_x && src_y && src_w && src_h
|
|
220
|
+
placement[:src_x] = src_x
|
|
221
|
+
placement[:src_y] = src_y
|
|
222
|
+
placement[:src_w] = src_w
|
|
223
|
+
placement[:src_h] = src_h
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
@image_placements[key] = placement
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Merge another buffer on top of this one (for overlays).
|
|
230
|
+
#
|
|
231
|
+
# Non-empty cells from the other buffer overwrite cells in this buffer.
|
|
232
|
+
#
|
|
233
|
+
# @param other [Buffer] the overlay buffer
|
|
234
|
+
# @return [void]
|
|
235
|
+
def merge(other)
|
|
236
|
+
other.each_cell_with_position do |x, y, cell|
|
|
237
|
+
next if cell.empty?
|
|
238
|
+
|
|
239
|
+
idx = index(x, y)
|
|
240
|
+
@cells[idx] = cell if idx
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Iterate over all cells with their absolute positions.
|
|
245
|
+
#
|
|
246
|
+
# @yield [x, y, cell] for each cell in the buffer
|
|
247
|
+
# @yieldparam x [Integer] column position
|
|
248
|
+
# @yieldparam y [Integer] row position
|
|
249
|
+
# @yieldparam cell [Cell]
|
|
250
|
+
# @return [void]
|
|
251
|
+
def each_cell_with_position
|
|
252
|
+
@cells.each_with_index do |cell, i|
|
|
253
|
+
x = @area.x + (i % @area.width)
|
|
254
|
+
y = @area.y + (i / @area.width)
|
|
255
|
+
yield x, y, cell
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Convert buffer contents to an ANSI-styled string for one-shot printing.
|
|
260
|
+
#
|
|
261
|
+
# Each row becomes a line separated by +\n+. Styles are emitted as
|
|
262
|
+
# ANSI SGR escape codes with reset at end of each styled row.
|
|
263
|
+
#
|
|
264
|
+
# @return [String] ANSI-escaped string ready for terminal output
|
|
265
|
+
#
|
|
266
|
+
# @example
|
|
267
|
+
# buf = Buffer.new(40, 5)
|
|
268
|
+
# buf.set_string(0, 0, "Hello", Style.new.fg(Color::GREEN))
|
|
269
|
+
# puts buf.to_ansi
|
|
270
|
+
def to_ansi
|
|
271
|
+
csi = "\e["
|
|
272
|
+
result = String.new(capacity: @area.area * 2)
|
|
273
|
+
prev_style = nil
|
|
274
|
+
|
|
275
|
+
@area.height.times do |row|
|
|
276
|
+
# Build the row content and track the last non-empty column
|
|
277
|
+
row_buf = String.new(capacity: @area.width * 2)
|
|
278
|
+
last_styled_col = -1
|
|
279
|
+
col = 0
|
|
280
|
+
display_col = 0 # track actual terminal column for cursor correction
|
|
281
|
+
|
|
282
|
+
while col < @area.width
|
|
283
|
+
cell = @cells[row * @area.width + col]
|
|
284
|
+
|
|
285
|
+
# Skip spacer cells from wide characters
|
|
286
|
+
if cell.symbol.empty?
|
|
287
|
+
col += 1
|
|
288
|
+
next
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Apply style changes
|
|
292
|
+
style = cell.style
|
|
293
|
+
unless style == prev_style
|
|
294
|
+
codes = style_to_sgr_codes(style, prev_style)
|
|
295
|
+
row_buf << "#{csi}#{codes.join(";")}m" unless codes.empty?
|
|
296
|
+
prev_style = style
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
row_buf << cell.symbol
|
|
300
|
+
|
|
301
|
+
# Track last non-default cell for trimming
|
|
302
|
+
unless cell.empty?
|
|
303
|
+
last_styled_col = col
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
w = Unicode.string_width(cell.symbol)
|
|
307
|
+
display_col += w
|
|
308
|
+
col += [w, 1].max
|
|
309
|
+
|
|
310
|
+
# After wide characters, emit cursor column to correct any
|
|
311
|
+
# terminal rendering drift (terminals may disagree about emoji width)
|
|
312
|
+
if w >= 2 && col < @area.width
|
|
313
|
+
row_buf << "#{csi}#{display_col + 1}G"
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Reset at end of row if we had any styling
|
|
318
|
+
if prev_style && prev_style != Style::DEFAULT
|
|
319
|
+
row_buf << "#{csi}0m"
|
|
320
|
+
prev_style = nil
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
result << row_buf
|
|
324
|
+
result << "\n" unless row == @area.height - 1
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
result
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Print the buffer's ANSI output to a stream.
|
|
331
|
+
#
|
|
332
|
+
# Convenience wrapper around {#to_ansi}.
|
|
333
|
+
#
|
|
334
|
+
# @param output [IO] output stream (default: $stdout)
|
|
335
|
+
# @return [void]
|
|
336
|
+
def print(output = $stdout)
|
|
337
|
+
output.write(to_ansi)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
protected
|
|
341
|
+
|
|
342
|
+
attr_reader :cells
|
|
343
|
+
|
|
344
|
+
private
|
|
345
|
+
|
|
346
|
+
def index(x, y)
|
|
347
|
+
return nil unless x >= @area.x && x < @area.right &&
|
|
348
|
+
y >= @area.y && y < @area.bottom
|
|
349
|
+
|
|
350
|
+
(y - @area.y) * @area.width + (x - @area.x)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def char_width(ch) = Unicode.char_width(ch)
|
|
354
|
+
|
|
355
|
+
# Generate SGR codes to transition from prev_style to style
|
|
356
|
+
def style_to_sgr_codes(style, prev_style)
|
|
357
|
+
codes = []
|
|
358
|
+
prev = prev_style || Style::DEFAULT
|
|
359
|
+
|
|
360
|
+
# Check if we need a full reset
|
|
361
|
+
removed_mods = prev.modifiers & ~style.modifiers
|
|
362
|
+
if removed_mods != 0 || (prev.fg && !style.fg) || (prev.bg && !style.bg)
|
|
363
|
+
codes << "0"
|
|
364
|
+
prev = Style::DEFAULT
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Foreground
|
|
368
|
+
if style.fg != prev.fg
|
|
369
|
+
codes << (style.fg ? style.fg.to_fg_ansi : "39")
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Background
|
|
373
|
+
if style.bg != prev.bg
|
|
374
|
+
codes << (style.bg ? style.bg.to_bg_ansi : "49")
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Modifiers added
|
|
378
|
+
added = style.modifiers & ~prev.modifiers
|
|
379
|
+
Modifier::SGR_ON.each do |flag, code|
|
|
380
|
+
codes << code if Modifier.contains?(added, flag)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
codes
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def each_cell_in(area)
|
|
387
|
+
y_start = [area.y, @area.y].max
|
|
388
|
+
y_end = [area.bottom, @area.bottom].min
|
|
389
|
+
x_start = [area.x, @area.x].max
|
|
390
|
+
x_end = [area.right, @area.right].min
|
|
391
|
+
|
|
392
|
+
(y_start...y_end).each do |y|
|
|
393
|
+
(x_start...x_end).each do |x|
|
|
394
|
+
idx = index(x, y)
|
|
395
|
+
yield idx if idx
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
data/lib/rubytui/cell.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
# The atomic unit of the rendering grid: a single character with a style.
|
|
5
|
+
#
|
|
6
|
+
# Each cell in a {Buffer} holds one character (or empty string for wide-char
|
|
7
|
+
# spacers) and an associated {Style}.
|
|
8
|
+
class Cell
|
|
9
|
+
# @return [String] the character displayed in this cell
|
|
10
|
+
attr_accessor :symbol
|
|
11
|
+
# @return [Style] the style applied to this cell
|
|
12
|
+
attr_accessor :style
|
|
13
|
+
|
|
14
|
+
# Default empty character (space).
|
|
15
|
+
EMPTY_CHAR = " "
|
|
16
|
+
|
|
17
|
+
# @param symbol [String] the character (default: space)
|
|
18
|
+
# @param style [Style] the style (default: {Style::DEFAULT})
|
|
19
|
+
def initialize(symbol = EMPTY_CHAR, style = Style::DEFAULT)
|
|
20
|
+
@symbol = symbol
|
|
21
|
+
@style = style
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Reset this cell to empty (space character, default style).
|
|
25
|
+
#
|
|
26
|
+
# @return [self]
|
|
27
|
+
def reset!
|
|
28
|
+
@symbol = EMPTY_CHAR
|
|
29
|
+
@style = Style::DEFAULT
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Set this cell's character and style.
|
|
34
|
+
#
|
|
35
|
+
# @param symbol [String] the character
|
|
36
|
+
# @param style [Style] the style (default: {Style::DEFAULT})
|
|
37
|
+
# @return [self]
|
|
38
|
+
def set(symbol, style = Style::DEFAULT)
|
|
39
|
+
@symbol = symbol
|
|
40
|
+
@style = style
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Compare by symbol and style.
|
|
45
|
+
#
|
|
46
|
+
# @param other [Object] object to compare against
|
|
47
|
+
# @return [Boolean] true if +other+ is a Cell with an equal symbol and style
|
|
48
|
+
def ==(other)
|
|
49
|
+
other.is_a?(Cell) && @symbol == other.symbol && @style == other.style
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Boolean] true if this cell has default content (space + default style)
|
|
53
|
+
def empty? = @symbol == EMPTY_CHAR && @style == Style::DEFAULT
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
# Terminal color supporting named (16), indexed (256), and RGB truecolor.
|
|
5
|
+
#
|
|
6
|
+
# Use the predefined constants for the standard 16 colors, or the factory
|
|
7
|
+
# methods for indexed and RGB colors.
|
|
8
|
+
#
|
|
9
|
+
# @example Named colors
|
|
10
|
+
# Style.new.fg(Color::RED).bg(Color::BLACK)
|
|
11
|
+
#
|
|
12
|
+
# @example 256-color palette
|
|
13
|
+
# Style.new.fg(Color.indexed(208)) # orange
|
|
14
|
+
#
|
|
15
|
+
# @example RGB truecolor
|
|
16
|
+
# Style.new.fg(Color.rgb(255, 128, 0))
|
|
17
|
+
class Color
|
|
18
|
+
# @return [Symbol] color kind — +:named+, +:indexed+, +:rgb+, or +:reset+
|
|
19
|
+
attr_reader :kind
|
|
20
|
+
# @return [Integer, Array<Integer>, nil] color value (index, [r,g,b], or named index)
|
|
21
|
+
attr_reader :value
|
|
22
|
+
|
|
23
|
+
# @param kind [Symbol] color kind
|
|
24
|
+
# @param value [Integer, Array<Integer>, nil] color value
|
|
25
|
+
def initialize(kind, value = nil)
|
|
26
|
+
@kind = kind
|
|
27
|
+
@value = value
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Value equality: same kind and value.
|
|
31
|
+
#
|
|
32
|
+
# @param other [Object] object to compare
|
|
33
|
+
# @return [Boolean] true if +other+ is a Color with equal {#kind} and {#value}
|
|
34
|
+
def ==(other)
|
|
35
|
+
other.is_a?(Color) && @kind == other.kind && @value == other.value
|
|
36
|
+
end
|
|
37
|
+
alias_method :eql?, :==
|
|
38
|
+
|
|
39
|
+
# @return [Integer] hash derived from {#kind} and {#value}
|
|
40
|
+
def hash = [@kind, @value].hash
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# Create a color from the 256-color indexed palette.
|
|
44
|
+
#
|
|
45
|
+
# @param n [Integer] color index (0-255)
|
|
46
|
+
# @return [Color]
|
|
47
|
+
# @raise [ArgumentError] if n is outside 0-255
|
|
48
|
+
def indexed(n)
|
|
49
|
+
raise ArgumentError, "Color index must be 0-255, got #{n}" unless (0..255).cover?(n)
|
|
50
|
+
|
|
51
|
+
new(:indexed, n).freeze
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Create an RGB truecolor.
|
|
55
|
+
#
|
|
56
|
+
# @param r [Integer] red component (0-255)
|
|
57
|
+
# @param g [Integer] green component (0-255)
|
|
58
|
+
# @param b [Integer] blue component (0-255)
|
|
59
|
+
# @return [Color]
|
|
60
|
+
def rgb(r, g, b)
|
|
61
|
+
new(:rgb, [r, g, b].freeze).freeze
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Generate ANSI foreground escape code fragment (without ESC[ prefix or m suffix).
|
|
66
|
+
#
|
|
67
|
+
# @return [String] SGR code fragment (e.g., "31", "38;5;208", "38;2;255;128;0")
|
|
68
|
+
def to_fg_ansi
|
|
69
|
+
case @kind
|
|
70
|
+
when :reset then "39"
|
|
71
|
+
when :named then (@value < 8 ? "#{30 + @value}" : "#{90 + @value - 8}")
|
|
72
|
+
when :indexed then "38;5;#{@value}"
|
|
73
|
+
when :rgb then "38;2;#{@value[0]};#{@value[1]};#{@value[2]}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Generate ANSI background escape code fragment.
|
|
78
|
+
#
|
|
79
|
+
# @return [String] SGR code fragment (e.g., "41", "48;5;208", "48;2;255;128;0")
|
|
80
|
+
def to_bg_ansi
|
|
81
|
+
case @kind
|
|
82
|
+
when :reset then "49"
|
|
83
|
+
when :named then (@value < 8 ? "#{40 + @value}" : "#{100 + @value - 8}")
|
|
84
|
+
when :indexed then "48;5;#{@value}"
|
|
85
|
+
when :rgb then "48;2;#{@value[0]};#{@value[1]};#{@value[2]}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @!group Standard Named Colors (16-color palette)
|
|
90
|
+
|
|
91
|
+
# Terminal default color (SGR 39 / 49).
|
|
92
|
+
RESET = new(:reset).freeze
|
|
93
|
+
# Black (SGR 30 / 40).
|
|
94
|
+
BLACK = new(:named, 0).freeze
|
|
95
|
+
# Red (SGR 31 / 41).
|
|
96
|
+
RED = new(:named, 1).freeze
|
|
97
|
+
# Green (SGR 32 / 42).
|
|
98
|
+
GREEN = new(:named, 2).freeze
|
|
99
|
+
# Yellow (SGR 33 / 43).
|
|
100
|
+
YELLOW = new(:named, 3).freeze
|
|
101
|
+
# Blue (SGR 34 / 44).
|
|
102
|
+
BLUE = new(:named, 4).freeze
|
|
103
|
+
# Magenta (SGR 35 / 45).
|
|
104
|
+
MAGENTA = new(:named, 5).freeze
|
|
105
|
+
# Cyan (SGR 36 / 46).
|
|
106
|
+
CYAN = new(:named, 6).freeze
|
|
107
|
+
# White (SGR 37 / 47).
|
|
108
|
+
WHITE = new(:named, 7).freeze
|
|
109
|
+
# Bright black, usually rendered as gray (SGR 90 / 100).
|
|
110
|
+
BRIGHT_BLACK = new(:named, 8).freeze
|
|
111
|
+
# Bright red (SGR 91 / 101).
|
|
112
|
+
BRIGHT_RED = new(:named, 9).freeze
|
|
113
|
+
# Bright green (SGR 92 / 102).
|
|
114
|
+
BRIGHT_GREEN = new(:named, 10).freeze
|
|
115
|
+
# Bright yellow (SGR 93 / 103).
|
|
116
|
+
BRIGHT_YELLOW = new(:named, 11).freeze
|
|
117
|
+
# Bright blue (SGR 94 / 104).
|
|
118
|
+
BRIGHT_BLUE = new(:named, 12).freeze
|
|
119
|
+
# Bright magenta (SGR 95 / 105).
|
|
120
|
+
BRIGHT_MAGENTA = new(:named, 13).freeze
|
|
121
|
+
# Bright cyan (SGR 96 / 106).
|
|
122
|
+
BRIGHT_CYAN = new(:named, 14).freeze
|
|
123
|
+
# Bright white (SGR 97 / 107).
|
|
124
|
+
BRIGHT_WHITE = new(:named, 15).freeze
|
|
125
|
+
|
|
126
|
+
# @!endgroup
|
|
127
|
+
|
|
128
|
+
# @!group Color Aliases
|
|
129
|
+
|
|
130
|
+
# Alias for {BRIGHT_BLACK}.
|
|
131
|
+
GRAY = BRIGHT_BLACK
|
|
132
|
+
# Alias for {BRIGHT_BLACK}.
|
|
133
|
+
GREY = BRIGHT_BLACK
|
|
134
|
+
# Alias for {BRIGHT_BLACK}.
|
|
135
|
+
DARK_GRAY = BRIGHT_BLACK
|
|
136
|
+
# Alias for {BRIGHT_BLACK}.
|
|
137
|
+
DARK_GREY = BRIGHT_BLACK
|
|
138
|
+
# Alias for {BRIGHT_RED}.
|
|
139
|
+
LIGHT_RED = BRIGHT_RED
|
|
140
|
+
# Alias for {BRIGHT_GREEN}.
|
|
141
|
+
LIGHT_GREEN = BRIGHT_GREEN
|
|
142
|
+
# Alias for {BRIGHT_YELLOW}.
|
|
143
|
+
LIGHT_YELLOW = BRIGHT_YELLOW
|
|
144
|
+
# Alias for {BRIGHT_BLUE}.
|
|
145
|
+
LIGHT_BLUE = BRIGHT_BLUE
|
|
146
|
+
# Alias for {BRIGHT_MAGENTA}.
|
|
147
|
+
LIGHT_MAGENTA = BRIGHT_MAGENTA
|
|
148
|
+
# Alias for {BRIGHT_CYAN}.
|
|
149
|
+
LIGHT_CYAN = BRIGHT_CYAN
|
|
150
|
+
|
|
151
|
+
# @!endgroup
|
|
152
|
+
end
|
|
153
|
+
end
|