tuile 0.2.0 → 0.3.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,761 @@
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
+ # @return [String]
499
+ def inspect
500
+ "#<#{self.class.name} #{to_s.inspect}>"
501
+ end
502
+
503
+ private
504
+
505
+ # @return [String]
506
+ def build_ansi
507
+ out = +""
508
+ current = Style::DEFAULT
509
+ @spans.each do |span|
510
+ out << sgr_diff(current, span.style)
511
+ out << span.text
512
+ current = span.style
513
+ end
514
+ out << Ansi::RESET unless current.default?
515
+ out
516
+ end
517
+
518
+ # @param spans [Array<Span>]
519
+ # @return [Array<Span>]
520
+ def normalize(spans)
521
+ result = []
522
+ spans.each do |span|
523
+ next if span.text.empty?
524
+
525
+ if !result.empty? && result.last.style == span.style
526
+ last = result.pop
527
+ result << Span.new(text: last.text + span.text, style: span.style)
528
+ else
529
+ result << span
530
+ end
531
+ end
532
+ result
533
+ end
534
+
535
+ # @param from [Style]
536
+ # @param to [Style]
537
+ # @return [String]
538
+ def sgr_diff(from, to)
539
+ return "" if from == to
540
+ return Ansi::RESET if to.default?
541
+
542
+ codes = []
543
+ codes << (to.bold ? 1 : 22) if from.bold != to.bold
544
+ codes << (to.italic ? 3 : 23) if from.italic != to.italic
545
+ codes << (to.underline ? 4 : 24) if from.underline != to.underline
546
+ codes.concat(color_codes(to.fg, base: 30, ext: 38)) if from.fg != to.fg
547
+ codes.concat(color_codes(to.bg, base: 40, ext: 48)) if from.bg != to.bg
548
+ return "" if codes.empty?
549
+
550
+ "\e[#{codes.join(";")}m"
551
+ end
552
+
553
+ # @param color [Symbol, Integer, Array<Integer>, nil]
554
+ # @param base [Integer] base SGR code — 30 for fg, 40 for bg.
555
+ # @param ext [Integer] extended-color SGR code — 38 for fg, 48 for bg.
556
+ # @return [Array<Integer>]
557
+ def color_codes(color, base:, ext:)
558
+ case color
559
+ when nil then [base + 9]
560
+ when Symbol
561
+ idx = Style::COLOR_SYMBOLS.index(color)
562
+ idx < 8 ? [base + idx] : [base + 60 + (idx - 8)]
563
+ when Integer then [ext, 5, color]
564
+ when Array then [ext, 2, *color]
565
+ end
566
+ end
567
+
568
+ # @param start_or_range [Integer, Range]
569
+ # @param len [Integer, nil]
570
+ # @param total [Integer] receiver's full display width.
571
+ # @return [Array(Integer, Integer)] normalized `[start_col, len_col]`.
572
+ def resolve_slice_bounds(start_or_range, len, total)
573
+ if start_or_range.is_a?(Range)
574
+ range = start_or_range
575
+ start = range.begin || 0
576
+ finish = range.end
577
+ start += total if start.negative?
578
+ if finish.nil?
579
+ finish = total
580
+ else
581
+ finish += total if finish.negative?
582
+ finish += 1 unless range.exclude_end?
583
+ end
584
+ [start, finish - start]
585
+ else
586
+ raise ArgumentError, "length is required when slicing with an Integer" if len.nil?
587
+
588
+ start = start_or_range
589
+ start += total if start.negative?
590
+ [start, len]
591
+ end
592
+ end
593
+
594
+ # @param start [Integer]
595
+ # @param len [Integer]
596
+ # @return [StyledString]
597
+ def slice_spans(start, len)
598
+ out = []
599
+ col = 0
600
+ @spans.each do |span|
601
+ span_width = Unicode::DisplayWidth.of(span.text)
602
+ span_end = col + span_width
603
+
604
+ next col = span_end if span_end <= start
605
+ break if col >= start + len
606
+
607
+ local_start = [0, start - col].max
608
+ local_end = [span_width, start + len - col].min
609
+ if local_end > local_start
610
+ sliced = slice_text_by_columns(span.text, local_start, local_end - local_start)
611
+ out << Span.new(text: sliced, style: span.style) unless sliced.empty?
612
+ end
613
+ col = span_end
614
+ end
615
+ self.class.new(out)
616
+ end
617
+
618
+ # @param hard_line [StyledString] one hard-broken line — no embedded `"\n"`.
619
+ # @param width [Integer]
620
+ # @return [Array<StyledString>]
621
+ def wrap_one(hard_line, width)
622
+ return [hard_line] if hard_line.empty?
623
+
624
+ result = []
625
+ line_chars = []
626
+ line_w = 0
627
+
628
+ tokenize_for_wrap(hard_line).each do |type, chars, w|
629
+ if type == :space
630
+ if line_w.zero?
631
+ # leading whitespace on a wrapped continuation: drop
632
+ elsif line_w + w <= width
633
+ line_chars.concat(chars)
634
+ line_w += w
635
+ else
636
+ result << chars_to_styled(line_chars)
637
+ line_chars = []
638
+ line_w = 0
639
+ end
640
+ elsif line_w + w <= width
641
+ line_chars.concat(chars)
642
+ line_w += w
643
+ elsif w > width
644
+ result << chars_to_styled(line_chars) unless line_w.zero?
645
+ chunks = hard_break_chars(chars, width)
646
+ chunks[0..-2].each { |chunk| result << chars_to_styled(chunk) }
647
+ line_chars = chunks.last
648
+ line_w = line_chars.sum { |triple| triple[2] }
649
+ else
650
+ result << chars_to_styled(line_chars)
651
+ line_chars = chars
652
+ line_w = w
653
+ end
654
+ end
655
+ result << chars_to_styled(line_chars)
656
+ result
657
+ end
658
+
659
+ # @param hard_line [StyledString]
660
+ # @return [Array<Array>] tokens shaped `[type, chars, w]` where `type` is
661
+ # `:space` or `:word`, `chars` is an `Array<[String, Style, Integer]>`
662
+ # (char, style, display width), and `w` is the token's total width.
663
+ def tokenize_for_wrap(hard_line)
664
+ tokens = []
665
+ current_chars = []
666
+ current_w = 0
667
+ current_type = nil
668
+
669
+ hard_line.each_char_with_style do |c, s|
670
+ type = [" ", "\t"].include?(c) ? :space : :word
671
+ cw = Unicode::DisplayWidth.of(c)
672
+ if current_type && current_type != type
673
+ tokens << [current_type, current_chars, current_w]
674
+ current_chars = []
675
+ current_w = 0
676
+ end
677
+ current_type = type
678
+ current_chars << [c, s, cw]
679
+ current_w += cw
680
+ end
681
+ tokens << [current_type, current_chars, current_w] unless current_chars.empty?
682
+ tokens
683
+ end
684
+
685
+ # @param chars [Array<Array>] `[char, style, width]` triples.
686
+ # @param width [Integer]
687
+ # @return [Array<Array<Array>>] each inner Array is a `chars`-shaped chunk.
688
+ def hard_break_chars(chars, width)
689
+ chunks = []
690
+ current = []
691
+ current_w = 0
692
+ chars.each do |triple|
693
+ cw = triple[2]
694
+ if current_w + cw > width && current_w.positive?
695
+ chunks << current
696
+ current = []
697
+ current_w = 0
698
+ end
699
+ current << triple
700
+ current_w += cw
701
+ end
702
+ chunks << current
703
+ chunks
704
+ end
705
+
706
+ # @param chars [Array<Array>] `[char, style, width]` triples.
707
+ # @return [StyledString]
708
+ def chars_to_styled(chars)
709
+ return self.class.new if chars.empty?
710
+
711
+ spans = []
712
+ current_text = +""
713
+ current_style = chars.first[1]
714
+ chars.each do |c, s, _|
715
+ if s == current_style
716
+ current_text << c
717
+ else
718
+ spans << Span.new(text: current_text, style: current_style)
719
+ current_text = +c
720
+ current_style = s
721
+ end
722
+ end
723
+ spans << Span.new(text: current_text, style: current_style)
724
+ self.class.new(spans)
725
+ end
726
+
727
+ # @param text [String]
728
+ # @param start_col [Integer]
729
+ # @param len_col [Integer]
730
+ # @return [String]
731
+ def slice_text_by_columns(text, start_col, len_col)
732
+ out = +""
733
+ col = 0
734
+ text.each_char do |c|
735
+ cw = Unicode::DisplayWidth.of(c)
736
+ char_end = col + cw
737
+ if char_end <= start_col
738
+ # entirely before slice — skip
739
+ elsif col >= start_col + len_col
740
+ break
741
+ elsif col >= start_col && char_end <= start_col + len_col
742
+ out << c
743
+ end
744
+ # any other case = partial overlap with a wide char — drop
745
+ col = char_end
746
+ end
747
+ out
748
+ end
749
+
750
+ # Canonical shared empty {StyledString}. Operations that produce an empty
751
+ # result (and callers that need a blank sentinel) can use this instead of
752
+ # allocating a fresh instance per call. Pre-warmed and frozen — the lazy
753
+ # {#display_width} / {#to_ansi} memoizations short-circuit on the already
754
+ # cached values, so reads on the frozen receiver do not attempt writes.
755
+ # @return [StyledString]
756
+ EMPTY = new.tap do |s|
757
+ s.display_width
758
+ s.to_ansi
759
+ end.freeze
760
+ end
761
+ end