tuile 0.5.0 → 0.6.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 +15 -0
- data/README.md +150 -4
- data/examples/file_commander.rb +4 -3
- data/examples/sampler.rb +1 -0
- 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/list.rb +29 -19
- data/lib/tuile/component/picker_window.rb +2 -2
- data/lib/tuile/component/popup.rb +11 -1
- 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 +9 -10
- data/lib/tuile/component/window.rb +70 -16
- data/lib/tuile/component.rb +74 -5
- data/lib/tuile/event_queue.rb +26 -2
- data/lib/tuile/fake_screen.rb +8 -0
- data/lib/tuile/keys.rb +10 -0
- data/lib/tuile/screen.rb +96 -4
- data/lib/tuile/sizing.rb +59 -0
- 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/sig/tuile.rbs +627 -41
- metadata +5 -15
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
|
|
@@ -197,6 +198,9 @@ module Tuile
|
|
|
197
198
|
#
|
|
198
199
|
# A constant per named color is pre-defined (`Color::RED`, `Color::BRIGHT_BLUE`,
|
|
199
200
|
# …) so callers can reach for `Color::RED` instead of building one each time.
|
|
201
|
+
# The 256-color palette gets the same treatment via {PALETTE_NAMES}:
|
|
202
|
+
# `Color::CADET_BLUE`, `Color::DODGER_BLUE1`, `Color::GREY37`, … — the
|
|
203
|
+
# standard xterm chart names for indices 16..255, each an exact palette cell.
|
|
200
204
|
# {.coerce} accepts anything {.new} accepts plus `nil` (terminal default) and
|
|
201
205
|
# an existing {Color} (returned as-is), so APIs that accept colors typically
|
|
202
206
|
# take `[Color, nil]` and pass through {.coerce}.
|
|
@@ -206,15 +210,26 @@ module Tuile
|
|
|
206
210
|
# Color.new(42) # 256-color palette
|
|
207
211
|
# Color.new([255, 100, 0]) # RGB
|
|
208
212
|
# Color::RED # constant
|
|
213
|
+
# Color.palette(42) # 256-color palette, explicit
|
|
214
|
+
# Color.rgb(255, 100, 0) # 24-bit RGB, explicit
|
|
215
|
+
# Color.hex("#ff6400") # 24-bit RGB from a CSS-style hex string
|
|
209
216
|
# Color.coerce(:red) # accepts raw forms, returns Color
|
|
210
217
|
# Color.coerce(nil) # nil → nil
|
|
211
218
|
# ```
|
|
212
219
|
#
|
|
220
|
+
# Which entry point to use is a deliberate policy split. High-traffic
|
|
221
|
+
# call sites ({StyledString} and friends) stay lenient and {.coerce} raw
|
|
222
|
+
# forms — you don't want factory ceremony on every styled span.
|
|
223
|
+
# Declaration sites ({Theme}, defined once per app) are strict and take
|
|
224
|
+
# only {Color} instances, where `Color.palette(130)` documents itself in
|
|
225
|
+
# a way the bare `130` (palette index? RGB channel?) does not.
|
|
226
|
+
#
|
|
213
227
|
# {#to_ansi} renders a full SGR escape (`"\e[31m"`); {#sgr_codes} returns the
|
|
214
228
|
# raw numeric codes so callers (notably {StyledString}) can combine them with
|
|
215
229
|
# other SGR attributes in a single sequence.
|
|
216
230
|
class Color
|
|
217
231
|
COLOR_SYMBOLS: ::Array[Symbol]
|
|
232
|
+
PALETTE_NAMES: ::Hash[Symbol, Integer]
|
|
218
233
|
|
|
219
234
|
# Coerces the input to a {Color}. `nil` passes through unchanged (callers
|
|
220
235
|
# use `nil` for the terminal default); an existing {Color} is returned
|
|
@@ -223,6 +238,35 @@ module Tuile
|
|
|
223
238
|
# _@param_ `value`
|
|
224
239
|
def self.coerce: ((Color | Symbol | Integer | ::Array[Integer])? value) -> Color?
|
|
225
240
|
|
|
241
|
+
# A color from the 256-color palette (SGR 38;5;N / 48;5;N). Same as
|
|
242
|
+
# `Color.new(index)`, but the name says what the bare integer is.
|
|
243
|
+
#
|
|
244
|
+
# _@param_ `index` — palette index, 0..255.
|
|
245
|
+
def self.palette: (Integer index) -> Color
|
|
246
|
+
|
|
247
|
+
# A 24-bit RGB color (SGR 38;2;R;G;B / 48;2;R;G;B). Same as
|
|
248
|
+
# `Color.new([r, g, b])`, but with the channels spelled out.
|
|
249
|
+
#
|
|
250
|
+
# _@param_ `red` — 0..255.
|
|
251
|
+
#
|
|
252
|
+
# _@param_ `green` — 0..255.
|
|
253
|
+
#
|
|
254
|
+
# _@param_ `blue` — 0..255.
|
|
255
|
+
def self.rgb: (Integer red, Integer green, Integer blue) -> Color
|
|
256
|
+
|
|
257
|
+
# A 24-bit RGB color from a CSS-style hex string — for when the value
|
|
258
|
+
# comes from a hex source (a designer's palette, a CSS variable). The
|
|
259
|
+
# leading `#` is optional, digits are case-insensitive, and the CSS
|
|
260
|
+
# 3-digit shorthand expands as in CSS (`"#345"` → `"#334455"`).
|
|
261
|
+
# 4/8-digit alpha forms are rejected: SGR has no alpha channel, and
|
|
262
|
+
# silently dropping it would lie about the rendered color.
|
|
263
|
+
#
|
|
264
|
+
# _@param_ `string` — e.g. `"#333333"`, `"5F9EA0"`, `"#333"`.
|
|
265
|
+
#
|
|
266
|
+
# _@return_ — same value form as {.rgb} — `Color.hex("#333") ==
|
|
267
|
+
# Color.rgb(51, 51, 51)`.
|
|
268
|
+
def self.hex: (String string) -> Color
|
|
269
|
+
|
|
226
270
|
# _@param_ `value` — see class-level docs for the three accepted forms.
|
|
227
271
|
def initialize: ((Symbol | Integer | ::Array[Integer]) value) -> void
|
|
228
272
|
|
|
@@ -268,6 +312,213 @@ module Tuile
|
|
|
268
312
|
attr_reader y: Integer
|
|
269
313
|
end
|
|
270
314
|
|
|
315
|
+
# A set of semantic colors the built-in components read when painting.
|
|
316
|
+
# The current theme lives at {Screen#theme}; components must look it up
|
|
317
|
+
# at paint time (inside `repaint`) rather than caching values, so that
|
|
318
|
+
# assigning {Screen#theme=} restyles everything via a single
|
|
319
|
+
# invalidate-everything pass.
|
|
320
|
+
#
|
|
321
|
+
# The primary API is the rendering helpers — {#active_bg},
|
|
322
|
+
# {#active_border}, {#input_bg}, {#hint} — which wrap a plain string in
|
|
323
|
+
# the token's SGR color (on the channel appropriate for the token's
|
|
324
|
+
# role) and reset:
|
|
325
|
+
#
|
|
326
|
+
# screen.theme.active_bg("[ Ok ]") # => "\e[48;5;59m[ Ok ]\e[0m"
|
|
327
|
+
# screen.theme.hint("quit") # => "\e[38;5;109mquit\e[0m"
|
|
328
|
+
#
|
|
329
|
+
# The helpers pass content through verbatim, so input may carry other
|
|
330
|
+
# escape sequences (e.g. {Component::Window} feeds its border string,
|
|
331
|
+
# cursor moves included). For span-aware styling — applying a token to a
|
|
332
|
+
# {StyledString} while preserving per-span colors — use the `*_color`
|
|
333
|
+
# readers instead (e.g. {Component::List} highlights its cursor row via
|
|
334
|
+
# `with_bg(theme.active_bg_color)`). Rule of thumb: plain chrome text →
|
|
335
|
+
# helper; structured text → `*_color` reader + {StyledString}.
|
|
336
|
+
#
|
|
337
|
+
# Two built-in themes are provided: {DARK} (the default; the colors Tuile
|
|
338
|
+
# has always used) and {LIGHT} (counterparts legible on light terminal
|
|
339
|
+
# backgrounds). A custom theme is one `with` away:
|
|
340
|
+
#
|
|
341
|
+
# screen.theme = Theme::DARK.with(active_border_color: Color::CYAN)
|
|
342
|
+
#
|
|
343
|
+
# Tokens deliberately cover only the *accents* Tuile paints. Everything
|
|
344
|
+
# else inherits the terminal's own default foreground/background, which
|
|
345
|
+
# already matches the user's terminal theme perfectly — that's why there
|
|
346
|
+
# is no global `bg`/`fg` token.
|
|
347
|
+
#
|
|
348
|
+
# Every token is a {Color} — and must be passed as one. Unlike the
|
|
349
|
+
# lenient {Color.coerce} call sites elsewhere in the framework, a theme
|
|
350
|
+
# is declared once per app, so it takes only {Color} instances: at a
|
|
351
|
+
# declaration site `Color.palette(130)` documents itself in a way the
|
|
352
|
+
# bare `130` does not (palette index? RGB channel?) — and the named
|
|
353
|
+
# palette constants (`Color::DARK_ORANGE3` *is* 130; see
|
|
354
|
+
# {Color::PALETTE_NAMES}) go one step further.
|
|
355
|
+
#
|
|
356
|
+
# ## App-specific tokens
|
|
357
|
+
#
|
|
358
|
+
# Beyond the built-in tokens, an app can carry its own colors in
|
|
359
|
+
# {#custom} — a frozen `Hash{Symbol => Color}` member. Look them up with
|
|
360
|
+
# {#[]} (fail-fast: a typo raises `KeyError`) and render with the
|
|
361
|
+
# generic {#fg} / {#bg} helpers:
|
|
362
|
+
#
|
|
363
|
+
# theme = Theme::DARK.with(custom: { accent: Color::DARK_ORANGE })
|
|
364
|
+
# theme[:accent] # => Color, e.g. for StyledString#with_fg
|
|
365
|
+
# theme.fg(:accent, "NEW") # => "\e[38;5;208mNEW\e[0m"
|
|
366
|
+
#
|
|
367
|
+
# Apps wanting semantic readers can subclass — `Data#with` preserves the
|
|
368
|
+
# subclass, so an `AppTheme` stays an `AppTheme` through `with`:
|
|
369
|
+
#
|
|
370
|
+
# class AppTheme < Tuile::Theme
|
|
371
|
+
# def accent(text) = fg(:accent, text)
|
|
372
|
+
# end
|
|
373
|
+
#
|
|
374
|
+
# Pair the dark and light variants in a {ThemeDef} and hand it to
|
|
375
|
+
# {Screen#theme_def=} so OS appearance flips pick the right one.
|
|
376
|
+
#
|
|
377
|
+
# @!attribute [r] active_bg_color
|
|
378
|
+
# Background highlight of the component the user is interacting with:
|
|
379
|
+
# the {Component::List} cursor row, the focused {Component::TextField} /
|
|
380
|
+
# {Component::TextArea} well, the focused {Component::Button}. "Active"
|
|
381
|
+
# matches the {Component#active?} focus-chain flag — this is the
|
|
382
|
+
# focus/selection highlight in conventional UI terms.
|
|
383
|
+
# @return [Color]
|
|
384
|
+
# @!attribute [r] active_border_color
|
|
385
|
+
# Foreground of a {Component::Window} border when the window is on the
|
|
386
|
+
# active (focus) chain.
|
|
387
|
+
# @return [Color]
|
|
388
|
+
# @!attribute [r] input_bg_color
|
|
389
|
+
# Resting background "well" of {Component::TextField} /
|
|
390
|
+
# {Component::TextArea} when *not* active — visibly a field, but
|
|
391
|
+
# distinctly subtler than {#active_bg_color}.
|
|
392
|
+
# @return [Color]
|
|
393
|
+
# @!attribute [r] hint_color
|
|
394
|
+
# Foreground of keyboard-shortcut captions in status-bar hints (the
|
|
395
|
+
# "quit" in "q quit") — see {#hint}.
|
|
396
|
+
# @return [Color]
|
|
397
|
+
# @!attribute [r] custom
|
|
398
|
+
# App-specific color tokens; empty in the built-in themes. Frozen —
|
|
399
|
+
# build a changed theme via `with(custom: ...)`. Prefer {#[]} for
|
|
400
|
+
# lookups (it fail-fasts on typos); read this directly to enumerate
|
|
401
|
+
# the tokens.
|
|
402
|
+
# @return [Hash{Symbol => Color}]
|
|
403
|
+
class Theme
|
|
404
|
+
DARK: Theme
|
|
405
|
+
LIGHT: Theme
|
|
406
|
+
|
|
407
|
+
# _@param_ `active_bg_color`
|
|
408
|
+
#
|
|
409
|
+
# _@param_ `active_border_color`
|
|
410
|
+
#
|
|
411
|
+
# _@param_ `input_bg_color`
|
|
412
|
+
#
|
|
413
|
+
# _@param_ `hint_color`
|
|
414
|
+
#
|
|
415
|
+
# _@param_ `custom` — app-specific tokens, see {#custom}.
|
|
416
|
+
def initialize: (
|
|
417
|
+
active_bg_color: Color,
|
|
418
|
+
active_border_color: Color,
|
|
419
|
+
input_bg_color: Color,
|
|
420
|
+
hint_color: Color,
|
|
421
|
+
?custom: ::Hash[Symbol, Color]
|
|
422
|
+
) -> void
|
|
423
|
+
|
|
424
|
+
# Looks up an app-specific token from {#custom}.
|
|
425
|
+
#
|
|
426
|
+
# _@param_ `token`
|
|
427
|
+
def []: (Symbol token) -> Color
|
|
428
|
+
|
|
429
|
+
# Renders `text` in the foreground color of the app-specific `token`
|
|
430
|
+
# — the generic counterpart of {#hint} for {#custom} tokens.
|
|
431
|
+
#
|
|
432
|
+
# _@param_ `token`
|
|
433
|
+
#
|
|
434
|
+
# _@param_ `text`
|
|
435
|
+
#
|
|
436
|
+
# _@return_ — ANSI-rendered text, ending with an SGR reset.
|
|
437
|
+
def fg: (Symbol token, String text) -> String
|
|
438
|
+
|
|
439
|
+
# Renders `text` on the background color of the app-specific `token`
|
|
440
|
+
# — the generic counterpart of {#active_bg} for {#custom} tokens.
|
|
441
|
+
#
|
|
442
|
+
# _@param_ `token`
|
|
443
|
+
#
|
|
444
|
+
# _@param_ `text`
|
|
445
|
+
#
|
|
446
|
+
# _@return_ — ANSI-rendered text, ending with an SGR reset.
|
|
447
|
+
def bg: (Symbol token, String text) -> String
|
|
448
|
+
|
|
449
|
+
# Renders `text` on the {#active_bg_color} background.
|
|
450
|
+
#
|
|
451
|
+
# _@param_ `text`
|
|
452
|
+
#
|
|
453
|
+
# _@return_ — ANSI-rendered text, ending with an SGR reset.
|
|
454
|
+
def active_bg: (String text) -> String
|
|
455
|
+
|
|
456
|
+
# Renders `text` in the {#active_border_color} foreground. Content
|
|
457
|
+
# passes through verbatim, so it may embed non-SGR escapes (cursor
|
|
458
|
+
# moves in a border string).
|
|
459
|
+
#
|
|
460
|
+
# _@param_ `text`
|
|
461
|
+
#
|
|
462
|
+
# _@return_ — ANSI-rendered text, ending with an SGR reset.
|
|
463
|
+
def active_border: (String text) -> String
|
|
464
|
+
|
|
465
|
+
# Renders `text` on the {#input_bg_color} background.
|
|
466
|
+
#
|
|
467
|
+
# _@param_ `text`
|
|
468
|
+
#
|
|
469
|
+
# _@return_ — ANSI-rendered text, ending with an SGR reset.
|
|
470
|
+
def input_bg: (String text) -> String
|
|
471
|
+
|
|
472
|
+
# Renders `text` in the {#hint_color} foreground, for status-bar hints,
|
|
473
|
+
# e.g. `"q #{screen.theme.hint("quit")}"`. The color is baked into the
|
|
474
|
+
# returned String, so strings built this way do *not* restyle when the
|
|
475
|
+
# theme changes — rebuild them instead (the framework's own call sites
|
|
476
|
+
# rebuild on every status-bar refresh).
|
|
477
|
+
#
|
|
478
|
+
# _@param_ `text`
|
|
479
|
+
#
|
|
480
|
+
# _@return_ — ANSI-rendered text, ending with an SGR reset.
|
|
481
|
+
def hint: (String text) -> String
|
|
482
|
+
|
|
483
|
+
# The single sanctioned place for verbatim SGR wrapping: `text` is not
|
|
484
|
+
# parsed or validated, so callers may embed non-SGR escapes. Emits the
|
|
485
|
+
# same bytes `StyledString.styled(text, ...).to_ansi` would for plain
|
|
486
|
+
# text.
|
|
487
|
+
#
|
|
488
|
+
# _@param_ `text`
|
|
489
|
+
#
|
|
490
|
+
# _@param_ `color`
|
|
491
|
+
#
|
|
492
|
+
# _@param_ `target` — `:fg` or `:bg`.
|
|
493
|
+
def wrap: (String text, Color color, Symbol target) -> String
|
|
494
|
+
|
|
495
|
+
# Background highlight of the component the user is interacting with:
|
|
496
|
+
# the {Component::List} cursor row, the focused {Component::TextField} /
|
|
497
|
+
# {Component::TextArea} well, the focused {Component::Button}. "Active"
|
|
498
|
+
# matches the {Component#active?} focus-chain flag — this is the
|
|
499
|
+
# focus/selection highlight in conventional UI terms.
|
|
500
|
+
attr_reader active_bg_color: Color
|
|
501
|
+
|
|
502
|
+
# Foreground of a {Component::Window} border when the window is on the
|
|
503
|
+
# active (focus) chain.
|
|
504
|
+
attr_reader active_border_color: Color
|
|
505
|
+
|
|
506
|
+
# Resting background "well" of {Component::TextField} /
|
|
507
|
+
# {Component::TextArea} when *not* active — visibly a field, but
|
|
508
|
+
# distinctly subtler than {#active_bg_color}.
|
|
509
|
+
attr_reader input_bg_color: Color
|
|
510
|
+
|
|
511
|
+
# Foreground of keyboard-shortcut captions in status-bar hints (the
|
|
512
|
+
# "quit" in "q quit") — see {#hint}.
|
|
513
|
+
attr_reader hint_color: Color
|
|
514
|
+
|
|
515
|
+
# App-specific color tokens; empty in the built-in themes. Frozen —
|
|
516
|
+
# build a changed theme via `with(custom: ...)`. Prefer {#[]} for
|
|
517
|
+
# lookups (it fail-fasts on typos); read this directly to enumerate
|
|
518
|
+
# the tokens.
|
|
519
|
+
attr_reader custom: ::Hash[Symbol, Color]
|
|
520
|
+
end
|
|
521
|
+
|
|
271
522
|
# The TTY screen. There is exactly one screen per app.
|
|
272
523
|
#
|
|
273
524
|
# A screen runs the event loop; call {#run_event_loop} to do that.
|
|
@@ -386,7 +637,7 @@ module Tuile
|
|
|
386
637
|
#
|
|
387
638
|
# screen.register_global_shortcut(Keys::CTRL_L,
|
|
388
639
|
# over_popups: true,
|
|
389
|
-
# hint: "^L #{
|
|
640
|
+
# hint: "^L #{screen.theme.hint("log")}") do
|
|
390
641
|
# log_popup.open
|
|
391
642
|
# end
|
|
392
643
|
#
|
|
@@ -394,7 +645,7 @@ module Tuile
|
|
|
394
645
|
#
|
|
395
646
|
# _@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
647
|
#
|
|
397
|
-
# _@param_ `hint` — preformatted status-bar hint (e.g. `"^L #{
|
|
648
|
+
# _@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
649
|
def register_global_shortcut: (String key, ?over_popups: bool, ?hint: String?) -> void
|
|
399
650
|
|
|
400
651
|
# Removes a shortcut previously installed by {#register_global_shortcut}.
|
|
@@ -462,6 +713,23 @@ module Tuile
|
|
|
462
713
|
# but only one focused.
|
|
463
714
|
def cursor_position: () -> Point?
|
|
464
715
|
|
|
716
|
+
# Startup color scheme: `:light` when {TerminalBackground.detect}
|
|
717
|
+
# reports a light terminal background, `:dark` otherwise (including
|
|
718
|
+
# when detection is inconclusive). Runs in the constructor — the
|
|
719
|
+
# OSC 11 reply arrives on stdin, which is only safe to read before
|
|
720
|
+
# {EventQueue#start_key_thread} owns it. {FakeScreen} overrides this
|
|
721
|
+
# to pin `:dark`, keeping specs deterministic and off the test
|
|
722
|
+
# runner's TTY.
|
|
723
|
+
#
|
|
724
|
+
# _@return_ — `:dark` or `:light`.
|
|
725
|
+
def detect_scheme: () -> Symbol
|
|
726
|
+
|
|
727
|
+
# An OS appearance flip arrived (mode-2031 report): remember the
|
|
728
|
+
# scheme and apply the matching member of {#theme_def}.
|
|
729
|
+
#
|
|
730
|
+
# _@param_ `scheme` — `:dark` or `:light`.
|
|
731
|
+
def on_color_scheme: (Symbol scheme) -> void
|
|
732
|
+
|
|
465
733
|
# Walks the current modal scope in pre-order, collects tab stops, and
|
|
466
734
|
# advances focus by one (wrapping). When the focused component isn't in
|
|
467
735
|
# the tab order (e.g. focus is parked on a popup/window chrome with no
|
|
@@ -545,6 +813,22 @@ module Tuile
|
|
|
545
813
|
# _@return_ — current screen size.
|
|
546
814
|
attr_reader size: Size
|
|
547
815
|
|
|
816
|
+
# The color {Theme} built-in components read at paint time: the member
|
|
817
|
+
# of {#theme_def} matching the terminal background detected at
|
|
818
|
+
# construction (see {TerminalBackground.detect}; inconclusive means
|
|
819
|
+
# dark). While the event loop runs, terminals supporting mode 2031
|
|
820
|
+
# push OS appearance changes ({EventQueue::ColorSchemeEvent}) and the
|
|
821
|
+
# screen re-picks from {#theme_def}.
|
|
822
|
+
attr_accessor theme: Theme
|
|
823
|
+
|
|
824
|
+
# The app's {ThemeDef} — the dark/light {Theme} pair the screen picks
|
|
825
|
+
# {#theme} from, at startup and on every OS appearance flip. Starts as
|
|
826
|
+
# {ThemeDef.default} ({ThemeDef::DEFAULT} unless reassigned — tests
|
|
827
|
+
# do, see {ThemeDef.default=}). Assigning a custom definition is the
|
|
828
|
+
# durable way to theme an app: unlike a bare {#theme=}, it survives
|
|
829
|
+
# the user toggling the OS appearance.
|
|
830
|
+
attr_accessor theme_def: ThemeDef
|
|
831
|
+
|
|
548
832
|
# _@return_ — the event queue.
|
|
549
833
|
attr_reader event_queue: EventQueue
|
|
550
834
|
|
|
@@ -566,6 +850,52 @@ module Tuile
|
|
|
566
850
|
end
|
|
567
851
|
end
|
|
568
852
|
|
|
853
|
+
# A sizing policy for a slot whose position is managed by a parent
|
|
854
|
+
# component (e.g. {Component::Window#footer}). Resolves one dimension at a
|
|
855
|
+
# time via {#resolve}, so the same value works for widths and heights.
|
|
856
|
+
#
|
|
857
|
+
# Three policies exist:
|
|
858
|
+
#
|
|
859
|
+
# - {FILL} — take everything the slot offers;
|
|
860
|
+
# - {WRAP_CONTENT} — take the component's natural extent (its
|
|
861
|
+
# {Component#content_size}), clamped to the slot;
|
|
862
|
+
# - {.fixed} — take exactly the given number of cells, clamped to the slot.
|
|
863
|
+
#
|
|
864
|
+
# Note that {WRAP_CONTENT} only makes sense for components that report a
|
|
865
|
+
# natural {Component#content_size} ({Component::Label}, {Component::Button},
|
|
866
|
+
# {Component::List}, …). Input components ({Component::TextField} et al.)
|
|
867
|
+
# report {Size::ZERO}, so a wrap-content slot collapses to zero width —
|
|
868
|
+
# i.e. the component becomes invisible. Use {.fixed} or {FILL} for those.
|
|
869
|
+
#
|
|
870
|
+
# @!attribute [r] mode
|
|
871
|
+
# @return [Symbol] `:fill`, `:wrap_content` or `:fixed`.
|
|
872
|
+
# @!attribute [r] amount
|
|
873
|
+
# @return [Integer, nil] the cell count for `:fixed`; `nil` otherwise.
|
|
874
|
+
class Sizing
|
|
875
|
+
FILL: Sizing
|
|
876
|
+
WRAP_CONTENT: Sizing
|
|
877
|
+
|
|
878
|
+
# _@param_ `amount` — the number of cells to occupy; 0 or greater.
|
|
879
|
+
#
|
|
880
|
+
# _@return_ — a fixed-size policy.
|
|
881
|
+
def self.fixed: (Integer amount) -> Sizing
|
|
882
|
+
|
|
883
|
+
# Resolves one dimension of a slot.
|
|
884
|
+
#
|
|
885
|
+
# _@param_ `available` — cells the slot offers; 0 or greater.
|
|
886
|
+
#
|
|
887
|
+
# _@param_ `content` — the component's natural extent on this axis (one dimension of its {Component#content_size}).
|
|
888
|
+
#
|
|
889
|
+
# _@return_ — the resolved extent, always in `0..available`.
|
|
890
|
+
def resolve: (Integer available, Integer content) -> Integer
|
|
891
|
+
|
|
892
|
+
# _@return_ — `:fill`, `:wrap_content` or `:fixed`.
|
|
893
|
+
attr_reader mode: Symbol
|
|
894
|
+
|
|
895
|
+
# _@return_ — the cell count for `:fixed`; `nil` otherwise.
|
|
896
|
+
attr_reader amount: Integer?
|
|
897
|
+
end
|
|
898
|
+
|
|
569
899
|
# A UI component which is positioned on the screen and draws characters into
|
|
570
900
|
# its bounding rectangle (in {#repaint}).
|
|
571
901
|
#
|
|
@@ -700,13 +1030,19 @@ module Tuile
|
|
|
700
1030
|
# _@param_ `child` — the just-detached child.
|
|
701
1031
|
def on_child_removed: (Component child) -> void
|
|
702
1032
|
|
|
703
|
-
#
|
|
704
|
-
#
|
|
705
|
-
#
|
|
706
|
-
#
|
|
707
|
-
#
|
|
708
|
-
#
|
|
709
|
-
|
|
1033
|
+
# Called by a child component whose {#content_size} just changed (fired
|
|
1034
|
+
# from the child's {#content_size=}). Does nothing by default — a plain
|
|
1035
|
+
# container is not size-coupled to its children. Containers that derive
|
|
1036
|
+
# their own natural size or child layout from a child's natural size
|
|
1037
|
+
# override this (e.g. {Component::Window} re-lays-out a
|
|
1038
|
+
# {Sizing::WRAP_CONTENT} footer and recomputes its own size from content;
|
|
1039
|
+
# {Component::Popup} re-self-sizes). If the receiver's own
|
|
1040
|
+
# {#content_size} changes as a consequence, its {#content_size=} notifies
|
|
1041
|
+
# *its* parent in turn — so the event bubbles exactly as far as geometry
|
|
1042
|
+
# keeps changing, and stops where it doesn't.
|
|
1043
|
+
#
|
|
1044
|
+
# _@param_ `child` — the resized direct child.
|
|
1045
|
+
def on_child_content_size_changed: (Component child) -> void
|
|
710
1046
|
|
|
711
1047
|
# Where the hardware terminal cursor should sit when this component is the
|
|
712
1048
|
# cursor owner. Returns `nil` to indicate the cursor should be hidden. The
|
|
@@ -759,6 +1095,35 @@ module Tuile
|
|
|
759
1095
|
# no parent.
|
|
760
1096
|
attr_accessor parent: Component?
|
|
761
1097
|
|
|
1098
|
+
# Called on every attached component (pre-order, popups included) when
|
|
1099
|
+
# {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=}
|
|
1100
|
+
# assignment and on OS appearance flips.
|
|
1101
|
+
#
|
|
1102
|
+
# Built-in components read {Screen#theme} at paint time, so their accents
|
|
1103
|
+
# restyle automatically; this hook exists for *content* whose colors the
|
|
1104
|
+
# app baked in from the old theme — a {Label#text} / {List#lines} /
|
|
1105
|
+
# {TextView#text} {StyledString} styled with `theme[:accent]` and the
|
|
1106
|
+
# like. Only the app knows which of its colors were theme-derived (as
|
|
1107
|
+
# opposed to inherent to the data, e.g. log-level colors), so it rebuilds
|
|
1108
|
+
# them here, re-running the same code that rendered them initially.
|
|
1109
|
+
#
|
|
1110
|
+
# Runs on the UI thread; {Screen#theme} already returns the new theme.
|
|
1111
|
+
# Mutating content (`text=`, `lines=`, …) is safe — repaint coalesces per
|
|
1112
|
+
# event-loop tick. Do not assign {Screen#theme=} from inside the hook.
|
|
1113
|
+
#
|
|
1114
|
+
# Subclasses overriding this should call `super` so an assigned
|
|
1115
|
+
# {#on_theme_changed=} listener keeps firing.
|
|
1116
|
+
attr_accessor on_theme_changed: Proc?
|
|
1117
|
+
|
|
1118
|
+
# The {Size} big enough to show the entire component contents without
|
|
1119
|
+
# scrolling. Plain components have no intrinsic content and report
|
|
1120
|
+
# {Size::ZERO}; content-bearing components (e.g. {Label}, {List},
|
|
1121
|
+
# {TextView}, {Window}) maintain it eagerly via {#content_size=} from
|
|
1122
|
+
# their mutators, so reads are O(1). Used by callers like
|
|
1123
|
+
# {Component::Popup} to auto-size to whatever content was assigned,
|
|
1124
|
+
# regardless of its concrete type, and by {Sizing::WRAP_CONTENT} slots.
|
|
1125
|
+
attr_accessor content_size: Size
|
|
1126
|
+
|
|
762
1127
|
# A scrollable list of items with cursor support.
|
|
763
1128
|
#
|
|
764
1129
|
# Items are modeled as {StyledString}s and painted directly into the
|
|
@@ -771,11 +1136,9 @@ module Tuile
|
|
|
771
1136
|
#
|
|
772
1137
|
# Cursor is supported; call {#cursor=} to change cursor behavior. The
|
|
773
1138
|
# 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.
|
|
1139
|
+
# list automatically. The cursor highlight overlays
|
|
1140
|
+
# {Theme#active_bg_color} while preserving each span's foreground color.
|
|
776
1141
|
class List < Component
|
|
777
|
-
CURSOR_BG: Integer
|
|
778
|
-
|
|
779
1142
|
def initialize: () -> void
|
|
780
1143
|
|
|
781
1144
|
# Sets new lines. Each entry is coerced into a {StyledString} (a
|
|
@@ -814,8 +1177,6 @@ module Tuile
|
|
|
814
1177
|
# _@param_ `lines` — entries are `String`, `StyledString`, or anything that responds to `#to_s`.
|
|
815
1178
|
def add_lines: (::Array[untyped] lines) -> void
|
|
816
1179
|
|
|
817
|
-
def content_size: () -> Size
|
|
818
|
-
|
|
819
1180
|
def focusable?: () -> bool
|
|
820
1181
|
|
|
821
1182
|
def tab_stop?: () -> bool
|
|
@@ -867,6 +1228,19 @@ module Tuile
|
|
|
867
1228
|
# is one, so the list snaps to the bottom on first paint.
|
|
868
1229
|
def on_width_changed: () -> void
|
|
869
1230
|
|
|
1231
|
+
# Natural size from scratch: longest line's display width plus the two
|
|
1232
|
+
# single-space gutters {#pad_to_row} adds, × line count. An empty list
|
|
1233
|
+
# is {Size::ZERO} (no gutters for no content).
|
|
1234
|
+
def compute_content_size: () -> Size
|
|
1235
|
+
|
|
1236
|
+
# Incremental {#content_size} update for appends: folds just the
|
|
1237
|
+
# appended lines into the running maximum, keeping {#add_lines}
|
|
1238
|
+
# O(appended) instead of re-scanning the whole list (LogWindow appends
|
|
1239
|
+
# a line per log statement).
|
|
1240
|
+
#
|
|
1241
|
+
# _@param_ `appended` — the just-appended lines (already concatenated onto {@lines}).
|
|
1242
|
+
def grow_content_size: (::Array[StyledString] appended) -> void
|
|
1243
|
+
|
|
870
1244
|
# Coerces and flattens a list of input entries into trimmed
|
|
871
1245
|
# {StyledString} lines. Each entry becomes a {StyledString} (String
|
|
872
1246
|
# via {StyledString.parse}, StyledString passed through, anything else
|
|
@@ -1154,11 +1528,6 @@ module Tuile
|
|
|
1154
1528
|
class Label < Component
|
|
1155
1529
|
def initialize: () -> void
|
|
1156
1530
|
|
|
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
1531
|
# Paints the text into {#rect}.
|
|
1163
1532
|
#
|
|
1164
1533
|
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
@@ -1169,6 +1538,11 @@ module Tuile
|
|
|
1169
1538
|
|
|
1170
1539
|
def on_width_changed: () -> void
|
|
1171
1540
|
|
|
1541
|
+
# Natural size: longest hard-line's display width × number of hard
|
|
1542
|
+
# lines. Computed on the *unclipped* text — sizing is intrinsic to the
|
|
1543
|
+
# content, not the viewport. Empty text yields {Size::ZERO}.
|
|
1544
|
+
def compute_content_size: () -> Size
|
|
1545
|
+
|
|
1172
1546
|
# Recomputes {@clipped_lines} for the current text and rect width.
|
|
1173
1547
|
# Each line is ellipsized to fit, padded with trailing spaces out to
|
|
1174
1548
|
# the full width, and pre-rendered to ANSI so {#repaint} is just a
|
|
@@ -1255,6 +1629,14 @@ module Tuile
|
|
|
1255
1629
|
# _@param_ `new_content`
|
|
1256
1630
|
def content=: (Component? new_content) -> void
|
|
1257
1631
|
|
|
1632
|
+
# Re-sizes (and recenters, when open) whenever the wrapped content's
|
|
1633
|
+
# natural size changes — e.g. a {Label}'s `text=`, a {List}'s
|
|
1634
|
+
# `add_line`, or a nested {Window} whose own content grew (the window
|
|
1635
|
+
# recomputes its {Component#content_size} and the change bubbles here).
|
|
1636
|
+
#
|
|
1637
|
+
# _@param_ `_child`
|
|
1638
|
+
def on_child_content_size_changed: (Component _child) -> void
|
|
1639
|
+
|
|
1258
1640
|
# Hint for the status bar: own "q Close" plus the wrapped content's hint.
|
|
1259
1641
|
def keyboard_hint: () -> String
|
|
1260
1642
|
|
|
@@ -1302,10 +1684,6 @@ module Tuile
|
|
|
1302
1684
|
|
|
1303
1685
|
def tab_stop?: () -> bool
|
|
1304
1686
|
|
|
1305
|
-
# _@return_ — natural width is `caption.length + 4` to fit
|
|
1306
|
-
# `[ caption ]`; height is 1.
|
|
1307
|
-
def content_size: () -> Size
|
|
1308
|
-
|
|
1309
1687
|
# _@param_ `key`
|
|
1310
1688
|
def handle_key: (String key) -> bool
|
|
1311
1689
|
|
|
@@ -1314,6 +1692,9 @@ module Tuile
|
|
|
1314
1692
|
|
|
1315
1693
|
def repaint: () -> void
|
|
1316
1694
|
|
|
1695
|
+
# Natural width is `caption.length + 4` to fit `[ caption ]`; height 1.
|
|
1696
|
+
def natural_size: () -> Size
|
|
1697
|
+
|
|
1317
1698
|
# _@return_ — the button's label.
|
|
1318
1699
|
attr_accessor caption: String
|
|
1319
1700
|
|
|
@@ -1408,11 +1789,21 @@ module Tuile
|
|
|
1408
1789
|
# _@param_ `value`
|
|
1409
1790
|
def scrollbar=: (bool value) -> void
|
|
1410
1791
|
|
|
1411
|
-
#
|
|
1412
|
-
#
|
|
1413
|
-
#
|
|
1414
|
-
|
|
1415
|
-
|
|
1792
|
+
# Sets the new content. Also recomputes the window's natural size.
|
|
1793
|
+
#
|
|
1794
|
+
# _@param_ `new_content`
|
|
1795
|
+
def content=: (Component? new_content) -> void
|
|
1796
|
+
|
|
1797
|
+
# Re-lays-out a {Sizing::WRAP_CONTENT} footer when the footer's natural
|
|
1798
|
+
# size changes, and folds a content resize into the window's own
|
|
1799
|
+
# natural size (whose change then bubbles to the window's parent — e.g.
|
|
1800
|
+
# a {Popup} re-self-sizes). The footer deliberately does *not*
|
|
1801
|
+
# participate in the window's {#content_size}: it is decoration
|
|
1802
|
+
# overlaying the border, and must not drive the window's size — if it
|
|
1803
|
+
# doesn't fit, it is clipped to the inner width.
|
|
1804
|
+
#
|
|
1805
|
+
# _@param_ `child`
|
|
1806
|
+
def on_child_content_size_changed: (Component child) -> void
|
|
1416
1807
|
|
|
1417
1808
|
# Fully repaints the window: both frame and contents.
|
|
1418
1809
|
#
|
|
@@ -1446,6 +1837,17 @@ module Tuile
|
|
|
1446
1837
|
# _@param_ `caption`
|
|
1447
1838
|
def build_frame: (String caption) -> String
|
|
1448
1839
|
|
|
1840
|
+
# Recomputes the window's natural size: content's natural size (or the
|
|
1841
|
+
# caption, whichever is wider) plus the 2-character border. The footer
|
|
1842
|
+
# is deliberately excluded — see {#on_child_content_size_changed}. A
|
|
1843
|
+
# window with no content or caption sizes to `Size.new(2, 2)` (bare
|
|
1844
|
+
# border).
|
|
1845
|
+
def update_content_size: () -> void
|
|
1846
|
+
|
|
1847
|
+
# Positions the footer over the bottom border row, with its width
|
|
1848
|
+
# resolved by {#footer_sizing} against the inner width. A
|
|
1849
|
+
# {Sizing::WRAP_CONTENT} footer with zero natural width gets an empty
|
|
1850
|
+
# rect — i.e. it is invisible, as if never assigned.
|
|
1449
1851
|
def layout_footer: () -> void
|
|
1450
1852
|
|
|
1451
1853
|
def on_focus: () -> void
|
|
@@ -1454,6 +1856,11 @@ module Tuile
|
|
|
1454
1856
|
# row.
|
|
1455
1857
|
attr_accessor footer: Component?
|
|
1456
1858
|
|
|
1859
|
+
# _@return_ — how the footer's width is computed from the window's
|
|
1860
|
+
# inner width; defaults to {Sizing::FILL} (the footer spans the full
|
|
1861
|
+
# inner width). The footer's height is always 1 (the border row).
|
|
1862
|
+
attr_accessor footer_sizing: Sizing
|
|
1863
|
+
|
|
1457
1864
|
# _@return_ — the current caption, empty by default.
|
|
1458
1865
|
attr_accessor caption: String
|
|
1459
1866
|
end
|
|
@@ -1475,9 +1882,6 @@ module Tuile
|
|
|
1475
1882
|
# plain `<textarea>` or text editor. A future `on_enter`/`on_submit`
|
|
1476
1883
|
# callback may opt out of that by consuming Enter instead.
|
|
1477
1884
|
class TextArea < Tuile::Component::TextInput
|
|
1478
|
-
ACTIVE_BG_SGR: String
|
|
1479
|
-
INACTIVE_BG_SGR: String
|
|
1480
|
-
|
|
1481
1885
|
def initialize: () -> void
|
|
1482
1886
|
|
|
1483
1887
|
def cursor_position: () -> Point?
|
|
@@ -2135,9 +2539,6 @@ module Tuile
|
|
|
2135
2539
|
# positioned by {Screen} after each repaint cycle when this component is
|
|
2136
2540
|
# focused; see {Component#cursor_position}.
|
|
2137
2541
|
class TextField < Tuile::Component::TextInput
|
|
2138
|
-
ACTIVE_BG_SGR: String
|
|
2139
|
-
INACTIVE_BG_SGR: String
|
|
2140
|
-
|
|
2141
2542
|
def initialize: () -> void
|
|
2142
2543
|
|
|
2143
2544
|
def cursor_position: () -> Point?
|
|
@@ -2212,9 +2613,6 @@ module Tuile
|
|
|
2212
2613
|
# effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
|
|
2213
2614
|
# keep the caret visible).
|
|
2214
2615
|
class TextInput < Component
|
|
2215
|
-
ACTIVE_BG_SGR: String
|
|
2216
|
-
INACTIVE_BG_SGR: String
|
|
2217
|
-
|
|
2218
2616
|
def initialize: () -> void
|
|
2219
2617
|
|
|
2220
2618
|
# _@return_ — true iff {#text} is the empty string.
|
|
@@ -2231,6 +2629,16 @@ module Tuile
|
|
|
2231
2629
|
# _@param_ `key`
|
|
2232
2630
|
def handle_key: (String key) -> bool
|
|
2233
2631
|
|
|
2632
|
+
# Renders `text` on the field's background well, looked up from the
|
|
2633
|
+
# current {Screen#theme} at paint time: {Theme#active_bg} when this
|
|
2634
|
+
# input is on the active (focus) chain, {Theme#input_bg} otherwise —
|
|
2635
|
+
# visibly a field either way, distinctly highlighted when active.
|
|
2636
|
+
#
|
|
2637
|
+
# _@param_ `text`
|
|
2638
|
+
#
|
|
2639
|
+
# _@return_ — ANSI-rendered text.
|
|
2640
|
+
def background: (String text) -> String
|
|
2641
|
+
|
|
2234
2642
|
# Input filter for {#text=}. Subclasses override to truncate or reject
|
|
2235
2643
|
# invalid input. Default coerces to String.
|
|
2236
2644
|
#
|
|
@@ -2398,6 +2806,71 @@ module Tuile
|
|
|
2398
2806
|
end
|
|
2399
2807
|
end
|
|
2400
2808
|
|
|
2809
|
+
# An app's theme definition: the {Theme} pair covering both terminal
|
|
2810
|
+
# appearances. {Screen} keeps one at {Screen#theme_def} (defaulting to
|
|
2811
|
+
# {DEFAULT}) and picks the member matching the detected background at
|
|
2812
|
+
# startup and on every OS appearance flip (mode 2031) — so a custom
|
|
2813
|
+
# definition survives the user toggling light/dark, where a bare
|
|
2814
|
+
# {Screen#theme=} assignment would be replaced.
|
|
2815
|
+
#
|
|
2816
|
+
# APP_THEME = Tuile::ThemeDef.new(
|
|
2817
|
+
# dark: Tuile::Theme::DARK.with(custom: { accent: Color::DARK_ORANGE }),
|
|
2818
|
+
# light: Tuile::Theme::LIGHT.with(custom: { accent: Color::DARK_ORANGE3 })
|
|
2819
|
+
# )
|
|
2820
|
+
# screen.theme_def = APP_THEME
|
|
2821
|
+
#
|
|
2822
|
+
# Both members must declare the same {Theme#custom} key set. Without
|
|
2823
|
+
# that, a token present only in one member would raise `KeyError` at
|
|
2824
|
+
# the unpredictable moment the user flips OS appearance; checking here
|
|
2825
|
+
# turns it into an immediate construction-time failure.
|
|
2826
|
+
#
|
|
2827
|
+
# @!attribute [r] dark
|
|
2828
|
+
# The theme applied on dark terminal backgrounds.
|
|
2829
|
+
# @return [Theme]
|
|
2830
|
+
# @!attribute [r] light
|
|
2831
|
+
# The theme applied on light terminal backgrounds.
|
|
2832
|
+
# @return [Theme]
|
|
2833
|
+
class ThemeDef
|
|
2834
|
+
DEFAULT: ThemeDef
|
|
2835
|
+
|
|
2836
|
+
# _@param_ `dark`
|
|
2837
|
+
#
|
|
2838
|
+
# _@param_ `light`
|
|
2839
|
+
def initialize: (dark: Theme, light: Theme) -> void
|
|
2840
|
+
|
|
2841
|
+
# The member for the given color scheme. Anything other than `:light`
|
|
2842
|
+
# selects {#dark}, matching {TerminalBackground.detect}'s
|
|
2843
|
+
# inconclusive-means-dark policy.
|
|
2844
|
+
#
|
|
2845
|
+
# _@param_ `scheme` — `:dark` or `:light`.
|
|
2846
|
+
def for: (Symbol scheme) -> Theme
|
|
2847
|
+
|
|
2848
|
+
# The definition newly-constructed {Screen}s start from (see
|
|
2849
|
+
# {Screen#theme_def}); initially {DEFAULT}. Reassigning affects
|
|
2850
|
+
# future screens only — an already-constructed screen keeps its
|
|
2851
|
+
# definition until {Screen#theme_def=}.
|
|
2852
|
+
#
|
|
2853
|
+
# Intended for test suites: production apps assign
|
|
2854
|
+
# {Screen#theme_def=} once at startup, but component specs build a
|
|
2855
|
+
# fresh {FakeScreen} per example, and a component reading a custom
|
|
2856
|
+
# token (`theme[:accent]`) would `KeyError` against the built-in
|
|
2857
|
+
# default. Point this at the app's definition once and every
|
|
2858
|
+
# {Screen.fake} carries it:
|
|
2859
|
+
#
|
|
2860
|
+
# Tuile::ThemeDef.default = APP_THEME # spec_helper.rb, once
|
|
2861
|
+
# before { Screen.fake } # theme[:accent] resolves
|
|
2862
|
+
def self.default: () -> ThemeDef
|
|
2863
|
+
|
|
2864
|
+
# _@param_ `theme_def`
|
|
2865
|
+
def self.default=: (ThemeDef value) -> ThemeDef
|
|
2866
|
+
|
|
2867
|
+
# The theme applied on dark terminal backgrounds.
|
|
2868
|
+
attr_reader dark: Theme
|
|
2869
|
+
|
|
2870
|
+
# The theme applied on light terminal backgrounds.
|
|
2871
|
+
attr_reader light: Theme
|
|
2872
|
+
end
|
|
2873
|
+
|
|
2401
2874
|
# An event queue. The idea is that all UI-related updates run from the thread
|
|
2402
2875
|
# which runs the event queue only; this removes any need for locking and/or
|
|
2403
2876
|
# need for thread-safety mechanisms.
|
|
@@ -2523,6 +2996,29 @@ module Tuile
|
|
|
2523
2996
|
attr_reader height: Integer
|
|
2524
2997
|
end
|
|
2525
2998
|
|
|
2999
|
+
# The terminal's color scheme changed — the user flipped the OS between
|
|
3000
|
+
# light and dark appearance. Terminals supporting mode 2031 (kitty,
|
|
3001
|
+
# foot, contour, ghostty, …) push the DSR-style report `\e[?997;1n`
|
|
3002
|
+
# (dark) / `\e[?997;2n` (light) once {Screen#run_event_loop} enables
|
|
3003
|
+
# the mode via {TerminalBackground::NOTIFY_ON}; the key thread parses
|
|
3004
|
+
# it into this event and {Screen#event_loop} follows by assigning the
|
|
3005
|
+
# matching {Theme}.
|
|
3006
|
+
#
|
|
3007
|
+
# @!attribute [r] scheme
|
|
3008
|
+
# @return [Symbol] `:light` or `:dark`.
|
|
3009
|
+
class ColorSchemeEvent
|
|
3010
|
+
REPORT: Regexp
|
|
3011
|
+
|
|
3012
|
+
# _@param_ `key` — key read via {Keys.getkey}.
|
|
3013
|
+
#
|
|
3014
|
+
# _@return_ — nil when `key` is not a
|
|
3015
|
+
# color-scheme report.
|
|
3016
|
+
def self.parse: (String key) -> ColorSchemeEvent?
|
|
3017
|
+
|
|
3018
|
+
# _@return_ — `:light` or `:dark`.
|
|
3019
|
+
attr_reader scheme: Symbol
|
|
3020
|
+
end
|
|
3021
|
+
|
|
2526
3022
|
# Emitted once when the queue is cleared, all messages are processed and the
|
|
2527
3023
|
# event loop will block waiting for more messages. Perfect time for
|
|
2528
3024
|
# repainting windows.
|
|
@@ -2597,6 +3093,11 @@ module Tuile
|
|
|
2597
3093
|
|
|
2598
3094
|
def invalidated_clear: () -> void
|
|
2599
3095
|
|
|
3096
|
+
# No terminal probing in tests: skip {TerminalBackground.detect}
|
|
3097
|
+
# (which would write an OSC 11 query to the test runner's TTY and
|
|
3098
|
+
# steal its input) and pin the deterministic default.
|
|
3099
|
+
def detect_scheme: () -> Symbol
|
|
3100
|
+
|
|
2600
3101
|
# _@return_ — whatever {#print} printed so far.
|
|
2601
3102
|
attr_reader prints: ::Array[String]
|
|
2602
3103
|
end
|
|
@@ -3151,6 +3652,91 @@ module Tuile
|
|
|
3151
3652
|
end
|
|
3152
3653
|
end
|
|
3153
3654
|
|
|
3655
|
+
# Detects whether the terminal background is light or dark, so {Screen}
|
|
3656
|
+
# can pick {Theme::LIGHT} or {Theme::DARK} automatically at startup.
|
|
3657
|
+
#
|
|
3658
|
+
# Two mechanisms, in order of reliability:
|
|
3659
|
+
#
|
|
3660
|
+
# 1. **OSC 11 query** — writes `ESC ] 11 ; ? BEL` to the terminal; modern
|
|
3661
|
+
# terminals (xterm, kitty, alacritty, wezterm, iTerm2, GNOME Terminal,
|
|
3662
|
+
# Windows Terminal) reply on stdin with the background color
|
|
3663
|
+
# (`\e]11;rgb:RRRR/GGGG/BBBB` + BEL or ST). The color's relative
|
|
3664
|
+
# luminance against a 0.5 threshold decides light vs dark. Terminals
|
|
3665
|
+
# that don't support the query simply never reply, so the read is
|
|
3666
|
+
# bounded by a short timeout.
|
|
3667
|
+
# 2. **`COLORFGBG` env var** — rxvt/konsole export `"fg;bg"` ANSI palette
|
|
3668
|
+
# indices. Less reliable (stale across SSH/tmux, often unset); used
|
|
3669
|
+
# only when OSC 11 yields nothing.
|
|
3670
|
+
#
|
|
3671
|
+
# **Timing matters**: the OSC 11 reply arrives on stdin, so the query
|
|
3672
|
+
# must complete before {EventQueue#start_key_thread} owns stdin —
|
|
3673
|
+
# otherwise the reply bytes get consumed as garbage keystrokes. {Screen}
|
|
3674
|
+
# calls {.detect} from its constructor, which apps run before
|
|
3675
|
+
# {Screen#run_event_loop}; don't call this after the event loop started.
|
|
3676
|
+
module TerminalBackground
|
|
3677
|
+
QUERY_TIMEOUT: Float
|
|
3678
|
+
QUERY: String
|
|
3679
|
+
REPLY: Regexp
|
|
3680
|
+
NOTIFY_ON: String
|
|
3681
|
+
NOTIFY_OFF: String
|
|
3682
|
+
|
|
3683
|
+
# Detects the terminal background. Queries OSC 11 when both `input`
|
|
3684
|
+
# and `output` are TTYs, falling back to `COLORFGBG`.
|
|
3685
|
+
#
|
|
3686
|
+
# _@param_ `input` — where the OSC 11 reply arrives (the TTY input).
|
|
3687
|
+
#
|
|
3688
|
+
# _@param_ `output` — where the query is written (the TTY output).
|
|
3689
|
+
#
|
|
3690
|
+
# _@param_ `env` — environment for the `COLORFGBG` fallback; defaults to `ENV` (which duck-types the `[]` lookup).
|
|
3691
|
+
#
|
|
3692
|
+
# _@param_ `timeout` — max seconds to wait for the OSC 11 reply.
|
|
3693
|
+
#
|
|
3694
|
+
# _@return_ — `:light`, `:dark`, or nil when undetectable.
|
|
3695
|
+
def self.detect: (
|
|
3696
|
+
?input: IO,
|
|
3697
|
+
?output: IO,
|
|
3698
|
+
?env: ::Hash[String, String],
|
|
3699
|
+
?timeout: Numeric
|
|
3700
|
+
) -> Symbol?
|
|
3701
|
+
|
|
3702
|
+
# Writes the OSC 11 query and classifies the reply. The whole
|
|
3703
|
+
# exchange runs with `input` in raw mode: the reply has no trailing
|
|
3704
|
+
# newline, so a canonical-mode read would block past the timeout,
|
|
3705
|
+
# and echo would smear the reply bytes onto the screen.
|
|
3706
|
+
#
|
|
3707
|
+
# _@param_ `input`
|
|
3708
|
+
#
|
|
3709
|
+
# _@param_ `output`
|
|
3710
|
+
#
|
|
3711
|
+
# _@param_ `timeout`
|
|
3712
|
+
def self.query_osc11: (IO input, IO output, Numeric timeout) -> Symbol?
|
|
3713
|
+
|
|
3714
|
+
# Accumulates reply bytes until a BEL/ST terminator or the deadline.
|
|
3715
|
+
# Terminals that don't support OSC 11 never reply — returning
|
|
3716
|
+
# whatever arrived (usually nothing) lets the caller fail soft.
|
|
3717
|
+
#
|
|
3718
|
+
# _@param_ `input`
|
|
3719
|
+
#
|
|
3720
|
+
# _@param_ `timeout`
|
|
3721
|
+
def self.read_reply: (IO input, Numeric timeout) -> String
|
|
3722
|
+
|
|
3723
|
+
# Relative luminance of the reported background, scaled per
|
|
3724
|
+
# component hex width (xterm replies 4 digits per channel, others 2).
|
|
3725
|
+
#
|
|
3726
|
+
# _@param_ `components` — three hex strings.
|
|
3727
|
+
#
|
|
3728
|
+
# _@return_ — `:light` or `:dark`.
|
|
3729
|
+
def self.classify: (::Array[String] components) -> Symbol
|
|
3730
|
+
|
|
3731
|
+
# `COLORFGBG` is `"fg;bg"` (rxvt sometimes `"fg;default;bg"`) with
|
|
3732
|
+
# ANSI palette indices. White-ish backgrounds — 7 (white) and the
|
|
3733
|
+
# bright range 9–15 — read as light; 0–6 and 8 as dark; anything
|
|
3734
|
+
# else (missing, `"default"`, out of range) is inconclusive.
|
|
3735
|
+
#
|
|
3736
|
+
# _@param_ `value`
|
|
3737
|
+
def self.from_colorfgbg: (String? value) -> Symbol?
|
|
3738
|
+
end
|
|
3739
|
+
|
|
3154
3740
|
# A vertical scrollbar that computes which character to draw at each row.
|
|
3155
3741
|
#
|
|
3156
3742
|
# Uses `█` for the handle (filled track) and `░` for the empty track. There
|