tuile 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,774 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # An immutable string-with-styling, modeled as a sequence of {Span}s where
5
+ # each span carries a complete {Style} (`fg`, `bg`, `bold`, `italic`,
6
+ # `underline`). Spans are non-overlapping and fully tile the string — every
7
+ # character has exactly one resolved style, no overlay layers to merge.
8
+ #
9
+ # Where this differs from threading SGR escapes through a plain `String`:
10
+ # slicing, wrapping, and concatenation operate on the structured spans, so
11
+ # they never have to "figure out what SGR state is active at column N" —
12
+ # the answer is just the containing span's `style`. The flip side is one
13
+ # extra type to construct (or parse) before doing styled-text math.
14
+ #
15
+ # ## Constructors
16
+ #
17
+ # ```ruby
18
+ # StyledString.new # empty
19
+ # StyledString.plain("hello") # default style
20
+ # StyledString.styled("hello", fg: :red, bold: true)
21
+ # StyledString.parse("\e[31mhello\e[0m world") # ANSI → spans
22
+ # ```
23
+ #
24
+ # ## Algebra
25
+ #
26
+ # All operations return a fresh {StyledString} — the underlying spans are
27
+ # frozen and shared. `+` coerces a `String` operand via {.parse}.
28
+ #
29
+ # ```ruby
30
+ # a + b # concatenate
31
+ # ss.slice(2, 5) # 5 display columns starting at column 2
32
+ # ss.slice(2..5) # range (inclusive end)
33
+ # ss.lines # split on "\n" → Array<StyledString>
34
+ # ss.each_char_with_style { |ch, style| ... }
35
+ # ```
36
+ #
37
+ # ## Rendering
38
+ #
39
+ # - `#to_s` — plain text, no SGR.
40
+ # - `#to_ansi` — minimal-diff SGR rendering, ending with `\e[0m` only when
41
+ # the last span carried a non-default style. Transitions to the default
42
+ # style emit `\e[0m` (shorter than re-emitting every off-code).
43
+ #
44
+ # ## Parser
45
+ #
46
+ # {.parse} is strict by design: it recognizes only the SGR codes
47
+ # corresponding to {Style}'s supported attributes (fg/bg/bold/italic/
48
+ # underline). Anything else — unmodeled attributes (dim, blink, reverse,
49
+ # strike, conceal, double-underline, overline, ...), unknown SGR codes, or
50
+ # non-SGR escapes (cursor moves, OSC) — raises {ParseError}. This keeps the
51
+ # round-trip parse(to_ansi(x)) == x contract honest.
52
+ class StyledString
53
+ # Raised by {.parse} on malformed or unsupported escape sequences.
54
+ class ParseError < Error; end
55
+
56
+ # A frozen value type describing the visual style of a {Span}.
57
+ #
58
+ # `fg` and `bg` accept:
59
+ # - `nil` — the terminal default (SGR 39 / 49)
60
+ # - a symbol from {COLOR_SYMBOLS} — 8 standard + 8 bright ANSI colors
61
+ # - an Integer 0..255 — 256-color palette index (SGR 38;5;N / 48;5;N)
62
+ # - an `[r, g, b]` Array of three 0..255 Integers — 24-bit RGB
63
+ #
64
+ # @!attribute [r] fg
65
+ # @return [Symbol, Integer, Array<Integer>, nil]
66
+ # @!attribute [r] bg
67
+ # @return [Symbol, Integer, Array<Integer>, nil]
68
+ # @!attribute [r] bold
69
+ # @return [Boolean]
70
+ # @!attribute [r] italic
71
+ # @return [Boolean]
72
+ # @!attribute [r] underline
73
+ # @return [Boolean]
74
+ class Style < Data.define(:fg, :bg, :bold, :italic, :underline)
75
+ # Symbolic color names recognized by {#fg} and {#bg}. Order is
76
+ # significant: indices 0..7 map to standard ANSI colors (SGR 30..37 fg
77
+ # / 40..47 bg); indices 8..15 map to bright variants (SGR 90..97 /
78
+ # 100..107).
79
+ # @return [Array<Symbol>]
80
+ COLOR_SYMBOLS = %i[
81
+ black red green yellow blue magenta cyan white
82
+ bright_black bright_red bright_green bright_yellow
83
+ bright_blue bright_magenta bright_cyan bright_white
84
+ ].freeze
85
+
86
+ # @param fg [Symbol, Integer, Array<Integer>, nil]
87
+ # @param bg [Symbol, Integer, Array<Integer>, nil]
88
+ # @param bold [Boolean]
89
+ # @param italic [Boolean]
90
+ # @param underline [Boolean]
91
+ # @return [Style]
92
+ # @raise [ArgumentError] when a color is not one of the accepted forms.
93
+ def self.new(fg: nil, bg: nil, bold: false, italic: false, underline: false)
94
+ validate_color!(fg, :fg)
95
+ validate_color!(bg, :bg)
96
+ super(fg:, bg:, bold:, italic:, underline:)
97
+ end
98
+
99
+ # @param color [Object]
100
+ # @param which [Symbol]
101
+ # @return [void]
102
+ def self.validate_color!(color, which)
103
+ return if color.nil? || COLOR_SYMBOLS.include?(color)
104
+ return if color.is_a?(Integer) && color.between?(0, 255)
105
+ return if color.is_a?(Array) && color.length == 3 &&
106
+ color.all? { |v| v.is_a?(Integer) && v.between?(0, 255) }
107
+
108
+ raise ArgumentError, "invalid #{which} color: #{color.inspect}"
109
+ end
110
+ private_class_method :validate_color!
111
+
112
+ # The style with no color and no attributes — what the terminal shows
113
+ # without any SGR applied.
114
+ # @return [Style]
115
+ DEFAULT = new
116
+
117
+ # @return [Boolean]
118
+ def default? = self == DEFAULT
119
+
120
+ # Returns a new {Style} with the given attributes overridden.
121
+ # @param overrides [Hash{Symbol => Object}]
122
+ # @return [Style]
123
+ def merge(**overrides) = self.class.new(**to_h.merge(overrides))
124
+ end
125
+
126
+ # A maximal run of text sharing a single {Style}. `text` is plain — it
127
+ # never contains ANSI escape sequences. Spans inside a {StyledString} are
128
+ # normalized: no empty text, no two adjacent spans share a style.
129
+ #
130
+ # @!attribute [r] text
131
+ # @return [String] frozen plain text.
132
+ # @!attribute [r] style
133
+ # @return [Style]
134
+ class Span < Data.define(:text, :style)
135
+ # @param text [String]
136
+ # @param style [Style]
137
+ def initialize(text:, style:)
138
+ raise ArgumentError, "text must be a String" unless text.is_a?(String)
139
+ raise ArgumentError, "style must be a #{Style}" unless style.is_a?(Style)
140
+
141
+ super(text: -text, style: style)
142
+ end
143
+ end
144
+
145
+ # @api private
146
+ # Hand-rolled SGR parser. State machine over a {StringScanner}: plain
147
+ # text accumulates into the current span; each `\e[...m` flushes the
148
+ # current span and updates the running {Style}. Anything outside the
149
+ # supported SGR alphabet raises {ParseError}.
150
+ class Parser
151
+ # @return [Array<Symbol>]
152
+ STANDARD_COLORS = Style::COLOR_SYMBOLS[0, 8].freeze
153
+ private_constant :STANDARD_COLORS
154
+
155
+ # @return [Array<Symbol>]
156
+ BRIGHT_COLORS = Style::COLOR_SYMBOLS[8, 8].freeze
157
+ private_constant :BRIGHT_COLORS
158
+
159
+ # @param input [String]
160
+ def initialize(input)
161
+ @scanner = StringScanner.new(input)
162
+ @style = Style::DEFAULT
163
+ @text = +""
164
+ @spans = []
165
+ end
166
+
167
+ # @return [StyledString]
168
+ def parse
169
+ until @scanner.eos?
170
+ if @scanner.peek(1) == "\e"
171
+ consume_escape
172
+ else
173
+ consume_text
174
+ end
175
+ end
176
+ flush
177
+ StyledString.new(@spans)
178
+ end
179
+
180
+ private
181
+
182
+ # @return [void]
183
+ def consume_text
184
+ chunk = @scanner.scan_until(/(?=\e)|\z/)
185
+ @text << chunk if chunk
186
+ end
187
+
188
+ # @return [void]
189
+ def consume_escape
190
+ @scanner.getch # \e
191
+ bracket = @scanner.getch
192
+ raise ParseError, "expected '[' after ESC, got #{bracket.inspect}" if bracket != "["
193
+
194
+ params = @scanner.scan(/[\d;]*/) || ""
195
+ final = @scanner.getch
196
+ raise ParseError, "unterminated escape sequence" if final.nil?
197
+ raise ParseError, "non-SGR CSI sequence (final byte #{final.inspect})" if final != "m"
198
+
199
+ flush
200
+ apply_sgr(params)
201
+ end
202
+
203
+ # @param params_str [String]
204
+ # @return [void]
205
+ def apply_sgr(params_str)
206
+ codes = params_str.empty? ? [0] : params_str.split(";").map(&:to_i)
207
+ i = 0
208
+ while i < codes.length
209
+ code = codes[i]
210
+ case code
211
+ when 0 then @style = Style::DEFAULT
212
+ when 1 then @style = @style.merge(bold: true)
213
+ when 22 then @style = @style.merge(bold: false)
214
+ when 3 then @style = @style.merge(italic: true)
215
+ when 23 then @style = @style.merge(italic: false)
216
+ when 4 then @style = @style.merge(underline: true)
217
+ when 24 then @style = @style.merge(underline: false)
218
+ when 30..37 then @style = @style.merge(fg: STANDARD_COLORS[code - 30])
219
+ when 38
220
+ i += consume_extended_color(codes, i, :fg)
221
+ next
222
+ when 39 then @style = @style.merge(fg: nil)
223
+ when 40..47 then @style = @style.merge(bg: STANDARD_COLORS[code - 40])
224
+ when 48
225
+ i += consume_extended_color(codes, i, :bg)
226
+ next
227
+ when 49 then @style = @style.merge(bg: nil)
228
+ when 90..97 then @style = @style.merge(fg: BRIGHT_COLORS[code - 90])
229
+ when 100..107 then @style = @style.merge(bg: BRIGHT_COLORS[code - 100])
230
+ else raise ParseError, "unsupported SGR code #{code}"
231
+ end
232
+ i += 1
233
+ end
234
+ end
235
+
236
+ # @param codes [Array<Integer>]
237
+ # @param index [Integer]
238
+ # @param target [Symbol] either `:fg` or `:bg`.
239
+ # @return [Integer] how many SGR codes were consumed (3 for 256-color, 5 for RGB).
240
+ def consume_extended_color(codes, index, target)
241
+ mode = codes[index + 1]
242
+ case mode
243
+ when 5
244
+ n = codes[index + 2]
245
+ raise ParseError, "invalid 256-color index #{n.inspect}" unless n&.between?(0, 255)
246
+
247
+ @style = @style.merge(target => n)
248
+ 3
249
+ when 2
250
+ r = codes[index + 2]
251
+ g = codes[index + 3]
252
+ b = codes[index + 4]
253
+ [r, g, b].each do |v|
254
+ raise ParseError, "invalid RGB component #{v.inspect}" unless v&.between?(0, 255)
255
+ end
256
+ @style = @style.merge(target => [r, g, b])
257
+ 5
258
+ else
259
+ raise ParseError, "unsupported extended-color selector #{mode.inspect}"
260
+ end
261
+ end
262
+
263
+ # @return [void]
264
+ def flush
265
+ return if @text.empty?
266
+
267
+ @spans << Span.new(text: @text.dup, style: @style)
268
+ @text = +""
269
+ end
270
+ end
271
+ private_constant :Parser
272
+
273
+ class << self
274
+ # @param text [#to_s]
275
+ # @return [StyledString]
276
+ def plain(text)
277
+ text = text.to_s
278
+ return EMPTY if text.empty?
279
+
280
+ new([Span.new(text: text, style: Style::DEFAULT)])
281
+ end
282
+
283
+ # @param text [#to_s]
284
+ # @param style_kwargs [Hash{Symbol => Object}] forwarded to {Style.new}.
285
+ # @return [StyledString]
286
+ def styled(text, **style_kwargs)
287
+ text = text.to_s
288
+ return EMPTY if text.empty?
289
+
290
+ new([Span.new(text: text, style: Style.new(**style_kwargs))])
291
+ end
292
+
293
+ # Parses an ANSI/SGR-coded string into a {StyledString}. A {StyledString}
294
+ # input is returned as-is. `nil` and the empty string both fast-path to
295
+ # {EMPTY}. Strings without any `\e` byte fast-path to a single
296
+ # default-styled span.
297
+ #
298
+ # @param input [String, StyledString, nil]
299
+ # @return [StyledString]
300
+ # @raise [ParseError] on unsupported or malformed escape sequences.
301
+ # @raise [TypeError] when `input` is none of String, StyledString, nil.
302
+ def parse(input)
303
+ case input
304
+ when nil then EMPTY
305
+ when StyledString then input
306
+ when String
307
+ return EMPTY if input.empty?
308
+ return new([Span.new(text: input, style: Style::DEFAULT)]) unless input.include?("\e")
309
+
310
+ Parser.new(input).parse
311
+ else
312
+ raise TypeError, "cannot parse #{input.class}"
313
+ end
314
+ end
315
+ end
316
+
317
+ # @return [Array<Span>] the frozen, normalized span list — no empty-text
318
+ # entries, no two adjacent entries sharing a style.
319
+ attr_reader :spans
320
+
321
+ # @param spans [Array<Span>]
322
+ def initialize(spans = [])
323
+ @spans = normalize(spans).freeze
324
+ end
325
+
326
+ # Total display width in terminal columns, accounting for Unicode wide
327
+ # characters (fullwidth CJK = 2 columns, combining marks = 0, etc.).
328
+ # Memoized — safe because spans are frozen and immutable.
329
+ # @return [Integer]
330
+ def display_width
331
+ @display_width ||= @spans.sum { |s| Unicode::DisplayWidth.of(s.text) }
332
+ end
333
+
334
+ # @return [Boolean]
335
+ def empty? = @spans.empty?
336
+
337
+ # Plain text concatenation across all spans — no SGR codes.
338
+ # @return [String]
339
+ def to_s
340
+ @spans.map(&:text).join
341
+ end
342
+
343
+ # Rendered ANSI string. Minimal-diff between adjacent spans: only the
344
+ # attributes that changed are emitted. A transition to the default style
345
+ # emits `\e[0m` (one code) instead of the longer "turn each attribute
346
+ # off" form. Always closes with `\e[0m` when the last span carried a
347
+ # non-default style, so the styled run doesn't bleed into subsequent
348
+ # output. Memoized — safe because spans are frozen and immutable.
349
+ # @return [String]
350
+ def to_ansi
351
+ @to_ansi ||= build_ansi
352
+ end
353
+
354
+ # @param other [Object]
355
+ # @return [Boolean]
356
+ def ==(other)
357
+ other.is_a?(StyledString) && @spans == other.spans
358
+ end
359
+ alias eql? ==
360
+
361
+ # @return [Integer]
362
+ def hash
363
+ @spans.hash
364
+ end
365
+
366
+ # Concatenation. A `String` operand is parsed via {.parse} before joining
367
+ # (so embedded ANSI escapes round-trip through spans).
368
+ # @param other [StyledString, String]
369
+ # @return [StyledString]
370
+ # @raise [TypeError] when `other` is neither.
371
+ def +(other)
372
+ other = self.class.parse(other) if other.is_a?(String)
373
+ raise TypeError, "cannot concatenate #{other.class} to StyledString" unless other.is_a?(StyledString)
374
+
375
+ self.class.new(@spans + other.spans)
376
+ end
377
+
378
+ # Substring by display columns, preserving spans. Characters whose column
379
+ # range only partially overlaps the slice (e.g. a 2-column CJK character
380
+ # straddling the start or end boundary) are dropped — never split.
381
+ #
382
+ # Accepts either `slice(start_col, len_col)` or `slice(range)`. Both
383
+ # forms support negative indices counting from the end of the string.
384
+ #
385
+ # @overload slice(start_col, len_col)
386
+ # @param start_col [Integer]
387
+ # @param len_col [Integer]
388
+ # @overload slice(range)
389
+ # @param range [Range<Integer>]
390
+ # @return [StyledString]
391
+ def slice(start_or_range, len = nil)
392
+ total = display_width
393
+ start, len = resolve_slice_bounds(start_or_range, len, total)
394
+ return self.class.new if len <= 0 || start.negative? || start >= total
395
+
396
+ len = [len, total - start].min
397
+ slice_spans(start, len)
398
+ end
399
+
400
+ # Truncates to a target column width, appending an ellipsis when
401
+ # characters were dropped. The ellipsis counts toward the target — the
402
+ # returned {StyledString}'s `display_width` never exceeds
403
+ # `display_width`. When `self` already fits, `self` is returned. When
404
+ # `display_width` is smaller than the ellipsis's own width, the ellipsis
405
+ # is sliced down to fit and no original content is included.
406
+ #
407
+ # @param display_width [Integer] target column width.
408
+ # @param ellipsis [String, StyledString] appended when truncation
409
+ # occurs. Defaults to the Unicode horizontal-ellipsis `…` (one
410
+ # column). A `String` is parsed via {.parse}, so ANSI in it is
411
+ # preserved.
412
+ # @return [StyledString]
413
+ def ellipsize(display_width, ellipsis = "…")
414
+ return self.class.new if display_width <= 0
415
+ return self if self.display_width <= display_width
416
+
417
+ ellipsis = self.class.parse(ellipsis)
418
+ return ellipsis.slice(0, display_width) if ellipsis.display_width >= display_width
419
+
420
+ slice(0, display_width - ellipsis.display_width) + ellipsis
421
+ end
422
+
423
+ # Splits on `"\n"`, preserving spans on each side. A trailing newline
424
+ # produces a trailing empty {StyledString} (matches `split("\n", -1)`).
425
+ # An empty {StyledString} returns a single empty entry, like `"".split`.
426
+ # @return [Array<StyledString>]
427
+ def lines
428
+ result = []
429
+ current_spans = []
430
+ @spans.each do |span|
431
+ parts = span.text.split("\n", -1)
432
+ parts.each_with_index do |part, idx|
433
+ if idx.positive?
434
+ result << self.class.new(current_spans)
435
+ current_spans = []
436
+ end
437
+ current_spans << Span.new(text: part, style: span.style) unless part.empty?
438
+ end
439
+ end
440
+ result << self.class.new(current_spans)
441
+ result
442
+ end
443
+
444
+ # Word-wraps to physical lines that each fit within `width` display
445
+ # columns, preserving spans and styles across breaks. Greedy word-wrap,
446
+ # hard-break for words wider than `width`, leading whitespace dropped on
447
+ # wrapped continuations, hard `"\n"` breaks preserved as separate output
448
+ # lines.
449
+ #
450
+ # Whitespace runs are space or tab; other characters are treated as word
451
+ # content. When a single character is wider than `width` (e.g. a 2-column
452
+ # CJK character with `width = 1`), it is still emitted on its own line at
453
+ # its natural width. The "no line exceeds `width`" guarantee therefore
454
+ # holds whenever every character is at most `width` columns wide.
455
+ #
456
+ # @param width [Integer, nil] target column width. `nil` or `<= 0` skips
457
+ # wrapping and returns each hard-line as-is, so callers can pass a
458
+ # stale viewport width without crashing.
459
+ # @return [Array<StyledString>] one entry per physical (output) line.
460
+ # An empty receiver returns `[]`.
461
+ def wrap(width)
462
+ return [] if empty?
463
+
464
+ hard_lines = lines
465
+ return hard_lines if width.nil? || width <= 0
466
+
467
+ result = []
468
+ hard_lines.each { |hl| result.concat(wrap_one(hl, width)) }
469
+ result
470
+ end
471
+
472
+ # Yields each character (per `String#each_char`) along with the {Style}
473
+ # it carries. Returns an `Enumerator` without a block.
474
+ # @yield [String, Style]
475
+ # @return [Enumerator, self]
476
+ def each_char_with_style
477
+ return enum_for(__method__) unless block_given?
478
+
479
+ @spans.each do |span|
480
+ span.text.each_char { |c| yield c, span.style }
481
+ end
482
+ self
483
+ end
484
+
485
+ # Returns a new {StyledString} with `bg` applied to every span, preserving
486
+ # each span's text and other style attributes (`fg`, `bold`, `italic`,
487
+ # `underline`). Useful for row-level highlights — the new bg overlays
488
+ # without dropping foreground colors the original styling carried.
489
+ #
490
+ # @param bg [Symbol, Integer, Array<Integer>, nil] background color, in
491
+ # any of the forms accepted by {Style.new}. `nil` clears bg back to
492
+ # the terminal default.
493
+ # @return [StyledString]
494
+ def with_bg(bg)
495
+ self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(bg: bg)) })
496
+ end
497
+
498
+ # Returns a new {StyledString} with `fg` applied to every span, preserving
499
+ # each span's text and other style attributes (`bg`, `bold`, `italic`,
500
+ # `underline`). The new fg overlays without dropping background colors or
501
+ # text attributes the original styling carried.
502
+ #
503
+ # @param fg [Symbol, Integer, Array<Integer>, nil] foreground color, in
504
+ # any of the forms accepted by {Style.new}. `nil` clears fg back to
505
+ # the terminal default.
506
+ # @return [StyledString]
507
+ def with_fg(fg)
508
+ self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(fg: fg)) })
509
+ end
510
+
511
+ # @return [String]
512
+ def inspect
513
+ "#<#{self.class.name} #{to_s.inspect}>"
514
+ end
515
+
516
+ private
517
+
518
+ # @return [String]
519
+ def build_ansi
520
+ out = +""
521
+ current = Style::DEFAULT
522
+ @spans.each do |span|
523
+ out << sgr_diff(current, span.style)
524
+ out << span.text
525
+ current = span.style
526
+ end
527
+ out << Ansi::RESET unless current.default?
528
+ out
529
+ end
530
+
531
+ # @param spans [Array<Span>]
532
+ # @return [Array<Span>]
533
+ def normalize(spans)
534
+ result = []
535
+ spans.each do |span|
536
+ next if span.text.empty?
537
+
538
+ if !result.empty? && result.last.style == span.style
539
+ last = result.pop
540
+ result << Span.new(text: last.text + span.text, style: span.style)
541
+ else
542
+ result << span
543
+ end
544
+ end
545
+ result
546
+ end
547
+
548
+ # @param from [Style]
549
+ # @param to [Style]
550
+ # @return [String]
551
+ def sgr_diff(from, to)
552
+ return "" if from == to
553
+ return Ansi::RESET if to.default?
554
+
555
+ codes = []
556
+ codes << (to.bold ? 1 : 22) if from.bold != to.bold
557
+ codes << (to.italic ? 3 : 23) if from.italic != to.italic
558
+ codes << (to.underline ? 4 : 24) if from.underline != to.underline
559
+ codes.concat(color_codes(to.fg, base: 30, ext: 38)) if from.fg != to.fg
560
+ codes.concat(color_codes(to.bg, base: 40, ext: 48)) if from.bg != to.bg
561
+ return "" if codes.empty?
562
+
563
+ "\e[#{codes.join(";")}m"
564
+ end
565
+
566
+ # @param color [Symbol, Integer, Array<Integer>, nil]
567
+ # @param base [Integer] base SGR code — 30 for fg, 40 for bg.
568
+ # @param ext [Integer] extended-color SGR code — 38 for fg, 48 for bg.
569
+ # @return [Array<Integer>]
570
+ def color_codes(color, base:, ext:)
571
+ case color
572
+ when nil then [base + 9]
573
+ when Symbol
574
+ idx = Style::COLOR_SYMBOLS.index(color)
575
+ idx < 8 ? [base + idx] : [base + 60 + (idx - 8)]
576
+ when Integer then [ext, 5, color]
577
+ when Array then [ext, 2, *color]
578
+ end
579
+ end
580
+
581
+ # @param start_or_range [Integer, Range]
582
+ # @param len [Integer, nil]
583
+ # @param total [Integer] receiver's full display width.
584
+ # @return [Array(Integer, Integer)] normalized `[start_col, len_col]`.
585
+ def resolve_slice_bounds(start_or_range, len, total)
586
+ if start_or_range.is_a?(Range)
587
+ range = start_or_range
588
+ start = range.begin || 0
589
+ finish = range.end
590
+ start += total if start.negative?
591
+ if finish.nil?
592
+ finish = total
593
+ else
594
+ finish += total if finish.negative?
595
+ finish += 1 unless range.exclude_end?
596
+ end
597
+ [start, finish - start]
598
+ else
599
+ raise ArgumentError, "length is required when slicing with an Integer" if len.nil?
600
+
601
+ start = start_or_range
602
+ start += total if start.negative?
603
+ [start, len]
604
+ end
605
+ end
606
+
607
+ # @param start [Integer]
608
+ # @param len [Integer]
609
+ # @return [StyledString]
610
+ def slice_spans(start, len)
611
+ out = []
612
+ col = 0
613
+ @spans.each do |span|
614
+ span_width = Unicode::DisplayWidth.of(span.text)
615
+ span_end = col + span_width
616
+
617
+ next col = span_end if span_end <= start
618
+ break if col >= start + len
619
+
620
+ local_start = [0, start - col].max
621
+ local_end = [span_width, start + len - col].min
622
+ if local_end > local_start
623
+ sliced = slice_text_by_columns(span.text, local_start, local_end - local_start)
624
+ out << Span.new(text: sliced, style: span.style) unless sliced.empty?
625
+ end
626
+ col = span_end
627
+ end
628
+ self.class.new(out)
629
+ end
630
+
631
+ # @param hard_line [StyledString] one hard-broken line — no embedded `"\n"`.
632
+ # @param width [Integer]
633
+ # @return [Array<StyledString>]
634
+ def wrap_one(hard_line, width)
635
+ return [hard_line] if hard_line.empty?
636
+
637
+ result = []
638
+ line_chars = []
639
+ line_w = 0
640
+
641
+ tokenize_for_wrap(hard_line).each do |type, chars, w|
642
+ if type == :space
643
+ if line_w.zero?
644
+ # leading whitespace on a wrapped continuation: drop
645
+ elsif line_w + w <= width
646
+ line_chars.concat(chars)
647
+ line_w += w
648
+ else
649
+ result << chars_to_styled(line_chars)
650
+ line_chars = []
651
+ line_w = 0
652
+ end
653
+ elsif line_w + w <= width
654
+ line_chars.concat(chars)
655
+ line_w += w
656
+ elsif w > width
657
+ result << chars_to_styled(line_chars) unless line_w.zero?
658
+ chunks = hard_break_chars(chars, width)
659
+ chunks[0..-2].each { |chunk| result << chars_to_styled(chunk) }
660
+ line_chars = chunks.last
661
+ line_w = line_chars.sum { |triple| triple[2] }
662
+ else
663
+ result << chars_to_styled(line_chars)
664
+ line_chars = chars
665
+ line_w = w
666
+ end
667
+ end
668
+ result << chars_to_styled(line_chars)
669
+ result
670
+ end
671
+
672
+ # @param hard_line [StyledString]
673
+ # @return [Array<Array>] tokens shaped `[type, chars, w]` where `type` is
674
+ # `:space` or `:word`, `chars` is an `Array<[String, Style, Integer]>`
675
+ # (char, style, display width), and `w` is the token's total width.
676
+ def tokenize_for_wrap(hard_line)
677
+ tokens = []
678
+ current_chars = []
679
+ current_w = 0
680
+ current_type = nil
681
+
682
+ hard_line.each_char_with_style do |c, s|
683
+ type = [" ", "\t"].include?(c) ? :space : :word
684
+ cw = Unicode::DisplayWidth.of(c)
685
+ if current_type && current_type != type
686
+ tokens << [current_type, current_chars, current_w]
687
+ current_chars = []
688
+ current_w = 0
689
+ end
690
+ current_type = type
691
+ current_chars << [c, s, cw]
692
+ current_w += cw
693
+ end
694
+ tokens << [current_type, current_chars, current_w] unless current_chars.empty?
695
+ tokens
696
+ end
697
+
698
+ # @param chars [Array<Array>] `[char, style, width]` triples.
699
+ # @param width [Integer]
700
+ # @return [Array<Array<Array>>] each inner Array is a `chars`-shaped chunk.
701
+ def hard_break_chars(chars, width)
702
+ chunks = []
703
+ current = []
704
+ current_w = 0
705
+ chars.each do |triple|
706
+ cw = triple[2]
707
+ if current_w + cw > width && current_w.positive?
708
+ chunks << current
709
+ current = []
710
+ current_w = 0
711
+ end
712
+ current << triple
713
+ current_w += cw
714
+ end
715
+ chunks << current
716
+ chunks
717
+ end
718
+
719
+ # @param chars [Array<Array>] `[char, style, width]` triples.
720
+ # @return [StyledString]
721
+ def chars_to_styled(chars)
722
+ return self.class.new if chars.empty?
723
+
724
+ spans = []
725
+ current_text = +""
726
+ current_style = chars.first[1]
727
+ chars.each do |c, s, _|
728
+ if s == current_style
729
+ current_text << c
730
+ else
731
+ spans << Span.new(text: current_text, style: current_style)
732
+ current_text = +c
733
+ current_style = s
734
+ end
735
+ end
736
+ spans << Span.new(text: current_text, style: current_style)
737
+ self.class.new(spans)
738
+ end
739
+
740
+ # @param text [String]
741
+ # @param start_col [Integer]
742
+ # @param len_col [Integer]
743
+ # @return [String]
744
+ def slice_text_by_columns(text, start_col, len_col)
745
+ out = +""
746
+ col = 0
747
+ text.each_char do |c|
748
+ cw = Unicode::DisplayWidth.of(c)
749
+ char_end = col + cw
750
+ if char_end <= start_col
751
+ # entirely before slice — skip
752
+ elsif col >= start_col + len_col
753
+ break
754
+ elsif col >= start_col && char_end <= start_col + len_col
755
+ out << c
756
+ end
757
+ # any other case = partial overlap with a wide char — drop
758
+ col = char_end
759
+ end
760
+ out
761
+ end
762
+
763
+ # Canonical shared empty {StyledString}. Operations that produce an empty
764
+ # result (and callers that need a blank sentinel) can use this instead of
765
+ # allocating a fresh instance per call. Pre-warmed and frozen — the lazy
766
+ # {#display_width} / {#to_ansi} memoizations short-circuit on the already
767
+ # cached values, so reads on the frozen receiver do not attempt writes.
768
+ # @return [StyledString]
769
+ EMPTY = new.tap do |s|
770
+ s.display_width
771
+ s.to_ansi
772
+ end.freeze
773
+ end
774
+ end