tuile 0.12.0 → 0.14.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 +116 -27
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +2342 -158
- data/README.md +151 -505
- data/TERMINOLOGY.md +15 -5
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +28 -20
- data/book/05-focus.md +137 -19
- data/book/06-theming.md +103 -2
- data/book/07-components.md +567 -27
- data/book/08-testing.md +34 -4
- data/book/09-styled-text.md +3 -3
- data/book/README.md +7 -5
- data/examples/file_commander.rb +23 -17
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +527 -108
- data/ideas/arrow-key-navigation.md +17 -1
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +28 -27
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +12 -3
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/has_content.rb +22 -9
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/layout.rb +0 -10
- data/lib/tuile/component/list.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +102 -11
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +24 -39
- data/lib/tuile/component/overlay.rb +192 -0
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +61 -123
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +17 -7
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +231 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area.rb +5 -4
- data/lib/tuile/component/text_field.rb +23 -6
- data/lib/tuile/component/text_view.rb +8 -5
- data/lib/tuile/component/window.rb +22 -46
- data/lib/tuile/component.rb +186 -31
- data/lib/tuile/event_queue.rb +45 -1
- data/lib/tuile/fake_screen.rb +40 -2
- data/lib/tuile/keys.rb +72 -0
- data/lib/tuile/screen.rb +212 -113
- data/lib/tuile/screen_pane.rb +125 -41
- data/lib/tuile/styled_string.rb +80 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2527 -358
- metadata +13 -3
- data/mise.toml +0 -2
data/lib/tuile/screen_pane.rb
CHANGED
|
@@ -5,10 +5,15 @@ module Tuile
|
|
|
5
5
|
#
|
|
6
6
|
# {Screen} is a singleton runtime owner (event loop, lock, terminal IO,
|
|
7
7
|
# invalidation set). All actual UI lives under a {ScreenPane}: the tiled
|
|
8
|
-
# {#content}
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
8
|
+
# {#content} and the {#popups} stack. Putting them under a single Component
|
|
9
|
+
# parent gives focus traversal a real root, makes {Component#attached?} a
|
|
10
|
+
# one-liner, and lets popup-focus repair fall out of the standard
|
|
11
|
+
# {Component#on_child_removed} hook.
|
|
12
|
+
#
|
|
13
|
+
# The pane owns no chrome of its own — no status bar, no reserved row.
|
|
14
|
+
# {#content} gets the full pane rect, and an app that wants a status line
|
|
15
|
+
# builds one into its own layout and drives it from
|
|
16
|
+
# {Screen#on_focus_changed=} (`D_status_bar`).
|
|
12
17
|
#
|
|
13
18
|
# The pane is not a {Component::Layout}: popups deliberately overlap content
|
|
14
19
|
# (Z-ordered, full overdraw, no clipping) and key/mouse dispatch follows
|
|
@@ -22,20 +27,15 @@ module Tuile
|
|
|
22
27
|
# user was, instead of falling through to {#content} and getting
|
|
23
28
|
# cascaded to the first focusable child.
|
|
24
29
|
@popup_prior_focus = {}
|
|
25
|
-
@status_bar = Component::Label.new
|
|
26
|
-
# Added first and never removed, so it is always the last child — which is
|
|
27
|
-
# the anchor `add_popup` inserts against.
|
|
28
|
-
add_child(@status_bar)
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
# @return [Component, nil] the tiled content component.
|
|
32
33
|
attr_reader :content
|
|
33
|
-
# @return [Array<Component>]
|
|
34
|
-
# topmost. Holds both
|
|
35
|
-
# ({Component::
|
|
34
|
+
# @return [Array<Component::Overlay>] the open overlays in stacking order;
|
|
35
|
+
# last is topmost. Holds both {Component::Popup} modals and bare
|
|
36
|
+
# {Component::Overlay}s ({Component::Overlay#modal?}). The array must not
|
|
37
|
+
# be mutated by callers.
|
|
36
38
|
attr_reader :popups
|
|
37
|
-
# @return [Component::Label] the bottom status bar.
|
|
38
|
-
attr_reader :status_bar
|
|
39
39
|
|
|
40
40
|
def focusable? = false
|
|
41
41
|
|
|
@@ -55,26 +55,26 @@ module Tuile
|
|
|
55
55
|
layout
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
# Adds
|
|
59
|
-
# and grabs focus; a
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
58
|
+
# Adds an overlay and invalidates it for repaint. A {Component::Popup} is
|
|
59
|
+
# centered and grabs focus; a bare {Component::Overlay} is left wherever
|
|
60
|
+
# the caller positioned it and does *not* take focus, so the component that
|
|
61
|
+
# was focused keeps the cursor and keeps receiving keys — the overlay
|
|
62
|
+
# floats above the content, driven from app code.
|
|
63
63
|
#
|
|
64
|
-
# The *whole subtree* is invalidated, not just the
|
|
64
|
+
# The *whole subtree* is invalidated, not just the overlay wrapper (which
|
|
65
65
|
# paints nothing on its own): a reopened popup may land on cells that the
|
|
66
66
|
# tiled content has since overpainted, and if its rect is unchanged from
|
|
67
67
|
# last time its content components won't re-invalidate themselves — so
|
|
68
|
-
# without this the
|
|
69
|
-
# @param window [Component::
|
|
68
|
+
# without this the overlay's contents would stay blank on reopen.
|
|
69
|
+
# @param window [Component::Overlay] any overlay, modal or not.
|
|
70
70
|
# @return [void]
|
|
71
71
|
def add_popup(window)
|
|
72
|
-
raise TypeError, "expected
|
|
72
|
+
raise TypeError, "expected Overlay, got #{window.inspect}" unless window.is_a? Component::Overlay
|
|
73
73
|
raise ArgumentError, "#{window} already has a parent #{window.parent}" unless window.parent.nil?
|
|
74
74
|
|
|
75
75
|
@popup_prior_focus[window] = screen.focused
|
|
76
76
|
@popups << window
|
|
77
|
-
add_child(window
|
|
77
|
+
add_child(window) # appended: popups paint over the tiled content
|
|
78
78
|
if window.modal?
|
|
79
79
|
window.center
|
|
80
80
|
screen.focused = window
|
|
@@ -124,11 +124,11 @@ module Tuile
|
|
|
124
124
|
# @return [Boolean] true if this pane currently hosts the popup.
|
|
125
125
|
def has_popup?(window) = @popups.include?(window) # rubocop:disable Naming/PredicatePrefix
|
|
126
126
|
|
|
127
|
-
# @return [Component::Popup, nil] the topmost
|
|
128
|
-
# only
|
|
129
|
-
# owner": the popup that scopes key dispatch, blocks mouse clicks,
|
|
130
|
-
#
|
|
131
|
-
#
|
|
127
|
+
# @return [Component::Popup, nil] the topmost modal overlay, or nil when
|
|
128
|
+
# only bare {Component::Overlay}s (or none) are open. This is the "modal
|
|
129
|
+
# owner": the popup that scopes key dispatch, blocks mouse clicks, and
|
|
130
|
+
# confines Tab cycling. Bare overlays are excluded — they float above the
|
|
131
|
+
# content without capturing input.
|
|
132
132
|
def modal_popup = @popups.reverse_each.find(&:modal?)
|
|
133
133
|
|
|
134
134
|
# Re-lays out children whenever the pane's own rect changes.
|
|
@@ -139,18 +139,17 @@ module Tuile
|
|
|
139
139
|
layout
|
|
140
140
|
end
|
|
141
141
|
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
142
|
+
# Gives {#content} the whole pane rect — the pane reserves nothing for
|
|
143
|
+
# itself. Each popup re-resolves its {Component::Popup#declared_size} against the new
|
|
144
|
+
# screen via {Component::Popup#reposition} — so a {Fraction} size tracks
|
|
145
|
+
# resize — repositioning itself (modal popups recenter; non-modal overlays
|
|
146
|
+
# keep the top-left their owner assigned).
|
|
147
147
|
# @return [void]
|
|
148
148
|
def layout
|
|
149
149
|
return if rect.empty?
|
|
150
150
|
|
|
151
|
-
@content
|
|
151
|
+
@content&.rect = rect
|
|
152
152
|
@popups.each(&:reposition)
|
|
153
|
-
@status_bar.rect = Rect.new(rect.left, rect.top + rect.height - 1, rect.width, 1)
|
|
154
153
|
end
|
|
155
154
|
|
|
156
155
|
# Pane paints nothing itself; its children paint over the entire rect.
|
|
@@ -181,18 +180,67 @@ module Tuile
|
|
|
181
180
|
bubble_key(key, scope)
|
|
182
181
|
end
|
|
183
182
|
|
|
183
|
+
# Delivers pasted text along the same focus chain {#handle_key} bubbles
|
|
184
|
+
# along, and with the same scoping — first {Component#handle_paste}
|
|
185
|
+
# returning true wins.
|
|
186
|
+
# @param text [String]
|
|
187
|
+
# @return [Boolean] true if the text was consumed.
|
|
188
|
+
def handle_paste(text)
|
|
189
|
+
scope = modal_popup || @content
|
|
190
|
+
return false if scope.nil?
|
|
191
|
+
|
|
192
|
+
chain = focus_chain(scope)
|
|
193
|
+
return false if chain.nil?
|
|
194
|
+
|
|
195
|
+
chain.each { |c| return true if c.handle_paste(text) }
|
|
196
|
+
false
|
|
197
|
+
end
|
|
198
|
+
|
|
184
199
|
# Mouse events check popups in reverse stacking order (topmost first), and
|
|
185
200
|
# fall through to content only when no popup is hit *and* no modal popup is
|
|
186
201
|
# open. This preserves modal click-blocking — an open modal eats clicks
|
|
187
202
|
# even outside its rect — while a non-modal overlay blocks nothing: clicks
|
|
188
203
|
# inside it route to it (e.g. click-to-select), clicks elsewhere reach the
|
|
189
204
|
# content beneath.
|
|
205
|
+
#
|
|
206
|
+
# A left click also *dismisses* the open popups it landed outside of that
|
|
207
|
+
# asked for it ({Component::Overlay#close_on_outside_click?}). That is a
|
|
208
|
+
# second thing happening on a click, but not a second dispatch: the click is
|
|
209
|
+
# still delivered exactly once, down one chain, and a dismissed popup is
|
|
210
|
+
# closed rather than told.
|
|
211
|
+
#
|
|
212
|
+
# "Outside" is measured against the {Component::Overlay#owner} chain, not
|
|
213
|
+
# against one rect and not against stacking order: the popup the click hit
|
|
214
|
+
# is kept, and so is every popup that one *belongs to*, transitively. That
|
|
215
|
+
# is what stops a dialog being dismissed by a click on a dropdown its own
|
|
216
|
+
# field opened, and a menu cascade being dismissed by a click on one of its
|
|
217
|
+
# own deeper panels. Order carries no meaning here — between unrelated
|
|
218
|
+
# overlays it is merely the order they opened in — so ownership is declared
|
|
219
|
+
# rather than inferred from the stack.
|
|
220
|
+
#
|
|
221
|
+
# Two halves of the ordering are load-bearing, and both are specced:
|
|
222
|
+
#
|
|
223
|
+
# - **Snapshot before routing.** A popup the delivered click *opens* must
|
|
224
|
+
# not be in the set (it would immediately dismiss itself — every
|
|
225
|
+
# {Component::Select} would be unopenable by mouse).
|
|
226
|
+
# - **Close after routing.** A widget toggling its own overlay from a click
|
|
227
|
+
# on its face closes it during delivery, and {Component::Popup#close} is
|
|
228
|
+
# idempotent, so the dismissal no-ops. Close *first* and the widget sees
|
|
229
|
+
# a shut overlay and reopens it — a Select's dropdown could then never be
|
|
230
|
+
# dismissed by clicking the Select.
|
|
231
|
+
#
|
|
232
|
+
# The snapshot is a fresh array for a third reason: a handler may close
|
|
233
|
+
# further popups, and `@popups` must not be mutated mid-iteration.
|
|
190
234
|
# @param event [MouseEvent]
|
|
191
235
|
# @return [void]
|
|
192
236
|
def handle_mouse(event)
|
|
193
|
-
|
|
194
|
-
|
|
237
|
+
hit = @popups.reverse_each.find { _1.rect.contains?(event.point) }
|
|
238
|
+
dismissable = event.button == :left ? @popups - kept_by(hit) : []
|
|
239
|
+
|
|
240
|
+
clicked = hit || (@content if modal_popup.nil?)
|
|
195
241
|
clicked&.handle_mouse(event)
|
|
242
|
+
|
|
243
|
+
dismissable.each { _1.close if _1.close_on_outside_click? }
|
|
196
244
|
end
|
|
197
245
|
|
|
198
246
|
# Focus repair when a child detaches. Default {Component#on_child_removed}
|
|
@@ -231,6 +279,33 @@ module Tuile
|
|
|
231
279
|
|
|
232
280
|
private
|
|
233
281
|
|
|
282
|
+
# The overlays a click counts as landing *inside*: the one it hit, plus
|
|
283
|
+
# every overlay that one belongs to, up the {Component::Overlay#owner}
|
|
284
|
+
# chain. An owner is any component, so it is resolved to the overlay
|
|
285
|
+
# enclosing it (an overlay resolves to itself) — which keeps the
|
|
286
|
+
# relationship a live tree question rather than one frozen when the overlay
|
|
287
|
+
# opened. The `include?` guard makes a mis-wired cycle terminate instead of
|
|
288
|
+
# hanging the UI thread.
|
|
289
|
+
# @param hit [Component::Overlay, nil] the overlay the click landed in, if any.
|
|
290
|
+
# @return [Array<Component::Overlay>]
|
|
291
|
+
def kept_by(hit)
|
|
292
|
+
kept = []
|
|
293
|
+
overlay = hit
|
|
294
|
+
while overlay && !kept.include?(overlay)
|
|
295
|
+
kept << overlay
|
|
296
|
+
overlay = enclosing_popup(overlay.owner)
|
|
297
|
+
end
|
|
298
|
+
kept
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# @param component [Component, nil]
|
|
302
|
+
# @return [Component::Overlay, nil] `component` itself when it is an
|
|
303
|
+
# overlay, else the nearest overlay above it, else nil.
|
|
304
|
+
def enclosing_popup(component)
|
|
305
|
+
component = component.parent until component.nil? || component.is_a?(Component::Overlay)
|
|
306
|
+
component
|
|
307
|
+
end
|
|
308
|
+
|
|
234
309
|
# Delivers `key` to {Screen#focused} and bubbles it up the ancestor chain,
|
|
235
310
|
# stopping at (and including) `scope`. Delivers to no one — returning false
|
|
236
311
|
# — when focus is nil or sits outside `scope`; the latter is what makes an
|
|
@@ -240,6 +315,18 @@ module Tuile
|
|
|
240
315
|
# @param scope [Component] the modal scope root (topmost popup or content).
|
|
241
316
|
# @return [Boolean] true if some component on the chain handled the key.
|
|
242
317
|
def bubble_key(key, scope)
|
|
318
|
+
chain = focus_chain(scope)
|
|
319
|
+
return false if chain.nil?
|
|
320
|
+
|
|
321
|
+
chain.each { |c| return true if c.handle_key(key) }
|
|
322
|
+
false
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# {Screen#focused} and its ancestors up to and including `scope`.
|
|
326
|
+
# @param scope [Component] the modal scope root (topmost popup or content).
|
|
327
|
+
# @return [Array<Component>, nil] the chain, innermost first; nil when
|
|
328
|
+
# focus is nil or sits outside `scope`.
|
|
329
|
+
def focus_chain(scope)
|
|
243
330
|
chain = []
|
|
244
331
|
cursor = screen.focused
|
|
245
332
|
until cursor.nil?
|
|
@@ -248,10 +335,7 @@ module Tuile
|
|
|
248
335
|
|
|
249
336
|
cursor = cursor.parent
|
|
250
337
|
end
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
chain.each { |c| return true if c.handle_key(key) }
|
|
254
|
-
false
|
|
338
|
+
chain.last.equal?(scope) ? chain : nil
|
|
255
339
|
end
|
|
256
340
|
|
|
257
341
|
# First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
|
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`, `strikethrough`). Spans are non-overlapping and fully tile
|
|
6
|
+
# `underline`, `strikethrough`, `inverse`). Spans are non-overlapping and fully tile
|
|
7
7
|
# the string — every character has exactly one resolved style, no overlay
|
|
8
8
|
# layers to merge, so the style at any column is just its span's `style`
|
|
9
9
|
# rather than a replay of the SGR state machine. The book's chapter 9 is
|
|
@@ -34,7 +34,7 @@ module Tuile
|
|
|
34
34
|
# ## Parser
|
|
35
35
|
#
|
|
36
36
|
# {.parse} is strict by default — it recognizes only the SGR codes for
|
|
37
|
-
# {Style}'s attributes (fg/bg/bold/italic/underline/strikethrough) and
|
|
37
|
+
# {Style}'s attributes (fg/bg/bold/italic/underline/strikethrough/inverse) and
|
|
38
38
|
# raises {ParseError} on anything else, keeping the `parse(to_ansi(x)) == x`
|
|
39
39
|
# round-trip honest. Pass `lenient: true` to instead discard everything it
|
|
40
40
|
# can't model (unmodeled SGR, cursor moves, OSC/DCS, stray escapes) and keep
|
|
@@ -62,17 +62,22 @@ module Tuile
|
|
|
62
62
|
# @return [Boolean]
|
|
63
63
|
# @!attribute [r] strikethrough
|
|
64
64
|
# @return [Boolean]
|
|
65
|
-
|
|
65
|
+
# @!attribute [r] inverse
|
|
66
|
+
# @return [Boolean] swap the fg/bg *in effect* at the cell (SGR 7) —
|
|
67
|
+
# including terminal defaults, which `fg`/`bg` can't name.
|
|
68
|
+
class Style < Data.define(:fg, :bg, :bold, :italic, :underline, :strikethrough, :inverse)
|
|
66
69
|
# @param fg [Color, Symbol, Integer, Array<Integer>, nil] coerced via {Color.coerce}.
|
|
67
70
|
# @param bg [Color, Symbol, Integer, Array<Integer>, nil] coerced via {Color.coerce}.
|
|
68
71
|
# @param bold [Boolean]
|
|
69
72
|
# @param italic [Boolean]
|
|
70
73
|
# @param underline [Boolean]
|
|
71
74
|
# @param strikethrough [Boolean]
|
|
75
|
+
# @param inverse [Boolean]
|
|
72
76
|
# @return [Style]
|
|
73
77
|
# @raise [ArgumentError] when a color is not one of the accepted forms.
|
|
74
|
-
def self.new(fg: nil, bg: nil, bold: false, italic: false, underline: false, strikethrough: false
|
|
75
|
-
|
|
78
|
+
def self.new(fg: nil, bg: nil, bold: false, italic: false, underline: false, strikethrough: false,
|
|
79
|
+
inverse: false)
|
|
80
|
+
super(fg: Color.coerce(fg), bg: Color.coerce(bg), bold:, italic:, underline:, strikethrough:, inverse:)
|
|
76
81
|
end
|
|
77
82
|
|
|
78
83
|
# The style with no color and no attributes — what the terminal shows
|
|
@@ -104,6 +109,7 @@ module Tuile
|
|
|
104
109
|
codes << (other.italic ? 3 : 23) if italic != other.italic
|
|
105
110
|
codes << (other.underline ? 4 : 24) if underline != other.underline
|
|
106
111
|
codes << (other.strikethrough ? 9 : 29) if strikethrough != other.strikethrough
|
|
112
|
+
codes << (other.inverse ? 7 : 27) if inverse != other.inverse
|
|
107
113
|
codes.concat(color_codes(other.fg, target: :fg)) if fg != other.fg
|
|
108
114
|
codes.concat(color_codes(other.bg, target: :bg)) if bg != other.bg
|
|
109
115
|
return "" if codes.empty?
|
|
@@ -282,6 +288,8 @@ module Tuile
|
|
|
282
288
|
when 23 then @style = @style.merge(italic: false)
|
|
283
289
|
when 4 then @style = @style.merge(underline: true)
|
|
284
290
|
when 24 then @style = @style.merge(underline: false)
|
|
291
|
+
when 7 then @style = @style.merge(inverse: true)
|
|
292
|
+
when 27 then @style = @style.merge(inverse: false)
|
|
285
293
|
when 9 then @style = @style.merge(strikethrough: true)
|
|
286
294
|
when 29 then @style = @style.merge(strikethrough: false)
|
|
287
295
|
when 30..37 then @style = @style.merge(fg: STANDARD_COLORS[code - 30])
|
|
@@ -405,7 +413,7 @@ module Tuile
|
|
|
405
413
|
# the parts of anything else. That is the one setting never wrong in the
|
|
406
414
|
# dangerous direction: under-measuring lets a glyph overrun its cell, which
|
|
407
415
|
# shifts the rest of the row, desyncs the cursor and escapes the component's
|
|
408
|
-
# rect, while over-measuring leaves a blank column. `
|
|
416
|
+
# rect, while over-measuring leaves a blank column. `D_cluster_width` has the
|
|
409
417
|
# per-setting reasoning.
|
|
410
418
|
# @return [Symbol]
|
|
411
419
|
EMOJI_WIDTH = :rgi
|
|
@@ -605,6 +613,10 @@ module Tuile
|
|
|
605
613
|
# so a log line keeps its red error-level bg while its plain text picks up an
|
|
606
614
|
# inherited panel tint.
|
|
607
615
|
#
|
|
616
|
+
# An `inverse` span counts as backgrounded and is skipped even when its `bg`
|
|
617
|
+
# member is nil: SGR 7 swaps the pair in effect, so a bg filled under it
|
|
618
|
+
# would recolor the span's *glyphs*, not the ground behind them.
|
|
619
|
+
#
|
|
608
620
|
# @param bg [Color, Symbol, Integer, Array<Integer>, nil] background color,
|
|
609
621
|
# coerced via {Color.coerce}. `nil` returns `self` unchanged.
|
|
610
622
|
# @return [StyledString]
|
|
@@ -613,7 +625,11 @@ module Tuile
|
|
|
613
625
|
|
|
614
626
|
bg = Color.coerce(bg)
|
|
615
627
|
self.class.new(@spans.map do |span|
|
|
616
|
-
span.style.bg.nil?
|
|
628
|
+
if span.style.bg.nil? && !span.style.inverse
|
|
629
|
+
Span.new(text: span.text, style: span.style.merge(bg: bg))
|
|
630
|
+
else
|
|
631
|
+
span
|
|
632
|
+
end
|
|
617
633
|
end)
|
|
618
634
|
end
|
|
619
635
|
|
|
@@ -630,6 +646,63 @@ module Tuile
|
|
|
630
646
|
self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(fg: fg)) })
|
|
631
647
|
end
|
|
632
648
|
|
|
649
|
+
# Returns a new {StyledString} with `bold` applied to every span, preserving
|
|
650
|
+
# each span's text and other style attributes (`fg`, `bg`, `italic`,
|
|
651
|
+
# `underline`, `strikethrough`). The bold-attribute counterpart of
|
|
652
|
+
# {#with_bg} / {#with_fg}: it emphasizes a whole run of app-authored,
|
|
653
|
+
# possibly multi-span content — a widget marking one caption out of several
|
|
654
|
+
# as selected, where the caption may already carry its own colors.
|
|
655
|
+
#
|
|
656
|
+
# There is deliberately no `under_bold` (the fill-unset counterpart
|
|
657
|
+
# {#under_bg} provides for backgrounds): a background is inherited down the
|
|
658
|
+
# component tree, so a span with none has a meaningful "unset" state to
|
|
659
|
+
# fill, while `bold` is a plain per-span attribute that is either on or
|
|
660
|
+
# off. Pass `bold: false` to clear it.
|
|
661
|
+
#
|
|
662
|
+
# @param bold [Boolean] whether the spans should be bold.
|
|
663
|
+
# @return [StyledString]
|
|
664
|
+
def with_bold(bold: true)
|
|
665
|
+
self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(bold:)) })
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# Returns a new {StyledString} with `underline` applied to every span,
|
|
669
|
+
# preserving each span's text and other style attributes (`fg`, `bg`,
|
|
670
|
+
# `bold`, `italic`, `strikethrough`). Slice and rejoin to underline *part*
|
|
671
|
+
# of a string, which is what a one-character cue needs:
|
|
672
|
+
#
|
|
673
|
+
# cap = StyledString.parse("File")
|
|
674
|
+
# cap.slice(0, 1).with_underline + cap.slice(1, cap.display_width - 1)
|
|
675
|
+
# # => "File" with the F underlined — a menu mnemonic
|
|
676
|
+
#
|
|
677
|
+
# Note {#slice} counts **columns**, not characters, so a caption with a
|
|
678
|
+
# wide glyph before the cue needs the prefix measured rather than counted.
|
|
679
|
+
#
|
|
680
|
+
# There is deliberately no `under_underline`, for the reason {#with_bold}
|
|
681
|
+
# spells out. Pass `underline: false` to clear it.
|
|
682
|
+
#
|
|
683
|
+
# @param underline [Boolean] whether the spans should be underlined.
|
|
684
|
+
# @return [StyledString]
|
|
685
|
+
def with_underline(underline: true)
|
|
686
|
+
self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(underline:)) })
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# Returns a new {StyledString} with `inverse` applied to every span,
|
|
690
|
+
# preserving each span's text and other style attributes. Inverse swaps
|
|
691
|
+
# whatever fg/bg are actually in effect at each cell — terminal defaults
|
|
692
|
+
# included — so a focus chip built with it reads as "backgrounded" on any
|
|
693
|
+
# terminal palette without picking a single color:
|
|
694
|
+
#
|
|
695
|
+
# StyledString.plain(" 1 VMs ").with_inverse # the inverted-chip idiom
|
|
696
|
+
#
|
|
697
|
+
# There is deliberately no `under_inverse`, for the reason {#with_bold}
|
|
698
|
+
# spells out. Pass `inverse: false` to clear it.
|
|
699
|
+
#
|
|
700
|
+
# @param inverse [Boolean] whether the spans should be inverted.
|
|
701
|
+
# @return [StyledString]
|
|
702
|
+
def with_inverse(inverse: true)
|
|
703
|
+
self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(inverse:)) })
|
|
704
|
+
end
|
|
705
|
+
|
|
633
706
|
# @return [String]
|
|
634
707
|
def inspect
|
|
635
708
|
"#<#{self.class.name} #{to_s.inspect}>"
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
|
-
# Detects
|
|
5
|
-
#
|
|
4
|
+
# Detects the terminal's background color — both the light/dark scheme
|
|
5
|
+
# {Screen} picks {Theme::LIGHT} or {Theme::DARK} from, and, when the
|
|
6
|
+
# terminal answers the query, the actual RGB behind it.
|
|
6
7
|
#
|
|
7
8
|
# Two mechanisms, in order of reliability:
|
|
8
9
|
#
|
|
@@ -15,14 +16,34 @@ module Tuile
|
|
|
15
16
|
# bounded by a short timeout.
|
|
16
17
|
# 2. **`COLORFGBG` env var** — rxvt/konsole export `"fg;bg"` ANSI palette
|
|
17
18
|
# indices. Less reliable (stale across SSH/tmux, often unset); used
|
|
18
|
-
# only when OSC 11 yields nothing.
|
|
19
|
+
# only when OSC 11 yields nothing. Palette indices carry no RGB, so
|
|
20
|
+
# this path fills in {Result#scheme} and leaves {Result#color} nil.
|
|
19
21
|
#
|
|
20
22
|
# **Timing matters**: the OSC 11 reply arrives on stdin, so the query
|
|
21
23
|
# must complete before {EventQueue#start_key_thread} owns stdin —
|
|
22
24
|
# otherwise the reply bytes get consumed as garbage keystrokes. {Screen}
|
|
23
25
|
# calls {.detect} from its constructor, which apps run before
|
|
24
26
|
# {Screen#run_event_loop}; don't call this after the event loop started.
|
|
27
|
+
#
|
|
28
|
+
# Once the loop *is* running the query is still available, from the other
|
|
29
|
+
# side: {Screen} writes {QUERY} on every OS appearance flip and the key
|
|
30
|
+
# thread — which owns stdin by then — reads the reply back through
|
|
31
|
+
# {Keys.getkey} and {.parse}. See {Screen#background_color}.
|
|
25
32
|
module TerminalBackground
|
|
33
|
+
# What a detection found: the light/dark `scheme`, and the background
|
|
34
|
+
# `color` when a terminal actually reported one.
|
|
35
|
+
#
|
|
36
|
+
# `color` is nil whenever the scheme came from the `COLORFGBG`
|
|
37
|
+
# fallback, so a consumer deriving a tint from the background must
|
|
38
|
+
# handle nil — plenty of terminals answer neither query.
|
|
39
|
+
#
|
|
40
|
+
# @!attribute [r] scheme
|
|
41
|
+
# @return [Symbol] `:light` or `:dark`.
|
|
42
|
+
# @!attribute [r] color
|
|
43
|
+
# @return [Color, nil] the reported background as 24-bit RGB, or nil
|
|
44
|
+
# when only `COLORFGBG` answered.
|
|
45
|
+
Result = Data.define(:scheme, :color)
|
|
46
|
+
|
|
26
47
|
# How long to wait for the OSC 11 reply. Generous for a local
|
|
27
48
|
# terminal; bounded so unsupporting terminals (which never reply)
|
|
28
49
|
# don't stall startup.
|
|
@@ -54,35 +75,63 @@ module Tuile
|
|
|
54
75
|
# Detects the terminal background. Queries OSC 11 when both `input`
|
|
55
76
|
# and `output` are TTYs, falling back to `COLORFGBG`.
|
|
56
77
|
#
|
|
78
|
+
# TerminalBackground.detect
|
|
79
|
+
# # => #<data Result scheme=:dark, color=#<Tuile::Color [30, 30, 46]>>
|
|
80
|
+
#
|
|
57
81
|
# @param input [IO] where the OSC 11 reply arrives (the TTY input).
|
|
58
82
|
# @param output [IO] where the query is written (the TTY output).
|
|
59
83
|
# @param env [Hash{String => String}] environment for the `COLORFGBG`
|
|
60
84
|
# fallback; defaults to `ENV` (which duck-types the `[]` lookup).
|
|
61
85
|
# @param timeout [Numeric] max seconds to wait for the OSC 11 reply.
|
|
62
|
-
# @return [
|
|
86
|
+
# @return [Result, nil] nil when the background is undetectable —
|
|
87
|
+
# neither mechanism answered.
|
|
63
88
|
def detect(input: $stdin, output: $stdout, env: ENV, timeout: QUERY_TIMEOUT)
|
|
64
89
|
osc = query_osc11(input, output, timeout) if input.tty? && output.tty?
|
|
65
|
-
osc
|
|
90
|
+
return osc if osc
|
|
91
|
+
|
|
92
|
+
scheme = from_colorfgbg(env["COLORFGBG"])
|
|
93
|
+
scheme && Result.new(scheme: scheme, color: nil)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Parses an OSC 11 reply — the terminal's answer to {QUERY}, matched
|
|
97
|
+
# anywhere in `reply`.
|
|
98
|
+
#
|
|
99
|
+
# TerminalBackground.parse("\e]11;rgb:1e1e/1e1e/2e2e\a").color
|
|
100
|
+
# # => #<Tuile::Color [30, 30, 46]>
|
|
101
|
+
#
|
|
102
|
+
# Public because a reply also arrives *mid-session*, long after
|
|
103
|
+
# {.detect}'s own bounded read: once the key thread owns stdin, a
|
|
104
|
+
# whole reply surfaces as one "key" from {Keys.getkey}.
|
|
105
|
+
#
|
|
106
|
+
# @param reply [String] raw terminal output that may contain a reply.
|
|
107
|
+
# @return [Result, nil] nil when `reply` holds no OSC 11 reply.
|
|
108
|
+
def parse(reply)
|
|
109
|
+
match = REPLY.match(reply)
|
|
110
|
+
return nil unless match
|
|
111
|
+
|
|
112
|
+
# Components arrive as 1–4 hex digits (terminals vary), so each is
|
|
113
|
+
# scaled by its own width: "ab" and "abab" are both ~0.67.
|
|
114
|
+
components = match.captures.map { |c| c.to_i(16).fdiv((16**c.length) - 1) }
|
|
115
|
+
Result.new(scheme: classify(components), color: to_color(components))
|
|
66
116
|
end
|
|
67
117
|
|
|
68
118
|
private
|
|
69
119
|
|
|
70
|
-
# Writes the OSC 11 query and
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
120
|
+
# Writes the OSC 11 query and parses the reply. The whole exchange
|
|
121
|
+
# runs with `input` in raw mode: the reply has no trailing newline,
|
|
122
|
+
# so a canonical-mode read would block past the timeout, and echo
|
|
123
|
+
# would smear the reply bytes onto the screen.
|
|
74
124
|
# @param input [IO]
|
|
75
125
|
# @param output [IO]
|
|
76
126
|
# @param timeout [Numeric]
|
|
77
|
-
# @return [
|
|
127
|
+
# @return [Result, nil]
|
|
78
128
|
def query_osc11(input, output, timeout)
|
|
79
129
|
reply = input.raw do
|
|
80
130
|
output.write(QUERY)
|
|
81
131
|
output.flush
|
|
82
132
|
read_reply(input, timeout)
|
|
83
133
|
end
|
|
84
|
-
|
|
85
|
-
match && classify(match.captures)
|
|
134
|
+
parse(reply)
|
|
86
135
|
rescue SystemCallError, IOError
|
|
87
136
|
nil
|
|
88
137
|
end
|
|
@@ -107,16 +156,25 @@ module Tuile
|
|
|
107
156
|
buffer
|
|
108
157
|
end
|
|
109
158
|
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
# @param components [Array<String>] three hex strings.
|
|
159
|
+
# Light or dark, by relative luminance against a 0.5 threshold.
|
|
160
|
+
# @param components [Array<Float>] red, green and blue, each 0.0..1.0.
|
|
113
161
|
# @return [Symbol] `:light` or `:dark`.
|
|
114
162
|
def classify(components)
|
|
115
|
-
r, g, b = components
|
|
163
|
+
r, g, b = components
|
|
116
164
|
luminance = (0.2126 * r) + (0.7152 * g) + (0.0722 * b)
|
|
117
165
|
luminance > 0.5 ? :light : :dark
|
|
118
166
|
end
|
|
119
167
|
|
|
168
|
+
# The reported background as a 24-bit {Color}. Quantizing xterm's
|
|
169
|
+
# 16-bit-per-channel reply down to 8 loses nothing a terminal can
|
|
170
|
+
# display, and keeps the exposed value in the type the rest of Tuile
|
|
171
|
+
# paints with.
|
|
172
|
+
# @param components [Array<Float>] red, green and blue, each 0.0..1.0.
|
|
173
|
+
# @return [Color]
|
|
174
|
+
def to_color(components)
|
|
175
|
+
Color.rgb(*components.map { (_1 * 255).round })
|
|
176
|
+
end
|
|
177
|
+
|
|
120
178
|
# `COLORFGBG` is `"fg;bg"` (rxvt sometimes `"fg;default;bg"`) with
|
|
121
179
|
# ANSI palette indices. White-ish backgrounds — 7 (white) and the
|
|
122
180
|
# bright range 9–15 — read as light; 0–6 and 8 as dark; anything
|
data/lib/tuile/version.rb
CHANGED