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.
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.append(word)
245
- # Copy spans for this word
246
- @spans.each do |span|
247
- if span.overlaps?(word_pos, word_end)
248
- new_start = [span.start - word_pos, 0].max + current_line.length - word.length
249
- new_end = [span.end - word_pos, word.length].min + current_line.length - word.length
250
- current_line.spans << Span.new(new_start, new_end, span.style)
251
- end
252
- end
253
- current_width += word_width
254
- elsif word_width > width
255
- # Word is too long, need to break it
256
- unless current_line.empty?
257
- lines << current_line
258
- current_line = Text.new(style: @style)
259
- current_width = 0
260
- end
261
-
262
- # Break long word
263
- word.each_char do |char|
264
- char_width = Cells.char_width(char)
265
- if current_width + char_width > width
266
- lines << current_line
267
- current_line = Text.new(style: @style)
268
- current_width = 0
269
- end
270
- current_line.append(char)
271
- current_width += char_width
272
- end
273
- else
274
- # Start new line
275
- lines << current_line unless current_line.empty?
276
- current_line = Text.new(style: @style)
277
- current_line.append(word.lstrip)
278
- current_width = Cells.cell_len(word.lstrip)
279
- end
280
-
281
- word_pos = word_end
282
- end
283
-
284
- lines << current_line unless current_line.empty?
285
- lines
286
- end
287
-
288
- # Highlight occurrences of words
289
- def highlight_words(words, style:)
290
- words.each do |word|
291
- pos = 0
292
- while (pos = @plain.index(word, pos))
293
- stylize(style, pos, pos + word.length)
294
- pos += word.length
295
- end
296
- end
297
- self
298
- end
299
-
300
- # Highlight occurrences matching a regex
301
- def highlight_regex(re, style:)
302
- @plain.scan(re) do
303
- match = Regexp.last_match
304
- stylize(style, match.begin(0), match.end(0))
305
- end
306
- self
307
- end
308
-
309
- # Copy the text object
310
- def copy
311
- dup
312
- end
313
-
314
- # Convert to segments for rendering
315
- # @return [Array<Segment>]
316
- def to_segments
317
- return [Segment.new(@plain, style: @style)] if @spans.empty?
318
-
319
- # Build a list of style changes
320
- changes = []
321
- @spans.each do |span|
322
- changes << [span.start, :start, span.style]
323
- changes << [span.end, :end, span.style]
324
- end
325
- changes.sort_by! { |c| [c[0], c[1] == :end ? 0 : 1] }
326
-
327
- segments = []
328
- active_styles = []
329
- pos = 0
330
-
331
- changes.each do |change_pos, change_type, style|
332
- if change_pos > pos
333
- # Emit segment for text between pos and change_pos
334
- combined_style = combine_styles(active_styles)
335
- combined_style = @style + combined_style if @style && combined_style
336
- combined_style ||= @style
337
-
338
- segments << Segment.new(@plain[pos...change_pos], style: combined_style)
339
- end
340
-
341
- if change_type == :start
342
- active_styles << style
343
- else
344
- active_styles.delete_at(active_styles.rindex(style) || active_styles.length)
345
- end
346
-
347
- pos = change_pos
348
- end
349
-
350
- # Emit remaining text
351
- if pos < @plain.length
352
- combined_style = combine_styles(active_styles)
353
- combined_style = @style + combined_style if @style && combined_style
354
- combined_style ||= @style
355
-
356
- segments << Segment.new(@plain[pos..], style: combined_style)
357
- end
358
-
359
- segments
360
- end
361
-
362
- # Render text to string with ANSI codes
363
- # @param color_system [Symbol] Color system
364
- # @return [String]
365
- def render(color_system: ColorSystem::TRUECOLOR)
366
- Segment.render(to_segments, color_system: color_system)
367
- end
368
-
369
- # @return [String]
370
- def to_s
371
- @plain
372
- end
373
-
374
- def inspect
375
- "#<Rich::Text #{@plain.inspect} spans=#{@spans.length}>"
376
- end
377
-
378
- def dup
379
- new_text = Text.new(@plain.dup, style: @style)
380
- @spans.each { |span| new_text.spans << span }
381
- new_text
382
- end
383
-
384
- class << self
385
- # Assemble text from multiple parts
386
- # @param parts [Array] Alternating text and style pairs
387
- # @return [Text]
388
- def assemble(*parts)
389
- text = Text.new
390
-
391
- parts.each do |part|
392
- case part
393
- when String
394
- text.append(part)
395
- when Array
396
- content, style = part
397
- text.append(content, style: style)
398
- when Text
399
- text.append_text(part)
400
- end
401
- end
402
-
403
- text
404
- end
405
-
406
- # Create styled text
407
- # @param content [String] Text content
408
- # @param style [String] Style definition
409
- # @return [Text]
410
- def styled(content, style)
411
- text = Text.new(content)
412
- text.stylize_all(style)
413
- text
414
- end
415
-
416
- # Create from markup
417
- # @param markup [String] Markup text
418
- # @return [Text]
419
- def from_markup(markup)
420
- Markup.parse(markup)
421
- end
422
- end
423
-
424
- private
425
-
426
- def combine_styles(styles)
427
- return nil if styles.empty?
428
- return styles.first if styles.length == 1
429
-
430
- styles.reduce { |combined, style| combined + style }
431
- end
432
- end
433
- end
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