rich-ruby 1.0.1 → 1.0.2
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 +80 -0
- data/LICENSE +21 -21
- data/README.md +547 -547
- data/docs/architecture.md +43 -0
- data/docs/cheat-sheet.md +52 -0
- data/docs/customization.md +53 -0
- data/docs/how-to-use.md +96 -0
- data/docs/test-report.md +112 -0
- data/docs/troubleshooting.md +36 -0
- data/docs/windows-notes.md +30 -0
- data/examples/demo.rb +106 -106
- data/examples/showcase.rb +420 -420
- data/examples/smoke_test.rb +41 -41
- data/examples/stress_test.rb +604 -604
- data/examples/syntax_markdown_demo.rb +166 -166
- data/examples/verify.rb +216 -215
- data/examples/visual_demo.rb +145 -145
- data/lib/rich/_palettes.rb +148 -148
- data/lib/rich/box.rb +342 -342
- data/lib/rich/cells.rb +524 -512
- data/lib/rich/color.rb +631 -628
- data/lib/rich/color_triplet.rb +227 -220
- data/lib/rich/console.rb +604 -549
- data/lib/rich/control.rb +332 -332
- data/lib/rich/json.rb +260 -254
- data/lib/rich/layout.rb +314 -314
- data/lib/rich/markdown.rb +531 -509
- data/lib/rich/markup.rb +186 -175
- data/lib/rich/panel.rb +318 -311
- data/lib/rich/progress.rb +430 -430
- data/lib/rich/segment.rb +387 -387
- data/lib/rich/style.rb +464 -433
- data/lib/rich/syntax.rb +1220 -1145
- data/lib/rich/table.rb +547 -525
- data/lib/rich/terminal_theme.rb +126 -126
- data/lib/rich/text.rb +460 -433
- data/lib/rich/tree.rb +220 -220
- data/lib/rich/version.rb +5 -5
- data/lib/rich/win32_console.rb +620 -582
- data/lib/rich.rb +108 -108
- metadata +15 -5
data/lib/rich/text.rb
CHANGED
|
@@ -1,433 +1,460 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "style"
|
|
4
|
-
require_relative "segment"
|
|
5
|
-
require_relative "cells"
|
|
6
|
-
|
|
7
|
-
module Rich
|
|
8
|
-
# A span of styled text within a Text object
|
|
9
|
-
class Span
|
|
10
|
-
# @return [Integer] Start position (inclusive)
|
|
11
|
-
attr_reader :start
|
|
12
|
-
|
|
13
|
-
# @return [Integer] End position (exclusive)
|
|
14
|
-
attr_reader :end
|
|
15
|
-
|
|
16
|
-
# @return [Style] Style for this span
|
|
17
|
-
attr_reader :style
|
|
18
|
-
|
|
19
|
-
def initialize(start_pos, end_pos, style)
|
|
20
|
-
@start = start_pos
|
|
21
|
-
@end = end_pos
|
|
22
|
-
@style = style.is_a?(String) ? Style.parse(style) : style
|
|
23
|
-
freeze
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# @return [Integer] Length of the span
|
|
27
|
-
def length
|
|
28
|
-
@end - @start
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Check if span overlaps with a range
|
|
32
|
-
def overlaps?(start_pos, end_pos)
|
|
33
|
-
@start < end_pos && @end > start_pos
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# Adjust span after insertion at position
|
|
37
|
-
def adjust_insert(position, length)
|
|
38
|
-
if position <= @start
|
|
39
|
-
Span.new(@start + length, @end + length, @style)
|
|
40
|
-
elsif position < @end
|
|
41
|
-
Span.new(@start, @end + length, @style)
|
|
42
|
-
else
|
|
43
|
-
self
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Adjust span after deletion at position
|
|
48
|
-
def adjust_delete(position, length)
|
|
49
|
-
delete_end = position + length
|
|
50
|
-
|
|
51
|
-
if delete_end <= @start
|
|
52
|
-
Span.new(@start - length, @end - length, @style)
|
|
53
|
-
elsif position >= @end
|
|
54
|
-
self
|
|
55
|
-
elsif position <= @start && delete_end >= @end
|
|
56
|
-
nil # Span completely deleted
|
|
57
|
-
elsif position <= @start
|
|
58
|
-
Span.new(position, @end - length, @style)
|
|
59
|
-
elsif delete_end >= @end
|
|
60
|
-
Span.new(@start, position, @style)
|
|
61
|
-
else
|
|
62
|
-
Span.new(@start, @end - length, @style)
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def inspect
|
|
67
|
-
"#<Rich::Span [#{@start}:#{@end}] #{@style}>"
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Rich text with style spans.
|
|
72
|
-
# Text objects contain plain text plus a list of style spans that define
|
|
73
|
-
# how different portions of the text should be rendered.
|
|
74
|
-
class Text
|
|
75
|
-
# @return [String] Plain text content
|
|
76
|
-
attr_reader :plain
|
|
77
|
-
|
|
78
|
-
# @return [Array<Span>] Style spans
|
|
79
|
-
attr_reader :spans
|
|
80
|
-
|
|
81
|
-
# @return [Style, nil] Base style for entire text
|
|
82
|
-
attr_reader :style
|
|
83
|
-
|
|
84
|
-
# @return [Symbol] Justification (:left, :center, :right, :full)
|
|
85
|
-
attr_reader :justify
|
|
86
|
-
|
|
87
|
-
# @return [Symbol] Overflow handling (:fold, :crop, :ellipsis)
|
|
88
|
-
attr_reader :overflow
|
|
89
|
-
|
|
90
|
-
# @return [Boolean] No wrap
|
|
91
|
-
attr_reader :no_wrap
|
|
92
|
-
|
|
93
|
-
# @return [Boolean] End with newline
|
|
94
|
-
attr_reader :end
|
|
95
|
-
|
|
96
|
-
# Create new Text
|
|
97
|
-
# @param text [String] Initial text
|
|
98
|
-
# @param style [Style, String, nil] Base style
|
|
99
|
-
# @param justify [Symbol] Text justification
|
|
100
|
-
# @param overflow [Symbol] Overflow handling
|
|
101
|
-
# @param no_wrap [Boolean] Disable wrapping
|
|
102
|
-
def initialize(
|
|
103
|
-
text = "",
|
|
104
|
-
style: nil,
|
|
105
|
-
justify: :left,
|
|
106
|
-
overflow: :fold,
|
|
107
|
-
no_wrap: false,
|
|
108
|
-
end_str: "\n"
|
|
109
|
-
)
|
|
110
|
-
@plain = +text.to_s
|
|
111
|
-
@spans = []
|
|
112
|
-
@style = style.is_a?(String) ? Style.parse(style) : style
|
|
113
|
-
@justify = justify
|
|
114
|
-
@overflow = overflow
|
|
115
|
-
@no_wrap = no_wrap
|
|
116
|
-
@end = end_str
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# @return [Integer] Length of text
|
|
120
|
-
def length
|
|
121
|
-
@plain.length
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
# @return [Integer] Cell width of text
|
|
125
|
-
def cell_length
|
|
126
|
-
Cells.cached_cell_len(@plain)
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
# @return [Boolean] True if text is empty
|
|
130
|
-
def empty?
|
|
131
|
-
@plain.empty?
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
# Append text with optional style
|
|
135
|
-
# @param text [String, Text] Text to append
|
|
136
|
-
# @param style [Style, String, nil] Style for appended text
|
|
137
|
-
# @return [self]
|
|
138
|
-
def append(text, style: nil)
|
|
139
|
-
if text.is_a?(Text)
|
|
140
|
-
append_text(text)
|
|
141
|
-
else
|
|
142
|
-
start_pos = @plain.length
|
|
143
|
-
@plain << text.to_s
|
|
144
|
-
|
|
145
|
-
if style
|
|
146
|
-
parsed_style = style.is_a?(String) ? Style.parse(style) : style
|
|
147
|
-
@spans << Span.new(start_pos, @plain.length, parsed_style)
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
self
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
alias << append
|
|
154
|
-
|
|
155
|
-
# Append a Text object
|
|
156
|
-
# @param other [Text] Text to append
|
|
157
|
-
# @return [self]
|
|
158
|
-
def append_text(other)
|
|
159
|
-
offset = @plain.length
|
|
160
|
-
@plain << other.plain
|
|
161
|
-
|
|
162
|
-
other.spans.each do |span|
|
|
163
|
-
@spans << Span.new(span.start + offset, span.end + offset, span.style)
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
self
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
# Apply a style to a range
|
|
170
|
-
# @param style [Style, String] Style to apply
|
|
171
|
-
# @param start_pos [Integer] Start position
|
|
172
|
-
# @param end_pos [Integer, nil] End position (nil = end of text)
|
|
173
|
-
# @return [self]
|
|
174
|
-
def stylize(style, start_pos = 0, end_pos = nil)
|
|
175
|
-
end_pos ||= @plain.length
|
|
176
|
-
return self if start_pos >= end_pos
|
|
177
|
-
|
|
178
|
-
parsed_style = style.is_a?(String) ? Style.parse(style) : style
|
|
179
|
-
@spans << Span.new(start_pos, end_pos, parsed_style)
|
|
180
|
-
self
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
# Apply a style to the entire text
|
|
184
|
-
# @param style [Style, String] Style to apply
|
|
185
|
-
# @return [self]
|
|
186
|
-
def stylize_all(style)
|
|
187
|
-
stylize(style, 0, @plain.length)
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
# Get a substring as a new Text object
|
|
191
|
-
# @param start_pos [Integer] Start position
|
|
192
|
-
# @param length [Integer, nil] Length (nil = to end)
|
|
193
|
-
# @return [Text]
|
|
194
|
-
def slice(start_pos, length = nil)
|
|
195
|
-
end_pos = length ? start_pos + length : @plain.length
|
|
196
|
-
new_text = Text.new(@plain[start_pos...end_pos], style: @style)
|
|
197
|
-
|
|
198
|
-
@spans.each do |span|
|
|
199
|
-
next unless span.overlaps?(start_pos, end_pos)
|
|
200
|
-
|
|
201
|
-
new_start = [span.start - start_pos, 0].max
|
|
202
|
-
new_end = [span.end - start_pos, end_pos - start_pos].min
|
|
203
|
-
new_text.spans << Span.new(new_start, new_end, span.style)
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
new_text
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
# Split text by a delimiter
|
|
210
|
-
# @param delimiter [String] Delimiter to split on
|
|
211
|
-
# @return [Array<Text>]
|
|
212
|
-
def split(delimiter = "\n")
|
|
213
|
-
parts = []
|
|
214
|
-
pos = 0
|
|
215
|
-
|
|
216
|
-
@plain.split(delimiter, -1).each do |part|
|
|
217
|
-
end_pos = pos + part.length
|
|
218
|
-
parts << slice(pos, part.length)
|
|
219
|
-
pos = end_pos + delimiter.length
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
parts
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
# Wrap text to a given width
|
|
226
|
-
# @param width [Integer] Maximum width
|
|
227
|
-
# @return [Array<Text>]
|
|
228
|
-
def wrap(width)
|
|
229
|
-
return [self.dup] if width <= 0 || cell_length <= width
|
|
230
|
-
|
|
231
|
-
lines = []
|
|
232
|
-
current_line = Text.new(style: @style)
|
|
233
|
-
current_width = 0
|
|
234
|
-
|
|
235
|
-
words = @plain.split(/(\s+)/)
|
|
236
|
-
word_pos = 0
|
|
237
|
-
|
|
238
|
-
words.each do |word|
|
|
239
|
-
word_width = Cells.cell_len(word)
|
|
240
|
-
word_end = word_pos + word.length
|
|
241
|
-
|
|
242
|
-
if current_width + word_width <= width
|
|
243
|
-
# Word fits
|
|
244
|
-
current_line.
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
#
|
|
275
|
-
lines << current_line unless current_line.empty?
|
|
276
|
-
current_line = Text.new(style: @style)
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
@plain
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "style"
|
|
4
|
+
require_relative "segment"
|
|
5
|
+
require_relative "cells"
|
|
6
|
+
|
|
7
|
+
module Rich
|
|
8
|
+
# A span of styled text within a Text object
|
|
9
|
+
class Span
|
|
10
|
+
# @return [Integer] Start position (inclusive)
|
|
11
|
+
attr_reader :start
|
|
12
|
+
|
|
13
|
+
# @return [Integer] End position (exclusive)
|
|
14
|
+
attr_reader :end
|
|
15
|
+
|
|
16
|
+
# @return [Style] Style for this span
|
|
17
|
+
attr_reader :style
|
|
18
|
+
|
|
19
|
+
def initialize(start_pos, end_pos, style)
|
|
20
|
+
@start = start_pos
|
|
21
|
+
@end = end_pos
|
|
22
|
+
@style = style.is_a?(String) ? Style.parse(style) : style
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Integer] Length of the span
|
|
27
|
+
def length
|
|
28
|
+
@end - @start
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Check if span overlaps with a range
|
|
32
|
+
def overlaps?(start_pos, end_pos)
|
|
33
|
+
@start < end_pos && @end > start_pos
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Adjust span after insertion at position
|
|
37
|
+
def adjust_insert(position, length)
|
|
38
|
+
if position <= @start
|
|
39
|
+
Span.new(@start + length, @end + length, @style)
|
|
40
|
+
elsif position < @end
|
|
41
|
+
Span.new(@start, @end + length, @style)
|
|
42
|
+
else
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Adjust span after deletion at position
|
|
48
|
+
def adjust_delete(position, length)
|
|
49
|
+
delete_end = position + length
|
|
50
|
+
|
|
51
|
+
if delete_end <= @start
|
|
52
|
+
Span.new(@start - length, @end - length, @style)
|
|
53
|
+
elsif position >= @end
|
|
54
|
+
self
|
|
55
|
+
elsif position <= @start && delete_end >= @end
|
|
56
|
+
nil # Span completely deleted
|
|
57
|
+
elsif position <= @start
|
|
58
|
+
Span.new(position, @end - length, @style)
|
|
59
|
+
elsif delete_end >= @end
|
|
60
|
+
Span.new(@start, position, @style)
|
|
61
|
+
else
|
|
62
|
+
Span.new(@start, @end - length, @style)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def inspect
|
|
67
|
+
"#<Rich::Span [#{@start}:#{@end}] #{@style}>"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Rich text with style spans.
|
|
72
|
+
# Text objects contain plain text plus a list of style spans that define
|
|
73
|
+
# how different portions of the text should be rendered.
|
|
74
|
+
class Text
|
|
75
|
+
# @return [String] Plain text content
|
|
76
|
+
attr_reader :plain
|
|
77
|
+
|
|
78
|
+
# @return [Array<Span>] Style spans
|
|
79
|
+
attr_reader :spans
|
|
80
|
+
|
|
81
|
+
# @return [Style, nil] Base style for entire text
|
|
82
|
+
attr_reader :style
|
|
83
|
+
|
|
84
|
+
# @return [Symbol] Justification (:left, :center, :right, :full)
|
|
85
|
+
attr_reader :justify
|
|
86
|
+
|
|
87
|
+
# @return [Symbol] Overflow handling (:fold, :crop, :ellipsis)
|
|
88
|
+
attr_reader :overflow
|
|
89
|
+
|
|
90
|
+
# @return [Boolean] No wrap
|
|
91
|
+
attr_reader :no_wrap
|
|
92
|
+
|
|
93
|
+
# @return [Boolean] End with newline
|
|
94
|
+
attr_reader :end
|
|
95
|
+
|
|
96
|
+
# Create new Text
|
|
97
|
+
# @param text [String] Initial text
|
|
98
|
+
# @param style [Style, String, nil] Base style
|
|
99
|
+
# @param justify [Symbol] Text justification
|
|
100
|
+
# @param overflow [Symbol] Overflow handling
|
|
101
|
+
# @param no_wrap [Boolean] Disable wrapping
|
|
102
|
+
def initialize(
|
|
103
|
+
text = "",
|
|
104
|
+
style: nil,
|
|
105
|
+
justify: :left,
|
|
106
|
+
overflow: :fold,
|
|
107
|
+
no_wrap: false,
|
|
108
|
+
end_str: "\n"
|
|
109
|
+
)
|
|
110
|
+
@plain = +text.to_s
|
|
111
|
+
@spans = []
|
|
112
|
+
@style = style.is_a?(String) ? Style.parse(style) : style
|
|
113
|
+
@justify = justify
|
|
114
|
+
@overflow = overflow
|
|
115
|
+
@no_wrap = no_wrap
|
|
116
|
+
@end = end_str
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [Integer] Length of text
|
|
120
|
+
def length
|
|
121
|
+
@plain.length
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [Integer] Cell width of text
|
|
125
|
+
def cell_length
|
|
126
|
+
Cells.cached_cell_len(@plain)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @return [Boolean] True if text is empty
|
|
130
|
+
def empty?
|
|
131
|
+
@plain.empty?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Append text with optional style
|
|
135
|
+
# @param text [String, Text] Text to append
|
|
136
|
+
# @param style [Style, String, nil] Style for appended text
|
|
137
|
+
# @return [self]
|
|
138
|
+
def append(text, style: nil)
|
|
139
|
+
if text.is_a?(Text)
|
|
140
|
+
append_text(text)
|
|
141
|
+
else
|
|
142
|
+
start_pos = @plain.length
|
|
143
|
+
@plain << text.to_s
|
|
144
|
+
|
|
145
|
+
if style
|
|
146
|
+
parsed_style = style.is_a?(String) ? Style.parse(style) : style
|
|
147
|
+
@spans << Span.new(start_pos, @plain.length, parsed_style)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
self
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
alias << append
|
|
154
|
+
|
|
155
|
+
# Append a Text object
|
|
156
|
+
# @param other [Text] Text to append
|
|
157
|
+
# @return [self]
|
|
158
|
+
def append_text(other)
|
|
159
|
+
offset = @plain.length
|
|
160
|
+
@plain << other.plain
|
|
161
|
+
|
|
162
|
+
other.spans.each do |span|
|
|
163
|
+
@spans << Span.new(span.start + offset, span.end + offset, span.style)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
self
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Apply a style to a range
|
|
170
|
+
# @param style [Style, String] Style to apply
|
|
171
|
+
# @param start_pos [Integer] Start position
|
|
172
|
+
# @param end_pos [Integer, nil] End position (nil = end of text)
|
|
173
|
+
# @return [self]
|
|
174
|
+
def stylize(style, start_pos = 0, end_pos = nil)
|
|
175
|
+
end_pos ||= @plain.length
|
|
176
|
+
return self if start_pos >= end_pos
|
|
177
|
+
|
|
178
|
+
parsed_style = style.is_a?(String) ? Style.parse(style) : style
|
|
179
|
+
@spans << Span.new(start_pos, end_pos, parsed_style)
|
|
180
|
+
self
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Apply a style to the entire text
|
|
184
|
+
# @param style [Style, String] Style to apply
|
|
185
|
+
# @return [self]
|
|
186
|
+
def stylize_all(style)
|
|
187
|
+
stylize(style, 0, @plain.length)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Get a substring as a new Text object
|
|
191
|
+
# @param start_pos [Integer] Start position
|
|
192
|
+
# @param length [Integer, nil] Length (nil = to end)
|
|
193
|
+
# @return [Text]
|
|
194
|
+
def slice(start_pos, length = nil)
|
|
195
|
+
end_pos = length ? start_pos + length : @plain.length
|
|
196
|
+
new_text = Text.new(@plain[start_pos...end_pos], style: @style)
|
|
197
|
+
|
|
198
|
+
@spans.each do |span|
|
|
199
|
+
next unless span.overlaps?(start_pos, end_pos)
|
|
200
|
+
|
|
201
|
+
new_start = [span.start - start_pos, 0].max
|
|
202
|
+
new_end = [span.end - start_pos, end_pos - start_pos].min
|
|
203
|
+
new_text.spans << Span.new(new_start, new_end, span.style)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
new_text
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Split text by a delimiter
|
|
210
|
+
# @param delimiter [String] Delimiter to split on
|
|
211
|
+
# @return [Array<Text>]
|
|
212
|
+
def split(delimiter = "\n")
|
|
213
|
+
parts = []
|
|
214
|
+
pos = 0
|
|
215
|
+
|
|
216
|
+
@plain.split(delimiter, -1).each do |part|
|
|
217
|
+
end_pos = pos + part.length
|
|
218
|
+
parts << slice(pos, part.length)
|
|
219
|
+
pos = end_pos + delimiter.length
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
parts
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Wrap text to a given width
|
|
226
|
+
# @param width [Integer] Maximum width
|
|
227
|
+
# @return [Array<Text>]
|
|
228
|
+
def wrap(width)
|
|
229
|
+
return [self.dup] if width <= 0 || cell_length <= width
|
|
230
|
+
|
|
231
|
+
lines = []
|
|
232
|
+
current_line = Text.new(style: @style)
|
|
233
|
+
current_width = 0
|
|
234
|
+
|
|
235
|
+
words = @plain.split(/(\s+)/)
|
|
236
|
+
word_pos = 0
|
|
237
|
+
|
|
238
|
+
words.each do |word|
|
|
239
|
+
word_width = Cells.cell_len(word)
|
|
240
|
+
word_end = word_pos + word.length
|
|
241
|
+
|
|
242
|
+
if current_width + word_width <= width
|
|
243
|
+
# Word fits on the current line
|
|
244
|
+
line_offset = current_line.length
|
|
245
|
+
current_line.append(word)
|
|
246
|
+
copy_spans_into(current_line, word_pos, word.length, line_offset)
|
|
247
|
+
current_width += word_width
|
|
248
|
+
elsif word_width > width
|
|
249
|
+
# Word is longer than the whole width: hard-break it character by
|
|
250
|
+
# character, carrying styles for every emitted character.
|
|
251
|
+
unless current_line.empty?
|
|
252
|
+
lines << current_line
|
|
253
|
+
current_line = Text.new(style: @style)
|
|
254
|
+
current_width = 0
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
char_pos = word_pos
|
|
258
|
+
word.each_char do |char|
|
|
259
|
+
char_width = Cells.char_width(char)
|
|
260
|
+
if current_width + char_width > width && !current_line.empty?
|
|
261
|
+
lines << current_line
|
|
262
|
+
current_line = Text.new(style: @style)
|
|
263
|
+
current_width = 0
|
|
264
|
+
end
|
|
265
|
+
line_offset = current_line.length
|
|
266
|
+
current_line.append(char)
|
|
267
|
+
copy_spans_into(current_line, char_pos, 1, line_offset)
|
|
268
|
+
current_width += char_width
|
|
269
|
+
char_pos += 1
|
|
270
|
+
end
|
|
271
|
+
else
|
|
272
|
+
# Word doesn't fit here but fits on a fresh line. Leading whitespace
|
|
273
|
+
# is stripped at the start of the new line; account for it so spans
|
|
274
|
+
# still line up.
|
|
275
|
+
lines << current_line unless current_line.empty?
|
|
276
|
+
current_line = Text.new(style: @style)
|
|
277
|
+
stripped = word.lstrip
|
|
278
|
+
lead = word.length - stripped.length
|
|
279
|
+
line_offset = current_line.length
|
|
280
|
+
current_line.append(stripped)
|
|
281
|
+
copy_spans_into(current_line, word_pos + lead, stripped.length, line_offset)
|
|
282
|
+
current_width = Cells.cell_len(stripped)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
word_pos = word_end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
lines << current_line unless current_line.empty?
|
|
289
|
+
lines
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Copy any spans overlapping the source range [src_start, src_start+src_len)
|
|
293
|
+
# onto +target_line+, shifted to begin at +line_offset+ within that line.
|
|
294
|
+
# @return [void]
|
|
295
|
+
def copy_spans_into(target_line, src_start, src_len, line_offset)
|
|
296
|
+
return if src_len <= 0
|
|
297
|
+
|
|
298
|
+
src_end = src_start + src_len
|
|
299
|
+
@spans.each do |span|
|
|
300
|
+
next unless span.overlaps?(src_start, src_end)
|
|
301
|
+
|
|
302
|
+
new_start = [span.start - src_start, 0].max + line_offset
|
|
303
|
+
new_end = [span.end - src_start, src_len].min + line_offset
|
|
304
|
+
target_line.spans << Span.new(new_start, new_end, span.style) if new_end > new_start
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Highlight occurrences of words
|
|
309
|
+
def highlight_words(words, style:)
|
|
310
|
+
words.each do |word|
|
|
311
|
+
pos = 0
|
|
312
|
+
while (pos = @plain.index(word, pos))
|
|
313
|
+
stylize(style, pos, pos + word.length)
|
|
314
|
+
pos += word.length
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
self
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Highlight occurrences matching a regex
|
|
321
|
+
def highlight_regex(re, style:)
|
|
322
|
+
@plain.scan(re) do
|
|
323
|
+
match = Regexp.last_match
|
|
324
|
+
stylize(style, match.begin(0), match.end(0))
|
|
325
|
+
end
|
|
326
|
+
self
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Copy the text object
|
|
330
|
+
def copy
|
|
331
|
+
dup
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Convert to segments for rendering
|
|
335
|
+
# @return [Array<Segment>]
|
|
336
|
+
def to_segments
|
|
337
|
+
return [Segment.new(@plain, style: @style)] if @spans.empty?
|
|
338
|
+
|
|
339
|
+
# Build a list of style changes
|
|
340
|
+
changes = []
|
|
341
|
+
@spans.each do |span|
|
|
342
|
+
changes << [span.start, :start, span.style]
|
|
343
|
+
changes << [span.end, :end, span.style]
|
|
344
|
+
end
|
|
345
|
+
changes.sort_by! { |c| [c[0], c[1] == :end ? 0 : 1] }
|
|
346
|
+
|
|
347
|
+
segments = []
|
|
348
|
+
active_styles = []
|
|
349
|
+
pos = 0
|
|
350
|
+
|
|
351
|
+
changes.each do |change_pos, change_type, style|
|
|
352
|
+
if change_pos > pos
|
|
353
|
+
# Emit segment for text between pos and change_pos
|
|
354
|
+
combined_style = combine_styles(active_styles)
|
|
355
|
+
combined_style = @style + combined_style if @style && combined_style
|
|
356
|
+
combined_style ||= @style
|
|
357
|
+
|
|
358
|
+
segments << Segment.new(@plain[pos...change_pos], style: combined_style)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
if change_type == :start
|
|
362
|
+
active_styles << style
|
|
363
|
+
else
|
|
364
|
+
active_styles.delete_at(active_styles.rindex(style) || active_styles.length)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
pos = change_pos
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Emit remaining text
|
|
371
|
+
if pos < @plain.length
|
|
372
|
+
combined_style = combine_styles(active_styles)
|
|
373
|
+
combined_style = @style + combined_style if @style && combined_style
|
|
374
|
+
combined_style ||= @style
|
|
375
|
+
|
|
376
|
+
segments << Segment.new(@plain[pos..], style: combined_style)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
segments
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Render text to string with ANSI codes
|
|
383
|
+
# @param color_system [Symbol] Color system
|
|
384
|
+
# @return [String]
|
|
385
|
+
def render(color_system: ColorSystem::TRUECOLOR)
|
|
386
|
+
Segment.render(to_segments, color_system: color_system)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# @return [String]
|
|
390
|
+
def to_s
|
|
391
|
+
@plain
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def inspect
|
|
395
|
+
"#<Rich::Text #{@plain.inspect} spans=#{@spans.length}>"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def dup
|
|
399
|
+
new_text = Text.new(
|
|
400
|
+
@plain.dup,
|
|
401
|
+
style: @style,
|
|
402
|
+
justify: @justify,
|
|
403
|
+
overflow: @overflow,
|
|
404
|
+
no_wrap: @no_wrap,
|
|
405
|
+
end_str: @end
|
|
406
|
+
)
|
|
407
|
+
@spans.each { |span| new_text.spans << span }
|
|
408
|
+
new_text
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
class << self
|
|
412
|
+
# Assemble text from multiple parts
|
|
413
|
+
# @param parts [Array] Alternating text and style pairs
|
|
414
|
+
# @return [Text]
|
|
415
|
+
def assemble(*parts)
|
|
416
|
+
text = Text.new
|
|
417
|
+
|
|
418
|
+
parts.each do |part|
|
|
419
|
+
case part
|
|
420
|
+
when String
|
|
421
|
+
text.append(part)
|
|
422
|
+
when Array
|
|
423
|
+
content, style = part
|
|
424
|
+
text.append(content, style: style)
|
|
425
|
+
when Text
|
|
426
|
+
text.append_text(part)
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
text
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# Create styled text
|
|
434
|
+
# @param content [String] Text content
|
|
435
|
+
# @param style [String] Style definition
|
|
436
|
+
# @return [Text]
|
|
437
|
+
def styled(content, style)
|
|
438
|
+
text = Text.new(content)
|
|
439
|
+
text.stylize_all(style)
|
|
440
|
+
text
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Create from markup
|
|
444
|
+
# @param markup [String] Markup text
|
|
445
|
+
# @return [Text]
|
|
446
|
+
def from_markup(markup)
|
|
447
|
+
Markup.parse(markup)
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
private
|
|
452
|
+
|
|
453
|
+
def combine_styles(styles)
|
|
454
|
+
return nil if styles.empty?
|
|
455
|
+
return styles.first if styles.length == 1
|
|
456
|
+
|
|
457
|
+
styles.reduce { |combined, style| combined + style }
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|