Class: Tuile::StyledString
- Inherits:
-
Object
- Object
- Tuile::StyledString
- 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[0monly 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.
new.tap do |s| s.display_width s.to_ansi end.freeze
Instance Attribute Summary collapse
-
#spans ⇒ ::Array[Span]
readonly
@return — the frozen, normalized span list — no empty-text entries, no two adjacent entries sharing a style.
Class Method Summary collapse
-
.parse(input, lenient: false) ⇒ StyledString
Parses an ANSI/SGR-coded string into a StyledString.
-
.plain(text) ⇒ StyledString
sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS @param
text. -
.styled(text, **style_kwargs) ⇒ StyledString
sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS @param
text.
Instance Method Summary collapse
-
#+(other) ⇒ StyledString
Concatenation.
-
#==(other) ⇒ Boolean
(also: #eql?)
@param
other. - #build_ansi ⇒ String
-
#chars_to_styled(chars) ⇒ StyledString
@param
chars—[char, style, width]triples. -
#display_width ⇒ Integer
Total display width in terminal columns, accounting for Unicode wide characters (fullwidth CJK = 2 columns, combining marks = 0, etc.).
-
#each_char_with_style ⇒ ::Enumerator[untyped], self
Yields each character (per
String#each_char) along with the Style it carries. -
#ellipsize(display_width, ellipsis = "…") ⇒ StyledString
Truncates to a target column width, appending an ellipsis when characters were dropped.
- #empty? ⇒ Boolean
-
#hard_break_chars(chars, width) ⇒ ::Array[::Array[::Array[untyped]]]
@param
chars—[char, style, width]triples. - #hash ⇒ Integer
-
#initialize(spans = []) ⇒ StyledString
constructor
@param
spans. - #inspect ⇒ String
-
#lines ⇒ ::Array[StyledString]
Splits on
"\n", preserving spans on each side. -
#normalize(spans) ⇒ ::Array[Span]
@param
spans. -
#resolve_slice_bounds(start_or_range, len, total) ⇒ [Integer, Integer]
@param
start_or_range. -
#slice(start_or_range, len = nil) ⇒ StyledString
Substring by display columns, preserving spans.
-
#slice_spans(start, len) ⇒ StyledString
@param
start. -
#slice_text_by_columns(text, start_col, len_col) ⇒ String
@param
text. -
#to_ansi ⇒ String
Rendered ANSI string.
-
#to_s ⇒ String
Plain text concatenation across all spans — no SGR codes.
-
#tokenize_for_wrap(hard_line) ⇒ ::Array[::Array[untyped]]
@param
hard_line. -
#with_bg(bg) ⇒ StyledString
Returns a new StyledString with
bgapplied to every span, preserving each span's text and other style attributes (fg,bold,italic,underline,strikethrough). -
#with_fg(fg) ⇒ StyledString
Returns a new StyledString with
fgapplied to every span, preserving each span's text and other style attributes (bg,bold,italic,underline,strikethrough). -
#wrap(width) ⇒ ::Array[StyledString]
Word-wraps to physical lines that each fit within
widthdisplay columns, preserving spans and styles across breaks. -
#wrap_one(hard_line, width) ⇒ ::Array[StyledString]
@param
hard_line— one hard-broken line — no embedded"\n".
Constructor Details
#initialize(spans = []) ⇒ StyledString
@param spans
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.
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.
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
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.
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
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
461 462 463 |
# File 'lib/tuile/styled_string.rb', line 461 def ==(other) other.is_a?(StyledString) && @spans == other.spans end |
#build_ansi ⇒ 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.
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_width ⇒ Integer
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.
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.
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.
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
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.
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 |
#hash ⇒ Integer
467 468 469 |
# File 'lib/tuile/styled_string.rb', line 467 def hash @spans.hash end |
#inspect ⇒ 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.
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
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].
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
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
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
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_ansi ⇒ String
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.
455 456 457 |
# File 'lib/tuile/styled_string.rb', line 455 def to_ansi @to_ansi ||= build_ansi end |
#to_s ⇒ String
Plain text concatenation across all spans — no SGR codes.
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.
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.
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.
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 [].
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
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 |