rich-ruby 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/LICENSE +21 -0
- data/README.md +546 -0
- data/examples/demo.rb +106 -0
- data/examples/showcase.rb +420 -0
- data/examples/smoke_test.rb +41 -0
- data/examples/stress_test.rb +604 -0
- data/examples/syntax_markdown_demo.rb +166 -0
- data/examples/verify.rb +215 -0
- data/examples/visual_demo.rb +145 -0
- data/lib/rich/_palettes.rb +148 -0
- data/lib/rich/box.rb +342 -0
- data/lib/rich/cells.rb +512 -0
- data/lib/rich/color.rb +628 -0
- data/lib/rich/color_triplet.rb +220 -0
- data/lib/rich/console.rb +549 -0
- data/lib/rich/control.rb +332 -0
- data/lib/rich/json.rb +254 -0
- data/lib/rich/layout.rb +314 -0
- data/lib/rich/markdown.rb +509 -0
- data/lib/rich/markup.rb +175 -0
- data/lib/rich/panel.rb +311 -0
- data/lib/rich/progress.rb +430 -0
- data/lib/rich/segment.rb +387 -0
- data/lib/rich/style.rb +433 -0
- data/lib/rich/syntax.rb +1145 -0
- data/lib/rich/table.rb +525 -0
- data/lib/rich/terminal_theme.rb +126 -0
- data/lib/rich/text.rb +433 -0
- data/lib/rich/tree.rb +220 -0
- data/lib/rich/version.rb +5 -0
- data/lib/rich/win32_console.rb +582 -0
- data/lib/rich.rb +108 -0
- metadata +106 -0
data/lib/rich/segment.rb
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "style"
|
|
4
|
+
require_relative "cells"
|
|
5
|
+
require_relative "control"
|
|
6
|
+
|
|
7
|
+
module Rich
|
|
8
|
+
# A piece of text with associated style.
|
|
9
|
+
# Segments are the fundamental unit produced by the rendering process
|
|
10
|
+
# and are ultimately converted to strings for terminal output.
|
|
11
|
+
class Segment
|
|
12
|
+
# @return [String] Text content
|
|
13
|
+
attr_reader :text
|
|
14
|
+
|
|
15
|
+
# @return [Style, nil] Style for the text
|
|
16
|
+
attr_reader :style
|
|
17
|
+
|
|
18
|
+
# @return [Array, nil] Control codes (non-printable)
|
|
19
|
+
attr_reader :control
|
|
20
|
+
|
|
21
|
+
# Create a new segment
|
|
22
|
+
# @param text [String] Text content
|
|
23
|
+
# @param style [Style, nil] Style to apply
|
|
24
|
+
# @param control [Array, nil] Control codes
|
|
25
|
+
def initialize(text = "", style: nil, control: nil)
|
|
26
|
+
@text = text.freeze
|
|
27
|
+
@style = style
|
|
28
|
+
@control = control&.freeze
|
|
29
|
+
freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Integer] Display width in terminal cells
|
|
33
|
+
def cell_length
|
|
34
|
+
return 0 if control?
|
|
35
|
+
|
|
36
|
+
Cells.cached_cell_len(@text)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Boolean] True if segment has text content
|
|
40
|
+
def present?
|
|
41
|
+
!@text.empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Boolean] True if segment is empty
|
|
45
|
+
def empty?
|
|
46
|
+
@text.empty?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Boolean] True if this is a control segment
|
|
50
|
+
def control?
|
|
51
|
+
!@control.nil?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Boolean] True if segment has text (used for truthiness)
|
|
55
|
+
def to_bool
|
|
56
|
+
present?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Split segment at a cell position
|
|
60
|
+
# @param cut [Integer] Cell position to split at
|
|
61
|
+
# @return [Array<Segment>] Two segments [before, after]
|
|
62
|
+
def split_cells(cut)
|
|
63
|
+
return [self.class.new("", style: @style), self] if cut <= 0
|
|
64
|
+
return [self, self.class.new("", style: @style)] if cut >= cell_length
|
|
65
|
+
|
|
66
|
+
self.class.split_at_cell(@text, cut, @style)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Get segment text with ANSI reset if needed
|
|
70
|
+
# @return [String]
|
|
71
|
+
def to_s
|
|
72
|
+
@text
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def inspect
|
|
76
|
+
if control?
|
|
77
|
+
"#<Rich::Segment control=#{@control.inspect}>"
|
|
78
|
+
elsif @style
|
|
79
|
+
"#<Rich::Segment #{@text.inspect} style=#{@style}>"
|
|
80
|
+
else
|
|
81
|
+
"#<Rich::Segment #{@text.inspect}>"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def ==(other)
|
|
86
|
+
return false unless other.is_a?(Segment)
|
|
87
|
+
|
|
88
|
+
@text == other.text && @style == other.style && @control == other.control
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
alias eql? ==
|
|
92
|
+
|
|
93
|
+
def hash
|
|
94
|
+
[@text, @style, @control].hash
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class << self
|
|
98
|
+
# Create a newline segment
|
|
99
|
+
# @return [Segment]
|
|
100
|
+
def line
|
|
101
|
+
@line ||= new("\n")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Create a blank segment with specified cell width
|
|
105
|
+
# @param cell_count [Integer] Width in cells
|
|
106
|
+
# @param style [Style, nil] Optional style
|
|
107
|
+
# @return [Segment]
|
|
108
|
+
def blank(cell_count, style: nil)
|
|
109
|
+
new(" " * cell_count, style: style)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Create a control segment
|
|
113
|
+
# @param control [Array] Control codes
|
|
114
|
+
# @return [Segment]
|
|
115
|
+
def control(control_codes)
|
|
116
|
+
new("", control: control_codes)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Apply style to an iterable of segments
|
|
120
|
+
# @param segments [Enumerable<Segment>] Segments to style
|
|
121
|
+
# @param style [Style, nil] Style to apply
|
|
122
|
+
# @param post_style [Style, nil] Style to apply after segment style
|
|
123
|
+
# @return [Enumerable<Segment>]
|
|
124
|
+
def apply_style(segments, style: nil, post_style: nil)
|
|
125
|
+
return segments if style.nil? && post_style.nil?
|
|
126
|
+
|
|
127
|
+
segments.map do |segment|
|
|
128
|
+
next segment if segment.control?
|
|
129
|
+
|
|
130
|
+
new_style = if segment.style
|
|
131
|
+
if style && post_style
|
|
132
|
+
style + segment.style + post_style
|
|
133
|
+
elsif style
|
|
134
|
+
style + segment.style
|
|
135
|
+
else
|
|
136
|
+
segment.style + post_style
|
|
137
|
+
end
|
|
138
|
+
else
|
|
139
|
+
style || post_style
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
new(segment.text, style: new_style, control: segment.control)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Filter segments by control status
|
|
147
|
+
# @param segments [Enumerable<Segment>] Segments to filter
|
|
148
|
+
# @param is_control [Boolean] Filter for control segments
|
|
149
|
+
# @return [Enumerable<Segment>]
|
|
150
|
+
def filter_control(segments, is_control: false)
|
|
151
|
+
segments.select { |s| s.control? == is_control }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Split segments into lines
|
|
155
|
+
# @param segments [Enumerable<Segment>] Segments to split
|
|
156
|
+
# @return [Array<Array<Segment>>] Array of lines
|
|
157
|
+
def split_lines(segments)
|
|
158
|
+
lines = []
|
|
159
|
+
current_line = []
|
|
160
|
+
|
|
161
|
+
segments.each do |segment|
|
|
162
|
+
if segment.text.include?("\n")
|
|
163
|
+
parts = segment.text.split("\n", -1)
|
|
164
|
+
parts.each_with_index do |part, index|
|
|
165
|
+
current_line << new(part, style: segment.style) unless part.empty?
|
|
166
|
+
|
|
167
|
+
if index < parts.length - 1
|
|
168
|
+
lines << current_line
|
|
169
|
+
current_line = []
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
else
|
|
173
|
+
current_line << segment
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
lines << current_line unless current_line.empty?
|
|
178
|
+
lines
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Split and crop segments to a given width
|
|
182
|
+
# @param segments [Enumerable<Segment>] Segments to process
|
|
183
|
+
# @param width [Integer] Maximum width
|
|
184
|
+
# @param style [Style, nil] Fill style
|
|
185
|
+
# @param pad [Boolean] Pad lines to width
|
|
186
|
+
# @param include_new_lines [Boolean] Include newline segments
|
|
187
|
+
# @return [Array<Array<Segment>>]
|
|
188
|
+
def split_and_crop_lines(segments, width, style: nil, pad: true, include_new_lines: true)
|
|
189
|
+
lines = split_lines(segments)
|
|
190
|
+
|
|
191
|
+
lines.map do |line|
|
|
192
|
+
cropped = adjust_line_length(line, width, style: style, pad: pad)
|
|
193
|
+
if include_new_lines
|
|
194
|
+
cropped + [new("\n")]
|
|
195
|
+
else
|
|
196
|
+
cropped
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Adjust line length by cropping or padding
|
|
202
|
+
# @param line [Array<Segment>] Line segments
|
|
203
|
+
# @param length [Integer] Target length
|
|
204
|
+
# @param style [Style, nil] Style for padding
|
|
205
|
+
# @param pad [Boolean] Whether to pad short lines
|
|
206
|
+
# @return [Array<Segment>]
|
|
207
|
+
def adjust_line_length(line, length, style: nil, pad: true)
|
|
208
|
+
current_length = get_line_length(line)
|
|
209
|
+
|
|
210
|
+
if current_length < length
|
|
211
|
+
# Pad if needed
|
|
212
|
+
if pad
|
|
213
|
+
pad_size = length - current_length
|
|
214
|
+
line + [blank(pad_size, style: style)]
|
|
215
|
+
else
|
|
216
|
+
line
|
|
217
|
+
end
|
|
218
|
+
elsif current_length > length
|
|
219
|
+
# Crop
|
|
220
|
+
crop_line(line, length)
|
|
221
|
+
else
|
|
222
|
+
line
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Get total cell length of a line
|
|
227
|
+
# @param line [Array<Segment>] Line segments
|
|
228
|
+
# @return [Integer]
|
|
229
|
+
def get_line_length(line)
|
|
230
|
+
line.sum(&:cell_length)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Get dimensions of lines
|
|
234
|
+
# @param lines [Array<Array<Segment>>] Lines
|
|
235
|
+
# @return [Array<Integer>] [width, height]
|
|
236
|
+
def get_shape(lines)
|
|
237
|
+
height = lines.length
|
|
238
|
+
width = lines.map { |line| get_line_length(line) }.max || 0
|
|
239
|
+
[width, height]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Crop a line to a maximum width
|
|
243
|
+
# @param line [Array<Segment>] Line segments
|
|
244
|
+
# @param max_width [Integer] Maximum width
|
|
245
|
+
# @return [Array<Segment>]
|
|
246
|
+
def crop_line(line, max_width)
|
|
247
|
+
result = []
|
|
248
|
+
remaining = max_width
|
|
249
|
+
|
|
250
|
+
line.each do |segment|
|
|
251
|
+
break if remaining <= 0
|
|
252
|
+
|
|
253
|
+
segment_width = segment.cell_length
|
|
254
|
+
|
|
255
|
+
if segment_width <= remaining
|
|
256
|
+
result << segment
|
|
257
|
+
remaining -= segment_width
|
|
258
|
+
else
|
|
259
|
+
# Need to split segment
|
|
260
|
+
before, _after = segment.split_cells(remaining)
|
|
261
|
+
result << before
|
|
262
|
+
remaining = 0
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
result
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Simplify consecutive segments with the same style
|
|
270
|
+
# @param segments [Array<Segment>] Segments to simplify
|
|
271
|
+
# @return [Array<Segment>]
|
|
272
|
+
def simplify(segments)
|
|
273
|
+
return segments if segments.empty?
|
|
274
|
+
|
|
275
|
+
result = []
|
|
276
|
+
current_text = +""
|
|
277
|
+
current_style = nil
|
|
278
|
+
current_control = nil
|
|
279
|
+
|
|
280
|
+
segments.each do |segment|
|
|
281
|
+
if segment.control?
|
|
282
|
+
# Flush text if any
|
|
283
|
+
unless current_text.empty?
|
|
284
|
+
result << new(current_text, style: current_style, control: current_control)
|
|
285
|
+
current_text = +""
|
|
286
|
+
end
|
|
287
|
+
result << segment
|
|
288
|
+
current_style = nil
|
|
289
|
+
current_control = nil
|
|
290
|
+
elsif segment.style == current_style
|
|
291
|
+
current_text << segment.text
|
|
292
|
+
else
|
|
293
|
+
unless current_text.empty?
|
|
294
|
+
result << new(current_text, style: current_style, control: current_control)
|
|
295
|
+
end
|
|
296
|
+
current_text = +segment.text
|
|
297
|
+
current_style = segment.style
|
|
298
|
+
current_control = nil
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
result << new(current_text, style: current_style) unless current_text.empty?
|
|
303
|
+
|
|
304
|
+
result
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Render segments to string with ANSI codes
|
|
308
|
+
# @param segments [Enumerable<Segment>] Segments to render
|
|
309
|
+
# @param color_system [Symbol] Color system to use
|
|
310
|
+
# @return [String]
|
|
311
|
+
def render(segments, color_system: ColorSystem::TRUECOLOR)
|
|
312
|
+
output = +""
|
|
313
|
+
last_style = nil
|
|
314
|
+
|
|
315
|
+
segments.each do |segment|
|
|
316
|
+
if segment.control?
|
|
317
|
+
# Handle control codes
|
|
318
|
+
segment.control.each do |control_code|
|
|
319
|
+
output << Control.generate(*control_code)
|
|
320
|
+
end
|
|
321
|
+
else
|
|
322
|
+
style = segment.style
|
|
323
|
+
|
|
324
|
+
if style != last_style
|
|
325
|
+
# Reset if needed
|
|
326
|
+
output << "\e[0m" if last_style
|
|
327
|
+
# Apply new style
|
|
328
|
+
output << style.render(color_system: color_system) if style
|
|
329
|
+
last_style = style
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
output << segment.text
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Reset at end if we had any style
|
|
337
|
+
output << "\e[0m" if last_style
|
|
338
|
+
|
|
339
|
+
output
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Split text at a cell position
|
|
343
|
+
# @param text [String] Text to split
|
|
344
|
+
# @param cut [Integer] Cell position
|
|
345
|
+
# @param style [Style, nil] Style to apply
|
|
346
|
+
# @return [Array<Segment>] Two segments
|
|
347
|
+
def split_at_cell(text, cut, style)
|
|
348
|
+
position = 0
|
|
349
|
+
cell_count = 0
|
|
350
|
+
insert_spaces = 0
|
|
351
|
+
|
|
352
|
+
text.each_char do |char|
|
|
353
|
+
char_width = Cells.char_width(char)
|
|
354
|
+
new_cell_count = cell_count + char_width
|
|
355
|
+
|
|
356
|
+
if new_cell_count > cut
|
|
357
|
+
# Split happens in the middle of a wide character
|
|
358
|
+
if char_width == 2 && cell_count + 1 == cut
|
|
359
|
+
insert_spaces = 2
|
|
360
|
+
end
|
|
361
|
+
break
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
cell_count = new_cell_count
|
|
365
|
+
position += 1
|
|
366
|
+
break if cell_count == cut
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
if insert_spaces > 0
|
|
370
|
+
[
|
|
371
|
+
new(text[0...position] + " " * (cut - cell_count), style: style),
|
|
372
|
+
new(" " * (insert_spaces - (cut - cell_count)) + text[position..], style: style)
|
|
373
|
+
]
|
|
374
|
+
else
|
|
375
|
+
[
|
|
376
|
+
new(text[0...position], style: style),
|
|
377
|
+
new(text[position..] || "", style: style)
|
|
378
|
+
]
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
private
|
|
383
|
+
|
|
384
|
+
# No private methods currently
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|