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, 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.ofcall in Tuile.:rgicredits 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_widthhas the per-setting reasoning. :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.
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. -
.validate_glyph(char, name) ⇒ String
Checks a glyph knob's value at assignment: exactly one grapheme cluster, one column wide.
Instance Method Summary collapse
-
#+(other) ⇒ StyledString
Concatenation.
-
#==(other) ⇒ Boolean
(also: #eql?)
@param
other. - #build_ansi ⇒ String
-
#center(width, pad = " ") ⇒ StyledString
Pads both sides out to
widthdisplay columns; see #ljust. -
#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. -
#each_glyph_with_style(styled) ⇒ void
Like #each_char_with_style but per grapheme cluster.
-
#ellipsize(display_width, ellipsis = "…", at: :end) ⇒ StyledString
Truncates to a target column width, marking the cut end with an ellipsis.
- #empty? ⇒ Boolean
-
#gap_to(width, pad) ⇒ Integer
@param
width. -
#glyphs_to_styled(glyphs) ⇒ StyledString
@param
glyphs—[grapheme cluster, style, width]triples. -
#hard_break_glyphs(glyphs, width) ⇒ ::Array[::Array[::Array[untyped]]]
@param
glyphs—[grapheme cluster, style, width]triples. - #hash ⇒ Integer
-
#initialize(spans = []) ⇒ StyledString
constructor
@param
spans. - #inspect ⇒ String
-
#lines ⇒ ::Array[StyledString]
Splits on
"\n", preserving spans on each side. -
#ljust(width, pad = " ") ⇒ StyledString
Pads on the right out to
widthdisplay columns — String#ljust counted in columns, so a CJK glyph or an emoji measures what it paints. -
#normalize(spans) ⇒ ::Array[Span]
@param
spans. -
#resolve_slice_bounds(start_or_range, len, total) ⇒ [Integer, Integer]
@param
start_or_range. -
#rjust(width, pad = " ") ⇒ StyledString
Pads on the left out to
widthdisplay columns; see #ljust. -
#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
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.
-
#to_ansi ⇒ String
Rendered ANSI string.
-
#to_s ⇒ String
Plain text concatenation across all spans — no SGR codes.
-
#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.
-
#under_bg(bg) ⇒ StyledString
Returns a copy with
bgset only on spans that have none; a span with an explicit bg is left untouched. -
#under_fg(fg) ⇒ StyledString
Returns a copy with
fgset only on spans that have none; a span with an explicit fg is left untouched. -
#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_bold(bold: true) ⇒ StyledString
Returns a new StyledString with
boldapplied to every span, preserving each span's text and other style attributes (fg,bg,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). -
#with_inverse(inverse: true) ⇒ StyledString
Returns a new StyledString with
inverseapplied to every span, preserving each span's text and other style attributes. -
#with_underline(underline: true) ⇒ StyledString
Returns a new StyledString with
underlineapplied to every span, preserving each span's text and other style attributes (fg,bg,bold,italic,strikethrough). -
#wrap(width) ⇒ ::Array[StyledString]
Word-wraps to rows that each fit within
widthdisplay columns, preserving spans and styles across breaks. -
#wrap_one(line, width) ⇒ ::Array[StyledString]
@param
line— one line — no embedded"\n".
Constructor Details
#initialize(spans = []) ⇒ StyledString
@param spans
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.
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.
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
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.
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.
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
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
490 491 492 |
# File 'lib/tuile/styled_string.rb', line 490 def ==(other) other.is_a?(StyledString) && @spans == other.spans end |
#build_ansi ⇒ 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.
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.).
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.
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
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.
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
469 |
# File 'lib/tuile/styled_string.rb', line 469 def empty? = @spans.empty? |
#gap_to(width, pad) ⇒ 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.
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.
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
496 497 498 |
# File 'lib/tuile/styled_string.rb', line 496 def hash @spans.hash end |
#inspect ⇒ 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.
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.
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
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].
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.
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
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
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 [].
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
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 |