Class: Tuile::StyledString

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

Overview

An immutable string-with-styling, modeled as a sequence of Spans where each span carries a complete Style (fg, bg, bold, italic, underline, strikethrough). 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

StyledString.new                                  # empty
StyledString.plain("hello")                       # default style
StyledString.styled("hello", fg: :red, bold: true)
StyledString.parse("\e[31mhello\e[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.

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 default: it recognizes only the SGR codes corresponding to Style's supported attributes (fg/bg/bold/italic/ underline/strikethrough). Anything else — unmodeled attributes (dim, blink, reverse, 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.

Pass lenient: true to instead discard everything the parser can't model and keep going — recognized fg/bg/bold/italic/underline/strikethrough codes still apply, and any unmodeled SGR code, malformed extended color, non-SGR CSI (cursor moves, \e[K), OSC/DCS/string sequence, or stray escape is silently dropped. This is the mode for piping in colored output you don't control (e.g. git --color through a pager): "give me the colors, throw the rest away." It is lossy by design — parse(x, lenient: true) does not round-trip back to x.

Defined Under Namespace

Classes: ParseError, Span, Style

Constant Summary collapse

EMPTY =

Canonical shared empty Tuile::StyledString. Operations that produce an empty result (and callers that need a blank sentinel) can use this instead of allocating a fresh instance per call. Pre-warmed and frozen — the lazy #display_width / #to_ansi memoizations short-circuit on the already cached values, so reads on the frozen receiver do not attempt writes.

Returns:

new.tap do |s|
  s.display_width
  s.to_ansi
end.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(spans = []) ⇒ StyledString

@param spans

Parameters:

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


427
428
429
# File 'lib/tuile/styled_string.rb', line 427

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

Instance Attribute Details

#spans::Array[Span] (readonly)

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

Returns:



424
425
426
# File 'lib/tuile/styled_string.rb', line 424

def spans
  @spans
end

Class Method Details

.parse(input, lenient: false) ⇒ 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.

@param input

@param lenient — when true, unmodeled SGR codes and non-SGR escapes are discarded instead of raising — see Tuile::StyledString "## Parser". Lossy: the result no longer round-trips to input.

Parameters:

  • input (String, StyledString, nil)
  • lenient: (Boolean) (defaults to: false)

Returns:



407
408
409
410
411
412
413
414
415
416
417
418
419
# File 'lib/tuile/styled_string.rb', line 407

def parse(input, lenient: false)
  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, lenient:).parse
  else
    raise TypeError, "cannot parse #{input.class}"
  end
end

.plain(text) ⇒ StyledString

sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS @param text

Parameters:

  • text (_ToS)

Returns:



377
378
379
380
381
382
# File 'lib/tuile/styled_string.rb', line 377

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

sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS @param text

@param style_kwargs — forwarded to Tuile::StyledString::Style.new.

Parameters:

  • text (_ToS)
  • style_kwargs (::Hash[Symbol, Object])

Returns:



387
388
389
390
391
392
# File 'lib/tuile/styled_string.rb', line 387

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).

@param other

Parameters:

Returns:



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

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?

@param other

Parameters:

  • other (Object)

Returns:

  • (Boolean)


461
462
463
# File 'lib/tuile/styled_string.rb', line 461

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

#build_ansiString

Returns:

  • (String)


624
625
626
627
628
629
630
631
632
633
634
# File 'lib/tuile/styled_string.rb', line 624

def build_ansi
  out = +""
  current = Style::DEFAULT
  @spans.each do |span|
    out << current.sgr_to(span.style)
    out << span.text
    current = span.style
  end
  out << Ansi::RESET unless current.default?
  out
end

#chars_to_styled(chars) ⇒ StyledString

@param chars[char, style, width] triples.

Parameters:

  • chars (::Array[::Array[untyped]])

Returns:



793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
# File 'lib/tuile/styled_string.rb', line 793

def chars_to_styled(chars)
  return self.class.new if chars.empty?

  spans = []
  current_text = +""
  current_style = chars.first[1]
  chars.each do |c, s, _|
    if s == current_style
      current_text << c
    else
      spans << Span.new(text: current_text, style: current_style)
      current_text = +c
      current_style = s
    end
  end
  spans << Span.new(text: current_text, style: current_style)
  self.class.new(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)


435
436
437
# File 'lib/tuile/styled_string.rb', line 435

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

#each_char_with_style::Enumerator[untyped], self

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

Returns:

  • (::Enumerator[untyped], self)


581
582
583
584
585
586
587
588
# File 'lib/tuile/styled_string.rb', line 581

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.

@param display_width — target column width.

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

Parameters:

  • display_width (Integer)
  • ellipsis (?(String | StyledString)) (defaults to: "…")

Returns:



518
519
520
521
522
523
524
525
526
# File 'lib/tuile/styled_string.rb', line 518

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)


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

def empty? = @spans.empty?

#hard_break_chars(chars, width) ⇒ ::Array[::Array[::Array[untyped]]]

@param chars[char, style, width] triples.

@param width

@return — each inner Array is a chars-shaped chunk.

Parameters:

  • chars (::Array[::Array[untyped]])
  • width (Integer)

Returns:

  • (::Array[::Array[::Array[untyped]]])


773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
# File 'lib/tuile/styled_string.rb', line 773

def hard_break_chars(chars, width)
  chunks = []
  current = []
  current_w = 0
  chars.each do |triple|
    cw = triple[2]
    if current_w + cw > width && current_w.positive?
      chunks << current
      current = []
      current_w = 0
    end
    current << triple
    current_w += cw
  end
  chunks << current
  chunks
end

#hashInteger

Returns:

  • (Integer)


467
468
469
# File 'lib/tuile/styled_string.rb', line 467

def hash
  @spans.hash
end

#inspectString

Returns:

  • (String)


617
618
619
# File 'lib/tuile/styled_string.rb', line 617

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

#lines::Array[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:



532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
# File 'lib/tuile/styled_string.rb', line 532

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

#normalize(spans) ⇒ ::Array[Span]

@param spans

Parameters:

  • spans (::Array[Span])

Returns:



638
639
640
641
642
643
644
645
646
647
648
649
650
651
# File 'lib/tuile/styled_string.rb', line 638

def normalize(spans)
  result = []
  spans.each do |span|
    next if span.text.empty?

    if !result.empty? && result.last.style == span.style
      last = result.pop
      result << Span.new(text: last.text + span.text, style: span.style)
    else
      result << span
    end
  end
  result
end

#resolve_slice_bounds(start_or_range, len, total) ⇒ [Integer, Integer]

@param start_or_range

@param len

@param total — receiver's full display width.

@return — normalized [start_col, len_col].

Parameters:

  • start_or_range (Integer, ::Range[untyped])
  • len (Integer, nil)
  • total (Integer)

Returns:

  • ([Integer, Integer])


657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
# File 'lib/tuile/styled_string.rb', line 657

def resolve_slice_bounds(start_or_range, len, total)
  if start_or_range.is_a?(Range)
    range = start_or_range
    start = range.begin || 0
    finish = range.end
    start += total if start.negative?
    if finish.nil?
      finish = total
    else
      finish += total if finish.negative?
      finish += 1 unless range.exclude_end?
    end
    [start, finish - start]
  else
    raise ArgumentError, "length is required when slicing with an Integer" if len.nil?

    start = start_or_range
    start += total if start.negative?
    [start, len]
  end
end

#slice(start_or_range, len = nil) ⇒ 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.

@param start_col

@param len_col

Parameters:

  • start_col (Integer)
  • len_col (Integer)

Returns:



496
497
498
499
500
501
502
503
# File 'lib/tuile/styled_string.rb', line 496

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

#slice_spans(start, len) ⇒ StyledString

@param start

@param len

Parameters:

  • start (Integer)
  • len (Integer)

Returns:



682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
# File 'lib/tuile/styled_string.rb', line 682

def slice_spans(start, len)
  out = []
  col = 0
  @spans.each do |span|
    span_width = Unicode::DisplayWidth.of(span.text)
    span_end = col + span_width

    next col = span_end if span_end <= start
    break if col >= start + len

    local_start = [0, start - col].max
    local_end = [span_width, start + len - col].min
    if local_end > local_start
      sliced = slice_text_by_columns(span.text, local_start, local_end - local_start)
      out << Span.new(text: sliced, style: span.style) unless sliced.empty?
    end
    col = span_end
  end
  self.class.new(out)
end

#slice_text_by_columns(text, start_col, len_col) ⇒ String

@param text

@param start_col

@param len_col

Parameters:

  • text (String)
  • start_col (Integer)
  • len_col (Integer)

Returns:

  • (String)


816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
# File 'lib/tuile/styled_string.rb', line 816

def slice_text_by_columns(text, start_col, len_col)
  out = +""
  col = 0
  text.each_char do |c|
    cw = Unicode::DisplayWidth.of(c)
    char_end = col + cw
    if char_end <= start_col
      # entirely before slice — skip
    elsif col >= start_col + len_col
      break
    elsif col >= start_col && char_end <= start_col + len_col
      out << c
    end
    # any other case = partial overlap with a wide char — drop
    col = char_end
  end
  out
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)


455
456
457
# File 'lib/tuile/styled_string.rb', line 455

def to_ansi
  @to_ansi ||= build_ansi
end

#to_sString

Plain text concatenation across all spans — no SGR codes.

Returns:

  • (String)


444
445
446
# File 'lib/tuile/styled_string.rb', line 444

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

#tokenize_for_wrap(hard_line) ⇒ ::Array[::Array[untyped]]

@param hard_line

@return — tokens shaped [type, chars, w] where type is :space or :word, chars is an Array<[String, Style, Integer]> (char, style, display width), and w is the token's total width.

Parameters:

Returns:

  • (::Array[::Array[untyped]])


748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
# File 'lib/tuile/styled_string.rb', line 748

def tokenize_for_wrap(hard_line)
  tokens = []
  current_chars = []
  current_w = 0
  current_type = nil

  hard_line.each_char_with_style do |c, s|
    type = [" ", "\t"].include?(c) ? :space : :word
    cw = Unicode::DisplayWidth.of(c)
    if current_type && current_type != type
      tokens << [current_type, current_chars, current_w]
      current_chars = []
      current_w = 0
    end
    current_type = type
    current_chars << [c, s, cw]
    current_w += cw
  end
  tokens << [current_type, current_chars, current_w] unless current_chars.empty?
  tokens
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, strikethrough). Useful for row-level highlights — the new bg overlays without dropping foreground colors the original styling carried.

@param bg — background color, coerced via Color.coerce. nil clears bg back to the terminal default.

Parameters:

  • bg (Color, Symbol, Integer, ::Array[Integer], nil)

Returns:



599
600
601
# File 'lib/tuile/styled_string.rb', line 599

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

#with_fg(fg) ⇒ StyledString

Returns a new Tuile::StyledString with fg applied to every span, preserving each span's text and other style attributes (bg, bold, italic, underline, strikethrough). The new fg overlays without dropping background colors or text attributes the original styling carried.

@param fg — foreground color, coerced via Color.coerce. nil clears fg back to the terminal default.

Parameters:

  • fg (Color, Symbol, Integer, ::Array[Integer], nil)

Returns:



612
613
614
# File 'lib/tuile/styled_string.rb', line 612

def with_fg(fg)
  self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(fg: fg)) })
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.

@param width — 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.

@return — one entry per physical (output) line. An empty receiver returns [].

Parameters:

  • width (Integer, nil)

Returns:



566
567
568
569
570
571
572
573
574
575
# File 'lib/tuile/styled_string.rb', line 566

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

#wrap_one(hard_line, width) ⇒ ::Array[StyledString]

@param hard_line — one hard-broken line — no embedded "\n".

@param width

Parameters:

Returns:



706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
# File 'lib/tuile/styled_string.rb', line 706

def wrap_one(hard_line, width)
  return [hard_line] if hard_line.empty?

  result = []
  line_chars = []
  line_w = 0

  tokenize_for_wrap(hard_line).each do |type, chars, w|
    if type == :space
      if line_w.zero?
        # leading whitespace on a wrapped continuation: drop
      elsif line_w + w <= width
        line_chars.concat(chars)
        line_w += w
      else
        result << chars_to_styled(line_chars)
        line_chars = []
        line_w = 0
      end
    elsif line_w + w <= width
      line_chars.concat(chars)
      line_w += w
    elsif w > width
      result << chars_to_styled(line_chars) unless line_w.zero?
      chunks = hard_break_chars(chars, width)
      chunks[0..-2].each { |chunk| result << chars_to_styled(chunk) }
      line_chars = chunks.last
      line_w = line_chars.sum { |triple| triple[2] }
    else
      result << chars_to_styled(line_chars)
      line_chars = chars
      line_w = w
    end
  end
  result << chars_to_styled(line_chars)
  result
end