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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +1 -1
- data/examples/sampler.rb +112 -3
- data/ideas/back-buffer.md +217 -0
- data/lib/tuile/ansi.rb +16 -0
- data/lib/tuile/buffer.rb +412 -0
- data/lib/tuile/component/button.rb +2 -5
- data/lib/tuile/component/has_content.rb +0 -6
- data/lib/tuile/component/label.rb +8 -8
- data/lib/tuile/component/layout.rb +1 -13
- data/lib/tuile/component/list.rb +45 -23
- data/lib/tuile/component/log_window.rb +21 -5
- data/lib/tuile/component/picker_window.rb +8 -6
- data/lib/tuile/component/popup.rb +69 -13
- data/lib/tuile/component/text_area.rb +1 -1
- data/lib/tuile/component/text_field.rb +1 -1
- data/lib/tuile/component/text_input.rb +25 -9
- data/lib/tuile/component/text_view.rb +30 -10
- data/lib/tuile/component/window.rb +21 -38
- data/lib/tuile/component.rb +30 -26
- data/lib/tuile/fake_screen.rb +14 -1
- data/lib/tuile/keys.rb +2 -6
- data/lib/tuile/rect.rb +12 -0
- data/lib/tuile/screen.rb +109 -113
- data/lib/tuile/screen_pane.rb +81 -20
- data/lib/tuile/styled_string.rb +164 -59
- data/lib/tuile/version.rb +1 -1
- data/mise.toml +2 -0
- data/sig/tuile.rbs +639 -133
- metadata +10 -4
data/lib/tuile/screen_pane.rb
CHANGED
|
@@ -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>]
|
|
32
|
-
# topmost.
|
|
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
|
|
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.
|
|
71
|
-
|
|
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).
|
|
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
|
|
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
|
-
#
|
|
127
|
-
# when
|
|
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
|
-
|
|
130
|
-
return
|
|
131
|
-
return @content.handle_key(key) unless @content.nil?
|
|
158
|
+
scope = modal_popup || @content
|
|
159
|
+
return false if scope.nil?
|
|
132
160
|
|
|
133
|
-
|
|
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*
|
|
138
|
-
#
|
|
139
|
-
#
|
|
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 {
|
|
144
|
-
clicked = @content if clicked.nil? &&
|
|
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(
|
|
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`.
|
data/lib/tuile/styled_string.rb
CHANGED
|
@@ -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
|
|
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,
|
|
49
|
-
#
|
|
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
|
-
|
|
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}.
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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].
|
|
226
|
-
|
|
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 <<
|
|
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
data/mise.toml
ADDED