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, inverse). Spans are non-overlapping and fully tile the string — every character has exactly one resolved style, no overlay layers to merge, so the style at any column is just its span's style rather than a replay of the SGR state machine. The book's chapter 9 is the long-form why (spans vs. a String full of escape codes).

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

Parser

StyledString.parse is strict by default — it recognizes only the SGR codes for Style's attributes (fg/bg/bold/italic/underline/strikethrough/inverse) and raises ParseError on anything else, keeping the parse(to_ansi(x)) == x round-trip honest. Pass lenient: true to instead discard everything it can't model (unmodeled SGR, cursor moves, OSC/DCS, stray escapes) and keep only the recognized colors — lossy by design, for piping in colored output you don't control. See the book for the full rationale.

Defined Under Namespace

Classes: ParseError, Span, Style

Constant Summary collapse

EMOJI_WIDTH =

The framework's single emoji-width policy, passed to every Unicode::DisplayWidth.of call in Tuile.

:rgi credits width 2 only to RGI sequences — the ones vendors actually ship a single glyph for — and sums the parts of anything else, erring only in the direction that cannot escape a component: Buffer#flush emits a dirty run contiguously, so a mis-measure of either sign shifts the rest of that run, but only an under-measure shifts it right, into a neighbour whose cells are clean and so never repainted. D_cluster_width has the per-setting reasoning.

Returns:

  • (Symbol)
:rgi
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: [])


457
458
459
# File 'lib/tuile/styled_string.rb', line 457

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:



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

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:



393
394
395
396
397
398
399
400
401
402
403
404
405
# File 'lib/tuile/styled_string.rb', line 393

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:



363
364
365
366
367
368
# File 'lib/tuile/styled_string.rb', line 363

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:



373
374
375
376
377
378
# File 'lib/tuile/styled_string.rb', line 373

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

.validate_glyph(char, name) ⇒ String

Checks a glyph knob's value at assignment: exactly one grapheme cluster, one column wide. A component painting one glyph per cell spills a wider one onto its neighbour — silently, with nothing in the frame to point at — so every glyph knob asks here rather than at paint.

def track_char=(char)
@track_char = StyledString.validate_glyph(char, :track_char)
end

East-Asian-Ambiguous glyphs pass: Tuile measures them one column by construction (D_ambiguous_width), so no check can catch a terminal drawing them two.

@param char

@param name — the knob, for the message.

@return — char, frozen.

Parameters:

  • char (String)
  • name (Symbol, String)

Returns:

  • (String)


425
426
427
428
429
430
431
432
433
434
435
436
# File 'lib/tuile/styled_string.rb', line 425

def validate_glyph(char, name)
  raise TypeError, "#{name} must be a String, got #{char.inspect}" unless char.is_a?(String)

  unless char.each_grapheme_cluster.take(2).size == 1
    raise ArgumentError, "#{name} must be exactly one grapheme cluster, got #{char.inspect}"
  end

  width = plain(char).display_width
  raise ArgumentError, "#{name} must be one column wide, got #{char.inspect} (#{width})" unless width == 1

  -char
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:



505
506
507
508
509
510
# File 'lib/tuile/styled_string.rb', line 505

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)


490
491
492
# File 'lib/tuile/styled_string.rb', line 490

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

#build_ansi ⇒ String

Returns:

  • (String)


834
835
836
837
838
839
840
841
842
843
844
# File 'lib/tuile/styled_string.rb', line 834

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

#center(width, pad = " ") ⇒ StyledString

Pads both sides out to width display columns; see #ljust. An odd gap puts the extra column on the right, as String#center does.

StyledString.plain("ab").center(5, "·").to_s   # => "·ab··"

@param width — target display width.

@param pad — the fill, one grapheme cluster one column wide.

Parameters:

  • width (Integer)
  • pad (String) (defaults to: " ")

Returns:



610
611
612
613
614
615
616
# File 'lib/tuile/styled_string.rb', line 610

def center(width, pad = " ")
  gap = gap_to(width, pad)
  return self if gap.zero?

  left = gap / 2
  self.class.plain(pad * left) + self + self.class.plain(pad * (gap - left))
end

#display_width ⇒ Integer

Total display width in terminal columns, accounting for Unicode wide characters (fullwidth CJK = 2 columns, combining marks = 0, etc.).

Returns:

  • (Integer)


464
465
466
# File 'lib/tuile/styled_string.rb', line 464

def display_width
  @display_width ||= @spans.sum { |s| Unicode::DisplayWidth.of(s.text, emoji: EMOJI_WIDTH) }
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)


680
681
682
683
684
685
686
687
# File 'lib/tuile/styled_string.rb', line 680

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

#each_glyph_with_style(styled) ⇒ void

This method returns an undefined value.

Like #each_char_with_style but per grapheme cluster. A cluster spanning a style boundary takes the style of its first span — pathological input, and splitting the cluster to honor both styles would paint a headless mark.

@param styled

Parameters:



1003
1004
1005
1006
1007
# File 'lib/tuile/styled_string.rb', line 1003

def each_glyph_with_style(styled)
  styled.spans.each do |span|
    span.text.each_grapheme_cluster { |g| yield g, span.style }
  end
end

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

Truncates to a target column width, marking the cut end with an ellipsis. The ellipsis counts toward the target — the returned Tuile::StyledString's display_width never exceeds display_width, and comes out a column short when the cut lands mid-cluster, since a wide glyph straddling the boundary is dropped rather than halved. 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.

at: :start keeps the tail instead, for text whose end identifies it and whose head is context — a path (…/shared/markdown/), a log line's message after its prefix.

@param display_width — target column width.

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

@param at — which end is cut: :end (the default) keeps the head and appends, :start keeps the tail and prepends.

Parameters:

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

Returns:



556
557
558
559
560
561
562
563
564
565
566
567
568
# File 'lib/tuile/styled_string.rb', line 556

def ellipsize(display_width, ellipsis = "…", at: :end)
  raise ArgumentError, "expected :start or :end, got #{at.inspect}" unless %i[start end].include?(at)
  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

  keep = display_width - ellipsis.display_width
  return ellipsis + slice(self.display_width - keep, keep) if at == :start

  slice(0, keep) + ellipsis
end

#empty? ⇒ Boolean

Returns:

  • (Boolean)


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

def empty? = @spans.empty?

#gap_to(width, pad) ⇒ Integer

@param width

@param pad

@return — columns #ljust and friends must fill, never negative.

Parameters:

  • width (Integer)
  • pad (String)

Returns:

  • (Integer)


867
868
869
870
871
# File 'lib/tuile/styled_string.rb', line 867

def gap_to(width, pad)
  # The default skips validation: the fill runs once per painted row.
  self.class.validate_glyph(pad, :pad) unless pad == " "
  (width - display_width).clamp(0, nil)
end

#glyphs_to_styled(glyphs) ⇒ StyledString

@param glyphs — [grapheme cluster, style, width] triples.

Parameters:

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

Returns:



1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
# File 'lib/tuile/styled_string.rb', line 1032

def glyphs_to_styled(glyphs)
  return self.class.new if glyphs.empty?

  spans = []
  current_text = +""
  current_style = glyphs.first[1]
  glyphs.each do |g, s, _|
    if s == current_style
      current_text << g
    else
      spans << Span.new(text: current_text, style: current_style)
      current_text = +g
      current_style = s
    end
  end
  spans << Span.new(text: current_text, style: current_style)
  self.class.new(spans)
end

#hard_break_glyphs(glyphs, width) ⇒ ::Array[::Array[::Array[untyped]]]

@param glyphs — [grapheme cluster, style, width] triples.

@param width

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

Parameters:

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

Returns:

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


1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
# File 'lib/tuile/styled_string.rb', line 1012

def hard_break_glyphs(glyphs, width)
  chunks = []
  current = []
  current_w = 0
  glyphs.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

Returns:

  • (Integer)


496
497
498
# File 'lib/tuile/styled_string.rb', line 496

def hash
  @spans.hash
end

#inspect ⇒ String

Returns:

  • (String)


827
828
829
# File 'lib/tuile/styled_string.rb', line 827

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:



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

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

#ljust(width, pad = " ") ⇒ StyledString

Pads on the right out to width display columns — String#ljust counted in columns, so a CJK glyph or an emoji measures what it paints.

StyledString.plain("日本").ljust(6).to_s   # => "日本  "
row.ellipsize(w).ljust(w)                  # exactly w columns

Never truncates: self comes back when already at least width wide, a negative gap included. The fill is unstyled, so it shows whatever background is painted under it.

@param width — target display width.

@param pad — the fill, one grapheme cluster one column wide.

Parameters:

  • width (Integer)
  • pad (String) (defaults to: " ")

Returns:



583
584
585
586
# File 'lib/tuile/styled_string.rb', line 583

def ljust(width, pad = " ")
  gap = gap_to(width, pad)
  gap.zero? ? self : self + self.class.plain(pad * gap)
end

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

@param spans

Parameters:

  • spans (::Array[Span])

Returns:



848
849
850
851
852
853
854
855
856
857
858
859
860
861
# File 'lib/tuile/styled_string.rb', line 848

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


877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
# File 'lib/tuile/styled_string.rb', line 877

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

#rjust(width, pad = " ") ⇒ StyledString

Pads on the left out to width display columns; see #ljust.

StyledString.plain("42").rjust(5).to_s   # => "   42"

@param width — target display width.

@param pad — the fill, one grapheme cluster one column wide.

Parameters:

  • width (Integer)
  • pad (String) (defaults to: " ")

Returns:



596
597
598
599
# File 'lib/tuile/styled_string.rb', line 596

def rjust(width, pad = " ")
  gap = gap_to(width, pad)
  gap.zero? ? self : self.class.plain(pad * gap) + self
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:



525
526
527
528
529
530
531
532
# File 'lib/tuile/styled_string.rb', line 525

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:



902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
# File 'lib/tuile/styled_string.rb', line 902

def slice_spans(start, len)
  out = []
  col = 0
  @spans.each do |span|
    span_width = Unicode::DisplayWidth.of(span.text, emoji: EMOJI_WIDTH)
    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

Walks grapheme clusters, so a slice boundary can never fall inside one: cutting a cluster would strand a combining mark with no base, which the painter drops outright, silently losing the accent off a letter.

@param text

@param start_col

@param len_col

Parameters:

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

Returns:

  • (String)


1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
# File 'lib/tuile/styled_string.rb', line 1058

def slice_text_by_columns(text, start_col, len_col)
  out = +""
  col = 0
  text.each_grapheme_cluster do |g|
    gw = Buffer.display_width(g)
    glyph_end = col + gw
    if glyph_end <= start_col
      # entirely before slice — skip
    elsif col >= start_col + len_col
      break
    elsif col >= start_col && glyph_end <= start_col + len_col
      out << g
    end
    # any other case = partial overlap with a wide glyph — drop
    col = glyph_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.

Returns:

  • (String)


484
485
486
# File 'lib/tuile/styled_string.rb', line 484

def to_ansi
  @to_ansi ||= build_ansi
end

#to_s ⇒ String

Plain text concatenation across all spans — no SGR codes.

Returns:

  • (String)


473
474
475
# File 'lib/tuile/styled_string.rb', line 473

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

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

Splits into whitespace/word tokens by grapheme cluster, not character: a cluster is the unit a terminal draws, so measuring its parts separately would both mis-total an emoji sequence and let a wrap break a letter away from its combining mark.

@param line

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

Parameters:

Returns:

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


974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
# File 'lib/tuile/styled_string.rb', line 974

def tokenize_for_wrap(line)
  tokens = []
  current_glyphs = []
  current_w = 0
  current_type = nil

  each_glyph_with_style(line) do |g, s|
    type = [" ", "\t"].include?(g) ? :space : :word
    gw = Buffer.display_width(g)
    if current_type && current_type != type
      tokens << [current_type, current_glyphs, current_w]
      current_glyphs = []
      current_w = 0
    end
    current_type = type
    current_glyphs << [g, s, gw]
    current_w += gw
  end
  tokens << [current_type, current_glyphs, current_w] unless current_glyphs.empty?
  tokens
end

#under_bg(bg) ⇒ StyledString

Returns a copy with bg set only on spans that have none; a span with an explicit bg is left untouched. The fill-unset counterpart of #with_bg (which overrides every span) — it slides a background under the content, so a log line keeps its red error-level bg while its plain text picks up an inherited panel tint.

An inverse span counts as backgrounded and is skipped even when its bg member is nil: SGR 7 swaps the pair in effect, so a bg filled under it would recolor the span's glyphs, not the ground behind them.

@param bg — background color, coerced via Color.coerce. nil returns self unchanged.

Parameters:

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

Returns:



715
716
717
718
719
720
721
722
723
724
725
726
# File 'lib/tuile/styled_string.rb', line 715

def under_bg(bg)
  return self if bg.nil?

  bg = Color.coerce(bg)
  self.class.new(@spans.map do |span|
    if span.style.bg.nil? && !span.style.inverse
      Span.new(text: span.text, style: span.style.merge(bg: bg))
    else
      span
    end
  end)
end

#under_fg(fg) ⇒ StyledString

Returns a copy with fg set only on spans that have none; a span with an explicit fg is left untouched. The foreground counterpart of #under_bg — it gives a caption a default tone while keeping the parts the app colored itself:

caption = StyledString.plain("Queue ") + StyledString.styled("3", fg: theme[:notice])
caption.under_fg(theme[:hint])   # "Queue " in :hint, "3" stays :notice

An inverse span is skipped even when its fg member is nil, as #under_bg skips it: SGR 7 swaps the pair, so a filled fg would become the span's background.

@param fg — foreground color, coerced via Color.coerce. nil returns self unchanged.

Parameters:

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

Returns:



756
757
758
759
760
761
762
763
764
765
766
767
# File 'lib/tuile/styled_string.rb', line 756

def under_fg(fg)
  return self if fg.nil?

  fg = Color.coerce(fg)
  self.class.new(@spans.map do |span|
    if span.style.fg.nil? && !span.style.inverse
      Span.new(text: span.text, style: span.style.merge(fg: fg))
    else
      span
    end
  end)
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:



698
699
700
# File 'lib/tuile/styled_string.rb', line 698

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

#with_bold(bold: true) ⇒ StyledString

Returns a new Tuile::StyledString with bold applied to every span, preserving each span's text and other style attributes (fg, bg, italic, underline, strikethrough). The bold-attribute counterpart of #with_bg / #with_fg: it emphasizes a whole run of app-authored, possibly multi-span content — a widget marking one caption out of several as selected, where the caption may already carry its own colors.

There is deliberately no under_bold (the fill-unset counterpart #under_bg provides for backgrounds): a background is inherited down the component tree, so a span with none has a meaningful "unset" state to fill, while bold is a plain per-span attribute that is either on or off. Pass bold: false to clear it.

@param bold — whether the spans should be bold.

Parameters:

  • bold: (Boolean) (defaults to: true)

Returns:



784
785
786
# File 'lib/tuile/styled_string.rb', line 784

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



737
738
739
# File 'lib/tuile/styled_string.rb', line 737

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

#with_inverse(inverse: true) ⇒ StyledString

Returns a new Tuile::StyledString with inverse applied to every span, preserving each span's text and other style attributes. Inverse swaps whatever fg/bg are actually in effect at each cell — terminal defaults included — so a focus chip built with it reads as "backgrounded" on any terminal palette without picking a single color:

StyledString.plain(" 1 VMs ").with_inverse   # the inverted-chip idiom

There is deliberately no under_inverse, for the reason #with_bold spells out. Pass inverse: false to clear it.

@param inverse — whether the spans should be inverted.

Parameters:

  • inverse: (Boolean) (defaults to: true)

Returns:



822
823
824
# File 'lib/tuile/styled_string.rb', line 822

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

#with_underline(underline: true) ⇒ StyledString

Returns a new Tuile::StyledString with underline applied to every span, preserving each span's text and other style attributes (fg, bg, bold, italic, strikethrough). Slice and rejoin to underline part of a string, which is what a one-character cue needs:

cap = StyledString.parse("File")
cap.slice(0, 1).with_underline + cap.slice(1, cap.display_width - 1)
# => "File" with the F underlined — a menu mnemonic

Note #slice counts columns, not characters, so a caption with a wide glyph before the cue needs the prefix measured rather than counted.

There is deliberately no under_underline, for the reason #with_bold spells out. Pass underline: false to clear it.

@param underline — whether the spans should be underlined.

Parameters:

  • underline: (Boolean) (defaults to: true)

Returns:



805
806
807
# File 'lib/tuile/styled_string.rb', line 805

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

#wrap(width) ⇒ ::Array[StyledString]

Word-wraps to rows 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.

An indent is content, so it survives onto the first row — but there is no hanging indent:

StyledString.plain("  read config").wrap(20).map(&:to_s)
# => ["  read config"]        indent kept; the line never wrapped
StyledString.plain("  read config").wrap(6).map(&:to_s)
# => ["  read", "config"]     ...but a continuation starts at column 0

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. An indent that alone exceeds width is dropped rather than given a row of its own.

@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 output row. An empty receiver returns [].

Parameters:

  • width (Integer, nil)

Returns:



665
666
667
668
669
670
671
672
673
674
# File 'lib/tuile/styled_string.rb', line 665

def wrap(width)
  return [] if empty?

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

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

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

@param line — one line — no embedded "\n".

@param width

Parameters:

Returns:



926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
# File 'lib/tuile/styled_string.rb', line 926

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

  result = []
  line_glyphs = []
  line_w = 0

  tokenize_for_wrap(line).each do |type, glyphs, w|
    if type == :space
      if line_w.zero? && (!result.empty? || w > width)
        # Nothing to emit: a continuation's leading run was consumed by the
        # break, and an indent wider than the viewport conveys no nesting.
      elsif line_w + w <= width
        line_glyphs.concat(glyphs)
        line_w += w
      else
        result << glyphs_to_styled(line_glyphs)
        line_glyphs = []
        line_w = 0
      end
    elsif line_w + w <= width
      line_glyphs.concat(glyphs)
      line_w += w
    elsif w > width
      result << glyphs_to_styled(line_glyphs) unless line_w.zero?
      chunks = hard_break_glyphs(glyphs, width)
      chunks[0..-2].each { |chunk| result << glyphs_to_styled(chunk) }
      line_glyphs = chunks.last
      line_w = line_glyphs.sum { |triple| triple[2] }
    else
      result << glyphs_to_styled(line_glyphs)
      line_glyphs = glyphs
      line_w = w
    end
  end
  result << glyphs_to_styled(line_glyphs)
  result
end