Class: Tuile::StyledString
- Inherits:
-
Object
- Object
- Tuile::StyledString
- Defined in:
- lib/tuile/styled_string.rb
Overview
An immutable string-with-styling, modeled as a sequence of Spans where each span carries a complete Style (‘fg`, `bg`, `bold`, `italic`, `underline`). Spans are non-overlapping and fully tile the string — every character has exactly one resolved style, no overlay layers to merge.
Where this differs from threading SGR escapes through a plain ‘String`: slicing, wrapping, and concatenation operate on the structured spans, so they never have to “figure out what SGR state is active at column N” —the answer is just the containing span’s ‘style`. The flip side is one extra type to construct (or parse) before doing styled-text math.
## Constructors
“‘ruby StyledString.new # empty StyledString.plain(“hello”) # default style StyledString.styled(“hello”, fg: :red, bold: true) StyledString.parse(“e[31mhelloe[0m world”) # ANSI → spans “`
## Algebra
All operations return a fresh StyledString — the underlying spans are frozen and shared. ‘+` coerces a `String` operand via StyledString.parse.
“‘ruby a + b # concatenate ss.slice(2, 5) # 5 display columns starting at column 2 ss.slice(2..5) # range (inclusive end) ss.lines # split on “n” → Array<StyledString> ss.each_char_with_style { |ch, style| … } “`
## Rendering
-
‘#to_s` — plain text, no SGR.
-
‘#to_ansi` — minimal-diff SGR rendering, ending with `e[0m` only when the last span carried a non-default style. Transitions to the default style emit `e[0m` (shorter than re-emitting every off-code).
## Parser
StyledString.parse is strict by design: it recognizes only the SGR codes corresponding to Style‘s supported attributes (fg/bg/bold/italic/ underline). Anything else — unmodeled attributes (dim, blink, reverse, strike, conceal, double-underline, overline, …), unknown SGR codes, or non-SGR escapes (cursor moves, OSC) — raises ParseError. This keeps the round-trip parse(to_ansi(x)) == x contract honest.
Defined Under Namespace
Classes: ParseError, Span, Style
Instance Attribute Summary collapse
-
#spans ⇒ Array<Span>
readonly
The frozen, normalized span list — no empty-text entries, no two adjacent entries sharing a style.
Class Method Summary collapse
-
.parse(input) ⇒ StyledString
Parses an ANSI/SGR-coded string into a StyledString.
- .plain(text) ⇒ StyledString
- .styled(text, **style_kwargs) ⇒ StyledString
Instance Method Summary collapse
-
#+(other) ⇒ StyledString
Concatenation.
- #==(other) ⇒ Boolean (also: #eql?)
-
#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 {|String, Style| ... } ⇒ Enumerator, 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
- #hash ⇒ Integer
-
#initialize(spans = []) ⇒ StyledString
constructor
A new instance of StyledString.
- #inspect ⇒ String
-
#lines ⇒ Array<StyledString>
Splits on ‘“n”`, preserving spans on each side.
-
#slice(start_or_range, len = nil) ⇒ StyledString
Substring by display columns, preserving spans.
-
#to_ansi ⇒ String
Rendered ANSI string.
-
#to_s ⇒ String
Plain text concatenation across all spans — no SGR codes.
-
#with_bg(bg) ⇒ StyledString
Returns a new StyledString with ‘bg` applied to every span, preserving each span’s text and other style attributes (‘fg`, `bold`, `italic`, `underline`).
-
#wrap(width) ⇒ Array<StyledString>
Word-wraps to physical lines that each fit within ‘width` display columns, preserving spans and styles across breaks.
Constructor Details
#initialize(spans = []) ⇒ StyledString
Returns a new instance of StyledString.
322 323 324 |
# File 'lib/tuile/styled_string.rb', line 322 def initialize(spans = []) @spans = normalize(spans).freeze end |
Instance Attribute Details
#spans ⇒ Array<Span> (readonly)
Returns the frozen, normalized span list — no empty-text entries, no two adjacent entries sharing a style.
319 320 321 |
# File 'lib/tuile/styled_string.rb', line 319 def spans @spans end |
Class Method Details
.parse(input) ⇒ StyledString
Parses an ANSI/SGR-coded string into a Tuile::StyledString. A Tuile::StyledString input is returned as-is. ‘nil` and the empty string both fast-path to EMPTY. Strings without any `e` byte fast-path to a single default-styled span.
302 303 304 305 306 307 308 309 310 311 312 313 314 |
# File 'lib/tuile/styled_string.rb', line 302 def parse(input) case input when nil then EMPTY when StyledString then input when String return EMPTY if input.empty? return new([Span.new(text: input, style: Style::DEFAULT)]) unless input.include?("\e") Parser.new(input).parse else raise TypeError, "cannot parse #{input.class}" end end |
.plain(text) ⇒ StyledString
276 277 278 279 280 281 |
# File 'lib/tuile/styled_string.rb', line 276 def plain(text) text = text.to_s return EMPTY if text.empty? new([Span.new(text: text, style: Style::DEFAULT)]) end |
.styled(text, **style_kwargs) ⇒ StyledString
286 287 288 289 290 291 |
# File 'lib/tuile/styled_string.rb', line 286 def styled(text, **style_kwargs) text = text.to_s return EMPTY if text.empty? new([Span.new(text: text, style: Style.new(**style_kwargs))]) end |
Instance Method Details
#+(other) ⇒ StyledString
Concatenation. A ‘String` operand is parsed via parse before joining (so embedded ANSI escapes round-trip through spans).
371 372 373 374 375 376 |
# File 'lib/tuile/styled_string.rb', line 371 def +(other) other = self.class.parse(other) if other.is_a?(String) raise TypeError, "cannot concatenate #{other.class} to StyledString" unless other.is_a?(StyledString) self.class.new(@spans + other.spans) end |
#==(other) ⇒ Boolean Also known as: eql?
356 357 358 |
# File 'lib/tuile/styled_string.rb', line 356 def ==(other) other.is_a?(StyledString) && @spans == other.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.
330 331 332 |
# File 'lib/tuile/styled_string.rb', line 330 def display_width @display_width ||= @spans.sum { |s| Unicode::DisplayWidth.of(s.text) } end |
#each_char_with_style {|String, Style| ... } ⇒ Enumerator, self
Yields each character (per ‘String#each_char`) along with the Style it carries. Returns an `Enumerator` without a block.
476 477 478 479 480 481 482 483 |
# File 'lib/tuile/styled_string.rb', line 476 def each_char_with_style return enum_for(__method__) unless block_given? @spans.each do |span| span.text.each_char { |c| yield c, span.style } end self end |
#ellipsize(display_width, ellipsis = "…") ⇒ StyledString
Truncates to a target column width, appending an ellipsis when characters were dropped. The ellipsis counts toward the target — the returned Tuile::StyledString‘s `display_width` never exceeds `display_width`. When `self` already fits, `self` is returned. When `display_width` is smaller than the ellipsis’s own width, the ellipsis is sliced down to fit and no original content is included.
413 414 415 416 417 418 419 420 421 |
# File 'lib/tuile/styled_string.rb', line 413 def ellipsize(display_width, ellipsis = "…") return self.class.new if display_width <= 0 return self if self.display_width <= display_width ellipsis = self.class.parse(ellipsis) return ellipsis.slice(0, display_width) if ellipsis.display_width >= display_width slice(0, display_width - ellipsis.display_width) + ellipsis end |
#empty? ⇒ Boolean
335 |
# File 'lib/tuile/styled_string.rb', line 335 def empty? = @spans.empty? |
#hash ⇒ Integer
362 363 364 |
# File 'lib/tuile/styled_string.rb', line 362 def hash @spans.hash end |
#inspect ⇒ String
499 500 501 |
# File 'lib/tuile/styled_string.rb', line 499 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`.
427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 |
# File 'lib/tuile/styled_string.rb', line 427 def lines result = [] current_spans = [] @spans.each do |span| parts = span.text.split("\n", -1) parts.each_with_index do |part, idx| if idx.positive? result << self.class.new(current_spans) current_spans = [] end current_spans << Span.new(text: part, style: span.style) unless part.empty? end end result << self.class.new(current_spans) result end |
#slice(start_col, len_col) ⇒ StyledString #slice(range) ⇒ StyledString
Substring by display columns, preserving spans. Characters whose column range only partially overlaps the slice (e.g. a 2-column CJK character straddling the start or end boundary) are dropped — never split.
Accepts either ‘slice(start_col, len_col)` or `slice(range)`. Both forms support negative indices counting from the end of the string.
391 392 393 394 395 396 397 398 |
# File 'lib/tuile/styled_string.rb', line 391 def slice(start_or_range, len = nil) total = display_width start, len = resolve_slice_bounds(start_or_range, len, total) return self.class.new if len <= 0 || start.negative? || start >= total len = [len, total - start].min slice_spans(start, len) end |
#to_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.
350 351 352 |
# File 'lib/tuile/styled_string.rb', line 350 def to_ansi @to_ansi ||= build_ansi end |
#to_s ⇒ String
Plain text concatenation across all spans — no SGR codes.
339 340 341 |
# File 'lib/tuile/styled_string.rb', line 339 def to_s @spans.map(&:text).join end |
#with_bg(bg) ⇒ StyledString
Returns a new Tuile::StyledString with ‘bg` applied to every span, preserving each span’s text and other style attributes (‘fg`, `bold`, `italic`, `underline`). Useful for row-level highlights — the new bg overlays without dropping foreground colors the original styling carried.
494 495 496 |
# File 'lib/tuile/styled_string.rb', line 494 def with_bg(bg) self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(bg: bg)) }) end |
#wrap(width) ⇒ Array<StyledString>
Word-wraps to physical lines that each fit within ‘width` display columns, preserving spans and styles across breaks. Greedy word-wrap, hard-break for words wider than `width`, leading whitespace dropped on wrapped continuations, hard `“n”` breaks preserved as separate output lines.
Whitespace runs are space or tab; other characters are treated as word content. When a single character is wider than ‘width` (e.g. a 2-column CJK character with `width = 1`), it is still emitted on its own line at its natural width. The “no line exceeds `width`” guarantee therefore holds whenever every character is at most `width` columns wide.
461 462 463 464 465 466 467 468 469 470 |
# File 'lib/tuile/styled_string.rb', line 461 def wrap(width) return [] if empty? hard_lines = lines return hard_lines if width.nil? || width <= 0 result = [] hard_lines.each { |hl| result.concat(wrap_one(hl, width)) } result end |