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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +141 -6
- data/examples/sampler.rb +33 -0
- data/lib/tuile/ansi.rb +14 -0
- data/lib/tuile/component/label.rb +64 -26
- data/lib/tuile/component/list.rb +197 -82
- data/lib/tuile/component/log_window.rb +12 -6
- data/lib/tuile/component/popup.rb +5 -5
- data/lib/tuile/component/text_area.rb +40 -137
- data/lib/tuile/component/text_field.rb +31 -151
- data/lib/tuile/component/text_input.rb +213 -0
- data/lib/tuile/component/text_view.rb +456 -0
- data/lib/tuile/component/window.rb +7 -12
- data/lib/tuile/component.rb +15 -3
- data/lib/tuile/keys.rb +91 -8
- data/lib/tuile/mouse_event.rb +23 -4
- data/lib/tuile/screen.rb +154 -12
- data/lib/tuile/styled_string.rb +774 -0
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1026 -174
- metadata +5 -2
- data/lib/tuile/truncate.rb +0 -83
|
@@ -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
|