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`).
-
#with_fg(fg) ⇒ StyledString
Returns a new StyledString with ‘fg` applied to every span, preserving each span’s text and other style attributes (‘bg`, `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.
294 295 296 |
# File 'lib/tuile/styled_string.rb', line 294 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.
291 292 293 |
# File 'lib/tuile/styled_string.rb', line 291 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.
274 275 276 277 278 279 280 281 282 283 284 285 286 |
# File 'lib/tuile/styled_string.rb', line 274 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
248 249 250 251 252 253 |
# File 'lib/tuile/styled_string.rb', line 248 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
258 259 260 261 262 263 |
# File 'lib/tuile/styled_string.rb', line 258 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).
343 344 345 346 347 348 |
# File 'lib/tuile/styled_string.rb', line 343 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?
328 329 330 |
# File 'lib/tuile/styled_string.rb', line 328 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.
302 303 304 |
# File 'lib/tuile/styled_string.rb', line 302 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.
448 449 450 451 452 453 454 455 |
# File 'lib/tuile/styled_string.rb', line 448 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.
385 386 387 388 389 390 391 392 393 |
# File 'lib/tuile/styled_string.rb', line 385 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
307 |
# File 'lib/tuile/styled_string.rb', line 307 def empty? = @spans.empty? |
#hash ⇒ Integer
334 335 336 |
# File 'lib/tuile/styled_string.rb', line 334 def hash @spans.hash end |
#inspect ⇒ String
484 485 486 |
# File 'lib/tuile/styled_string.rb', line 484 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`.
399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 |
# File 'lib/tuile/styled_string.rb', line 399 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.
363 364 365 366 367 368 369 370 |
# File 'lib/tuile/styled_string.rb', line 363 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.
322 323 324 |
# File 'lib/tuile/styled_string.rb', line 322 def to_ansi @to_ansi ||= build_ansi end |
#to_s ⇒ String
Plain text concatenation across all spans — no SGR codes.
311 312 313 |
# File 'lib/tuile/styled_string.rb', line 311 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.
466 467 468 |
# File 'lib/tuile/styled_string.rb', line 466 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`). The new fg overlays without dropping background colors or text attributes the original styling carried.
479 480 481 |
# File 'lib/tuile/styled_string.rb', line 479 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.
433 434 435 436 437 438 439 440 441 442 |
# File 'lib/tuile/styled_string.rb', line 433 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 |