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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -27
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +2342 -158
  5. data/README.md +151 -505
  6. data/TERMINOLOGY.md +15 -5
  7. data/book/01-first-app.md +22 -17
  8. data/book/02-repaint.md +18 -5
  9. data/book/03-layout.md +28 -20
  10. data/book/05-focus.md +137 -19
  11. data/book/06-theming.md +103 -2
  12. data/book/07-components.md +567 -27
  13. data/book/08-testing.md +34 -4
  14. data/book/09-styled-text.md +3 -3
  15. data/book/README.md +7 -5
  16. data/examples/file_commander.rb +23 -17
  17. data/examples/hello_world.rb +17 -5
  18. data/examples/sampler.rb +527 -108
  19. data/ideas/arrow-key-navigation.md +17 -1
  20. data/ideas/modal-backdrop.md +24 -0
  21. data/ideas/new-components.md +28 -27
  22. data/lib/tuile/ansi.rb +10 -0
  23. data/lib/tuile/buffer.rb +51 -3
  24. data/lib/tuile/color.rb +143 -0
  25. data/lib/tuile/color_depth.rb +80 -0
  26. data/lib/tuile/component/abstract_string_field.rb +36 -0
  27. data/lib/tuile/component/button.rb +3 -3
  28. data/lib/tuile/component/checkbox.rb +3 -3
  29. data/lib/tuile/component/combo_box.rb +12 -3
  30. data/lib/tuile/component/confirm_window.rb +442 -0
  31. data/lib/tuile/component/has_content.rb +22 -9
  32. data/lib/tuile/component/has_value.rb +1 -1
  33. data/lib/tuile/component/info_window.rb +64 -16
  34. data/lib/tuile/component/layout.rb +0 -10
  35. data/lib/tuile/component/list.rb +22 -0
  36. data/lib/tuile/component/list_dropdown.rb +102 -11
  37. data/lib/tuile/component/log_text_view.rb +71 -0
  38. data/lib/tuile/component/log_window.rb +13 -48
  39. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  40. data/lib/tuile/component/menu_bar.rb +582 -0
  41. data/lib/tuile/component/notification.rb +24 -39
  42. data/lib/tuile/component/overlay.rb +192 -0
  43. data/lib/tuile/component/picker_window.rb +0 -5
  44. data/lib/tuile/component/popup.rb +61 -123
  45. data/lib/tuile/component/progress_bar.rb +1 -1
  46. data/lib/tuile/component/select.rb +17 -7
  47. data/lib/tuile/component/slot.rb +54 -0
  48. data/lib/tuile/component/tab_sheet.rb +231 -0
  49. data/lib/tuile/component/tabs.rb +528 -0
  50. data/lib/tuile/component/text_area.rb +5 -4
  51. data/lib/tuile/component/text_field.rb +23 -6
  52. data/lib/tuile/component/text_view.rb +8 -5
  53. data/lib/tuile/component/window.rb +22 -46
  54. data/lib/tuile/component.rb +186 -31
  55. data/lib/tuile/event_queue.rb +45 -1
  56. data/lib/tuile/fake_screen.rb +40 -2
  57. data/lib/tuile/keys.rb +72 -0
  58. data/lib/tuile/screen.rb +212 -113
  59. data/lib/tuile/screen_pane.rb +125 -41
  60. data/lib/tuile/styled_string.rb +80 -7
  61. data/lib/tuile/terminal_background.rb +74 -16
  62. data/lib/tuile/version.rb +1 -1
  63. data/sig/tuile.rbs +2527 -358
  64. metadata +13 -3
  65. data/mise.toml +0 -2
@@ -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}, the modal {#popups} stack, and the bottom {#status_bar}.
9
- # Putting them under a single Component parent gives focus traversal a real
10
- # root, makes {Component#attached?} a one-liner, and lets popup-focus repair
11
- # fall out of the standard {Component#on_child_removed} hook.
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>] overlay popups in stacking order; last is
34
- # topmost. Holds both modal popups and non-modal overlays
35
- # ({Component::Popup#modal?}). The array must not be mutated by callers.
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 a popup and invalidates it for repaint. A modal popup is centered
59
- # and grabs focus; a non-modal overlay ({Component::Popup#modal?} false) is
60
- # left wherever the caller positions it and does *not* take focus, so the
61
- # component that was focused keeps the cursor and keeps receiving keys —
62
- # the overlay floats above the content, driven from app code.
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 popup wrapper (which
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 popup's contents would stay blank on reopen.
69
- # @param window [Component::Popup]
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 Popup, got #{window.inspect}" unless window.is_a? Component::Popup
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, at: @children.index(@status_bar))
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 *modal* popup, or nil when
128
- # only non-modal overlays (or no popups) are open. This is the "modal
129
- # owner": the popup that scopes key dispatch, blocks mouse clicks, owns
130
- # the status bar, and confines Tab cycling. Non-modal overlays are
131
- # excluded — they float above the content without capturing input.
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
- # Lays out content (full pane minus the bottom row) and the status bar
143
- # (bottom row). Each popup re-resolves its {Component::Popup#size} against
144
- # the new screen via {Component::Popup#reposition} — so a {Fraction} size
145
- # tracks resize — repositioning itself (modal popups recenter; non-modal
146
- # overlays keep the top-left their owner assigned).
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.rect = Rect.new(rect.left, rect.top, rect.width, [rect.height - 1, 0].max) unless @content.nil?
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
- clicked = @popups.reverse_each.find { _1.rect.contains?(event.point) }
194
- clicked = @content if clicked.nil? && modal_popup.nil?
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
- return false unless chain.last.equal?(scope)
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
@@ -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
- class Style < Data.define(:fg, :bg, :bold, :italic, :underline, :strikethrough)
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
- super(fg: Color.coerce(fg), bg: Color.coerce(bg), bold:, italic:, underline:, strikethrough:)
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. `D-cluster-width` has the
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? ? Span.new(text: span.text, style: span.style.merge(bg: bg)) : span
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 whether the terminal background is light or dark, so {Screen}
5
- # can pick {Theme::LIGHT} or {Theme::DARK} automatically at startup.
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 [Symbol, nil] `:light`, `:dark`, or nil when undetectable.
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 || from_colorfgbg(env["COLORFGBG"])
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 classifies the reply. The whole
71
- # exchange runs with `input` in raw mode: the reply has no trailing
72
- # newline, so a canonical-mode read would block past the timeout,
73
- # and echo would smear the reply bytes onto the screen.
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 [Symbol, nil]
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
- match = REPLY.match(reply)
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
- # Relative luminance of the reported background, scaled per
111
- # component hex width (xterm replies 4 digits per channel, others 2).
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.map { |c| c.to_i(16).fdiv((16**c.length) - 1) }
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Tuile
4
4
  # @return [String]
5
- VERSION = "0.12.0"
5
+ VERSION = "0.14.0"
6
6
  end