tuile 0.6.0 → 0.8.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.
@@ -28,8 +28,9 @@ module Tuile
28
28
 
29
29
  # @return [Component, nil] the tiled content component.
30
30
  attr_reader :content
31
- # @return [Array<Component>] modal popups in stacking order; last is
32
- # topmost. The array must not be mutated by callers.
31
+ # @return [Array<Component>] overlay popups in stacking order; last is
32
+ # topmost. Holds both modal popups and non-modal overlays
33
+ # ({Component::Popup#modal?}). The array must not be mutated by callers.
33
34
  attr_reader :popups
34
35
  # @return [Component::Label] the bottom status bar.
35
36
  attr_reader :status_bar
@@ -57,7 +58,11 @@ module Tuile
57
58
  layout
58
59
  end
59
60
 
60
- # Adds a popup, centers it, focuses it, and invalidates it for repaint.
61
+ # Adds a popup and invalidates it for repaint. A modal popup is centered
62
+ # and grabs focus; a non-modal overlay ({Component::Popup#modal?} false) is
63
+ # left wherever the caller positions it and does *not* take focus, so the
64
+ # component that was focused keeps the cursor and keeps receiving keys —
65
+ # the overlay floats above the content, driven from app code.
61
66
  # @param window [Component::Popup]
62
67
  # @return [void]
63
68
  def add_popup(window)
@@ -67,8 +72,10 @@ module Tuile
67
72
  @popup_prior_focus[window] = screen.focused
68
73
  @popups << window
69
74
  window.parent = self
70
- window.center
71
- screen.focused = window
75
+ if window.modal?
76
+ window.center
77
+ screen.focused = window
78
+ end
72
79
  screen.invalidate(window)
73
80
  end
74
81
 
@@ -100,6 +107,13 @@ module Tuile
100
107
  # @return [Boolean] true if this pane currently hosts the popup.
101
108
  def has_popup?(window) = @popups.include?(window) # rubocop:disable Naming/PredicatePrefix
102
109
 
110
+ # @return [Component::Popup, nil] the topmost *modal* popup, or nil when
111
+ # only non-modal overlays (or no popups) are open. This is the "modal
112
+ # owner": the popup that scopes key dispatch, blocks mouse clicks, owns
113
+ # the status bar, and confines Tab cycling. Non-modal overlays are
114
+ # excluded — they float above the content without capturing input.
115
+ def modal_popup = @popups.reverse_each.find(&:modal?)
116
+
103
117
  # Re-lays out children whenever the pane's own rect changes.
104
118
  # @param new_rect [Rect]
105
119
  # @return [void]
@@ -109,13 +123,14 @@ module Tuile
109
123
  end
110
124
 
111
125
  # Lays out content (full pane minus the bottom row) and the status bar
112
- # (bottom row). Popups self-position via {Component::Popup#center}.
126
+ # (bottom row). Modal popups self-recenter via {Component::Popup#center};
127
+ # non-modal overlays keep the position their owner assigned.
113
128
  # @return [void]
114
129
  def layout
115
130
  return if rect.empty?
116
131
 
117
132
  @content.rect = Rect.new(rect.left, rect.top, rect.width, [rect.height - 1, 0].max) unless @content.nil?
118
- @popups.each(&:center)
133
+ @popups.each { |p| p.center if p.modal? }
119
134
  @status_bar.rect = Rect.new(rect.left, rect.top + rect.height - 1, rect.width, 1)
120
135
  end
121
136
 
@@ -123,32 +138,55 @@ module Tuile
123
138
  # @return [void]
124
139
  def repaint; end
125
140
 
126
- # Topmost popup is modal: it eats keys. Falls through to content only
127
- # when no popup is open.
141
+ # Dispatches a key in two phases, both scoped to the topmost *modal* popup
142
+ # (when one is open) or else the tiled {#content}. Non-modal overlays are
143
+ # never the scope: focus stays in the content beneath them, and the overlay
144
+ # is driven by app code (which forwards keys to it explicitly), so it
145
+ # doesn't appear in this path at all.
146
+ #
147
+ # 1. *Capture* — a {Component#key_shortcut} match anywhere in the scope
148
+ # focuses that component and consumes the key. Suppressed while a
149
+ # cursor-owner ({Screen#cursor_position}) is mid-edit, so typing into a
150
+ # {Component::TextField} isn't hijacked by a sibling's shortcut.
151
+ # 2. *Delivery* — the key is handed to {Screen#focused} and bubbles up its
152
+ # ancestor chain to the scope root; the first component to return true
153
+ # wins. Focus that is nil or sits outside the scope receives nothing,
154
+ # which is what keeps an open modal popup modal.
155
+ # @param key [String]
156
+ # @return [Boolean] true if the key was handled.
128
157
  def handle_key(key)
129
- topmost = @popups.last
130
- return topmost.handle_key(key) unless topmost.nil?
131
- return @content.handle_key(key) unless @content.nil?
158
+ scope = modal_popup || @content
159
+ return false if scope.nil?
132
160
 
133
- false
161
+ if screen.cursor_position.nil?
162
+ target = scope.find_shortcut_component(key)
163
+ unless target.nil?
164
+ screen.focused = target
165
+ return true
166
+ end
167
+ end
168
+
169
+ bubble_key(key, scope)
134
170
  end
135
171
 
136
172
  # Mouse events check popups in reverse stacking order (topmost first), and
137
- # fall through to content only when no popup is hit *and* there are no
138
- # popups open. This preserves modal click-blocking: an open popup eats
139
- # clicks even outside its rect.
173
+ # fall through to content only when no popup is hit *and* no modal popup is
174
+ # open. This preserves modal click-blocking an open modal eats clicks
175
+ # even outside its rect — while a non-modal overlay blocks nothing: clicks
176
+ # inside it route to it (e.g. click-to-select), clicks elsewhere reach the
177
+ # content beneath.
140
178
  # @param event [MouseEvent]
141
179
  # @return [void]
142
180
  def handle_mouse(event)
143
- clicked = @popups.reverse_each.find { it.rect.contains?(event.point) }
144
- clicked = @content if clicked.nil? && @popups.empty?
181
+ clicked = @popups.reverse_each.find { _1.rect.contains?(event.point) }
182
+ clicked = @content if clicked.nil? && modal_popup.nil?
145
183
  clicked&.handle_mouse(event)
146
184
  end
147
185
 
148
186
  # Focus repair when a child detaches. Default {Component#on_child_removed}
149
187
  # would refocus to `self` (the pane), which isn't a useful focus target.
150
188
  # Instead, route focus to the first interactable widget in the now-topmost
151
- # popup; falling back to the focus snapshotted when this popup was opened
189
+ # modal popup; falling back to the focus snapshotted when this popup was opened
152
190
  # (if still attached and still focusable); then to the first interactable
153
191
  # widget in {#content}; then to {#content} itself; then nil.
154
192
  #
@@ -167,7 +205,7 @@ module Tuile
167
205
  cursor = f
168
206
  while cursor
169
207
  if cursor == child
170
- fallback = first_tab_stop_or_root(@popups.last)
208
+ fallback = first_tab_stop_or_root(modal_popup)
171
209
  if fallback.nil? && @removing_popup_prior&.attached? && @removing_popup_prior.focusable?
172
210
  fallback = @removing_popup_prior
173
211
  end
@@ -181,6 +219,29 @@ module Tuile
181
219
 
182
220
  private
183
221
 
222
+ # Delivers `key` to {Screen#focused} and bubbles it up the ancestor chain,
223
+ # stopping at (and including) `scope`. Delivers to no one — returning false
224
+ # — when focus is nil or sits outside `scope`; the latter is what makes an
225
+ # open popup modal, since focus is always inside it and content beneath
226
+ # never receives keys.
227
+ # @param key [String]
228
+ # @param scope [Component] the modal scope root (topmost popup or content).
229
+ # @return [Boolean] true if some component on the chain handled the key.
230
+ def bubble_key(key, scope)
231
+ chain = []
232
+ cursor = screen.focused
233
+ until cursor.nil?
234
+ chain << cursor
235
+ break if cursor.equal?(scope)
236
+
237
+ cursor = cursor.parent
238
+ end
239
+ return false unless chain.last.equal?(scope)
240
+
241
+ chain.each { |c| return true if c.handle_key(key) }
242
+ false
243
+ end
244
+
184
245
  # First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
185
246
  # back to `root` itself when the subtree has no tab stops. Returns `nil`
186
247
  # if `root` is `nil`.
@@ -3,7 +3,7 @@
3
3
  module Tuile
4
4
  # An immutable string-with-styling, modeled as a sequence of {Span}s where
5
5
  # each span carries a complete {Style} (`fg`, `bg`, `bold`, `italic`,
6
- # `underline`). Spans are non-overlapping and fully tile the string — every
6
+ # `underline`, `strikethrough`). Spans are non-overlapping and fully tile the string — every
7
7
  # character has exactly one resolved style, no overlay layers to merge.
8
8
  #
9
9
  # Where this differs from threading SGR escapes through a plain `String`:
@@ -43,12 +43,21 @@ module Tuile
43
43
  #
44
44
  # ## Parser
45
45
  #
46
- # {.parse} is strict by design: it recognizes only the SGR codes
46
+ # {.parse} is strict by default: it recognizes only the SGR codes
47
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
48
+ # underline/strikethrough). Anything else — unmodeled attributes (dim, blink,
49
+ # reverse, conceal, double-underline, overline, ...), unknown SGR codes, or
50
50
  # non-SGR escapes (cursor moves, OSC) — raises {ParseError}. This keeps the
51
51
  # round-trip parse(to_ansi(x)) == x contract honest.
52
+ #
53
+ # Pass `lenient: true` to instead **discard** everything the parser can't
54
+ # model and keep going — recognized fg/bg/bold/italic/underline/strikethrough codes still
55
+ # apply, and any unmodeled SGR code, malformed extended color, non-SGR CSI
56
+ # (cursor moves, `\e[K`), OSC/DCS/string sequence, or stray escape is
57
+ # silently dropped. This is the mode for piping in colored output you don't
58
+ # control (e.g. `git --color` through a pager): "give me the colors, throw
59
+ # the rest away." It is lossy by design — `parse(x, lenient: true)` does not
60
+ # round-trip back to `x`.
52
61
  class StyledString
53
62
  # Raised by {.parse} on malformed or unsupported escape sequences.
54
63
  class ParseError < Error; end
@@ -69,16 +78,19 @@ module Tuile
69
78
  # @return [Boolean]
70
79
  # @!attribute [r] underline
71
80
  # @return [Boolean]
72
- class Style < Data.define(:fg, :bg, :bold, :italic, :underline)
81
+ # @!attribute [r] strikethrough
82
+ # @return [Boolean]
83
+ class Style < Data.define(:fg, :bg, :bold, :italic, :underline, :strikethrough)
73
84
  # @param fg [Color, Symbol, Integer, Array<Integer>, nil] coerced via {Color.coerce}.
74
85
  # @param bg [Color, Symbol, Integer, Array<Integer>, nil] coerced via {Color.coerce}.
75
86
  # @param bold [Boolean]
76
87
  # @param italic [Boolean]
77
88
  # @param underline [Boolean]
89
+ # @param strikethrough [Boolean]
78
90
  # @return [Style]
79
91
  # @raise [ArgumentError] when a color is not one of the accepted forms.
80
- def self.new(fg: nil, bg: nil, bold: false, italic: false, underline: false)
81
- super(fg: Color.coerce(fg), bg: Color.coerce(bg), bold:, italic:, underline:)
92
+ def self.new(fg: nil, bg: nil, bold: false, italic: false, underline: false, strikethrough: false)
93
+ super(fg: Color.coerce(fg), bg: Color.coerce(bg), bold:, italic:, underline:, strikethrough:)
82
94
  end
83
95
 
84
96
  # The style with no color and no attributes — what the terminal shows
@@ -93,6 +105,45 @@ module Tuile
93
105
  # @param overrides [Hash{Symbol => Object}]
94
106
  # @return [Style]
95
107
  def merge(**overrides) = self.class.new(**to_h.merge(overrides))
108
+
109
+ # Minimal SGR escape that transitions a terminal already showing `self`
110
+ # into `other`: only the attributes that differ are emitted. Returns
111
+ # `""` when the styles are identical (nothing to do), and {Ansi::RESET}
112
+ # (`\e[0m`, one code) when `other` is the default style — shorter than
113
+ # turning each attribute off individually.
114
+ #
115
+ # Shared by {StyledString#to_ansi} (diffing span-to-span from the default
116
+ # style) and {Buffer}'s flush (diffing cell-to-cell against the style the
117
+ # terminal currently holds), so both emit identical minimal sequences.
118
+ # @param other [Style] the style to transition to.
119
+ # @return [String]
120
+ def sgr_to(other)
121
+ return "" if self == other
122
+ return Ansi::RESET if other.default?
123
+
124
+ codes = []
125
+ codes << (other.bold ? 1 : 22) if bold != other.bold
126
+ codes << (other.italic ? 3 : 23) if italic != other.italic
127
+ codes << (other.underline ? 4 : 24) if underline != other.underline
128
+ codes << (other.strikethrough ? 9 : 29) if strikethrough != other.strikethrough
129
+ codes.concat(color_codes(other.fg, target: :fg)) if fg != other.fg
130
+ codes.concat(color_codes(other.bg, target: :bg)) if bg != other.bg
131
+ return "" if codes.empty?
132
+
133
+ "\e[#{codes.join(";")}m"
134
+ end
135
+
136
+ private
137
+
138
+ # @param color [Color, nil]
139
+ # @param target [Symbol] either `:fg` or `:bg`.
140
+ # @return [Array<Integer>] SGR codes; `[39]` / `[49]` for the "default"
141
+ # reset when `color` is `nil`, otherwise delegated to {Color#sgr_codes}.
142
+ def color_codes(color, target:)
143
+ return [target == :fg ? 39 : 49] if color.nil?
144
+
145
+ color.sgr_codes(target)
146
+ end
96
147
  end
97
148
 
98
149
  # A maximal run of text sharing a single {Style}. `text` is plain — it
@@ -117,8 +168,9 @@ module Tuile
117
168
  # @api private
118
169
  # Hand-rolled SGR parser. State machine over a {StringScanner}: plain
119
170
  # text accumulates into the current span; each `\e[...m` flushes the
120
- # current span and updates the running {Style}. Anything outside the
121
- # supported SGR alphabet raises {ParseError}.
171
+ # current span and updates the running {Style}. In strict mode anything
172
+ # outside the supported SGR alphabet raises {ParseError}; in lenient mode
173
+ # it is consumed and discarded (see {StyledString} "## Parser").
122
174
  class Parser
123
175
  # @return [Array<Symbol>]
124
176
  STANDARD_COLORS = Color::COLOR_SYMBOLS[0, 8].freeze
@@ -128,9 +180,20 @@ module Tuile
128
180
  BRIGHT_COLORS = Color::COLOR_SYMBOLS[8, 8].freeze
129
181
  private_constant :BRIGHT_COLORS
130
182
 
183
+ # ESC-introducers (the byte after `\e`) whose payload runs until a string
184
+ # terminator (ST `\e\\` or BEL): OSC `]`, DCS `P`, SOS `X`, PM `^`,
185
+ # APC `_`. In lenient mode the whole sequence — payload included — is
186
+ # swallowed so it never leaks into span text.
187
+ # @return [Array<String>]
188
+ STRING_INTRODUCERS = %w(] P X ^ _).freeze
189
+ private_constant :STRING_INTRODUCERS
190
+
131
191
  # @param input [String]
132
- def initialize(input)
192
+ # @param lenient [Boolean] when true, discard unmodeled SGR codes and
193
+ # non-SGR escapes instead of raising {ParseError}.
194
+ def initialize(input, lenient: false)
133
195
  @scanner = StringScanner.new(input)
196
+ @lenient = lenient
134
197
  @style = Style::DEFAULT
135
198
  @text = +""
136
199
  @spans = []
@@ -160,16 +223,70 @@ module Tuile
160
223
  # @return [void]
161
224
  def consume_escape
162
225
  @scanner.getch # \e
163
- bracket = @scanner.getch
164
- raise ParseError, "expected '[' after ESC, got #{bracket.inspect}" if bracket != "["
226
+ intro = @scanner.getch
227
+ case intro
228
+ when "[" then consume_csi
229
+ when nil then raise ParseError, "unterminated escape sequence" unless @lenient
230
+ else
231
+ raise ParseError, "expected '[' after ESC, got #{intro.inspect}" unless @lenient
232
+
233
+ consume_non_csi(intro)
234
+ end
235
+ end
165
236
 
166
- params = @scanner.scan(/[\d;]*/) || ""
237
+ # Consumes a CSI sequence (`\e[` already eaten). A well-formed SGR
238
+ # (`\e[...m` with numeric/`;` params and no intermediates) is applied;
239
+ # anything else is a non-SGR or malformed CSI — raises in strict mode,
240
+ # swallowed in lenient. Scans the full CSI grammar (parameter bytes
241
+ # `\x30-\x3F`, intermediate bytes `\x20-\x2F`, final byte) so lenient
242
+ # mode consumes the whole sequence even for private-marker forms like
243
+ # `\e[?25l`.
244
+ # @return [void]
245
+ def consume_csi
246
+ params = @scanner.scan(/[\x30-\x3F]*/) || ""
247
+ intermediates = @scanner.scan(/[\x20-\x2F]*/) || ""
167
248
  final = @scanner.getch
168
- raise ParseError, "unterminated escape sequence" if final.nil?
169
- raise ParseError, "non-SGR CSI sequence (final byte #{final.inspect})" if final != "m"
249
+
250
+ if final == "m" && intermediates.empty? && params.match?(/\A[\d;]*\z/)
251
+ flush
252
+ return apply_sgr(params)
253
+ end
254
+
255
+ raise ParseError, "unterminated escape sequence" if final.nil? && !@lenient
256
+ raise ParseError, "non-SGR CSI sequence (final byte #{final.inspect})" unless @lenient
170
257
 
171
258
  flush
172
- apply_sgr(params)
259
+ end
260
+
261
+ # Lenient-only: discards a non-CSI escape (`\e` and `intro` already
262
+ # eaten). OSC/DCS/string sequences run to their string terminator; an
263
+ # nF escape (`\e( B`) eats its intermediates plus one final byte; any
264
+ # other Fe/Fp/Fs escape was complete in `intro` alone.
265
+ # @param intro [String] the byte after `\e` (never `"["`).
266
+ # @return [void]
267
+ def consume_non_csi(intro)
268
+ flush
269
+ if STRING_INTRODUCERS.include?(intro)
270
+ consume_string_sequence
271
+ elsif intro.match?(/[\x20-\x2F]/)
272
+ @scanner.scan(/[\x20-\x2F]*/)
273
+ @scanner.getch
274
+ end
275
+ end
276
+
277
+ # Lenient-only: swallows an OSC/DCS/string-sequence payload up to and
278
+ # including its terminator (BEL, or ST `\e\\`), or to EOS if unterminated.
279
+ # @return [void]
280
+ def consume_string_sequence
281
+ until @scanner.eos?
282
+ ch = @scanner.getch
283
+ break if ch == "\a"
284
+
285
+ if ch == "\e"
286
+ @scanner.getch if @scanner.peek(1) == "\\"
287
+ break
288
+ end
289
+ end
173
290
  end
174
291
 
175
292
  # @param params_str [String]
@@ -187,6 +304,8 @@ module Tuile
187
304
  when 23 then @style = @style.merge(italic: false)
188
305
  when 4 then @style = @style.merge(underline: true)
189
306
  when 24 then @style = @style.merge(underline: false)
307
+ when 9 then @style = @style.merge(strikethrough: true)
308
+ when 29 then @style = @style.merge(strikethrough: false)
190
309
  when 30..37 then @style = @style.merge(fg: STANDARD_COLORS[code - 30])
191
310
  when 38
192
311
  i += consume_extended_color(codes, i, :fg)
@@ -199,7 +318,7 @@ module Tuile
199
318
  when 49 then @style = @style.merge(bg: nil)
200
319
  when 90..97 then @style = @style.merge(fg: BRIGHT_COLORS[code - 90])
201
320
  when 100..107 then @style = @style.merge(bg: BRIGHT_COLORS[code - 100])
202
- else raise ParseError, "unsupported SGR code #{code}"
321
+ else raise ParseError, "unsupported SGR code #{code}" unless @lenient
203
322
  end
204
323
  i += 1
205
324
  end
@@ -208,27 +327,37 @@ module Tuile
208
327
  # @param codes [Array<Integer>]
209
328
  # @param index [Integer]
210
329
  # @param target [Symbol] either `:fg` or `:bg`.
211
- # @return [Integer] how many SGR codes were consumed (3 for 256-color, 5 for RGB).
330
+ # @return [Integer] how many SGR codes were consumed. In lenient mode a
331
+ # malformed color is skipped rather than applied, but the same count is
332
+ # returned (3 for 256-color, 5 for RGB) so the running index advances
333
+ # past its operands; an unknown selector skips just `38`/`48` + the
334
+ # selector byte (2), letting the rest be reprocessed.
212
335
  def consume_extended_color(codes, index, target)
213
336
  mode = codes[index + 1]
214
337
  case mode
215
338
  when 5
216
339
  n = codes[index + 2]
217
- raise ParseError, "invalid 256-color index #{n.inspect}" unless n&.between?(0, 255)
218
-
219
- @style = @style.merge(target => n)
340
+ if n&.between?(0, 255)
341
+ @style = @style.merge(target => n)
342
+ elsif !@lenient
343
+ raise ParseError, "invalid 256-color index #{n.inspect}"
344
+ end
220
345
  3
221
346
  when 2
222
347
  r = codes[index + 2]
223
348
  g = codes[index + 3]
224
349
  b = codes[index + 4]
225
- [r, g, b].each do |v|
226
- raise ParseError, "invalid RGB component #{v.inspect}" unless v&.between?(0, 255)
350
+ if [r, g, b].all? { |v| v&.between?(0, 255) }
351
+ @style = @style.merge(target => [r, g, b])
352
+ elsif !@lenient
353
+ bad = [r, g, b].find { |v| !v&.between?(0, 255) }
354
+ raise ParseError, "invalid RGB component #{bad.inspect}"
227
355
  end
228
- @style = @style.merge(target => [r, g, b])
229
356
  5
230
357
  else
231
- raise ParseError, "unsupported extended-color selector #{mode.inspect}"
358
+ raise ParseError, "unsupported extended-color selector #{mode.inspect}" unless @lenient
359
+
360
+ 2
232
361
  end
233
362
  end
234
363
 
@@ -268,10 +397,14 @@ module Tuile
268
397
  # default-styled span.
269
398
  #
270
399
  # @param input [String, StyledString, nil]
400
+ # @param lenient [Boolean] when true, unmodeled SGR codes and non-SGR
401
+ # escapes are discarded instead of raising — see {StyledString}
402
+ # "## Parser". Lossy: the result no longer round-trips to `input`.
271
403
  # @return [StyledString]
272
- # @raise [ParseError] on unsupported or malformed escape sequences.
404
+ # @raise [ParseError] on unsupported or malformed escape sequences
405
+ # (strict mode only).
273
406
  # @raise [TypeError] when `input` is none of String, StyledString, nil.
274
- def parse(input)
407
+ def parse(input, lenient: false)
275
408
  case input
276
409
  when nil then EMPTY
277
410
  when StyledString then input
@@ -279,7 +412,7 @@ module Tuile
279
412
  return EMPTY if input.empty?
280
413
  return new([Span.new(text: input, style: Style::DEFAULT)]) unless input.include?("\e")
281
414
 
282
- Parser.new(input).parse
415
+ Parser.new(input, lenient:).parse
283
416
  else
284
417
  raise TypeError, "cannot parse #{input.class}"
285
418
  end
@@ -456,7 +589,7 @@ module Tuile
456
589
 
457
590
  # Returns a new {StyledString} with `bg` applied to every span, preserving
458
591
  # each span's text and other style attributes (`fg`, `bold`, `italic`,
459
- # `underline`). Useful for row-level highlights — the new bg overlays
592
+ # `underline`, `strikethrough`). Useful for row-level highlights — the new bg overlays
460
593
  # without dropping foreground colors the original styling carried.
461
594
  #
462
595
  # @param bg [Color, Symbol, Integer, Array<Integer>, nil] background
@@ -469,7 +602,7 @@ module Tuile
469
602
 
470
603
  # Returns a new {StyledString} with `fg` applied to every span, preserving
471
604
  # each span's text and other style attributes (`bg`, `bold`, `italic`,
472
- # `underline`). The new fg overlays without dropping background colors or
605
+ # `underline`, `strikethrough`). The new fg overlays without dropping background colors or
473
606
  # text attributes the original styling carried.
474
607
  #
475
608
  # @param fg [Color, Symbol, Integer, Array<Integer>, nil] foreground
@@ -492,7 +625,7 @@ module Tuile
492
625
  out = +""
493
626
  current = Style::DEFAULT
494
627
  @spans.each do |span|
495
- out << sgr_diff(current, span.style)
628
+ out << current.sgr_to(span.style)
496
629
  out << span.text
497
630
  current = span.style
498
631
  end
@@ -517,34 +650,6 @@ module Tuile
517
650
  result
518
651
  end
519
652
 
520
- # @param from [Style]
521
- # @param to [Style]
522
- # @return [String]
523
- def sgr_diff(from, to)
524
- return "" if from == to
525
- return Ansi::RESET if to.default?
526
-
527
- codes = []
528
- codes << (to.bold ? 1 : 22) if from.bold != to.bold
529
- codes << (to.italic ? 3 : 23) if from.italic != to.italic
530
- codes << (to.underline ? 4 : 24) if from.underline != to.underline
531
- codes.concat(color_codes(to.fg, target: :fg)) if from.fg != to.fg
532
- codes.concat(color_codes(to.bg, target: :bg)) if from.bg != to.bg
533
- return "" if codes.empty?
534
-
535
- "\e[#{codes.join(";")}m"
536
- end
537
-
538
- # @param color [Color, nil]
539
- # @param target [Symbol] `:fg` or `:bg`.
540
- # @return [Array<Integer>] SGR codes; `[39]` / `[49]` for the "default" reset
541
- # when `color` is `nil`, otherwise delegated to {Color#sgr_codes}.
542
- def color_codes(color, target:)
543
- return [target == :fg ? 39 : 49] if color.nil?
544
-
545
- color.sgr_codes(target)
546
- end
547
-
548
653
  # @param start_or_range [Integer, Range]
549
654
  # @param len [Integer, nil]
550
655
  # @param total [Integer] receiver's full display width.
data/lib/tuile/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Tuile
4
4
  # @return [String]
5
- VERSION = "0.6.0"
5
+ VERSION = "0.8.0"
6
6
  end
data/mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ ruby = "3.3"