Class: Rich::Text

Inherits:
Object
  • Object
show all
Defined in:
lib/rich/text.rb

Overview

Rich text with style spans. Text objects contain plain text plus a list of style spans that define how different portions of the text should be rendered.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(text = "", style: nil, justify: :left, overflow: :fold, no_wrap: false, end_str: "\n") ⇒ Text

Create new Text

Parameters:

  • text (String) (defaults to: "")

    Initial text

  • style (Style, String, nil) (defaults to: nil)

    Base style

  • justify (Symbol) (defaults to: :left)

    Text justification

  • overflow (Symbol) (defaults to: :fold)

    Overflow handling

  • no_wrap (Boolean) (defaults to: false)

    Disable wrapping



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/rich/text.rb', line 102

def initialize(
  text = "",
  style: nil,
  justify: :left,
  overflow: :fold,
  no_wrap: false,
  end_str: "\n"
)
  @plain = +text.to_s
  @spans = []
  @style = style.is_a?(String) ? Style.parse(style) : style
  @justify = justify
  @overflow = overflow
  @no_wrap = no_wrap
  @end = end_str
end

Instance Attribute Details

#endBoolean (readonly)

Returns End with newline.

Returns:

  • (Boolean)

    End with newline



94
95
96
# File 'lib/rich/text.rb', line 94

def end
  @end
end

#justifySymbol (readonly)

Returns Justification (:left, :center, :right, :full).

Returns:

  • (Symbol)

    Justification (:left, :center, :right, :full)



85
86
87
# File 'lib/rich/text.rb', line 85

def justify
  @justify
end

#no_wrapBoolean (readonly)

Returns No wrap.

Returns:

  • (Boolean)

    No wrap



91
92
93
# File 'lib/rich/text.rb', line 91

def no_wrap
  @no_wrap
end

#overflowSymbol (readonly)

Returns Overflow handling (:fold, :crop, :ellipsis).

Returns:

  • (Symbol)

    Overflow handling (:fold, :crop, :ellipsis)



88
89
90
# File 'lib/rich/text.rb', line 88

def overflow
  @overflow
end

#plainString (readonly)

Returns Plain text content.

Returns:

  • (String)

    Plain text content



76
77
78
# File 'lib/rich/text.rb', line 76

def plain
  @plain
end

#spansArray<Span> (readonly)

Returns Style spans.

Returns:

  • (Array<Span>)

    Style spans



79
80
81
# File 'lib/rich/text.rb', line 79

def spans
  @spans
end

#styleStyle? (readonly)

Returns Base style for entire text.

Returns:

  • (Style, nil)

    Base style for entire text



82
83
84
# File 'lib/rich/text.rb', line 82

def style
  @style
end

Class Method Details

.assemble(*parts) ⇒ Text

Assemble text from multiple parts

Parameters:

  • parts (Array)

    Alternating text and style pairs

Returns:



415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
# File 'lib/rich/text.rb', line 415

def assemble(*parts)
  text = Text.new

  parts.each do |part|
    case part
    when String
      text.append(part)
    when Array
      content, style = part
      text.append(content, style: style)
    when Text
      text.append_text(part)
    end
  end

  text
end

.from_markup(markup) ⇒ Text

Create from markup

Parameters:

  • markup (String)

    Markup text

Returns:



446
447
448
# File 'lib/rich/text.rb', line 446

def from_markup(markup)
  Markup.parse(markup)
end

.styled(content, style) ⇒ Text

Create styled text

Parameters:

  • content (String)

    Text content

  • style (String)

    Style definition

Returns:



437
438
439
440
441
# File 'lib/rich/text.rb', line 437

def styled(content, style)
  text = Text.new(content)
  text.stylize_all(style)
  text
end

Instance Method Details

#append(text, style: nil) ⇒ self Also known as: <<

Append text with optional style

Parameters:

  • text (String, Text)

    Text to append

  • style (Style, String, nil) (defaults to: nil)

    Style for appended text

Returns:

  • (self)


138
139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'lib/rich/text.rb', line 138

def append(text, style: nil)
  if text.is_a?(Text)
    append_text(text)
  else
    start_pos = @plain.length
    @plain << text.to_s

    if style
      parsed_style = style.is_a?(String) ? Style.parse(style) : style
      @spans << Span.new(start_pos, @plain.length, parsed_style)
    end
  end
  self
end

#append_text(other) ⇒ self

Append a Text object

Parameters:

  • other (Text)

    Text to append

Returns:

  • (self)


158
159
160
161
162
163
164
165
166
167
# File 'lib/rich/text.rb', line 158

def append_text(other)
  offset = @plain.length
  @plain << other.plain

  other.spans.each do |span|
    @spans << Span.new(span.start + offset, span.end + offset, span.style)
  end

  self
end

#cell_lengthInteger

Returns Cell width of text.

Returns:

  • (Integer)

    Cell width of text



125
126
127
# File 'lib/rich/text.rb', line 125

def cell_length
  Cells.cached_cell_len(@plain)
end

#copyObject

Copy the text object



330
331
332
# File 'lib/rich/text.rb', line 330

def copy
  dup
end

#copy_spans_into(target_line, src_start, src_len, line_offset) ⇒ void

This method returns an undefined value.

Copy any spans overlapping the source range [src_start, src_start+src_len) onto target_line, shifted to begin at line_offset within that line.



295
296
297
298
299
300
301
302
303
304
305
306
# File 'lib/rich/text.rb', line 295

def copy_spans_into(target_line, src_start, src_len, line_offset)
  return if src_len <= 0

  src_end = src_start + src_len
  @spans.each do |span|
    next unless span.overlaps?(src_start, src_end)

    new_start = [span.start - src_start, 0].max + line_offset
    new_end = [span.end - src_start, src_len].min + line_offset
    target_line.spans << Span.new(new_start, new_end, span.style) if new_end > new_start
  end
end

#dupObject



398
399
400
401
402
403
404
405
406
407
408
409
# File 'lib/rich/text.rb', line 398

def dup
  new_text = Text.new(
    @plain.dup,
    style: @style,
    justify: @justify,
    overflow: @overflow,
    no_wrap: @no_wrap,
    end_str: @end
  )
  @spans.each { |span| new_text.spans << span }
  new_text
end

#empty?Boolean

Returns True if text is empty.

Returns:

  • (Boolean)

    True if text is empty



130
131
132
# File 'lib/rich/text.rb', line 130

def empty?
  @plain.empty?
end

#highlight_regex(re, style:) ⇒ Object

Highlight occurrences matching a regex



321
322
323
324
325
326
327
# File 'lib/rich/text.rb', line 321

def highlight_regex(re, style:)
  @plain.scan(re) do
    match = Regexp.last_match
    stylize(style, match.begin(0), match.end(0))
  end
  self
end

#highlight_words(words, style:) ⇒ Object

Highlight occurrences of words



309
310
311
312
313
314
315
316
317
318
# File 'lib/rich/text.rb', line 309

def highlight_words(words, style:)
  words.each do |word|
    pos = 0
    while (pos = @plain.index(word, pos))
      stylize(style, pos, pos + word.length)
      pos += word.length
    end
  end
  self
end

#inspectObject



394
395
396
# File 'lib/rich/text.rb', line 394

def inspect
  "#<Rich::Text #{@plain.inspect} spans=#{@spans.length}>"
end

#lengthInteger

Returns Length of text.

Returns:

  • (Integer)

    Length of text



120
121
122
# File 'lib/rich/text.rb', line 120

def length
  @plain.length
end

#render(color_system: ColorSystem::TRUECOLOR) ⇒ String

Render text to string with ANSI codes

Parameters:

  • color_system (Symbol) (defaults to: ColorSystem::TRUECOLOR)

    Color system

Returns:

  • (String)


385
386
387
# File 'lib/rich/text.rb', line 385

def render(color_system: ColorSystem::TRUECOLOR)
  Segment.render(to_segments, color_system: color_system)
end

#slice(start_pos, length = nil) ⇒ Text

Get a substring as a new Text object

Parameters:

  • start_pos (Integer)

    Start position

  • length (Integer, nil) (defaults to: nil)

    Length (nil = to end)

Returns:



194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/rich/text.rb', line 194

def slice(start_pos, length = nil)
  end_pos = length ? start_pos + length : @plain.length
  new_text = Text.new(@plain[start_pos...end_pos], style: @style)

  @spans.each do |span|
    next unless span.overlaps?(start_pos, end_pos)

    new_start = [span.start - start_pos, 0].max
    new_end = [span.end - start_pos, end_pos - start_pos].min
    new_text.spans << Span.new(new_start, new_end, span.style)
  end

  new_text
end

#split(delimiter = "\n") ⇒ Array<Text>

Split text by a delimiter

Parameters:

  • delimiter (String) (defaults to: "\n")

    Delimiter to split on

Returns:



212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/rich/text.rb', line 212

def split(delimiter = "\n")
  parts = []
  pos = 0

  @plain.split(delimiter, -1).each do |part|
    end_pos = pos + part.length
    parts << slice(pos, part.length)
    pos = end_pos + delimiter.length
  end

  parts
end

#stylize(style, start_pos = 0, end_pos = nil) ⇒ self

Apply a style to a range

Parameters:

  • style (Style, String)

    Style to apply

  • start_pos (Integer) (defaults to: 0)

    Start position

  • end_pos (Integer, nil) (defaults to: nil)

    End position (nil = end of text)

Returns:

  • (self)


174
175
176
177
178
179
180
181
# File 'lib/rich/text.rb', line 174

def stylize(style, start_pos = 0, end_pos = nil)
  end_pos ||= @plain.length
  return self if start_pos >= end_pos

  parsed_style = style.is_a?(String) ? Style.parse(style) : style
  @spans << Span.new(start_pos, end_pos, parsed_style)
  self
end

#stylize_all(style) ⇒ self

Apply a style to the entire text

Parameters:

  • style (Style, String)

    Style to apply

Returns:

  • (self)


186
187
188
# File 'lib/rich/text.rb', line 186

def stylize_all(style)
  stylize(style, 0, @plain.length)
end

#to_sString

Returns:

  • (String)


390
391
392
# File 'lib/rich/text.rb', line 390

def to_s
  @plain
end

#to_segmentsArray<Segment>

Convert to segments for rendering

Returns:



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
372
373
374
375
376
377
378
379
380
# File 'lib/rich/text.rb', line 336

def to_segments
  return [Segment.new(@plain, style: @style)] if @spans.empty?

  # Build a list of style changes
  changes = []
  @spans.each do |span|
    changes << [span.start, :start, span.style]
    changes << [span.end, :end, span.style]
  end
  changes.sort_by! { |c| [c[0], c[1] == :end ? 0 : 1] }

  segments = []
  active_styles = []
  pos = 0

  changes.each do |change_pos, change_type, style|
    if change_pos > pos
      # Emit segment for text between pos and change_pos
      combined_style = combine_styles(active_styles)
      combined_style = @style + combined_style if @style && combined_style
      combined_style ||= @style

      segments << Segment.new(@plain[pos...change_pos], style: combined_style)
    end

    if change_type == :start
      active_styles << style
    else
      active_styles.delete_at(active_styles.rindex(style) || active_styles.length)
    end

    pos = change_pos
  end

  # Emit remaining text
  if pos < @plain.length
    combined_style = combine_styles(active_styles)
    combined_style = @style + combined_style if @style && combined_style
    combined_style ||= @style

    segments << Segment.new(@plain[pos..], style: combined_style)
  end

  segments
end

#wrap(width) ⇒ Array<Text>

Wrap text to a given width

Parameters:

  • width (Integer)

    Maximum width

Returns:



228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
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
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
# File 'lib/rich/text.rb', line 228

def wrap(width)
  return [self.dup] if width <= 0 || cell_length <= width

  lines = []
  current_line = Text.new(style: @style)
  current_width = 0

  words = @plain.split(/(\s+)/)
  word_pos = 0

  words.each do |word|
    word_width = Cells.cell_len(word)
    word_end = word_pos + word.length

    if current_width + word_width <= width
      # Word fits on the current line
      line_offset = current_line.length
      current_line.append(word)
      copy_spans_into(current_line, word_pos, word.length, line_offset)
      current_width += word_width
    elsif word_width > width
      # Word is longer than the whole width: hard-break it character by
      # character, carrying styles for every emitted character.
      unless current_line.empty?
        lines << current_line
        current_line = Text.new(style: @style)
        current_width = 0
      end

      char_pos = word_pos
      word.each_char do |char|
        char_width = Cells.char_width(char)
        if current_width + char_width > width && !current_line.empty?
          lines << current_line
          current_line = Text.new(style: @style)
          current_width = 0
        end
        line_offset = current_line.length
        current_line.append(char)
        copy_spans_into(current_line, char_pos, 1, line_offset)
        current_width += char_width
        char_pos += 1
      end
    else
      # Word doesn't fit here but fits on a fresh line. Leading whitespace
      # is stripped at the start of the new line; account for it so spans
      # still line up.
      lines << current_line unless current_line.empty?
      current_line = Text.new(style: @style)
      stripped = word.lstrip
      lead = word.length - stripped.length
      line_offset = current_line.length
      current_line.append(stripped)
      copy_spans_into(current_line, word_pos + lead, stripped.length, line_offset)
      current_width = Cells.cell_len(stripped)
    end

    word_pos = word_end
  end

  lines << current_line unless current_line.empty?
  lines
end