tuile 0.5.0 → 0.7.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 +20 -0
- data/README.md +151 -5
- data/examples/file_commander.rb +4 -3
- data/examples/sampler.rb +4 -3
- data/lib/tuile/ansi.rb +4 -3
- data/lib/tuile/color.rb +122 -0
- data/lib/tuile/component/button.rb +9 -5
- data/lib/tuile/component/label.rb +13 -15
- data/lib/tuile/component/layout.rb +1 -1
- data/lib/tuile/component/list.rb +64 -31
- data/lib/tuile/component/log_window.rb +1 -0
- data/lib/tuile/component/picker_window.rb +4 -4
- data/lib/tuile/component/popup.rb +34 -3
- data/lib/tuile/component/text_area.rb +1 -2
- data/lib/tuile/component/text_field.rb +1 -2
- data/lib/tuile/component/text_input.rb +10 -15
- data/lib/tuile/component/text_view.rb +33 -13
- data/lib/tuile/component/window.rb +70 -16
- data/lib/tuile/component.rb +75 -6
- data/lib/tuile/event_queue.rb +26 -2
- data/lib/tuile/fake_screen.rb +8 -0
- data/lib/tuile/keys.rb +9 -3
- data/lib/tuile/rect.rb +12 -0
- data/lib/tuile/screen.rb +118 -20
- data/lib/tuile/screen_pane.rb +1 -1
- data/lib/tuile/sizing.rb +59 -0
- data/lib/tuile/styled_string.rb +125 -30
- data/lib/tuile/terminal_background.rb +137 -0
- data/lib/tuile/theme.rb +202 -0
- data/lib/tuile/theme_def.rb +85 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +0 -1
- data/mise.toml +2 -0
- data/sig/tuile.rbs +755 -62
- metadata +12 -18
data/sig/tuile.rbs
CHANGED
|
@@ -17,9 +17,10 @@ module Tuile
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
# ANSI escape sequence constants. Tuile emits colors and text attributes
|
|
20
|
-
# via
|
|
21
|
-
# Rendition", `ESC [ <params> m` — e.g. `\e[31m` red, `\e[1m`
|
|
22
|
-
# `\e[0m` reset).
|
|
20
|
+
# via {StyledString} / {Color}, which produce **SGR** sequences ("Select
|
|
21
|
+
# Graphic Rendition", `ESC [ <params> m` — e.g. `\e[31m` red, `\e[1m`
|
|
22
|
+
# bold, `\e[0m` reset). Host apps may also use Rainbow, which emits the
|
|
23
|
+
# same form.
|
|
23
24
|
module Ansi
|
|
24
25
|
RESET: String
|
|
25
26
|
end
|
|
@@ -135,6 +136,13 @@ module Tuile
|
|
|
135
136
|
# _@param_ `point`
|
|
136
137
|
def contains?: (Point point) -> bool
|
|
137
138
|
|
|
139
|
+
# _@param_ `other` — another rectangle.
|
|
140
|
+
#
|
|
141
|
+
# _@return_ — true if `other` lies entirely within this rectangle.
|
|
142
|
+
# Uses the same half-open edges as {#contains?} (right/bottom exclusive).
|
|
143
|
+
# An {#empty? empty} `other` covers no cells, so it is trivially contained.
|
|
144
|
+
def contains_rect?: (Rect other) -> bool
|
|
145
|
+
|
|
138
146
|
def size: () -> Size
|
|
139
147
|
|
|
140
148
|
def top_left: () -> Point
|
|
@@ -197,6 +205,9 @@ module Tuile
|
|
|
197
205
|
#
|
|
198
206
|
# A constant per named color is pre-defined (`Color::RED`, `Color::BRIGHT_BLUE`,
|
|
199
207
|
# …) so callers can reach for `Color::RED` instead of building one each time.
|
|
208
|
+
# The 256-color palette gets the same treatment via {PALETTE_NAMES}:
|
|
209
|
+
# `Color::CADET_BLUE`, `Color::DODGER_BLUE1`, `Color::GREY37`, … — the
|
|
210
|
+
# standard xterm chart names for indices 16..255, each an exact palette cell.
|
|
200
211
|
# {.coerce} accepts anything {.new} accepts plus `nil` (terminal default) and
|
|
201
212
|
# an existing {Color} (returned as-is), so APIs that accept colors typically
|
|
202
213
|
# take `[Color, nil]` and pass through {.coerce}.
|
|
@@ -206,15 +217,26 @@ module Tuile
|
|
|
206
217
|
# Color.new(42) # 256-color palette
|
|
207
218
|
# Color.new([255, 100, 0]) # RGB
|
|
208
219
|
# Color::RED # constant
|
|
220
|
+
# Color.palette(42) # 256-color palette, explicit
|
|
221
|
+
# Color.rgb(255, 100, 0) # 24-bit RGB, explicit
|
|
222
|
+
# Color.hex("#ff6400") # 24-bit RGB from a CSS-style hex string
|
|
209
223
|
# Color.coerce(:red) # accepts raw forms, returns Color
|
|
210
224
|
# Color.coerce(nil) # nil → nil
|
|
211
225
|
# ```
|
|
212
226
|
#
|
|
227
|
+
# Which entry point to use is a deliberate policy split. High-traffic
|
|
228
|
+
# call sites ({StyledString} and friends) stay lenient and {.coerce} raw
|
|
229
|
+
# forms — you don't want factory ceremony on every styled span.
|
|
230
|
+
# Declaration sites ({Theme}, defined once per app) are strict and take
|
|
231
|
+
# only {Color} instances, where `Color.palette(130)` documents itself in
|
|
232
|
+
# a way the bare `130` (palette index? RGB channel?) does not.
|
|
233
|
+
#
|
|
213
234
|
# {#to_ansi} renders a full SGR escape (`"\e[31m"`); {#sgr_codes} returns the
|
|
214
235
|
# raw numeric codes so callers (notably {StyledString}) can combine them with
|
|
215
236
|
# other SGR attributes in a single sequence.
|
|
216
237
|
class Color
|
|
217
238
|
COLOR_SYMBOLS: ::Array[Symbol]
|
|
239
|
+
PALETTE_NAMES: ::Hash[Symbol, Integer]
|
|
218
240
|
|
|
219
241
|
# Coerces the input to a {Color}. `nil` passes through unchanged (callers
|
|
220
242
|
# use `nil` for the terminal default); an existing {Color} is returned
|
|
@@ -223,6 +245,35 @@ module Tuile
|
|
|
223
245
|
# _@param_ `value`
|
|
224
246
|
def self.coerce: ((Color | Symbol | Integer | ::Array[Integer])? value) -> Color?
|
|
225
247
|
|
|
248
|
+
# A color from the 256-color palette (SGR 38;5;N / 48;5;N). Same as
|
|
249
|
+
# `Color.new(index)`, but the name says what the bare integer is.
|
|
250
|
+
#
|
|
251
|
+
# _@param_ `index` — palette index, 0..255.
|
|
252
|
+
def self.palette: (Integer index) -> Color
|
|
253
|
+
|
|
254
|
+
# A 24-bit RGB color (SGR 38;2;R;G;B / 48;2;R;G;B). Same as
|
|
255
|
+
# `Color.new([r, g, b])`, but with the channels spelled out.
|
|
256
|
+
#
|
|
257
|
+
# _@param_ `red` — 0..255.
|
|
258
|
+
#
|
|
259
|
+
# _@param_ `green` — 0..255.
|
|
260
|
+
#
|
|
261
|
+
# _@param_ `blue` — 0..255.
|
|
262
|
+
def self.rgb: (Integer red, Integer green, Integer blue) -> Color
|
|
263
|
+
|
|
264
|
+
# A 24-bit RGB color from a CSS-style hex string — for when the value
|
|
265
|
+
# comes from a hex source (a designer's palette, a CSS variable). The
|
|
266
|
+
# leading `#` is optional, digits are case-insensitive, and the CSS
|
|
267
|
+
# 3-digit shorthand expands as in CSS (`"#345"` → `"#334455"`).
|
|
268
|
+
# 4/8-digit alpha forms are rejected: SGR has no alpha channel, and
|
|
269
|
+
# silently dropping it would lie about the rendered color.
|
|
270
|
+
#
|
|
271
|
+
# _@param_ `string` — e.g. `"#333333"`, `"5F9EA0"`, `"#333"`.
|
|
272
|
+
#
|
|
273
|
+
# _@return_ — same value form as {.rgb} — `Color.hex("#333") ==
|
|
274
|
+
# Color.rgb(51, 51, 51)`.
|
|
275
|
+
def self.hex: (String string) -> Color
|
|
276
|
+
|
|
226
277
|
# _@param_ `value` — see class-level docs for the three accepted forms.
|
|
227
278
|
def initialize: ((Symbol | Integer | ::Array[Integer]) value) -> void
|
|
228
279
|
|
|
@@ -268,6 +319,213 @@ module Tuile
|
|
|
268
319
|
attr_reader y: Integer
|
|
269
320
|
end
|
|
270
321
|
|
|
322
|
+
# A set of semantic colors the built-in components read when painting.
|
|
323
|
+
# The current theme lives at {Screen#theme}; components must look it up
|
|
324
|
+
# at paint time (inside `repaint`) rather than caching values, so that
|
|
325
|
+
# assigning {Screen#theme=} restyles everything via a single
|
|
326
|
+
# invalidate-everything pass.
|
|
327
|
+
#
|
|
328
|
+
# The primary API is the rendering helpers — {#active_bg},
|
|
329
|
+
# {#active_border}, {#input_bg}, {#hint} — which wrap a plain string in
|
|
330
|
+
# the token's SGR color (on the channel appropriate for the token's
|
|
331
|
+
# role) and reset:
|
|
332
|
+
#
|
|
333
|
+
# screen.theme.active_bg("[ Ok ]") # => "\e[48;5;59m[ Ok ]\e[0m"
|
|
334
|
+
# screen.theme.hint("quit") # => "\e[38;5;109mquit\e[0m"
|
|
335
|
+
#
|
|
336
|
+
# The helpers pass content through verbatim, so input may carry other
|
|
337
|
+
# escape sequences (e.g. {Component::Window} feeds its border string,
|
|
338
|
+
# cursor moves included). For span-aware styling — applying a token to a
|
|
339
|
+
# {StyledString} while preserving per-span colors — use the `*_color`
|
|
340
|
+
# readers instead (e.g. {Component::List} highlights its cursor row via
|
|
341
|
+
# `with_bg(theme.active_bg_color)`). Rule of thumb: plain chrome text →
|
|
342
|
+
# helper; structured text → `*_color` reader + {StyledString}.
|
|
343
|
+
#
|
|
344
|
+
# Two built-in themes are provided: {DARK} (the default; the colors Tuile
|
|
345
|
+
# has always used) and {LIGHT} (counterparts legible on light terminal
|
|
346
|
+
# backgrounds). A custom theme is one `with` away:
|
|
347
|
+
#
|
|
348
|
+
# screen.theme = Theme::DARK.with(active_border_color: Color::CYAN)
|
|
349
|
+
#
|
|
350
|
+
# Tokens deliberately cover only the *accents* Tuile paints. Everything
|
|
351
|
+
# else inherits the terminal's own default foreground/background, which
|
|
352
|
+
# already matches the user's terminal theme perfectly — that's why there
|
|
353
|
+
# is no global `bg`/`fg` token.
|
|
354
|
+
#
|
|
355
|
+
# Every token is a {Color} — and must be passed as one. Unlike the
|
|
356
|
+
# lenient {Color.coerce} call sites elsewhere in the framework, a theme
|
|
357
|
+
# is declared once per app, so it takes only {Color} instances: at a
|
|
358
|
+
# declaration site `Color.palette(130)` documents itself in a way the
|
|
359
|
+
# bare `130` does not (palette index? RGB channel?) — and the named
|
|
360
|
+
# palette constants (`Color::DARK_ORANGE3` *is* 130; see
|
|
361
|
+
# {Color::PALETTE_NAMES}) go one step further.
|
|
362
|
+
#
|
|
363
|
+
# ## App-specific tokens
|
|
364
|
+
#
|
|
365
|
+
# Beyond the built-in tokens, an app can carry its own colors in
|
|
366
|
+
# {#custom} — a frozen `Hash{Symbol => Color}` member. Look them up with
|
|
367
|
+
# {#[]} (fail-fast: a typo raises `KeyError`) and render with the
|
|
368
|
+
# generic {#fg} / {#bg} helpers:
|
|
369
|
+
#
|
|
370
|
+
# theme = Theme::DARK.with(custom: { accent: Color::DARK_ORANGE })
|
|
371
|
+
# theme[:accent] # => Color, e.g. for StyledString#with_fg
|
|
372
|
+
# theme.fg(:accent, "NEW") # => "\e[38;5;208mNEW\e[0m"
|
|
373
|
+
#
|
|
374
|
+
# Apps wanting semantic readers can subclass — `Data#with` preserves the
|
|
375
|
+
# subclass, so an `AppTheme` stays an `AppTheme` through `with`:
|
|
376
|
+
#
|
|
377
|
+
# class AppTheme < Tuile::Theme
|
|
378
|
+
# def accent(text) = fg(:accent, text)
|
|
379
|
+
# end
|
|
380
|
+
#
|
|
381
|
+
# Pair the dark and light variants in a {ThemeDef} and hand it to
|
|
382
|
+
# {Screen#theme_def=} so OS appearance flips pick the right one.
|
|
383
|
+
#
|
|
384
|
+
# @!attribute [r] active_bg_color
|
|
385
|
+
# Background highlight of the component the user is interacting with:
|
|
386
|
+
# the {Component::List} cursor row, the focused {Component::TextField} /
|
|
387
|
+
# {Component::TextArea} well, the focused {Component::Button}. "Active"
|
|
388
|
+
# matches the {Component#active?} focus-chain flag — this is the
|
|
389
|
+
# focus/selection highlight in conventional UI terms.
|
|
390
|
+
# @return [Color]
|
|
391
|
+
# @!attribute [r] active_border_color
|
|
392
|
+
# Foreground of a {Component::Window} border when the window is on the
|
|
393
|
+
# active (focus) chain.
|
|
394
|
+
# @return [Color]
|
|
395
|
+
# @!attribute [r] input_bg_color
|
|
396
|
+
# Resting background "well" of {Component::TextField} /
|
|
397
|
+
# {Component::TextArea} when *not* active — visibly a field, but
|
|
398
|
+
# distinctly subtler than {#active_bg_color}.
|
|
399
|
+
# @return [Color]
|
|
400
|
+
# @!attribute [r] hint_color
|
|
401
|
+
# Foreground of keyboard-shortcut captions in status-bar hints (the
|
|
402
|
+
# "quit" in "q quit") — see {#hint}.
|
|
403
|
+
# @return [Color]
|
|
404
|
+
# @!attribute [r] custom
|
|
405
|
+
# App-specific color tokens; empty in the built-in themes. Frozen —
|
|
406
|
+
# build a changed theme via `with(custom: ...)`. Prefer {#[]} for
|
|
407
|
+
# lookups (it fail-fasts on typos); read this directly to enumerate
|
|
408
|
+
# the tokens.
|
|
409
|
+
# @return [Hash{Symbol => Color}]
|
|
410
|
+
class Theme
|
|
411
|
+
DARK: Theme
|
|
412
|
+
LIGHT: Theme
|
|
413
|
+
|
|
414
|
+
# _@param_ `active_bg_color`
|
|
415
|
+
#
|
|
416
|
+
# _@param_ `active_border_color`
|
|
417
|
+
#
|
|
418
|
+
# _@param_ `input_bg_color`
|
|
419
|
+
#
|
|
420
|
+
# _@param_ `hint_color`
|
|
421
|
+
#
|
|
422
|
+
# _@param_ `custom` — app-specific tokens, see {#custom}.
|
|
423
|
+
def initialize: (
|
|
424
|
+
active_bg_color: Color,
|
|
425
|
+
active_border_color: Color,
|
|
426
|
+
input_bg_color: Color,
|
|
427
|
+
hint_color: Color,
|
|
428
|
+
?custom: ::Hash[Symbol, Color]
|
|
429
|
+
) -> void
|
|
430
|
+
|
|
431
|
+
# Looks up an app-specific token from {#custom}.
|
|
432
|
+
#
|
|
433
|
+
# _@param_ `token`
|
|
434
|
+
def []: (Symbol token) -> Color
|
|
435
|
+
|
|
436
|
+
# Renders `text` in the foreground color of the app-specific `token`
|
|
437
|
+
# — the generic counterpart of {#hint} for {#custom} tokens.
|
|
438
|
+
#
|
|
439
|
+
# _@param_ `token`
|
|
440
|
+
#
|
|
441
|
+
# _@param_ `text`
|
|
442
|
+
#
|
|
443
|
+
# _@return_ — ANSI-rendered text, ending with an SGR reset.
|
|
444
|
+
def fg: (Symbol token, String text) -> String
|
|
445
|
+
|
|
446
|
+
# Renders `text` on the background color of the app-specific `token`
|
|
447
|
+
# — the generic counterpart of {#active_bg} for {#custom} tokens.
|
|
448
|
+
#
|
|
449
|
+
# _@param_ `token`
|
|
450
|
+
#
|
|
451
|
+
# _@param_ `text`
|
|
452
|
+
#
|
|
453
|
+
# _@return_ — ANSI-rendered text, ending with an SGR reset.
|
|
454
|
+
def bg: (Symbol token, String text) -> String
|
|
455
|
+
|
|
456
|
+
# Renders `text` on the {#active_bg_color} background.
|
|
457
|
+
#
|
|
458
|
+
# _@param_ `text`
|
|
459
|
+
#
|
|
460
|
+
# _@return_ — ANSI-rendered text, ending with an SGR reset.
|
|
461
|
+
def active_bg: (String text) -> String
|
|
462
|
+
|
|
463
|
+
# Renders `text` in the {#active_border_color} foreground. Content
|
|
464
|
+
# passes through verbatim, so it may embed non-SGR escapes (cursor
|
|
465
|
+
# moves in a border string).
|
|
466
|
+
#
|
|
467
|
+
# _@param_ `text`
|
|
468
|
+
#
|
|
469
|
+
# _@return_ — ANSI-rendered text, ending with an SGR reset.
|
|
470
|
+
def active_border: (String text) -> String
|
|
471
|
+
|
|
472
|
+
# Renders `text` on the {#input_bg_color} background.
|
|
473
|
+
#
|
|
474
|
+
# _@param_ `text`
|
|
475
|
+
#
|
|
476
|
+
# _@return_ — ANSI-rendered text, ending with an SGR reset.
|
|
477
|
+
def input_bg: (String text) -> String
|
|
478
|
+
|
|
479
|
+
# Renders `text` in the {#hint_color} foreground, for status-bar hints,
|
|
480
|
+
# e.g. `"q #{screen.theme.hint("quit")}"`. The color is baked into the
|
|
481
|
+
# returned String, so strings built this way do *not* restyle when the
|
|
482
|
+
# theme changes — rebuild them instead (the framework's own call sites
|
|
483
|
+
# rebuild on every status-bar refresh).
|
|
484
|
+
#
|
|
485
|
+
# _@param_ `text`
|
|
486
|
+
#
|
|
487
|
+
# _@return_ — ANSI-rendered text, ending with an SGR reset.
|
|
488
|
+
def hint: (String text) -> String
|
|
489
|
+
|
|
490
|
+
# The single sanctioned place for verbatim SGR wrapping: `text` is not
|
|
491
|
+
# parsed or validated, so callers may embed non-SGR escapes. Emits the
|
|
492
|
+
# same bytes `StyledString.styled(text, ...).to_ansi` would for plain
|
|
493
|
+
# text.
|
|
494
|
+
#
|
|
495
|
+
# _@param_ `text`
|
|
496
|
+
#
|
|
497
|
+
# _@param_ `color`
|
|
498
|
+
#
|
|
499
|
+
# _@param_ `target` — `:fg` or `:bg`.
|
|
500
|
+
def wrap: (String text, Color color, Symbol target) -> String
|
|
501
|
+
|
|
502
|
+
# Background highlight of the component the user is interacting with:
|
|
503
|
+
# the {Component::List} cursor row, the focused {Component::TextField} /
|
|
504
|
+
# {Component::TextArea} well, the focused {Component::Button}. "Active"
|
|
505
|
+
# matches the {Component#active?} focus-chain flag — this is the
|
|
506
|
+
# focus/selection highlight in conventional UI terms.
|
|
507
|
+
attr_reader active_bg_color: Color
|
|
508
|
+
|
|
509
|
+
# Foreground of a {Component::Window} border when the window is on the
|
|
510
|
+
# active (focus) chain.
|
|
511
|
+
attr_reader active_border_color: Color
|
|
512
|
+
|
|
513
|
+
# Resting background "well" of {Component::TextField} /
|
|
514
|
+
# {Component::TextArea} when *not* active — visibly a field, but
|
|
515
|
+
# distinctly subtler than {#active_bg_color}.
|
|
516
|
+
attr_reader input_bg_color: Color
|
|
517
|
+
|
|
518
|
+
# Foreground of keyboard-shortcut captions in status-bar hints (the
|
|
519
|
+
# "quit" in "q quit") — see {#hint}.
|
|
520
|
+
attr_reader hint_color: Color
|
|
521
|
+
|
|
522
|
+
# App-specific color tokens; empty in the built-in themes. Frozen —
|
|
523
|
+
# build a changed theme via `with(custom: ...)`. Prefer {#[]} for
|
|
524
|
+
# lookups (it fail-fasts on typos); read this directly to enumerate
|
|
525
|
+
# the tokens.
|
|
526
|
+
attr_reader custom: ::Hash[Symbol, Color]
|
|
527
|
+
end
|
|
528
|
+
|
|
271
529
|
# The TTY screen. There is exactly one screen per app.
|
|
272
530
|
#
|
|
273
531
|
# A screen runs the event loop; call {#run_event_loop} to do that.
|
|
@@ -386,7 +644,7 @@ module Tuile
|
|
|
386
644
|
#
|
|
387
645
|
# screen.register_global_shortcut(Keys::CTRL_L,
|
|
388
646
|
# over_popups: true,
|
|
389
|
-
# hint: "^L #{
|
|
647
|
+
# hint: "^L #{screen.theme.hint("log")}") do
|
|
390
648
|
# log_popup.open
|
|
391
649
|
# end
|
|
392
650
|
#
|
|
@@ -394,7 +652,7 @@ module Tuile
|
|
|
394
652
|
#
|
|
395
653
|
# _@param_ `over_popups` — when true, fires even while a modal popup is open (pre-empting the popup's own key handling). When false (default), the shortcut is suppressed while any popup is open and the popup gets the key instead.
|
|
396
654
|
#
|
|
397
|
-
# _@param_ `hint` — preformatted status-bar hint (e.g. `"^L #{
|
|
655
|
+
# _@param_ `hint` — preformatted status-bar hint (e.g. `"^L #{screen.theme.hint("log")}"`). When nil (default) the shortcut is silent in the status bar. The colors are baked into the string, so a later {#theme=} does not restyle it — re-register if needed.
|
|
398
656
|
def register_global_shortcut: (String key, ?over_popups: bool, ?hint: String?) -> void
|
|
399
657
|
|
|
400
658
|
# Removes a shortcut previously installed by {#register_global_shortcut}.
|
|
@@ -414,6 +672,15 @@ module Tuile
|
|
|
414
672
|
# _@param_ `window`
|
|
415
673
|
def remove_popup: (Component::Popup window) -> void
|
|
416
674
|
|
|
675
|
+
# Invalidates the entire attached tree, forcing every component to repaint
|
|
676
|
+
# on the next cycle. Needed whenever something overdraws the scene without
|
|
677
|
+
# clipping and then exposes what was underneath — a closing popup
|
|
678
|
+
# ({#remove_popup}), or a popup that shrinks or moves so its new {#rect} no
|
|
679
|
+
# longer covers the cells it previously painted ({Component::Popup#rect=}).
|
|
680
|
+
# The popup-only fast path in {#repaint} can't clear those vacated cells on
|
|
681
|
+
# its own, so we accept the cost of a full repaint.
|
|
682
|
+
def needs_full_repaint: () -> void
|
|
683
|
+
|
|
417
684
|
# Internal — use {Component::Popup#open?} instead.
|
|
418
685
|
#
|
|
419
686
|
# _@param_ `window`
|
|
@@ -462,6 +729,23 @@ module Tuile
|
|
|
462
729
|
# but only one focused.
|
|
463
730
|
def cursor_position: () -> Point?
|
|
464
731
|
|
|
732
|
+
# Startup color scheme: `:light` when {TerminalBackground.detect}
|
|
733
|
+
# reports a light terminal background, `:dark` otherwise (including
|
|
734
|
+
# when detection is inconclusive). Runs in the constructor — the
|
|
735
|
+
# OSC 11 reply arrives on stdin, which is only safe to read before
|
|
736
|
+
# {EventQueue#start_key_thread} owns it. {FakeScreen} overrides this
|
|
737
|
+
# to pin `:dark`, keeping specs deterministic and off the test
|
|
738
|
+
# runner's TTY.
|
|
739
|
+
#
|
|
740
|
+
# _@return_ — `:dark` or `:light`.
|
|
741
|
+
def detect_scheme: () -> Symbol
|
|
742
|
+
|
|
743
|
+
# An OS appearance flip arrived (mode-2031 report): remember the
|
|
744
|
+
# scheme and apply the matching member of {#theme_def}.
|
|
745
|
+
#
|
|
746
|
+
# _@param_ `scheme` — `:dark` or `:light`.
|
|
747
|
+
def on_color_scheme: (Symbol scheme) -> void
|
|
748
|
+
|
|
465
749
|
# Walks the current modal scope in pre-order, collects tab stops, and
|
|
466
750
|
# advances focus by one (wrapping). When the focused component isn't in
|
|
467
751
|
# the tab order (e.g. focus is parked on a popup/window chrome with no
|
|
@@ -487,10 +771,6 @@ module Tuile
|
|
|
487
771
|
# starts. {#size} provides correct size of the terminal.
|
|
488
772
|
def layout: () -> void
|
|
489
773
|
|
|
490
|
-
# Called after a popup is closed. Since a popup can cover any window,
|
|
491
|
-
# top-level component or other popups, we need to redraw everything.
|
|
492
|
-
def needs_full_repaint: () -> void
|
|
493
|
-
|
|
494
774
|
# A key has been pressed on the keyboard. Handle it, or forward to active
|
|
495
775
|
# window.
|
|
496
776
|
#
|
|
@@ -545,6 +825,22 @@ module Tuile
|
|
|
545
825
|
# _@return_ — current screen size.
|
|
546
826
|
attr_reader size: Size
|
|
547
827
|
|
|
828
|
+
# The color {Theme} built-in components read at paint time: the member
|
|
829
|
+
# of {#theme_def} matching the terminal background detected at
|
|
830
|
+
# construction (see {TerminalBackground.detect}; inconclusive means
|
|
831
|
+
# dark). While the event loop runs, terminals supporting mode 2031
|
|
832
|
+
# push OS appearance changes ({EventQueue::ColorSchemeEvent}) and the
|
|
833
|
+
# screen re-picks from {#theme_def}.
|
|
834
|
+
attr_accessor theme: Theme
|
|
835
|
+
|
|
836
|
+
# The app's {ThemeDef} — the dark/light {Theme} pair the screen picks
|
|
837
|
+
# {#theme} from, at startup and on every OS appearance flip. Starts as
|
|
838
|
+
# {ThemeDef.default} ({ThemeDef::DEFAULT} unless reassigned — tests
|
|
839
|
+
# do, see {ThemeDef.default=}). Assigning a custom definition is the
|
|
840
|
+
# durable way to theme an app: unlike a bare {#theme=}, it survives
|
|
841
|
+
# the user toggling the OS appearance.
|
|
842
|
+
attr_accessor theme_def: ThemeDef
|
|
843
|
+
|
|
548
844
|
# _@return_ — the event queue.
|
|
549
845
|
attr_reader event_queue: EventQueue
|
|
550
846
|
|
|
@@ -566,6 +862,52 @@ module Tuile
|
|
|
566
862
|
end
|
|
567
863
|
end
|
|
568
864
|
|
|
865
|
+
# A sizing policy for a slot whose position is managed by a parent
|
|
866
|
+
# component (e.g. {Component::Window#footer}). Resolves one dimension at a
|
|
867
|
+
# time via {#resolve}, so the same value works for widths and heights.
|
|
868
|
+
#
|
|
869
|
+
# Three policies exist:
|
|
870
|
+
#
|
|
871
|
+
# - {FILL} — take everything the slot offers;
|
|
872
|
+
# - {WRAP_CONTENT} — take the component's natural extent (its
|
|
873
|
+
# {Component#content_size}), clamped to the slot;
|
|
874
|
+
# - {.fixed} — take exactly the given number of cells, clamped to the slot.
|
|
875
|
+
#
|
|
876
|
+
# Note that {WRAP_CONTENT} only makes sense for components that report a
|
|
877
|
+
# natural {Component#content_size} ({Component::Label}, {Component::Button},
|
|
878
|
+
# {Component::List}, …). Input components ({Component::TextField} et al.)
|
|
879
|
+
# report {Size::ZERO}, so a wrap-content slot collapses to zero width —
|
|
880
|
+
# i.e. the component becomes invisible. Use {.fixed} or {FILL} for those.
|
|
881
|
+
#
|
|
882
|
+
# @!attribute [r] mode
|
|
883
|
+
# @return [Symbol] `:fill`, `:wrap_content` or `:fixed`.
|
|
884
|
+
# @!attribute [r] amount
|
|
885
|
+
# @return [Integer, nil] the cell count for `:fixed`; `nil` otherwise.
|
|
886
|
+
class Sizing
|
|
887
|
+
FILL: Sizing
|
|
888
|
+
WRAP_CONTENT: Sizing
|
|
889
|
+
|
|
890
|
+
# _@param_ `amount` — the number of cells to occupy; 0 or greater.
|
|
891
|
+
#
|
|
892
|
+
# _@return_ — a fixed-size policy.
|
|
893
|
+
def self.fixed: (Integer amount) -> Sizing
|
|
894
|
+
|
|
895
|
+
# Resolves one dimension of a slot.
|
|
896
|
+
#
|
|
897
|
+
# _@param_ `available` — cells the slot offers; 0 or greater.
|
|
898
|
+
#
|
|
899
|
+
# _@param_ `content` — the component's natural extent on this axis (one dimension of its {Component#content_size}).
|
|
900
|
+
#
|
|
901
|
+
# _@return_ — the resolved extent, always in `0..available`.
|
|
902
|
+
def resolve: (Integer available, Integer content) -> Integer
|
|
903
|
+
|
|
904
|
+
# _@return_ — `:fill`, `:wrap_content` or `:fixed`.
|
|
905
|
+
attr_reader mode: Symbol
|
|
906
|
+
|
|
907
|
+
# _@return_ — the cell count for `:fixed`; `nil` otherwise.
|
|
908
|
+
attr_reader amount: Integer?
|
|
909
|
+
end
|
|
910
|
+
|
|
569
911
|
# A UI component which is positioned on the screen and draws characters into
|
|
570
912
|
# its bounding rectangle (in {#repaint}).
|
|
571
913
|
#
|
|
@@ -700,13 +1042,19 @@ module Tuile
|
|
|
700
1042
|
# _@param_ `child` — the just-detached child.
|
|
701
1043
|
def on_child_removed: (Component child) -> void
|
|
702
1044
|
|
|
703
|
-
#
|
|
704
|
-
#
|
|
705
|
-
#
|
|
706
|
-
#
|
|
707
|
-
#
|
|
708
|
-
#
|
|
709
|
-
|
|
1045
|
+
# Called by a child component whose {#content_size} just changed (fired
|
|
1046
|
+
# from the child's {#content_size=}). Does nothing by default — a plain
|
|
1047
|
+
# container is not size-coupled to its children. Containers that derive
|
|
1048
|
+
# their own natural size or child layout from a child's natural size
|
|
1049
|
+
# override this (e.g. {Component::Window} re-lays-out a
|
|
1050
|
+
# {Sizing::WRAP_CONTENT} footer and recomputes its own size from content;
|
|
1051
|
+
# {Component::Popup} re-self-sizes). If the receiver's own
|
|
1052
|
+
# {#content_size} changes as a consequence, its {#content_size=} notifies
|
|
1053
|
+
# *its* parent in turn — so the event bubbles exactly as far as geometry
|
|
1054
|
+
# keeps changing, and stops where it doesn't.
|
|
1055
|
+
#
|
|
1056
|
+
# _@param_ `child` — the resized direct child.
|
|
1057
|
+
def on_child_content_size_changed: (Component child) -> void
|
|
710
1058
|
|
|
711
1059
|
# Where the hardware terminal cursor should sit when this component is the
|
|
712
1060
|
# cursor owner. Returns `nil` to indicate the cursor should be hidden. The
|
|
@@ -759,6 +1107,35 @@ module Tuile
|
|
|
759
1107
|
# no parent.
|
|
760
1108
|
attr_accessor parent: Component?
|
|
761
1109
|
|
|
1110
|
+
# Called on every attached component (pre-order, popups included) when
|
|
1111
|
+
# {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=}
|
|
1112
|
+
# assignment and on OS appearance flips.
|
|
1113
|
+
#
|
|
1114
|
+
# Built-in components read {Screen#theme} at paint time, so their accents
|
|
1115
|
+
# restyle automatically; this hook exists for *content* whose colors the
|
|
1116
|
+
# app baked in from the old theme — a {Label#text} / {List#lines} /
|
|
1117
|
+
# {TextView#text} {StyledString} styled with `theme[:accent]` and the
|
|
1118
|
+
# like. Only the app knows which of its colors were theme-derived (as
|
|
1119
|
+
# opposed to inherent to the data, e.g. log-level colors), so it rebuilds
|
|
1120
|
+
# them here, re-running the same code that rendered them initially.
|
|
1121
|
+
#
|
|
1122
|
+
# Runs on the UI thread; {Screen#theme} already returns the new theme.
|
|
1123
|
+
# Mutating content (`text=`, `lines=`, …) is safe — repaint coalesces per
|
|
1124
|
+
# event-loop tick. Do not assign {Screen#theme=} from inside the hook.
|
|
1125
|
+
#
|
|
1126
|
+
# Subclasses overriding this should call `super` so an assigned
|
|
1127
|
+
# {#on_theme_changed=} listener keeps firing.
|
|
1128
|
+
attr_accessor on_theme_changed: Proc?
|
|
1129
|
+
|
|
1130
|
+
# The {Size} big enough to show the entire component contents without
|
|
1131
|
+
# scrolling. Plain components have no intrinsic content and report
|
|
1132
|
+
# {Size::ZERO}; content-bearing components (e.g. {Label}, {List},
|
|
1133
|
+
# {TextView}, {Window}) maintain it eagerly via {#content_size=} from
|
|
1134
|
+
# their mutators, so reads are O(1). Used by callers like
|
|
1135
|
+
# {Component::Popup} to auto-size to whatever content was assigned,
|
|
1136
|
+
# regardless of its concrete type, and by {Sizing::WRAP_CONTENT} slots.
|
|
1137
|
+
attr_accessor content_size: Size
|
|
1138
|
+
|
|
762
1139
|
# A scrollable list of items with cursor support.
|
|
763
1140
|
#
|
|
764
1141
|
# Items are modeled as {StyledString}s and painted directly into the
|
|
@@ -771,13 +1148,17 @@ module Tuile
|
|
|
771
1148
|
#
|
|
772
1149
|
# Cursor is supported; call {#cursor=} to change cursor behavior. The
|
|
773
1150
|
# cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the
|
|
774
|
-
# list automatically. The cursor highlight overlays
|
|
775
|
-
# while preserving each span's foreground color.
|
|
1151
|
+
# list automatically. The cursor highlight overlays
|
|
1152
|
+
# {Theme#active_bg_color} while preserving each span's foreground color.
|
|
776
1153
|
class List < Component
|
|
777
|
-
CURSOR_BG: Integer
|
|
778
|
-
|
|
779
1154
|
def initialize: () -> void
|
|
780
1155
|
|
|
1156
|
+
# _@return_ — whether {#auto_scroll} is currently tailing. True
|
|
1157
|
+
# while the viewport sits at the last line; flips to false the moment
|
|
1158
|
+
# the user scrolls up, and back to true once they scroll to the bottom
|
|
1159
|
+
# again. Only consulted when {#auto_scroll} is enabled.
|
|
1160
|
+
def following?: () -> bool
|
|
1161
|
+
|
|
781
1162
|
# Sets new lines. Each entry is coerced into a {StyledString} (a
|
|
782
1163
|
# `String` is parsed via {StyledString.parse}, so embedded ANSI is
|
|
783
1164
|
# honored; a {StyledString} is used as-is; anything else is stringified
|
|
@@ -814,8 +1195,6 @@ module Tuile
|
|
|
814
1195
|
# _@param_ `lines` — entries are `String`, `StyledString`, or anything that responds to `#to_s`.
|
|
815
1196
|
def add_lines: (::Array[untyped] lines) -> void
|
|
816
1197
|
|
|
817
|
-
def content_size: () -> Size
|
|
818
|
-
|
|
819
1198
|
def focusable?: () -> bool
|
|
820
1199
|
|
|
821
1200
|
def tab_stop?: () -> bool
|
|
@@ -867,6 +1246,19 @@ module Tuile
|
|
|
867
1246
|
# is one, so the list snaps to the bottom on first paint.
|
|
868
1247
|
def on_width_changed: () -> void
|
|
869
1248
|
|
|
1249
|
+
# Natural size from scratch: longest line's display width plus the two
|
|
1250
|
+
# single-space gutters {#pad_to_row} adds, × line count. An empty list
|
|
1251
|
+
# is {Size::ZERO} (no gutters for no content).
|
|
1252
|
+
def compute_content_size: () -> Size
|
|
1253
|
+
|
|
1254
|
+
# Incremental {#content_size} update for appends: folds just the
|
|
1255
|
+
# appended lines into the running maximum, keeping {#add_lines}
|
|
1256
|
+
# O(appended) instead of re-scanning the whole list (LogWindow appends
|
|
1257
|
+
# a line per log statement).
|
|
1258
|
+
#
|
|
1259
|
+
# _@param_ `appended` — the just-appended lines (already concatenated onto {@lines}).
|
|
1260
|
+
def grow_content_size: (::Array[StyledString] appended) -> void
|
|
1261
|
+
|
|
870
1262
|
# Coerces and flattens a list of input entries into trimmed
|
|
871
1263
|
# {StyledString} lines. Each entry becomes a {StyledString} (String
|
|
872
1264
|
# via {StyledString.parse}, StyledString passed through, anything else
|
|
@@ -935,6 +1327,10 @@ module Tuile
|
|
|
935
1327
|
# _@return_ — the max value of {#top_line}.
|
|
936
1328
|
def top_line_max: () -> Integer
|
|
937
1329
|
|
|
1330
|
+
# _@return_ — whether the viewport is pinned to the last line.
|
|
1331
|
+
# Drives {#following?}: re-evaluated on every {#top_line=}.
|
|
1332
|
+
def at_bottom?: () -> bool
|
|
1333
|
+
|
|
938
1334
|
# _@return_ — the number of visible lines.
|
|
939
1335
|
def viewport_lines: () -> Integer
|
|
940
1336
|
|
|
@@ -951,6 +1347,11 @@ module Tuile
|
|
|
951
1347
|
# which would leave `top_line` past the last item once a real rect
|
|
952
1348
|
# arrives. {#on_width_changed} re-runs this hook when the rect grows so
|
|
953
1349
|
# the snap-to-bottom intent is preserved.
|
|
1350
|
+
#
|
|
1351
|
+
# Gated on {#following?}: once the user scrolls up off the bottom the
|
|
1352
|
+
# cursor snap and viewport pin are both skipped, so reading older
|
|
1353
|
+
# content is not interrupted by incoming lines. {#top_line=} re-arms
|
|
1354
|
+
# `@follow` when the viewport returns to the bottom.
|
|
954
1355
|
def update_top_line_if_auto_scroll: () -> void
|
|
955
1356
|
|
|
956
1357
|
# _@return_ — whether the scrollbar should be drawn right now.
|
|
@@ -1006,7 +1407,10 @@ module Tuile
|
|
|
1006
1407
|
attr_accessor on_cursor_changed: Proc?
|
|
1007
1408
|
|
|
1008
1409
|
# _@return_ — if true and a line is added or new content is set,
|
|
1009
|
-
# auto-scrolls to the bottom
|
|
1410
|
+
# auto-scrolls to the bottom — but only while the viewport is already
|
|
1411
|
+
# pinned to the last line (see {#following?}). Scroll up to read older
|
|
1412
|
+
# content and appends stop yanking you back down; scroll back to the
|
|
1413
|
+
# bottom and tailing resumes.
|
|
1010
1414
|
attr_accessor auto_scroll: bool
|
|
1011
1415
|
|
|
1012
1416
|
# _@return_ — top line of the viewport. 0 or positive.
|
|
@@ -1154,11 +1558,6 @@ module Tuile
|
|
|
1154
1558
|
class Label < Component
|
|
1155
1559
|
def initialize: () -> void
|
|
1156
1560
|
|
|
1157
|
-
# _@return_ — longest hard-line's display width × number of hard
|
|
1158
|
-
# lines. Reported on the *unclipped* text — sizing is intrinsic to
|
|
1159
|
-
# the content, not the viewport. Empty text returns `Size.new(0, 0)`.
|
|
1160
|
-
def content_size: () -> Size
|
|
1161
|
-
|
|
1162
1561
|
# Paints the text into {#rect}.
|
|
1163
1562
|
#
|
|
1164
1563
|
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
@@ -1169,6 +1568,11 @@ module Tuile
|
|
|
1169
1568
|
|
|
1170
1569
|
def on_width_changed: () -> void
|
|
1171
1570
|
|
|
1571
|
+
# Natural size: longest hard-line's display width × number of hard
|
|
1572
|
+
# lines. Computed on the *unclipped* text — sizing is intrinsic to the
|
|
1573
|
+
# content, not the viewport. Empty text yields {Size::ZERO}.
|
|
1574
|
+
def compute_content_size: () -> Size
|
|
1575
|
+
|
|
1172
1576
|
# Recomputes {@clipped_lines} for the current text and rect width.
|
|
1173
1577
|
# Each line is ellipsized to fit, padded with trailing spaces out to
|
|
1174
1578
|
# the full width, and pre-rendered to ANSI so {#repaint} is just a
|
|
@@ -1223,6 +1627,17 @@ module Tuile
|
|
|
1223
1627
|
|
|
1224
1628
|
def focusable?: () -> bool
|
|
1225
1629
|
|
|
1630
|
+
# Reassigns the popup's rect, escalating to a full scene repaint when an
|
|
1631
|
+
# open popup shrinks or moves so its new rect no longer covers the cells
|
|
1632
|
+
# it previously painted. A popup overdraws the scene without clipping and
|
|
1633
|
+
# nothing clears underneath it, so {Screen#repaint}'s popup-only fast path
|
|
1634
|
+
# would repaint into the new rect and leave the vacated cells showing
|
|
1635
|
+
# stale content. When the new rect fully covers the old one (the popup
|
|
1636
|
+
# only grew), the fast path is correct and the full repaint is skipped.
|
|
1637
|
+
#
|
|
1638
|
+
# _@param_ `new_rect`
|
|
1639
|
+
def rect=: (Rect new_rect) -> void
|
|
1640
|
+
|
|
1226
1641
|
# Mounts this popup on the {Screen}. Recomputes the popup's size from
|
|
1227
1642
|
# the current content first, so reopening a popup whose content has
|
|
1228
1643
|
# grown or shrunk while closed picks up the new size.
|
|
@@ -1255,6 +1670,14 @@ module Tuile
|
|
|
1255
1670
|
# _@param_ `new_content`
|
|
1256
1671
|
def content=: (Component? new_content) -> void
|
|
1257
1672
|
|
|
1673
|
+
# Re-sizes (and recenters, when open) whenever the wrapped content's
|
|
1674
|
+
# natural size changes — e.g. a {Label}'s `text=`, a {List}'s
|
|
1675
|
+
# `add_line`, or a nested {Window} whose own content grew (the window
|
|
1676
|
+
# recomputes its {Component#content_size} and the change bubbles here).
|
|
1677
|
+
#
|
|
1678
|
+
# _@param_ `_child`
|
|
1679
|
+
def on_child_content_size_changed: (Component _child) -> void
|
|
1680
|
+
|
|
1258
1681
|
# Hint for the status bar: own "q Close" plus the wrapped content's hint.
|
|
1259
1682
|
def keyboard_hint: () -> String
|
|
1260
1683
|
|
|
@@ -1270,6 +1693,11 @@ module Tuile
|
|
|
1270
1693
|
|
|
1271
1694
|
# Recompute width/height from {#content}'s natural size and recenter
|
|
1272
1695
|
# if currently open. Called whenever content is (re)assigned.
|
|
1696
|
+
#
|
|
1697
|
+
# Computes the final (centered) rect and assigns it in one step rather
|
|
1698
|
+
# than positioning at the origin and then centering: the intermediate
|
|
1699
|
+
# origin rect rarely covers the previous one, which would make
|
|
1700
|
+
# {#rect=}'s shrink/move detection fire a full repaint on every resize.
|
|
1273
1701
|
def update_rect: () -> void
|
|
1274
1702
|
|
|
1275
1703
|
# _@param_ `event`
|
|
@@ -1277,9 +1705,6 @@ module Tuile
|
|
|
1277
1705
|
|
|
1278
1706
|
def children: () -> ::Array[Component]
|
|
1279
1707
|
|
|
1280
|
-
# _@param_ `rect`
|
|
1281
|
-
def rect=: (Rect rect) -> void
|
|
1282
|
-
|
|
1283
1708
|
def on_focus: () -> void
|
|
1284
1709
|
end
|
|
1285
1710
|
|
|
@@ -1302,10 +1727,6 @@ module Tuile
|
|
|
1302
1727
|
|
|
1303
1728
|
def tab_stop?: () -> bool
|
|
1304
1729
|
|
|
1305
|
-
# _@return_ — natural width is `caption.length + 4` to fit
|
|
1306
|
-
# `[ caption ]`; height is 1.
|
|
1307
|
-
def content_size: () -> Size
|
|
1308
|
-
|
|
1309
1730
|
# _@param_ `key`
|
|
1310
1731
|
def handle_key: (String key) -> bool
|
|
1311
1732
|
|
|
@@ -1314,6 +1735,9 @@ module Tuile
|
|
|
1314
1735
|
|
|
1315
1736
|
def repaint: () -> void
|
|
1316
1737
|
|
|
1738
|
+
# Natural width is `caption.length + 4` to fit `[ caption ]`; height 1.
|
|
1739
|
+
def natural_size: () -> Size
|
|
1740
|
+
|
|
1317
1741
|
# _@return_ — the button's label.
|
|
1318
1742
|
attr_accessor caption: String
|
|
1319
1743
|
|
|
@@ -1408,11 +1832,21 @@ module Tuile
|
|
|
1408
1832
|
# _@param_ `value`
|
|
1409
1833
|
def scrollbar=: (bool value) -> void
|
|
1410
1834
|
|
|
1411
|
-
#
|
|
1412
|
-
#
|
|
1413
|
-
#
|
|
1414
|
-
|
|
1415
|
-
|
|
1835
|
+
# Sets the new content. Also recomputes the window's natural size.
|
|
1836
|
+
#
|
|
1837
|
+
# _@param_ `new_content`
|
|
1838
|
+
def content=: (Component? new_content) -> void
|
|
1839
|
+
|
|
1840
|
+
# Re-lays-out a {Sizing::WRAP_CONTENT} footer when the footer's natural
|
|
1841
|
+
# size changes, and folds a content resize into the window's own
|
|
1842
|
+
# natural size (whose change then bubbles to the window's parent — e.g.
|
|
1843
|
+
# a {Popup} re-self-sizes). The footer deliberately does *not*
|
|
1844
|
+
# participate in the window's {#content_size}: it is decoration
|
|
1845
|
+
# overlaying the border, and must not drive the window's size — if it
|
|
1846
|
+
# doesn't fit, it is clipped to the inner width.
|
|
1847
|
+
#
|
|
1848
|
+
# _@param_ `child`
|
|
1849
|
+
def on_child_content_size_changed: (Component child) -> void
|
|
1416
1850
|
|
|
1417
1851
|
# Fully repaints the window: both frame and contents.
|
|
1418
1852
|
#
|
|
@@ -1446,6 +1880,17 @@ module Tuile
|
|
|
1446
1880
|
# _@param_ `caption`
|
|
1447
1881
|
def build_frame: (String caption) -> String
|
|
1448
1882
|
|
|
1883
|
+
# Recomputes the window's natural size: content's natural size (or the
|
|
1884
|
+
# caption, whichever is wider) plus the 2-character border. The footer
|
|
1885
|
+
# is deliberately excluded — see {#on_child_content_size_changed}. A
|
|
1886
|
+
# window with no content or caption sizes to `Size.new(2, 2)` (bare
|
|
1887
|
+
# border).
|
|
1888
|
+
def update_content_size: () -> void
|
|
1889
|
+
|
|
1890
|
+
# Positions the footer over the bottom border row, with its width
|
|
1891
|
+
# resolved by {#footer_sizing} against the inner width. A
|
|
1892
|
+
# {Sizing::WRAP_CONTENT} footer with zero natural width gets an empty
|
|
1893
|
+
# rect — i.e. it is invisible, as if never assigned.
|
|
1449
1894
|
def layout_footer: () -> void
|
|
1450
1895
|
|
|
1451
1896
|
def on_focus: () -> void
|
|
@@ -1454,6 +1899,11 @@ module Tuile
|
|
|
1454
1899
|
# row.
|
|
1455
1900
|
attr_accessor footer: Component?
|
|
1456
1901
|
|
|
1902
|
+
# _@return_ — how the footer's width is computed from the window's
|
|
1903
|
+
# inner width; defaults to {Sizing::FILL} (the footer spans the full
|
|
1904
|
+
# inner width). The footer's height is always 1 (the border row).
|
|
1905
|
+
attr_accessor footer_sizing: Sizing
|
|
1906
|
+
|
|
1457
1907
|
# _@return_ — the current caption, empty by default.
|
|
1458
1908
|
attr_accessor caption: String
|
|
1459
1909
|
end
|
|
@@ -1475,9 +1925,6 @@ module Tuile
|
|
|
1475
1925
|
# plain `<textarea>` or text editor. A future `on_enter`/`on_submit`
|
|
1476
1926
|
# callback may opt out of that by consuming Enter instead.
|
|
1477
1927
|
class TextArea < Tuile::Component::TextInput
|
|
1478
|
-
ACTIVE_BG_SGR: String
|
|
1479
|
-
INACTIVE_BG_SGR: String
|
|
1480
|
-
|
|
1481
1928
|
def initialize: () -> void
|
|
1482
1929
|
|
|
1483
1930
|
def cursor_position: () -> Point?
|
|
@@ -1581,6 +2028,12 @@ module Tuile
|
|
|
1581
2028
|
# O(total spans).
|
|
1582
2029
|
def text: () -> StyledString
|
|
1583
2030
|
|
|
2031
|
+
# _@return_ — whether {#auto_scroll} is currently tailing. True
|
|
2032
|
+
# while the viewport sits at the last line; flips to false the moment
|
|
2033
|
+
# the user scrolls up, and back to true once they scroll to the bottom
|
|
2034
|
+
# again. Only consulted when {#auto_scroll} is enabled.
|
|
2035
|
+
def following?: () -> bool
|
|
2036
|
+
|
|
1584
2037
|
# Replaces the text. Embedded `\n` characters become hard line breaks.
|
|
1585
2038
|
# A `String` is parsed via {StyledString.parse} (so embedded ANSI is
|
|
1586
2039
|
# honored); a `StyledString` is used as-is; `nil` is coerced to an
|
|
@@ -1912,8 +2365,16 @@ module Tuile
|
|
|
1912
2365
|
# _@param_ `target` — desired top line; clamped to `[0, top_line_max]`.
|
|
1913
2366
|
def move_top_line_to: (Integer target) -> void
|
|
1914
2367
|
|
|
2368
|
+
# Gated on {#following?}: once the user scrolls up off the bottom the
|
|
2369
|
+
# viewport pin is skipped, so reading older content is not interrupted
|
|
2370
|
+
# by incoming lines. {#top_line=} re-arms `@follow` when the viewport
|
|
2371
|
+
# returns to the bottom.
|
|
1915
2372
|
def update_top_line_if_auto_scroll: () -> void
|
|
1916
2373
|
|
|
2374
|
+
# _@return_ — whether the viewport is pinned to the last line.
|
|
2375
|
+
# Drives {#following?}: re-evaluated on every {#top_line=}.
|
|
2376
|
+
def at_bottom?: () -> bool
|
|
2377
|
+
|
|
1917
2378
|
def scrollbar_visible?: () -> bool
|
|
1918
2379
|
|
|
1919
2380
|
# Pads `line` with trailing default-styled spaces out to `width` display
|
|
@@ -1946,7 +2407,10 @@ module Tuile
|
|
|
1946
2407
|
attr_accessor scrollbar_visibility: Symbol
|
|
1947
2408
|
|
|
1948
2409
|
# _@return_ — if true, mutating the text scrolls the viewport so
|
|
1949
|
-
# the last line stays in view
|
|
2410
|
+
# the last line stays in view — but only while the viewport is already
|
|
2411
|
+
# pinned to the last line (see {#following?}). Scroll up to read older
|
|
2412
|
+
# content and appends stop yanking you back down; scroll back to the
|
|
2413
|
+
# bottom and tailing resumes. Default `false`.
|
|
1950
2414
|
attr_accessor auto_scroll: bool
|
|
1951
2415
|
|
|
1952
2416
|
# _@return_ — longest hard-line's display width × number of hard
|
|
@@ -2135,9 +2599,6 @@ module Tuile
|
|
|
2135
2599
|
# positioned by {Screen} after each repaint cycle when this component is
|
|
2136
2600
|
# focused; see {Component#cursor_position}.
|
|
2137
2601
|
class TextField < Tuile::Component::TextInput
|
|
2138
|
-
ACTIVE_BG_SGR: String
|
|
2139
|
-
INACTIVE_BG_SGR: String
|
|
2140
|
-
|
|
2141
2602
|
def initialize: () -> void
|
|
2142
2603
|
|
|
2143
2604
|
def cursor_position: () -> Point?
|
|
@@ -2212,9 +2673,6 @@ module Tuile
|
|
|
2212
2673
|
# effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
|
|
2213
2674
|
# keep the caret visible).
|
|
2214
2675
|
class TextInput < Component
|
|
2215
|
-
ACTIVE_BG_SGR: String
|
|
2216
|
-
INACTIVE_BG_SGR: String
|
|
2217
|
-
|
|
2218
2676
|
def initialize: () -> void
|
|
2219
2677
|
|
|
2220
2678
|
# _@return_ — true iff {#text} is the empty string.
|
|
@@ -2231,6 +2689,16 @@ module Tuile
|
|
|
2231
2689
|
# _@param_ `key`
|
|
2232
2690
|
def handle_key: (String key) -> bool
|
|
2233
2691
|
|
|
2692
|
+
# Renders `text` on the field's background well, looked up from the
|
|
2693
|
+
# current {Screen#theme} at paint time: {Theme#active_bg} when this
|
|
2694
|
+
# input is on the active (focus) chain, {Theme#input_bg} otherwise —
|
|
2695
|
+
# visibly a field either way, distinctly highlighted when active.
|
|
2696
|
+
#
|
|
2697
|
+
# _@param_ `text`
|
|
2698
|
+
#
|
|
2699
|
+
# _@return_ — ANSI-rendered text.
|
|
2700
|
+
def background: (String text) -> String
|
|
2701
|
+
|
|
2234
2702
|
# Input filter for {#text=}. Subclasses override to truncate or reject
|
|
2235
2703
|
# invalid input. Default coerces to String.
|
|
2236
2704
|
#
|
|
@@ -2398,6 +2866,71 @@ module Tuile
|
|
|
2398
2866
|
end
|
|
2399
2867
|
end
|
|
2400
2868
|
|
|
2869
|
+
# An app's theme definition: the {Theme} pair covering both terminal
|
|
2870
|
+
# appearances. {Screen} keeps one at {Screen#theme_def} (defaulting to
|
|
2871
|
+
# {DEFAULT}) and picks the member matching the detected background at
|
|
2872
|
+
# startup and on every OS appearance flip (mode 2031) — so a custom
|
|
2873
|
+
# definition survives the user toggling light/dark, where a bare
|
|
2874
|
+
# {Screen#theme=} assignment would be replaced.
|
|
2875
|
+
#
|
|
2876
|
+
# APP_THEME = Tuile::ThemeDef.new(
|
|
2877
|
+
# dark: Tuile::Theme::DARK.with(custom: { accent: Color::DARK_ORANGE }),
|
|
2878
|
+
# light: Tuile::Theme::LIGHT.with(custom: { accent: Color::DARK_ORANGE3 })
|
|
2879
|
+
# )
|
|
2880
|
+
# screen.theme_def = APP_THEME
|
|
2881
|
+
#
|
|
2882
|
+
# Both members must declare the same {Theme#custom} key set. Without
|
|
2883
|
+
# that, a token present only in one member would raise `KeyError` at
|
|
2884
|
+
# the unpredictable moment the user flips OS appearance; checking here
|
|
2885
|
+
# turns it into an immediate construction-time failure.
|
|
2886
|
+
#
|
|
2887
|
+
# @!attribute [r] dark
|
|
2888
|
+
# The theme applied on dark terminal backgrounds.
|
|
2889
|
+
# @return [Theme]
|
|
2890
|
+
# @!attribute [r] light
|
|
2891
|
+
# The theme applied on light terminal backgrounds.
|
|
2892
|
+
# @return [Theme]
|
|
2893
|
+
class ThemeDef
|
|
2894
|
+
DEFAULT: ThemeDef
|
|
2895
|
+
|
|
2896
|
+
# _@param_ `dark`
|
|
2897
|
+
#
|
|
2898
|
+
# _@param_ `light`
|
|
2899
|
+
def initialize: (dark: Theme, light: Theme) -> void
|
|
2900
|
+
|
|
2901
|
+
# The member for the given color scheme. Anything other than `:light`
|
|
2902
|
+
# selects {#dark}, matching {TerminalBackground.detect}'s
|
|
2903
|
+
# inconclusive-means-dark policy.
|
|
2904
|
+
#
|
|
2905
|
+
# _@param_ `scheme` — `:dark` or `:light`.
|
|
2906
|
+
def for: (Symbol scheme) -> Theme
|
|
2907
|
+
|
|
2908
|
+
# The definition newly-constructed {Screen}s start from (see
|
|
2909
|
+
# {Screen#theme_def}); initially {DEFAULT}. Reassigning affects
|
|
2910
|
+
# future screens only — an already-constructed screen keeps its
|
|
2911
|
+
# definition until {Screen#theme_def=}.
|
|
2912
|
+
#
|
|
2913
|
+
# Intended for test suites: production apps assign
|
|
2914
|
+
# {Screen#theme_def=} once at startup, but component specs build a
|
|
2915
|
+
# fresh {FakeScreen} per example, and a component reading a custom
|
|
2916
|
+
# token (`theme[:accent]`) would `KeyError` against the built-in
|
|
2917
|
+
# default. Point this at the app's definition once and every
|
|
2918
|
+
# {Screen.fake} carries it:
|
|
2919
|
+
#
|
|
2920
|
+
# Tuile::ThemeDef.default = APP_THEME # spec_helper.rb, once
|
|
2921
|
+
# before { Screen.fake } # theme[:accent] resolves
|
|
2922
|
+
def self.default: () -> ThemeDef
|
|
2923
|
+
|
|
2924
|
+
# _@param_ `theme_def`
|
|
2925
|
+
def self.default=: (ThemeDef value) -> ThemeDef
|
|
2926
|
+
|
|
2927
|
+
# The theme applied on dark terminal backgrounds.
|
|
2928
|
+
attr_reader dark: Theme
|
|
2929
|
+
|
|
2930
|
+
# The theme applied on light terminal backgrounds.
|
|
2931
|
+
attr_reader light: Theme
|
|
2932
|
+
end
|
|
2933
|
+
|
|
2401
2934
|
# An event queue. The idea is that all UI-related updates run from the thread
|
|
2402
2935
|
# which runs the event queue only; this removes any need for locking and/or
|
|
2403
2936
|
# need for thread-safety mechanisms.
|
|
@@ -2523,6 +3056,29 @@ module Tuile
|
|
|
2523
3056
|
attr_reader height: Integer
|
|
2524
3057
|
end
|
|
2525
3058
|
|
|
3059
|
+
# The terminal's color scheme changed — the user flipped the OS between
|
|
3060
|
+
# light and dark appearance. Terminals supporting mode 2031 (kitty,
|
|
3061
|
+
# foot, contour, ghostty, …) push the DSR-style report `\e[?997;1n`
|
|
3062
|
+
# (dark) / `\e[?997;2n` (light) once {Screen#run_event_loop} enables
|
|
3063
|
+
# the mode via {TerminalBackground::NOTIFY_ON}; the key thread parses
|
|
3064
|
+
# it into this event and {Screen#event_loop} follows by assigning the
|
|
3065
|
+
# matching {Theme}.
|
|
3066
|
+
#
|
|
3067
|
+
# @!attribute [r] scheme
|
|
3068
|
+
# @return [Symbol] `:light` or `:dark`.
|
|
3069
|
+
class ColorSchemeEvent
|
|
3070
|
+
REPORT: Regexp
|
|
3071
|
+
|
|
3072
|
+
# _@param_ `key` — key read via {Keys.getkey}.
|
|
3073
|
+
#
|
|
3074
|
+
# _@return_ — nil when `key` is not a
|
|
3075
|
+
# color-scheme report.
|
|
3076
|
+
def self.parse: (String key) -> ColorSchemeEvent?
|
|
3077
|
+
|
|
3078
|
+
# _@return_ — `:light` or `:dark`.
|
|
3079
|
+
attr_reader scheme: Symbol
|
|
3080
|
+
end
|
|
3081
|
+
|
|
2526
3082
|
# Emitted once when the queue is cleared, all messages are processed and the
|
|
2527
3083
|
# event loop will block waiting for more messages. Perfect time for
|
|
2528
3084
|
# repainting windows.
|
|
@@ -2597,6 +3153,11 @@ module Tuile
|
|
|
2597
3153
|
|
|
2598
3154
|
def invalidated_clear: () -> void
|
|
2599
3155
|
|
|
3156
|
+
# No terminal probing in tests: skip {TerminalBackground.detect}
|
|
3157
|
+
# (which would write an OSC 11 query to the test runner's TTY and
|
|
3158
|
+
# steal its input) and pin the deterministic default.
|
|
3159
|
+
def detect_scheme: () -> Symbol
|
|
3160
|
+
|
|
2600
3161
|
# _@return_ — whatever {#print} printed so far.
|
|
2601
3162
|
attr_reader prints: ::Array[String]
|
|
2602
3163
|
end
|
|
@@ -2751,7 +3312,7 @@ module Tuile
|
|
|
2751
3312
|
|
|
2752
3313
|
# An immutable string-with-styling, modeled as a sequence of {Span}s where
|
|
2753
3314
|
# each span carries a complete {Style} (`fg`, `bg`, `bold`, `italic`,
|
|
2754
|
-
# `underline`). Spans are non-overlapping and fully tile the string — every
|
|
3315
|
+
# `underline`, `strikethrough`). Spans are non-overlapping and fully tile the string — every
|
|
2755
3316
|
# character has exactly one resolved style, no overlay layers to merge.
|
|
2756
3317
|
#
|
|
2757
3318
|
# Where this differs from threading SGR escapes through a plain `String`:
|
|
@@ -2791,12 +3352,21 @@ module Tuile
|
|
|
2791
3352
|
#
|
|
2792
3353
|
# ## Parser
|
|
2793
3354
|
#
|
|
2794
|
-
# {.parse} is strict by
|
|
3355
|
+
# {.parse} is strict by default: it recognizes only the SGR codes
|
|
2795
3356
|
# corresponding to {Style}'s supported attributes (fg/bg/bold/italic/
|
|
2796
|
-
# underline). Anything else — unmodeled attributes (dim, blink,
|
|
2797
|
-
#
|
|
3357
|
+
# underline/strikethrough). Anything else — unmodeled attributes (dim, blink,
|
|
3358
|
+
# reverse, conceal, double-underline, overline, ...), unknown SGR codes, or
|
|
2798
3359
|
# non-SGR escapes (cursor moves, OSC) — raises {ParseError}. This keeps the
|
|
2799
3360
|
# round-trip parse(to_ansi(x)) == x contract honest.
|
|
3361
|
+
#
|
|
3362
|
+
# Pass `lenient: true` to instead **discard** everything the parser can't
|
|
3363
|
+
# model and keep going — recognized fg/bg/bold/italic/underline/strikethrough codes still
|
|
3364
|
+
# apply, and any unmodeled SGR code, malformed extended color, non-SGR CSI
|
|
3365
|
+
# (cursor moves, `\e[K`), OSC/DCS/string sequence, or stray escape is
|
|
3366
|
+
# silently dropped. This is the mode for piping in colored output you don't
|
|
3367
|
+
# control (e.g. `git --color` through a pager): "give me the colors, throw
|
|
3368
|
+
# the rest away." It is lossy by design — `parse(x, lenient: true)` does not
|
|
3369
|
+
# round-trip back to `x`.
|
|
2800
3370
|
class StyledString
|
|
2801
3371
|
EMPTY: StyledString
|
|
2802
3372
|
|
|
@@ -2816,7 +3386,9 @@ module Tuile
|
|
|
2816
3386
|
# default-styled span.
|
|
2817
3387
|
#
|
|
2818
3388
|
# _@param_ `input`
|
|
2819
|
-
|
|
3389
|
+
#
|
|
3390
|
+
# _@param_ `lenient` — when true, unmodeled SGR codes and non-SGR escapes are discarded instead of raising — see {StyledString} "## Parser". Lossy: the result no longer round-trips to `input`.
|
|
3391
|
+
def self.parse: ((String | StyledString)? input, ?lenient: bool) -> StyledString
|
|
2820
3392
|
|
|
2821
3393
|
# _@param_ `spans`
|
|
2822
3394
|
def initialize: (?::Array[Span] spans) -> void
|
|
@@ -2903,7 +3475,7 @@ module Tuile
|
|
|
2903
3475
|
|
|
2904
3476
|
# Returns a new {StyledString} with `bg` applied to every span, preserving
|
|
2905
3477
|
# each span's text and other style attributes (`fg`, `bold`, `italic`,
|
|
2906
|
-
# `underline`). Useful for row-level highlights — the new bg overlays
|
|
3478
|
+
# `underline`, `strikethrough`). Useful for row-level highlights — the new bg overlays
|
|
2907
3479
|
# without dropping foreground colors the original styling carried.
|
|
2908
3480
|
#
|
|
2909
3481
|
# _@param_ `bg` — background color, coerced via {Color.coerce}. `nil` clears bg back to the terminal default.
|
|
@@ -2911,7 +3483,7 @@ module Tuile
|
|
|
2911
3483
|
|
|
2912
3484
|
# Returns a new {StyledString} with `fg` applied to every span, preserving
|
|
2913
3485
|
# each span's text and other style attributes (`bg`, `bold`, `italic`,
|
|
2914
|
-
# `underline`). The new fg overlays without dropping background colors or
|
|
3486
|
+
# `underline`, `strikethrough`). The new fg overlays without dropping background colors or
|
|
2915
3487
|
# text attributes the original styling carried.
|
|
2916
3488
|
#
|
|
2917
3489
|
# _@param_ `fg` — foreground color, coerced via {Color.coerce}. `nil` clears fg back to the terminal default.
|
|
@@ -3004,6 +3576,8 @@ module Tuile
|
|
|
3004
3576
|
# @return [Boolean]
|
|
3005
3577
|
# @!attribute [r] underline
|
|
3006
3578
|
# @return [Boolean]
|
|
3579
|
+
# @!attribute [r] strikethrough
|
|
3580
|
+
# @return [Boolean]
|
|
3007
3581
|
class Style
|
|
3008
3582
|
DEFAULT: Style
|
|
3009
3583
|
|
|
@@ -3016,12 +3590,15 @@ module Tuile
|
|
|
3016
3590
|
# _@param_ `italic`
|
|
3017
3591
|
#
|
|
3018
3592
|
# _@param_ `underline`
|
|
3593
|
+
#
|
|
3594
|
+
# _@param_ `strikethrough`
|
|
3019
3595
|
def self.new: (
|
|
3020
3596
|
?fg: (Color | Symbol | Integer | ::Array[Integer])?,
|
|
3021
3597
|
?bg: (Color | Symbol | Integer | ::Array[Integer])?,
|
|
3022
3598
|
?bold: bool,
|
|
3023
3599
|
?italic: bool,
|
|
3024
|
-
?underline: bool
|
|
3600
|
+
?underline: bool,
|
|
3601
|
+
?strikethrough: bool
|
|
3025
3602
|
) -> Style
|
|
3026
3603
|
|
|
3027
3604
|
def default?: () -> bool
|
|
@@ -3040,6 +3617,8 @@ module Tuile
|
|
|
3040
3617
|
attr_reader italic: bool
|
|
3041
3618
|
|
|
3042
3619
|
attr_reader underline: bool
|
|
3620
|
+
|
|
3621
|
+
attr_reader strikethrough: bool
|
|
3043
3622
|
end
|
|
3044
3623
|
|
|
3045
3624
|
# A maximal run of text sharing a single {Style}. `text` is plain — it
|
|
@@ -3065,14 +3644,18 @@ module Tuile
|
|
|
3065
3644
|
# @api private
|
|
3066
3645
|
# Hand-rolled SGR parser. State machine over a {StringScanner}: plain
|
|
3067
3646
|
# text accumulates into the current span; each `\e[...m` flushes the
|
|
3068
|
-
# current span and updates the running {Style}.
|
|
3069
|
-
# supported SGR alphabet raises {ParseError}
|
|
3647
|
+
# current span and updates the running {Style}. In strict mode anything
|
|
3648
|
+
# outside the supported SGR alphabet raises {ParseError}; in lenient mode
|
|
3649
|
+
# it is consumed and discarded (see {StyledString} "## Parser").
|
|
3070
3650
|
class Parser
|
|
3071
3651
|
STANDARD_COLORS: ::Array[Symbol]
|
|
3072
3652
|
BRIGHT_COLORS: ::Array[Symbol]
|
|
3653
|
+
STRING_INTRODUCERS: ::Array[String]
|
|
3073
3654
|
|
|
3074
3655
|
# _@param_ `input`
|
|
3075
|
-
|
|
3656
|
+
#
|
|
3657
|
+
# _@param_ `lenient` — when true, discard unmodeled SGR codes and non-SGR escapes instead of raising {ParseError}.
|
|
3658
|
+
def initialize: (String input, ?lenient: bool) -> void
|
|
3076
3659
|
|
|
3077
3660
|
def parse: () -> StyledString
|
|
3078
3661
|
|
|
@@ -3080,6 +3663,27 @@ module Tuile
|
|
|
3080
3663
|
|
|
3081
3664
|
def consume_escape: () -> void
|
|
3082
3665
|
|
|
3666
|
+
# Consumes a CSI sequence (`\e[` already eaten). A well-formed SGR
|
|
3667
|
+
# (`\e[...m` with numeric/`;` params and no intermediates) is applied;
|
|
3668
|
+
# anything else is a non-SGR or malformed CSI — raises in strict mode,
|
|
3669
|
+
# swallowed in lenient. Scans the full CSI grammar (parameter bytes
|
|
3670
|
+
# `\x30-\x3F`, intermediate bytes `\x20-\x2F`, final byte) so lenient
|
|
3671
|
+
# mode consumes the whole sequence even for private-marker forms like
|
|
3672
|
+
# `\e[?25l`.
|
|
3673
|
+
def consume_csi: () -> void
|
|
3674
|
+
|
|
3675
|
+
# Lenient-only: discards a non-CSI escape (`\e` and `intro` already
|
|
3676
|
+
# eaten). OSC/DCS/string sequences run to their string terminator; an
|
|
3677
|
+
# nF escape (`\e( B`) eats its intermediates plus one final byte; any
|
|
3678
|
+
# other Fe/Fp/Fs escape was complete in `intro` alone.
|
|
3679
|
+
#
|
|
3680
|
+
# _@param_ `intro` — the byte after `\e` (never `"["`).
|
|
3681
|
+
def consume_non_csi: (String intro) -> void
|
|
3682
|
+
|
|
3683
|
+
# Lenient-only: swallows an OSC/DCS/string-sequence payload up to and
|
|
3684
|
+
# including its terminator (BEL, or ST `\e\\`), or to EOS if unterminated.
|
|
3685
|
+
def consume_string_sequence: () -> void
|
|
3686
|
+
|
|
3083
3687
|
# _@param_ `params_str`
|
|
3084
3688
|
def apply_sgr: (String params_str) -> void
|
|
3085
3689
|
|
|
@@ -3089,7 +3693,11 @@ module Tuile
|
|
|
3089
3693
|
#
|
|
3090
3694
|
# _@param_ `target` — either `:fg` or `:bg`.
|
|
3091
3695
|
#
|
|
3092
|
-
# _@return_ — how many SGR codes were consumed
|
|
3696
|
+
# _@return_ — how many SGR codes were consumed. In lenient mode a
|
|
3697
|
+
# malformed color is skipped rather than applied, but the same count is
|
|
3698
|
+
# returned (3 for 256-color, 5 for RGB) so the running index advances
|
|
3699
|
+
# past its operands; an unknown selector skips just `38`/`48` + the
|
|
3700
|
+
# selector byte (2), letting the rest be reprocessed.
|
|
3093
3701
|
def consume_extended_color: (::Array[Integer] codes, Integer index, Symbol target) -> Integer
|
|
3094
3702
|
|
|
3095
3703
|
def flush: () -> void
|
|
@@ -3151,6 +3759,91 @@ module Tuile
|
|
|
3151
3759
|
end
|
|
3152
3760
|
end
|
|
3153
3761
|
|
|
3762
|
+
# Detects whether the terminal background is light or dark, so {Screen}
|
|
3763
|
+
# can pick {Theme::LIGHT} or {Theme::DARK} automatically at startup.
|
|
3764
|
+
#
|
|
3765
|
+
# Two mechanisms, in order of reliability:
|
|
3766
|
+
#
|
|
3767
|
+
# 1. **OSC 11 query** — writes `ESC ] 11 ; ? BEL` to the terminal; modern
|
|
3768
|
+
# terminals (xterm, kitty, alacritty, wezterm, iTerm2, GNOME Terminal,
|
|
3769
|
+
# Windows Terminal) reply on stdin with the background color
|
|
3770
|
+
# (`\e]11;rgb:RRRR/GGGG/BBBB` + BEL or ST). The color's relative
|
|
3771
|
+
# luminance against a 0.5 threshold decides light vs dark. Terminals
|
|
3772
|
+
# that don't support the query simply never reply, so the read is
|
|
3773
|
+
# bounded by a short timeout.
|
|
3774
|
+
# 2. **`COLORFGBG` env var** — rxvt/konsole export `"fg;bg"` ANSI palette
|
|
3775
|
+
# indices. Less reliable (stale across SSH/tmux, often unset); used
|
|
3776
|
+
# only when OSC 11 yields nothing.
|
|
3777
|
+
#
|
|
3778
|
+
# **Timing matters**: the OSC 11 reply arrives on stdin, so the query
|
|
3779
|
+
# must complete before {EventQueue#start_key_thread} owns stdin —
|
|
3780
|
+
# otherwise the reply bytes get consumed as garbage keystrokes. {Screen}
|
|
3781
|
+
# calls {.detect} from its constructor, which apps run before
|
|
3782
|
+
# {Screen#run_event_loop}; don't call this after the event loop started.
|
|
3783
|
+
module TerminalBackground
|
|
3784
|
+
QUERY_TIMEOUT: Float
|
|
3785
|
+
QUERY: String
|
|
3786
|
+
REPLY: Regexp
|
|
3787
|
+
NOTIFY_ON: String
|
|
3788
|
+
NOTIFY_OFF: String
|
|
3789
|
+
|
|
3790
|
+
# Detects the terminal background. Queries OSC 11 when both `input`
|
|
3791
|
+
# and `output` are TTYs, falling back to `COLORFGBG`.
|
|
3792
|
+
#
|
|
3793
|
+
# _@param_ `input` — where the OSC 11 reply arrives (the TTY input).
|
|
3794
|
+
#
|
|
3795
|
+
# _@param_ `output` — where the query is written (the TTY output).
|
|
3796
|
+
#
|
|
3797
|
+
# _@param_ `env` — environment for the `COLORFGBG` fallback; defaults to `ENV` (which duck-types the `[]` lookup).
|
|
3798
|
+
#
|
|
3799
|
+
# _@param_ `timeout` — max seconds to wait for the OSC 11 reply.
|
|
3800
|
+
#
|
|
3801
|
+
# _@return_ — `:light`, `:dark`, or nil when undetectable.
|
|
3802
|
+
def self.detect: (
|
|
3803
|
+
?input: IO,
|
|
3804
|
+
?output: IO,
|
|
3805
|
+
?env: ::Hash[String, String],
|
|
3806
|
+
?timeout: Numeric
|
|
3807
|
+
) -> Symbol?
|
|
3808
|
+
|
|
3809
|
+
# Writes the OSC 11 query and classifies the reply. The whole
|
|
3810
|
+
# exchange runs with `input` in raw mode: the reply has no trailing
|
|
3811
|
+
# newline, so a canonical-mode read would block past the timeout,
|
|
3812
|
+
# and echo would smear the reply bytes onto the screen.
|
|
3813
|
+
#
|
|
3814
|
+
# _@param_ `input`
|
|
3815
|
+
#
|
|
3816
|
+
# _@param_ `output`
|
|
3817
|
+
#
|
|
3818
|
+
# _@param_ `timeout`
|
|
3819
|
+
def self.query_osc11: (IO input, IO output, Numeric timeout) -> Symbol?
|
|
3820
|
+
|
|
3821
|
+
# Accumulates reply bytes until a BEL/ST terminator or the deadline.
|
|
3822
|
+
# Terminals that don't support OSC 11 never reply — returning
|
|
3823
|
+
# whatever arrived (usually nothing) lets the caller fail soft.
|
|
3824
|
+
#
|
|
3825
|
+
# _@param_ `input`
|
|
3826
|
+
#
|
|
3827
|
+
# _@param_ `timeout`
|
|
3828
|
+
def self.read_reply: (IO input, Numeric timeout) -> String
|
|
3829
|
+
|
|
3830
|
+
# Relative luminance of the reported background, scaled per
|
|
3831
|
+
# component hex width (xterm replies 4 digits per channel, others 2).
|
|
3832
|
+
#
|
|
3833
|
+
# _@param_ `components` — three hex strings.
|
|
3834
|
+
#
|
|
3835
|
+
# _@return_ — `:light` or `:dark`.
|
|
3836
|
+
def self.classify: (::Array[String] components) -> Symbol
|
|
3837
|
+
|
|
3838
|
+
# `COLORFGBG` is `"fg;bg"` (rxvt sometimes `"fg;default;bg"`) with
|
|
3839
|
+
# ANSI palette indices. White-ish backgrounds — 7 (white) and the
|
|
3840
|
+
# bright range 9–15 — read as light; 0–6 and 8 as dark; anything
|
|
3841
|
+
# else (missing, `"default"`, out of range) is inconclusive.
|
|
3842
|
+
#
|
|
3843
|
+
# _@param_ `value`
|
|
3844
|
+
def self.from_colorfgbg: (String? value) -> Symbol?
|
|
3845
|
+
end
|
|
3846
|
+
|
|
3154
3847
|
# A vertical scrollbar that computes which character to draw at each row.
|
|
3155
3848
|
#
|
|
3156
3849
|
# Uses `█` for the handle (filled track) and `░` for the empty track. There
|