Class: Tuile::StyledString

Inherits:
Object
  • Object
show all
Defined in:
lib/tuile/styled_string.rb

Overview

An immutable string-with-styling, modeled as a sequence of Spans where each span carries a complete Style (‘fg`, `bg`, `bold`, `italic`, `underline`). Spans are non-overlapping and fully tile the string — every character has exactly one resolved style, no overlay layers to merge.

Where this differs from threading SGR escapes through a plain ‘String`: slicing, wrapping, and concatenation operate on the structured spans, so they never have to “figure out what SGR state is active at column N” —the answer is just the containing span’s ‘style`. The flip side is one extra type to construct (or parse) before doing styled-text math.

## Constructors

“‘ruby StyledString.new # empty StyledString.plain(“hello”) # default style StyledString.styled(“hello”, fg: :red, bold: true) StyledString.parse(“e[31mhelloe[0m world”) # ANSI → spans “`

## Algebra

All operations return a fresh StyledString — the underlying spans are frozen and shared. ‘+` coerces a `String` operand via StyledString.parse.

“‘ruby a + b # concatenate ss.slice(2, 5) # 5 display columns starting at column 2 ss.slice(2..5) # range (inclusive end) ss.lines # split on “n” → Array<StyledString> ss.each_char_with_style { |ch, style| … } “`

## Rendering

  • ‘#to_s` — plain text, no SGR.

  • ‘#to_ansi` — minimal-diff SGR rendering, ending with `e[0m` only when the last span carried a non-default style. Transitions to the default style emit `e[0m` (shorter than re-emitting every off-code).

## Parser

StyledString.parse is strict by design: it recognizes only the SGR codes corresponding to Style‘s supported attributes (fg/bg/bold/italic/ underline). Anything else — unmodeled attributes (dim, blink, reverse, strike, conceal, double-underline, overline, …), unknown SGR codes, or non-SGR escapes (cursor moves, OSC) — raises ParseError. This keeps the round-trip parse(to_ansi(x)) == x contract honest.

Defined Under Namespace

Classes: ParseError, Span, Style

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(spans = []) ⇒ StyledString

Returns a new instance of StyledString.

Parameters:

  • spans (Array<Span>) (defaults to: [])


322
323
324
# File 'lib/tuile/styled_string.rb', line 322

def initialize(spans = [])
  @spans = normalize(spans).freeze
end

Instance Attribute Details

#spansArray<Span> (readonly)

Returns the frozen, normalized span list — no empty-text entries, no two adjacent entries sharing a style.

Returns:

  • (Array<Span>)

    the frozen, normalized span list — no empty-text entries, no two adjacent entries sharing a style.



319
320
321
# File 'lib/tuile/styled_string.rb', line 319

def spans
  @spans
end

Class Method Details

.parse(input) ⇒ StyledString

Parses an ANSI/SGR-coded string into a Tuile::StyledString. A Tuile::StyledString input is returned as-is. ‘nil` and the empty string both fast-path to EMPTY. Strings without any `e` byte fast-path to a single default-styled span.

Parameters:

Returns:

Raises:

  • (ParseError)

    on unsupported or malformed escape sequences.

  • (TypeError)

    when ‘input` is none of String, StyledString, nil.



302
303
304
305
306
307
308
309
310
311
312
313
314
# File 'lib/tuile/styled_string.rb', line 302

def parse(input)
  case input
  when nil then EMPTY
  when StyledString then input
  when String
    return EMPTY if input.empty?
    return new([Span.new(text: input, style: Style::DEFAULT)]) unless input.include?("\e")

    Parser.new(input).parse
  else
    raise TypeError, "cannot parse #{input.class}"
  end
end

.plain(text) ⇒ StyledString

Parameters:

Returns:



276
277
278
279
280
281
# File 'lib/tuile/styled_string.rb', line 276

def plain(text)
  text = text.to_s
  return EMPTY if text.empty?

  new([Span.new(text: text, style: Style::DEFAULT)])
end

.styled(text, **style_kwargs) ⇒ StyledString

Parameters:

Returns:



286
287
288
289
290
291
# File 'lib/tuile/styled_string.rb', line 286

def styled(text, **style_kwargs)
  text = text.to_s
  return EMPTY if text.empty?

  new([Span.new(text: text, style: Style.new(**style_kwargs))])
end

Instance Method Details

#+(other) ⇒ StyledString

Concatenation. A ‘String` operand is parsed via parse before joining (so embedded ANSI escapes round-trip through spans).

Parameters:

Returns:

Raises:

  • (TypeError)

    when ‘other` is neither.



371
372
373
374
375
376
# File 'lib/tuile/styled_string.rb', line 371

def +(other)
  other = self.class.parse(other) if other.is_a?(String)
  raise TypeError, "cannot concatenate #{other.class} to StyledString" unless other.is_a?(StyledString)

  self.class.new(@spans + other.spans)
end

#==(other) ⇒ Boolean Also known as: eql?

Parameters:

  • other (Object)

Returns:

  • (Boolean)


356
357
358
# File 'lib/tuile/styled_string.rb', line 356

def ==(other)
  other.is_a?(StyledString) && @spans == other.spans
end

#display_widthInteger

Total display width in terminal columns, accounting for Unicode wide characters (fullwidth CJK = 2 columns, combining marks = 0, etc.). Memoized — safe because spans are frozen and immutable.

Returns:

  • (Integer)


330
331
332
# File 'lib/tuile/styled_string.rb', line 330

def display_width
  @display_width ||= @spans.sum { |s| Unicode::DisplayWidth.of(s.text) }
end

#each_char_with_style {|String, Style| ... } ⇒ Enumerator, self

Yields each character (per ‘String#each_char`) along with the Style it carries. Returns an `Enumerator` without a block.

Yields:

Returns:

  • (Enumerator, self)


476
477
478
479
480
481
482
483
# File 'lib/tuile/styled_string.rb', line 476

def each_char_with_style
  return enum_for(__method__) unless block_given?

  @spans.each do |span|
    span.text.each_char { |c| yield c, span.style }
  end
  self
end

#ellipsize(display_width, ellipsis = "…") ⇒ StyledString

Truncates to a target column width, appending an ellipsis when characters were dropped. The ellipsis counts toward the target — the returned Tuile::StyledString‘s `display_width` never exceeds `display_width`. When `self` already fits, `self` is returned. When `display_width` is smaller than the ellipsis’s own width, the ellipsis is sliced down to fit and no original content is included.

Parameters:

  • display_width (Integer)

    target column width.

  • ellipsis (String, StyledString) (defaults to: "…")

    appended when truncation occurs. Defaults to the Unicode horizontal-ellipsis ‘…` (one column). A `String` is parsed via parse, so ANSI in it is preserved.

Returns:



413
414
415
416
417
418
419
420
421
# File 'lib/tuile/styled_string.rb', line 413

def ellipsize(display_width, ellipsis = "")
  return self.class.new if display_width <= 0
  return self if self.display_width <= display_width

  ellipsis = self.class.parse(ellipsis)
  return ellipsis.slice(0, display_width) if ellipsis.display_width >= display_width

  slice(0, display_width - ellipsis.display_width) + ellipsis
end

#empty?Boolean

Returns:

  • (Boolean)


335
# File 'lib/tuile/styled_string.rb', line 335

def empty? = @spans.empty?

#hashInteger

Returns:

  • (Integer)


362
363
364
# File 'lib/tuile/styled_string.rb', line 362

def hash
  @spans.hash
end

#inspectString

Returns:

  • (String)


499
500
501
# File 'lib/tuile/styled_string.rb', line 499

def inspect
  "#<#{self.class.name} #{to_s.inspect}>"
end

#linesArray<StyledString>

Splits on ‘“n”`, preserving spans on each side. A trailing newline produces a trailing empty Tuile::StyledString (matches `split(“n”, -1)`). An empty Tuile::StyledString returns a single empty entry, like `“”.split`.

Returns:



427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
# File 'lib/tuile/styled_string.rb', line 427

def lines
  result = []
  current_spans = []
  @spans.each do |span|
    parts = span.text.split("\n", -1)
    parts.each_with_index do |part, idx|
      if idx.positive?
        result << self.class.new(current_spans)
        current_spans = []
      end
      current_spans << Span.new(text: part, style: span.style) unless part.empty?
    end
  end
  result << self.class.new(current_spans)
  result
end

#slice(start_col, len_col) ⇒ StyledString #slice(range) ⇒ StyledString

Substring by display columns, preserving spans. Characters whose column range only partially overlaps the slice (e.g. a 2-column CJK character straddling the start or end boundary) are dropped — never split.

Accepts either ‘slice(start_col, len_col)` or `slice(range)`. Both forms support negative indices counting from the end of the string.

Overloads:

  • #slice(start_col, len_col) ⇒ StyledString

    Parameters:

    • start_col (Integer)
    • len_col (Integer)
  • #slice(range) ⇒ StyledString

    Parameters:

    • range (Range<Integer>)

Returns:



391
392
393
394
395
396
397
398
# File 'lib/tuile/styled_string.rb', line 391

def slice(start_or_range, len = nil)
  total = display_width
  start, len = resolve_slice_bounds(start_or_range, len, total)
  return self.class.new if len <= 0 || start.negative? || start >= total

  len = [len, total - start].min
  slice_spans(start, len)
end

#to_ansiString

Rendered ANSI string. Minimal-diff between adjacent spans: only the attributes that changed are emitted. A transition to the default style emits ‘e[0m` (one code) instead of the longer “turn each attribute off” form. Always closes with `e[0m` when the last span carried a non-default style, so the styled run doesn’t bleed into subsequent output. Memoized — safe because spans are frozen and immutable.

Returns:

  • (String)


350
351
352
# File 'lib/tuile/styled_string.rb', line 350

def to_ansi
  @to_ansi ||= build_ansi
end

#to_sString

Plain text concatenation across all spans — no SGR codes.

Returns:

  • (String)


339
340
341
# File 'lib/tuile/styled_string.rb', line 339

def to_s
  @spans.map(&:text).join
end

#with_bg(bg) ⇒ StyledString

Returns a new Tuile::StyledString with ‘bg` applied to every span, preserving each span’s text and other style attributes (‘fg`, `bold`, `italic`, `underline`). Useful for row-level highlights — the new bg overlays without dropping foreground colors the original styling carried.

Parameters:

  • bg (Symbol, Integer, Array<Integer>, nil)

    background color, in any of the forms accepted by Tuile::StyledString::Style.new. ‘nil` clears bg back to the terminal default.

Returns:



494
495
496
# File 'lib/tuile/styled_string.rb', line 494

def with_bg(bg)
  self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(bg: bg)) })
end

#wrap(width) ⇒ Array<StyledString>

Word-wraps to physical lines that each fit within ‘width` display columns, preserving spans and styles across breaks. Greedy word-wrap, hard-break for words wider than `width`, leading whitespace dropped on wrapped continuations, hard `“n”` breaks preserved as separate output lines.

Whitespace runs are space or tab; other characters are treated as word content. When a single character is wider than ‘width` (e.g. a 2-column CJK character with `width = 1`), it is still emitted on its own line at its natural width. The “no line exceeds `width`” guarantee therefore holds whenever every character is at most `width` columns wide.

Parameters:

  • width (Integer, nil)

    target column width. ‘nil` or `<= 0` skips wrapping and returns each hard-line as-is, so callers can pass a stale viewport width without crashing.

Returns:

  • (Array<StyledString>)

    one entry per physical (output) line. An empty receiver returns ‘[]`.



461
462
463
464
465
466
467
468
469
470
# File 'lib/tuile/styled_string.rb', line 461

def wrap(width)
  return [] if empty?

  hard_lines = lines
  return hard_lines if width.nil? || width <= 0

  result = []
  hard_lines.each { |hl| result.concat(wrap_one(hl, width)) }
  result
end