tuile 0.4.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 +28 -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 +249 -0
- data/lib/tuile/component/button.rb +9 -5
- data/lib/tuile/component/label.rb +44 -16
- 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 +696 -58
- data/lib/tuile/component/window.rb +70 -16
- data/lib/tuile/component.rb +74 -5
- data/lib/tuile/event_queue.rb +130 -11
- data/lib/tuile/fake_event_queue.rb +69 -0
- data/lib/tuile/fake_screen.rb +8 -0
- data/lib/tuile/keys.rb +10 -0
- data/lib/tuile/screen.rb +98 -4
- data/lib/tuile/sizing.rb +59 -0
- data/lib/tuile/styled_string.rb +28 -61
- 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 +1160 -93
- metadata +6 -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
|
|
@@ -188,6 +189,113 @@ module Tuile
|
|
|
188
189
|
attr_reader height: Integer
|
|
189
190
|
end
|
|
190
191
|
|
|
192
|
+
# An immutable terminal color. Accepts the three forms ANSI/SGR understands:
|
|
193
|
+
#
|
|
194
|
+
# - a Symbol from {COLOR_SYMBOLS} — 8 standard + 8 bright named colors
|
|
195
|
+
# (SGR 30..37 / 90..97 for fg, 40..47 / 100..107 for bg)
|
|
196
|
+
# - an Integer 0..255 — the 256-color palette (SGR 38;5;N / 48;5;N)
|
|
197
|
+
# - an Array of three Integers 0..255 — 24-bit RGB (SGR 38;2;R;G;B / 48;2;R;G;B)
|
|
198
|
+
#
|
|
199
|
+
# A constant per named color is pre-defined (`Color::RED`, `Color::BRIGHT_BLUE`,
|
|
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.
|
|
204
|
+
# {.coerce} accepts anything {.new} accepts plus `nil` (terminal default) and
|
|
205
|
+
# an existing {Color} (returned as-is), so APIs that accept colors typically
|
|
206
|
+
# take `[Color, nil]` and pass through {.coerce}.
|
|
207
|
+
#
|
|
208
|
+
# ```ruby
|
|
209
|
+
# Color.new(:red) # named
|
|
210
|
+
# Color.new(42) # 256-color palette
|
|
211
|
+
# Color.new([255, 100, 0]) # RGB
|
|
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
|
|
216
|
+
# Color.coerce(:red) # accepts raw forms, returns Color
|
|
217
|
+
# Color.coerce(nil) # nil → nil
|
|
218
|
+
# ```
|
|
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
|
+
#
|
|
227
|
+
# {#to_ansi} renders a full SGR escape (`"\e[31m"`); {#sgr_codes} returns the
|
|
228
|
+
# raw numeric codes so callers (notably {StyledString}) can combine them with
|
|
229
|
+
# other SGR attributes in a single sequence.
|
|
230
|
+
class Color
|
|
231
|
+
COLOR_SYMBOLS: ::Array[Symbol]
|
|
232
|
+
PALETTE_NAMES: ::Hash[Symbol, Integer]
|
|
233
|
+
|
|
234
|
+
# Coerces the input to a {Color}. `nil` passes through unchanged (callers
|
|
235
|
+
# use `nil` for the terminal default); an existing {Color} is returned
|
|
236
|
+
# as-is; otherwise the value is fed to {.new}.
|
|
237
|
+
#
|
|
238
|
+
# _@param_ `value`
|
|
239
|
+
def self.coerce: ((Color | Symbol | Integer | ::Array[Integer])? value) -> Color?
|
|
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
|
+
|
|
270
|
+
# _@param_ `value` — see class-level docs for the three accepted forms.
|
|
271
|
+
def initialize: ((Symbol | Integer | ::Array[Integer]) value) -> void
|
|
272
|
+
|
|
273
|
+
# SGR parameter codes for emitting this color as either a foreground
|
|
274
|
+
# (`target: :fg`) or background (`target: :bg`). Returned as an array so
|
|
275
|
+
# callers can splice them into a multi-attribute SGR (e.g. bold + color).
|
|
276
|
+
#
|
|
277
|
+
# _@param_ `target` — `:fg` or `:bg`.
|
|
278
|
+
def sgr_codes: (?Symbol target) -> ::Array[Integer]
|
|
279
|
+
|
|
280
|
+
# Full SGR escape sequence for this color (e.g. `"\e[31m"`). Useful for
|
|
281
|
+
# `print`-style direct emission; for composing with other attributes use
|
|
282
|
+
# {#sgr_codes} instead.
|
|
283
|
+
#
|
|
284
|
+
# _@param_ `target` — `:fg` or `:bg`.
|
|
285
|
+
def to_ansi: (?Symbol target) -> String
|
|
286
|
+
|
|
287
|
+
# _@param_ `other`
|
|
288
|
+
def ==: (Object other) -> bool
|
|
289
|
+
|
|
290
|
+
def hash: () -> Integer
|
|
291
|
+
|
|
292
|
+
def inspect: () -> String
|
|
293
|
+
|
|
294
|
+
# The underlying raw representation — a Symbol, Integer, or frozen
|
|
295
|
+
# Array<Integer>.
|
|
296
|
+
attr_reader value: (Symbol | Integer | ::Array[Integer])
|
|
297
|
+
end
|
|
298
|
+
|
|
191
299
|
# A point with `x` and `y` integer coordinates, both 0-based.
|
|
192
300
|
#
|
|
193
301
|
# @!attribute [r] x
|
|
@@ -204,6 +312,213 @@ module Tuile
|
|
|
204
312
|
attr_reader y: Integer
|
|
205
313
|
end
|
|
206
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
|
+
|
|
207
522
|
# The TTY screen. There is exactly one screen per app.
|
|
208
523
|
#
|
|
209
524
|
# A screen runs the event loop; call {#run_event_loop} to do that.
|
|
@@ -322,7 +637,7 @@ module Tuile
|
|
|
322
637
|
#
|
|
323
638
|
# screen.register_global_shortcut(Keys::CTRL_L,
|
|
324
639
|
# over_popups: true,
|
|
325
|
-
# hint: "^L #{
|
|
640
|
+
# hint: "^L #{screen.theme.hint("log")}") do
|
|
326
641
|
# log_popup.open
|
|
327
642
|
# end
|
|
328
643
|
#
|
|
@@ -330,7 +645,7 @@ module Tuile
|
|
|
330
645
|
#
|
|
331
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.
|
|
332
647
|
#
|
|
333
|
-
# _@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.
|
|
334
649
|
def register_global_shortcut: (String key, ?over_popups: bool, ?hint: String?) -> void
|
|
335
650
|
|
|
336
651
|
# Removes a shortcut previously installed by {#register_global_shortcut}.
|
|
@@ -398,6 +713,23 @@ module Tuile
|
|
|
398
713
|
# but only one focused.
|
|
399
714
|
def cursor_position: () -> Point?
|
|
400
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
|
+
|
|
401
733
|
# Walks the current modal scope in pre-order, collects tab stops, and
|
|
402
734
|
# advances focus by one (wrapping). When the focused component isn't in
|
|
403
735
|
# the tab order (e.g. focus is parked on a popup/window chrome with no
|
|
@@ -481,6 +813,22 @@ module Tuile
|
|
|
481
813
|
# _@return_ — current screen size.
|
|
482
814
|
attr_reader size: Size
|
|
483
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
|
+
|
|
484
832
|
# _@return_ — the event queue.
|
|
485
833
|
attr_reader event_queue: EventQueue
|
|
486
834
|
|
|
@@ -502,6 +850,52 @@ module Tuile
|
|
|
502
850
|
end
|
|
503
851
|
end
|
|
504
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
|
+
|
|
505
899
|
# A UI component which is positioned on the screen and draws characters into
|
|
506
900
|
# its bounding rectangle (in {#repaint}).
|
|
507
901
|
#
|
|
@@ -636,13 +1030,19 @@ module Tuile
|
|
|
636
1030
|
# _@param_ `child` — the just-detached child.
|
|
637
1031
|
def on_child_removed: (Component child) -> void
|
|
638
1032
|
|
|
639
|
-
#
|
|
640
|
-
#
|
|
641
|
-
#
|
|
642
|
-
#
|
|
643
|
-
#
|
|
644
|
-
#
|
|
645
|
-
|
|
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
|
|
646
1046
|
|
|
647
1047
|
# Where the hardware terminal cursor should sit when this component is the
|
|
648
1048
|
# cursor owner. Returns `nil` to indicate the cursor should be hidden. The
|
|
@@ -695,6 +1095,35 @@ module Tuile
|
|
|
695
1095
|
# no parent.
|
|
696
1096
|
attr_accessor parent: Component?
|
|
697
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
|
+
|
|
698
1127
|
# A scrollable list of items with cursor support.
|
|
699
1128
|
#
|
|
700
1129
|
# Items are modeled as {StyledString}s and painted directly into the
|
|
@@ -707,11 +1136,9 @@ module Tuile
|
|
|
707
1136
|
#
|
|
708
1137
|
# Cursor is supported; call {#cursor=} to change cursor behavior. The
|
|
709
1138
|
# cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the
|
|
710
|
-
# list automatically. The cursor highlight overlays
|
|
711
|
-
# 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.
|
|
712
1141
|
class List < Component
|
|
713
|
-
CURSOR_BG: Integer
|
|
714
|
-
|
|
715
1142
|
def initialize: () -> void
|
|
716
1143
|
|
|
717
1144
|
# Sets new lines. Each entry is coerced into a {StyledString} (a
|
|
@@ -750,8 +1177,6 @@ module Tuile
|
|
|
750
1177
|
# _@param_ `lines` — entries are `String`, `StyledString`, or anything that responds to `#to_s`.
|
|
751
1178
|
def add_lines: (::Array[untyped] lines) -> void
|
|
752
1179
|
|
|
753
|
-
def content_size: () -> Size
|
|
754
|
-
|
|
755
1180
|
def focusable?: () -> bool
|
|
756
1181
|
|
|
757
1182
|
def tab_stop?: () -> bool
|
|
@@ -803,6 +1228,19 @@ module Tuile
|
|
|
803
1228
|
# is one, so the list snaps to the bottom on first paint.
|
|
804
1229
|
def on_width_changed: () -> void
|
|
805
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
|
+
|
|
806
1244
|
# Coerces and flattens a list of input entries into trimmed
|
|
807
1245
|
# {StyledString} lines. Each entry becomes a {StyledString} (String
|
|
808
1246
|
# via {StyledString.parse}, StyledString passed through, anything else
|
|
@@ -1090,11 +1528,6 @@ module Tuile
|
|
|
1090
1528
|
class Label < Component
|
|
1091
1529
|
def initialize: () -> void
|
|
1092
1530
|
|
|
1093
|
-
# _@return_ — longest hard-line's display width × number of hard
|
|
1094
|
-
# lines. Reported on the *unclipped* text — sizing is intrinsic to
|
|
1095
|
-
# the content, not the viewport. Empty text returns `Size.new(0, 0)`.
|
|
1096
|
-
def content_size: () -> Size
|
|
1097
|
-
|
|
1098
1531
|
# Paints the text into {#rect}.
|
|
1099
1532
|
#
|
|
1100
1533
|
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
@@ -1105,13 +1538,22 @@ module Tuile
|
|
|
1105
1538
|
|
|
1106
1539
|
def on_width_changed: () -> void
|
|
1107
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
|
+
|
|
1108
1546
|
# Recomputes {@clipped_lines} for the current text and rect width.
|
|
1109
1547
|
# Each line is ellipsized to fit, padded with trailing spaces out to
|
|
1110
1548
|
# the full width, and pre-rendered to ANSI so {#repaint} is just a
|
|
1111
1549
|
# lookup + screen.print per row. {@blank_line} covers rows past the
|
|
1112
|
-
# last text line.
|
|
1550
|
+
# last text line. When {#bg} is set, every produced line (and the
|
|
1551
|
+
# blank row) has the bg applied uniformly.
|
|
1113
1552
|
def update_clipped_lines: () -> void
|
|
1114
1553
|
|
|
1554
|
+
# _@param_ `line`
|
|
1555
|
+
def apply_bg: (StyledString line) -> StyledString
|
|
1556
|
+
|
|
1115
1557
|
# _@param_ `line`
|
|
1116
1558
|
#
|
|
1117
1559
|
# _@param_ `width`
|
|
@@ -1120,6 +1562,11 @@ module Tuile
|
|
|
1120
1562
|
# _@return_ — the current text. Defaults to an empty
|
|
1121
1563
|
# {StyledString}.
|
|
1122
1564
|
attr_accessor text: (StyledString | String)?
|
|
1565
|
+
|
|
1566
|
+
# _@return_ — background color applied uniformly across every
|
|
1567
|
+
# painted row (including padding past the text). `nil` (default)
|
|
1568
|
+
# leaves whatever bg the text's own styling carries.
|
|
1569
|
+
attr_accessor bg: (Color | Symbol | Integer | ::Array[Integer])?
|
|
1123
1570
|
end
|
|
1124
1571
|
|
|
1125
1572
|
# A modal overlay that wraps any {Component} as its content. Popup itself
|
|
@@ -1182,6 +1629,14 @@ module Tuile
|
|
|
1182
1629
|
# _@param_ `new_content`
|
|
1183
1630
|
def content=: (Component? new_content) -> void
|
|
1184
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
|
+
|
|
1185
1640
|
# Hint for the status bar: own "q Close" plus the wrapped content's hint.
|
|
1186
1641
|
def keyboard_hint: () -> String
|
|
1187
1642
|
|
|
@@ -1229,10 +1684,6 @@ module Tuile
|
|
|
1229
1684
|
|
|
1230
1685
|
def tab_stop?: () -> bool
|
|
1231
1686
|
|
|
1232
|
-
# _@return_ — natural width is `caption.length + 4` to fit
|
|
1233
|
-
# `[ caption ]`; height is 1.
|
|
1234
|
-
def content_size: () -> Size
|
|
1235
|
-
|
|
1236
1687
|
# _@param_ `key`
|
|
1237
1688
|
def handle_key: (String key) -> bool
|
|
1238
1689
|
|
|
@@ -1241,6 +1692,9 @@ module Tuile
|
|
|
1241
1692
|
|
|
1242
1693
|
def repaint: () -> void
|
|
1243
1694
|
|
|
1695
|
+
# Natural width is `caption.length + 4` to fit `[ caption ]`; height 1.
|
|
1696
|
+
def natural_size: () -> Size
|
|
1697
|
+
|
|
1244
1698
|
# _@return_ — the button's label.
|
|
1245
1699
|
attr_accessor caption: String
|
|
1246
1700
|
|
|
@@ -1335,11 +1789,21 @@ module Tuile
|
|
|
1335
1789
|
# _@param_ `value`
|
|
1336
1790
|
def scrollbar=: (bool value) -> void
|
|
1337
1791
|
|
|
1338
|
-
#
|
|
1339
|
-
#
|
|
1340
|
-
#
|
|
1341
|
-
|
|
1342
|
-
|
|
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
|
|
1343
1807
|
|
|
1344
1808
|
# Fully repaints the window: both frame and contents.
|
|
1345
1809
|
#
|
|
@@ -1373,6 +1837,17 @@ module Tuile
|
|
|
1373
1837
|
# _@param_ `caption`
|
|
1374
1838
|
def build_frame: (String caption) -> String
|
|
1375
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.
|
|
1376
1851
|
def layout_footer: () -> void
|
|
1377
1852
|
|
|
1378
1853
|
def on_focus: () -> void
|
|
@@ -1381,6 +1856,11 @@ module Tuile
|
|
|
1381
1856
|
# row.
|
|
1382
1857
|
attr_accessor footer: Component?
|
|
1383
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
|
+
|
|
1384
1864
|
# _@return_ — the current caption, empty by default.
|
|
1385
1865
|
attr_accessor caption: String
|
|
1386
1866
|
end
|
|
@@ -1402,9 +1882,6 @@ module Tuile
|
|
|
1402
1882
|
# plain `<textarea>` or text editor. A future `on_enter`/`on_submit`
|
|
1403
1883
|
# callback may opt out of that by consuming Enter instead.
|
|
1404
1884
|
class TextArea < Tuile::Component::TextInput
|
|
1405
|
-
ACTIVE_BG_SGR: String
|
|
1406
|
-
INACTIVE_BG_SGR: String
|
|
1407
|
-
|
|
1408
1885
|
def initialize: () -> void
|
|
1409
1886
|
|
|
1410
1887
|
def cursor_position: () -> Point?
|
|
@@ -1513,9 +1990,27 @@ module Tuile
|
|
|
1513
1990
|
# honored); a `StyledString` is used as-is; `nil` is coerced to an
|
|
1514
1991
|
# empty {StyledString}.
|
|
1515
1992
|
#
|
|
1993
|
+
# Detaches every existing {Region} (including the original default)
|
|
1994
|
+
# and installs a fresh internal default region that owns all the new
|
|
1995
|
+
# hard lines. Any handle the caller was holding becomes detached and
|
|
1996
|
+
# raises on use — see {Region#attached?}. The no-op short-circuit
|
|
1997
|
+
# (matching value, same {StyledString}) preserves existing regions.
|
|
1998
|
+
#
|
|
1516
1999
|
# _@param_ `value`
|
|
1517
2000
|
def text=: ((String | StyledString)? value) -> void
|
|
1518
2001
|
|
|
2002
|
+
# Creates a new empty {Region} at the spatial tail of the document
|
|
2003
|
+
# and returns its handle. Subsequent {#append} / {#<<} / {#add_line}
|
|
2004
|
+
# calls route through this new region (since it is now the spatial
|
|
2005
|
+
# tail). Earlier regions keep their content and their handles stay
|
|
2006
|
+
# valid; their {Region#range} shifts as later regions grow.
|
|
2007
|
+
#
|
|
2008
|
+
# Apps streaming logically-distinct sections (e.g. an LLM's "thinking"
|
|
2009
|
+
# vs. "assistant" output) create one region per section, hold the
|
|
2010
|
+
# handles, and call `region.append` / `region.text=` directly when
|
|
2011
|
+
# they need to grow or rewrite an earlier section.
|
|
2012
|
+
def create_region: () -> Region
|
|
2013
|
+
|
|
1519
2014
|
# _@return_ — true iff {#text} is empty (no hard lines).
|
|
1520
2015
|
def empty?: () -> bool
|
|
1521
2016
|
|
|
@@ -1568,6 +2063,56 @@ module Tuile
|
|
|
1568
2063
|
# _@param_ `n` — number of hard lines to drop; must be >= 0.
|
|
1569
2064
|
def remove_last_n_lines: (Integer n) -> void
|
|
1570
2065
|
|
|
2066
|
+
# Replaces a contiguous range of hard lines with the parsed content
|
|
2067
|
+
# of `str`. The replacement is parsed exactly like {#text=} and
|
|
2068
|
+
# {#append}: a {String} is run through {StyledString.parse} (so
|
|
2069
|
+
# embedded ANSI is honored), a {StyledString} is used as-is, `nil`
|
|
2070
|
+
# behaves like an empty replacement (the range is deleted). Embedded
|
|
2071
|
+
# `"\n"` in the replacement produces multiple hard lines, so a single
|
|
2072
|
+
# `replace` can grow or shrink the buffer.
|
|
2073
|
+
#
|
|
2074
|
+
# `range` selects which hard lines to swap out:
|
|
2075
|
+
#
|
|
2076
|
+
# - an `Integer` `n` is shorthand for `n..n` (replace one existing
|
|
2077
|
+
# line — `n` must be in `[0, hard-line count)`);
|
|
2078
|
+
# - a non-empty `Range` of hard-line indices replaces those lines;
|
|
2079
|
+
# - an empty `Range` (e.g. `2...2`, or the canonical end-insertion
|
|
2080
|
+
# `hard_lines.size...hard_lines.size`) is *insertion* at that
|
|
2081
|
+
# position — no lines are removed. {#insert} is a thin alias for
|
|
2082
|
+
# this case.
|
|
2083
|
+
#
|
|
2084
|
+
# Endpoints must be non-negative integers; `begin` may equal
|
|
2085
|
+
# `hard-line count` (insertion at the end), `end` may not exceed
|
|
2086
|
+
# `hard-line count - 1`. `nil` endpoints (beginless / endless ranges)
|
|
2087
|
+
# are not accepted.
|
|
2088
|
+
#
|
|
2089
|
+
# Cost is roughly `O(from + length + new content)`: the splice
|
|
2090
|
+
# updates only the affected slice of the physical-row buffer, using
|
|
2091
|
+
# the per-hard-line wrap-count cache to locate the starting offset
|
|
2092
|
+
# without re-wrapping preceding lines. Lines outside the splice are
|
|
2093
|
+
# never re-wrapped. {#top_line} is clamped if the new line count
|
|
2094
|
+
# puts it past the end; {#auto_scroll} pins it to the bottom as
|
|
2095
|
+
# usual. The call is a no-op (no invalidation) when the parsed
|
|
2096
|
+
# replacement equals the covered range (vacuously true for an empty
|
|
2097
|
+
# range plus empty replacement, so `replace(n...n, "")` is a cheap
|
|
2098
|
+
# no-op).
|
|
2099
|
+
#
|
|
2100
|
+
# _@param_ `range` — hard-line indices to replace.
|
|
2101
|
+
#
|
|
2102
|
+
# _@param_ `str` — replacement content.
|
|
2103
|
+
def replace: ((::Range[untyped] | Integer) range, (String | StyledString)? str) -> void
|
|
2104
|
+
|
|
2105
|
+
# Inserts `str` at hard-line index `at`. Equivalent to
|
|
2106
|
+
# `replace(at...at, str)` — a no-removal splice that grows the buffer
|
|
2107
|
+
# by the parsed line count. `at == hard-line count` is allowed and
|
|
2108
|
+
# appends at the end; for that case {#append} / {#add_line} are
|
|
2109
|
+
# usually more idiomatic.
|
|
2110
|
+
#
|
|
2111
|
+
# _@param_ `at` — 0-based hard-line index in `[0, hard-line count]`.
|
|
2112
|
+
#
|
|
2113
|
+
# _@param_ `str` — content to insert.
|
|
2114
|
+
def insert: (Integer at, (String | StyledString)? str) -> void
|
|
2115
|
+
|
|
1571
2116
|
# Clears the text. Equivalent to `text = ""`.
|
|
1572
2117
|
def clear: () -> void
|
|
1573
2118
|
|
|
@@ -1593,41 +2138,164 @@ module Tuile
|
|
|
1593
2138
|
# this hook.
|
|
1594
2139
|
def on_width_changed: () -> void
|
|
1595
2140
|
|
|
2141
|
+
# Validates and unpacks a {#replace}-style range argument into
|
|
2142
|
+
# inclusive `[from, to]` line indices. An `Integer` `n` becomes
|
|
2143
|
+
# `[n, n]` (which must point at an existing line — `Integer` is
|
|
2144
|
+
# never insertion sugar). A `Range` is normalized for
|
|
2145
|
+
# `exclude_end?`; `to == from - 1` is a valid empty range
|
|
2146
|
+
# (insertion at `from`), and `from` may equal `size` for
|
|
2147
|
+
# end-insertion. Shared by {#replace} and {Region#replace};
|
|
2148
|
+
# `size` is the buffer or region line count, and `what` is the
|
|
2149
|
+
# entity name woven into error messages.
|
|
2150
|
+
#
|
|
2151
|
+
# _@param_ `range`
|
|
2152
|
+
#
|
|
2153
|
+
# _@param_ `size`
|
|
2154
|
+
#
|
|
2155
|
+
# _@param_ `what`
|
|
2156
|
+
def normalize_replace_range: ((::Range[untyped] | Integer) range, ?Integer size, ?String what) -> [Integer, Integer]
|
|
2157
|
+
|
|
2158
|
+
# Hard-line index where `region` begins in {@hard_lines} — derived
|
|
2159
|
+
# by summing the line counts of all regions that precede it.
|
|
2160
|
+
#
|
|
2161
|
+
# _@param_ `region`
|
|
2162
|
+
def region_start_index: (Region region) -> Integer
|
|
2163
|
+
|
|
2164
|
+
# Joined {StyledString} of the hard lines that `region` owns. Mirrors
|
|
2165
|
+
# {#text} but scoped to one region.
|
|
2166
|
+
#
|
|
2167
|
+
# _@param_ `region`
|
|
2168
|
+
def text_for_region: (Region region) -> StyledString
|
|
2169
|
+
|
|
2170
|
+
# Replaces all of `region`'s hard lines with the parsed content of
|
|
2171
|
+
# `value`. Symmetric with {#text=}, scoped to one region. Empty/nil
|
|
2172
|
+
# content empties the region (no visible blank line). Works on
|
|
2173
|
+
# already-empty regions (insertion at the region's position).
|
|
2174
|
+
#
|
|
2175
|
+
# _@param_ `region`
|
|
2176
|
+
#
|
|
2177
|
+
# _@param_ `value`
|
|
2178
|
+
def set_region_text: (Region region, (String | StyledString)? value) -> void
|
|
2179
|
+
|
|
2180
|
+
# Region-scoped {#replace}. Validates `range` against
|
|
2181
|
+
# `region.line_count`, translates region-relative indices to
|
|
2182
|
+
# absolute buffer indices, splices, and updates the region's count.
|
|
2183
|
+
#
|
|
2184
|
+
# _@param_ `region`
|
|
2185
|
+
#
|
|
2186
|
+
# _@param_ `range`
|
|
2187
|
+
#
|
|
2188
|
+
# _@param_ `str`
|
|
2189
|
+
def replace_in_region: (Region region, (::Range[untyped] | Integer) range, (String | StyledString)? str) -> void
|
|
2190
|
+
|
|
2191
|
+
# Verbatim append into `region`.
|
|
2192
|
+
#
|
|
2193
|
+
# _@param_ `region`
|
|
2194
|
+
#
|
|
2195
|
+
# _@param_ `str`
|
|
2196
|
+
def append_to_region: (Region region, (String | StyledString)? str) -> void
|
|
2197
|
+
|
|
2198
|
+
# Drops the last `n` hard lines from `region`'s tail via
|
|
2199
|
+
# {#splice_hard_lines}. `n` is clamped to the region's current
|
|
2200
|
+
# line count; callers guarantee `n > 0` and the region is
|
|
2201
|
+
# non-empty (the {Region#remove_last_n_lines} guard handles the
|
|
2202
|
+
# no-op cases).
|
|
2203
|
+
#
|
|
2204
|
+
# _@param_ `region`
|
|
2205
|
+
#
|
|
2206
|
+
# _@param_ `n`
|
|
2207
|
+
def remove_last_n_from_region: (Region region, Integer n) -> void
|
|
2208
|
+
|
|
2209
|
+
# Drops `region` from {@regions}: its hard lines are removed via
|
|
2210
|
+
# {#splice_hard_lines}, the handle is detached, and the always-one
|
|
2211
|
+
# default is restored if the removal would have left zero regions.
|
|
2212
|
+
# Skips the rewrap / invalidate work when the region was empty
|
|
2213
|
+
# (the buffer didn't change), but always detaches.
|
|
2214
|
+
#
|
|
2215
|
+
# _@param_ `region`
|
|
2216
|
+
def remove_region: (Region region) -> void
|
|
2217
|
+
|
|
2218
|
+
# Adjusts region line counts after a {@hard_lines} splice that
|
|
2219
|
+
# removed `removed_count` lines at index `from` and inserted
|
|
2220
|
+
# `added_count` in their place. Two passes:
|
|
2221
|
+
#
|
|
2222
|
+
# 1. Subtract each region's overlap with the removed range (uses
|
|
2223
|
+
# the original counts to compute positions). Remember the first
|
|
2224
|
+
# region that lost lines — that's the natural home for the
|
|
2225
|
+
# replacement content.
|
|
2226
|
+
# 2. Credit `added_count` to that region. For pure insertions (no
|
|
2227
|
+
# removal), there's no "first overlapping region" to pick from;
|
|
2228
|
+
# walk regions and credit the latest one starting at `from` (the
|
|
2229
|
+
# boundary tiebreaker matches the spatial-tail-routing of
|
|
2230
|
+
# {#append}). Past-the-end inserts fall back to the tail region.
|
|
2231
|
+
#
|
|
2232
|
+
# _@param_ `from`
|
|
2233
|
+
#
|
|
2234
|
+
# _@param_ `removed_count`
|
|
2235
|
+
#
|
|
2236
|
+
# _@param_ `added_count`
|
|
2237
|
+
def update_region_counts: (Integer from, Integer removed_count, Integer added_count) -> void
|
|
2238
|
+
|
|
1596
2239
|
# _@return_ — number of visible lines.
|
|
1597
2240
|
def viewport_lines: () -> Integer
|
|
1598
2241
|
|
|
1599
2242
|
# _@return_ — the max value of {#top_line} for scroll-key clamping.
|
|
1600
2243
|
def top_line_max: () -> Integer
|
|
1601
2244
|
|
|
1602
|
-
#
|
|
1603
|
-
#
|
|
1604
|
-
#
|
|
1605
|
-
#
|
|
1606
|
-
#
|
|
1607
|
-
# {@top_line} if the new line count puts it out of
|
|
2245
|
+
# Full rebuild of {@physical_lines} and {@hard_line_wrap_counts}
|
|
2246
|
+
# from {@hard_lines}. Called when wrap width changes (which
|
|
2247
|
+
# invalidates every cached row count) and from {#text=} (which
|
|
2248
|
+
# replaces the whole logical model). Mid-buffer mutators splice
|
|
2249
|
+
# incrementally via {#splice_hard_lines} and do *not* go through
|
|
2250
|
+
# here. Clamps {@top_line} if the new line count puts it out of
|
|
2251
|
+
# range.
|
|
1608
2252
|
def rewrap: () -> void
|
|
1609
2253
|
|
|
1610
|
-
# Wraps `hard_line` at `width` and
|
|
1611
|
-
#
|
|
1612
|
-
# and degenerate `width <= 0` both emit a single {@blank_line}
|
|
1613
|
-
# matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
|
|
1614
|
-
# would have produced
|
|
2254
|
+
# Wraps `hard_line` at `width` and returns the padded physical rows
|
|
2255
|
+
# alongside the row count. Empty hard lines (e.g. from a `"\n\n"`
|
|
2256
|
+
# run) and degenerate `width <= 0` both emit a single {@blank_line}
|
|
2257
|
+
# row, matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
|
|
2258
|
+
# would have produced.
|
|
1615
2259
|
#
|
|
1616
|
-
# _@param_ `hard_line`
|
|
2260
|
+
# _@param_ `hard_line`
|
|
1617
2261
|
#
|
|
1618
2262
|
# _@param_ `width`
|
|
1619
|
-
def
|
|
2263
|
+
def wrap_hard_line: (StyledString hard_line, Integer width) -> [::Array[StyledString], Integer]
|
|
1620
2264
|
|
|
1621
|
-
#
|
|
1622
|
-
#
|
|
1623
|
-
# input). Used by {#append} when extending the last hard line: its
|
|
1624
|
-
# old wrapped rows are dropped, then the extended hard line is
|
|
1625
|
-
# re-wrapped and appended.
|
|
2265
|
+
# Appends `hard_line` to the tail of {@hard_lines}, updating the
|
|
2266
|
+
# wrap-count cache and {@physical_lines} in lockstep.
|
|
1626
2267
|
#
|
|
1627
2268
|
# _@param_ `hard_line`
|
|
1628
2269
|
#
|
|
1629
2270
|
# _@param_ `width`
|
|
1630
|
-
def
|
|
2271
|
+
def push_hard_line: (StyledString hard_line, Integer width) -> void
|
|
2272
|
+
|
|
2273
|
+
# Pops the last hard line, the corresponding cache entry, and the
|
|
2274
|
+
# physical rows that hard line contributed. Returns the popped
|
|
2275
|
+
# hard line.
|
|
2276
|
+
def pop_hard_line: () -> StyledString
|
|
2277
|
+
|
|
2278
|
+
# Splices `new_hard_lines` into the buffer in place of the `count`
|
|
2279
|
+
# hard lines starting at index `from`. Updates {@hard_lines},
|
|
2280
|
+
# {@hard_line_wrap_counts}, and {@physical_lines} consistently.
|
|
2281
|
+
# The starting physical-row offset is computed in O(`from`) integer
|
|
2282
|
+
# adds via the cache — no wraps of preceding hard lines. Wraps are
|
|
2283
|
+
# done only for the new content, so total cost is
|
|
2284
|
+
# `O(from + count + new_hard_lines.sum(&:display_width))`.
|
|
2285
|
+
#
|
|
2286
|
+
# _@param_ `from`
|
|
2287
|
+
#
|
|
2288
|
+
# _@param_ `count` — number of existing hard lines to remove.
|
|
2289
|
+
#
|
|
2290
|
+
# _@param_ `new_hard_lines`
|
|
2291
|
+
def splice_hard_lines: (Integer from, Integer count, ::Array[StyledString] new_hard_lines) -> void
|
|
2292
|
+
|
|
2293
|
+
# _@param_ `idx`
|
|
2294
|
+
#
|
|
2295
|
+
# _@return_ — the {@physical_lines} index where the hard line
|
|
2296
|
+
# at {@hard_lines}`[idx]` starts. O(`idx`) integer adds via the
|
|
2297
|
+
# wrap-count cache.
|
|
2298
|
+
def phys_offset_at: (Integer idx) -> Integer
|
|
1631
2299
|
|
|
1632
2300
|
# Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
|
|
1633
2301
|
# default-styled `"\n"` between hard lines. Called from the {#text}
|
|
@@ -1691,6 +2359,136 @@ module Tuile
|
|
|
1691
2359
|
# `Size.new(0, 0)`. Maintained incrementally by {#text=} and
|
|
1692
2360
|
# {#append}, so reads are O(1).
|
|
1693
2361
|
attr_reader content_size: Size
|
|
2362
|
+
|
|
2363
|
+
# A logical section of a {TextView}'s text — a contiguous run of
|
|
2364
|
+
# hard lines the app wants to address as a unit (e.g. an LLM's
|
|
2365
|
+
# "thinking" output vs. its assistant message). The view always
|
|
2366
|
+
# has at least one region, an internal default that owns whatever
|
|
2367
|
+
# hard lines aren't claimed by an app-created region.
|
|
2368
|
+
#
|
|
2369
|
+
# Apps don't construct regions directly; call {TextView#create_region}
|
|
2370
|
+
# to get one. The handle stays valid as long as the region is
|
|
2371
|
+
# attached — i.e. until {TextView#text=} (or {TextView#clear}) wipes
|
|
2372
|
+
# the slate and installs a fresh internal default. Detached regions
|
|
2373
|
+
# raise {RuntimeError} on every mutator and reader.
|
|
2374
|
+
#
|
|
2375
|
+
# A region's position is derived from its sibling order and counts,
|
|
2376
|
+
# so growing or shrinking an earlier region implicitly shifts the
|
|
2377
|
+
# ranges of all later regions. Empty regions occupy zero rows but
|
|
2378
|
+
# still hold a position in the sequence; `region.text = ""` collapses
|
|
2379
|
+
# a region's visible footprint without detaching it. Pre-creating
|
|
2380
|
+
# empty placeholder regions is supported and is the natural pattern
|
|
2381
|
+
# for "I'll fill this in later" layouts.
|
|
2382
|
+
class Region
|
|
2383
|
+
# _@param_ `view` — the owning view (never `nil` at construction).
|
|
2384
|
+
#
|
|
2385
|
+
# _@param_ `line_count` — number of hard lines this region owns.
|
|
2386
|
+
def initialize: (TextView view, ?Integer line_count) -> void
|
|
2387
|
+
|
|
2388
|
+
# _@return_ — `true` while the region is owned by its
|
|
2389
|
+
# {TextView}. Becomes `false` permanently once detached
|
|
2390
|
+
# (typically by {TextView#text=} / {TextView#clear}).
|
|
2391
|
+
def attached?: () -> bool
|
|
2392
|
+
|
|
2393
|
+
# _@return_ — true iff the region owns zero hard lines.
|
|
2394
|
+
# Empty regions render nothing — they still hold a position in
|
|
2395
|
+
# the sequence, so subsequent mutations route to them as usual.
|
|
2396
|
+
def empty?: () -> bool
|
|
2397
|
+
|
|
2398
|
+
# _@return_ — the joined content of just this region's
|
|
2399
|
+
# hard lines. Empty regions return {StyledString::EMPTY}.
|
|
2400
|
+
def text: () -> StyledString
|
|
2401
|
+
|
|
2402
|
+
# Replaces all of this region's hard lines with the parsed content
|
|
2403
|
+
# of `value`. Accepts the same inputs as {TextView#text=}; empty
|
|
2404
|
+
# or `nil` content collapses the region to zero hard lines.
|
|
2405
|
+
#
|
|
2406
|
+
# _@param_ `value`
|
|
2407
|
+
def text=: ((String | StyledString)? value) -> void
|
|
2408
|
+
|
|
2409
|
+
# Verbatim append into this region's tail. Same semantics as
|
|
2410
|
+
# {TextView#append} but scoped to the region: embedded `"\n"`
|
|
2411
|
+
# creates new hard lines within the region, no-leading-newline
|
|
2412
|
+
# input extends the region's last hard line. Empty / `nil` input
|
|
2413
|
+
# is a no-op (but still raises when detached). When the region is
|
|
2414
|
+
# the spatial tail of the view, this uses the incremental
|
|
2415
|
+
# {TextView#append} path; mid-document regions splice the affected
|
|
2416
|
+
# slice of the physical-row buffer (lines outside the region are
|
|
2417
|
+
# not re-wrapped).
|
|
2418
|
+
#
|
|
2419
|
+
# _@param_ `str`
|
|
2420
|
+
def append: ((String | StyledString)? str) -> void
|
|
2421
|
+
|
|
2422
|
+
# _@return_ — the hard-line indices this region currently
|
|
2423
|
+
# occupies — `start...(start + line_count)`. Empty regions
|
|
2424
|
+
# return a degenerate exclusive range at their position (e.g.
|
|
2425
|
+
# `5...5`). The result is computed on each call and so always
|
|
2426
|
+
# reflects sibling mutations.
|
|
2427
|
+
def range: () -> ::Range[untyped]
|
|
2428
|
+
|
|
2429
|
+
# Removes this region from its view. The region's hard lines (if
|
|
2430
|
+
# any) are deleted from the buffer — subsequent regions' ranges
|
|
2431
|
+
# shift up by `line_count` — and the handle detaches permanently.
|
|
2432
|
+
# The view keeps its always-≥1-region invariant: if this was the
|
|
2433
|
+
# only remaining region, a fresh internal default is installed
|
|
2434
|
+
# (the app doesn't get a handle to it; call
|
|
2435
|
+
# {TextView#create_region} again to start tracking).
|
|
2436
|
+
#
|
|
2437
|
+
# Idempotent: calling `remove` on an already-detached region is a
|
|
2438
|
+
# silent no-op (unlike the other mutators, which raise). This
|
|
2439
|
+
# lets cleanup paths blindly call `remove` without first checking
|
|
2440
|
+
# {#attached?}.
|
|
2441
|
+
def remove: () -> void
|
|
2442
|
+
|
|
2443
|
+
# Appends `str` as a new entry in this region: starts a fresh
|
|
2444
|
+
# hard line first (when the region is non-empty), then appends
|
|
2445
|
+
# `str`. Scoped equivalent of {TextView#add_line}. On an empty
|
|
2446
|
+
# region behaves like {#append}.
|
|
2447
|
+
#
|
|
2448
|
+
# _@param_ `str`
|
|
2449
|
+
def add_line: ((String | StyledString)? str) -> void
|
|
2450
|
+
|
|
2451
|
+
# Replaces a contiguous range of this region's hard lines with the
|
|
2452
|
+
# parsed content of `str`. Region-scoped counterpart of
|
|
2453
|
+
# {TextView#replace}: indices are 0-based **within the region**
|
|
2454
|
+
# (so `replace(0, "x")` rewrites the region's first line, not
|
|
2455
|
+
# the buffer's). Same range conventions apply — `Integer`,
|
|
2456
|
+
# inclusive/exclusive `Range`, empty range as insertion at
|
|
2457
|
+
# `begin`, and `begin == line_count` for end-insertion.
|
|
2458
|
+
#
|
|
2459
|
+
# _@param_ `range` — region-relative hard-line indices.
|
|
2460
|
+
#
|
|
2461
|
+
# _@param_ `str` — replacement content.
|
|
2462
|
+
def replace: ((::Range[untyped] | Integer) range, (String | StyledString)? str) -> void
|
|
2463
|
+
|
|
2464
|
+
# Inserts `str` at region-relative hard-line index `at`.
|
|
2465
|
+
# Equivalent to `replace(at...at, str)`. Region-scoped counterpart
|
|
2466
|
+
# of {TextView#insert}; `at == line_count` is allowed and appends
|
|
2467
|
+
# at the region's tail.
|
|
2468
|
+
#
|
|
2469
|
+
# _@param_ `at` — region-relative index in `[0, line_count]`.
|
|
2470
|
+
#
|
|
2471
|
+
# _@param_ `str`
|
|
2472
|
+
def insert: (Integer at, (String | StyledString)? str) -> void
|
|
2473
|
+
|
|
2474
|
+
# Drops the last `n` hard lines from this region's tail.
|
|
2475
|
+
# Subsequent regions' ranges shift up by the number actually
|
|
2476
|
+
# dropped. `n` is clamped to {#line_count}, so passing a large
|
|
2477
|
+
# `n` empties the region — the handle stays attached (use
|
|
2478
|
+
# {#remove} when the goal is to drop the region itself).
|
|
2479
|
+
# `n == 0` and an already-empty region are no-ops.
|
|
2480
|
+
#
|
|
2481
|
+
# _@param_ `n`
|
|
2482
|
+
def remove_last_n_lines: (Integer n) -> void
|
|
2483
|
+
|
|
2484
|
+
def detach!: () -> void
|
|
2485
|
+
|
|
2486
|
+
def check_attached: () -> void
|
|
2487
|
+
|
|
2488
|
+
# _@return_ — number of hard lines this region owns. Safe to
|
|
2489
|
+
# read on a detached region (no error raised).
|
|
2490
|
+
attr_accessor line_count: (Integer | untyped)
|
|
2491
|
+
end
|
|
1694
2492
|
end
|
|
1695
2493
|
|
|
1696
2494
|
# Shows a log. Construct your logger pointed at a {LogWindow::IO} to route
|
|
@@ -1741,9 +2539,6 @@ module Tuile
|
|
|
1741
2539
|
# positioned by {Screen} after each repaint cycle when this component is
|
|
1742
2540
|
# focused; see {Component#cursor_position}.
|
|
1743
2541
|
class TextField < Tuile::Component::TextInput
|
|
1744
|
-
ACTIVE_BG_SGR: String
|
|
1745
|
-
INACTIVE_BG_SGR: String
|
|
1746
|
-
|
|
1747
2542
|
def initialize: () -> void
|
|
1748
2543
|
|
|
1749
2544
|
def cursor_position: () -> Point?
|
|
@@ -1818,9 +2613,6 @@ module Tuile
|
|
|
1818
2613
|
# effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
|
|
1819
2614
|
# keep the caret visible).
|
|
1820
2615
|
class TextInput < Component
|
|
1821
|
-
ACTIVE_BG_SGR: String
|
|
1822
|
-
INACTIVE_BG_SGR: String
|
|
1823
|
-
|
|
1824
2616
|
def initialize: () -> void
|
|
1825
2617
|
|
|
1826
2618
|
# _@return_ — true iff {#text} is the empty string.
|
|
@@ -1837,6 +2629,16 @@ module Tuile
|
|
|
1837
2629
|
# _@param_ `key`
|
|
1838
2630
|
def handle_key: (String key) -> bool
|
|
1839
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
|
+
|
|
1840
2642
|
# Input filter for {#text=}. Subclasses override to truncate or reject
|
|
1841
2643
|
# invalid input. Default coerces to String.
|
|
1842
2644
|
#
|
|
@@ -2004,6 +2806,71 @@ module Tuile
|
|
|
2004
2806
|
end
|
|
2005
2807
|
end
|
|
2006
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
|
+
|
|
2007
2874
|
# An event queue. The idea is that all UI-related updates run from the thread
|
|
2008
2875
|
# which runs the event queue only; this removes any need for locking and/or
|
|
2009
2876
|
# need for thread-safety mechanisms.
|
|
@@ -2032,13 +2899,39 @@ module Tuile
|
|
|
2032
2899
|
# Awaits until the event queue is empty (all events have been processed).
|
|
2033
2900
|
def await_empty: () -> void
|
|
2034
2901
|
|
|
2902
|
+
# Schedules `block` to fire on the event-loop thread roughly `fps` times
|
|
2903
|
+
# per second, passing a 0-based monotonically increasing tick counter. Use
|
|
2904
|
+
# it for animations (e.g. a `/-\|` spinner in a {Component::Label}) or
|
|
2905
|
+
# periodic UI refresh from a background task.
|
|
2906
|
+
#
|
|
2907
|
+
# The returned {Ticker} controls the schedule — call {Ticker#cancel} to
|
|
2908
|
+
# stop it.
|
|
2909
|
+
#
|
|
2910
|
+
# **Errors:** if `block` raises, the {Ticker} cancels itself and the
|
|
2911
|
+
# exception flows through the normal event-loop error path — i.e.
|
|
2912
|
+
# {Screen#on_error} for the default Tuile setup. Auto-cancel prevents a
|
|
2913
|
+
# broken block from spamming `on_error` at the tick rate.
|
|
2914
|
+
#
|
|
2915
|
+
# Tickers reuse `concurrent-ruby`'s shared timer thread
|
|
2916
|
+
# ({Concurrent}.global_timer_set) — adding more tickers does not add more
|
|
2917
|
+
# threads, just more work on the shared scheduler.
|
|
2918
|
+
#
|
|
2919
|
+
# _@param_ `fps` — firings per second, must be positive. Fractional values are fine (`fps: 0.5` ⇒ one tick every two seconds).
|
|
2920
|
+
def tick: (Numeric fps) ?{ (Integer tick) -> void } -> Ticker
|
|
2921
|
+
|
|
2035
2922
|
# Runs the event loop and blocks. Must be run from at most one thread at the
|
|
2036
2923
|
# same time. Blocks until some thread calls {#stop}. Calls block for all
|
|
2037
|
-
# events
|
|
2038
|
-
# running this function.
|
|
2924
|
+
# events; the block is always called from the thread running this function.
|
|
2039
2925
|
#
|
|
2040
|
-
# Any exception raised by block is re-thrown, causing this function to
|
|
2041
|
-
# terminate.
|
|
2926
|
+
# Any exception raised by the block is re-thrown, causing this function to
|
|
2927
|
+
# terminate. Wrap the block body in `rescue` if you want to handle errors
|
|
2928
|
+
# without tearing down the loop — see {Screen#event_loop} for an example.
|
|
2929
|
+
#
|
|
2930
|
+
# **Procs are yielded too.** A {#submit}ed block arrives as a `Proc` event;
|
|
2931
|
+
# the consumer is responsible for invoking it (typically `event.call`).
|
|
2932
|
+
# Yielding rather than dispatching inline means a raise inside the
|
|
2933
|
+
# submitted block flows through the consumer's `rescue` like any other
|
|
2934
|
+
# event-handler error, instead of bypassing it.
|
|
2042
2935
|
def run_loop: () ?{ (Object event) -> void } -> void
|
|
2043
2936
|
|
|
2044
2937
|
# _@return_ — true if this thread is running inside an event queue.
|
|
@@ -2103,12 +2996,65 @@ module Tuile
|
|
|
2103
2996
|
attr_reader height: Integer
|
|
2104
2997
|
end
|
|
2105
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
|
+
|
|
2106
3022
|
# Emitted once when the queue is cleared, all messages are processed and the
|
|
2107
3023
|
# event loop will block waiting for more messages. Perfect time for
|
|
2108
3024
|
# repainting windows.
|
|
2109
3025
|
class EmptyQueueEvent
|
|
2110
3026
|
include Singleton
|
|
2111
3027
|
end
|
|
3028
|
+
|
|
3029
|
+
# Handle returned by {EventQueue#tick}. Cancel a running ticker via
|
|
3030
|
+
# {#cancel}.
|
|
3031
|
+
#
|
|
3032
|
+
# Internally wraps a `Concurrent::TimerTask` whose firing posts a single
|
|
3033
|
+
# submit-block to the owning {EventQueue}; the user's block therefore
|
|
3034
|
+
# always runs on the event-loop thread and may freely mutate UI. If the
|
|
3035
|
+
# user block raises, the Ticker auto-cancels and the exception is
|
|
3036
|
+
# re-raised so it flows through the loop's normal error handling
|
|
3037
|
+
# ({Screen#on_error} for the default Tuile setup).
|
|
3038
|
+
class Ticker
|
|
3039
|
+
# _@param_ `event_queue` — queue to dispatch tick calls onto.
|
|
3040
|
+
#
|
|
3041
|
+
# _@param_ `fps` — firings per second (positive).
|
|
3042
|
+
#
|
|
3043
|
+
# _@param_ `block` — called as `block.call(tick_count)` on each fire.
|
|
3044
|
+
def initialize: (EventQueue event_queue, Numeric fps, Proc block) -> void
|
|
3045
|
+
|
|
3046
|
+
# _@return_ — true once {#cancel} has been called.
|
|
3047
|
+
def cancelled?: () -> bool
|
|
3048
|
+
|
|
3049
|
+
# Stops the ticker. Idempotent and safe to call from any thread,
|
|
3050
|
+
# including from inside the tick block. Any tick already queued on the
|
|
3051
|
+
# event loop at the moment of cancellation is dropped before the user
|
|
3052
|
+
# block runs.
|
|
3053
|
+
def cancel: () -> void
|
|
3054
|
+
|
|
3055
|
+
# Runs on the event-loop thread.
|
|
3056
|
+
def fire: () -> void
|
|
3057
|
+
end
|
|
2112
3058
|
end
|
|
2113
3059
|
|
|
2114
3060
|
# Testing only — a screen which doesn't paint anything and pretends that the
|
|
@@ -2147,6 +3093,11 @@ module Tuile
|
|
|
2147
3093
|
|
|
2148
3094
|
def invalidated_clear: () -> void
|
|
2149
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
|
+
|
|
2150
3101
|
# _@return_ — whatever {#print} printed so far.
|
|
2151
3102
|
attr_reader prints: ::Array[String]
|
|
2152
3103
|
end
|
|
@@ -2456,16 +3407,16 @@ module Tuile
|
|
|
2456
3407
|
# `underline`). Useful for row-level highlights — the new bg overlays
|
|
2457
3408
|
# without dropping foreground colors the original styling carried.
|
|
2458
3409
|
#
|
|
2459
|
-
# _@param_ `bg` — background color,
|
|
2460
|
-
def with_bg: ((Symbol | Integer | ::Array[Integer])? bg) -> StyledString
|
|
3410
|
+
# _@param_ `bg` — background color, coerced via {Color.coerce}. `nil` clears bg back to the terminal default.
|
|
3411
|
+
def with_bg: ((Color | Symbol | Integer | ::Array[Integer])? bg) -> StyledString
|
|
2461
3412
|
|
|
2462
3413
|
# Returns a new {StyledString} with `fg` applied to every span, preserving
|
|
2463
3414
|
# each span's text and other style attributes (`bg`, `bold`, `italic`,
|
|
2464
3415
|
# `underline`). The new fg overlays without dropping background colors or
|
|
2465
3416
|
# text attributes the original styling carried.
|
|
2466
3417
|
#
|
|
2467
|
-
# _@param_ `fg` — foreground color,
|
|
2468
|
-
def with_fg: ((Symbol | Integer | ::Array[Integer])? fg) -> StyledString
|
|
3418
|
+
# _@param_ `fg` — foreground color, coerced via {Color.coerce}. `nil` clears fg back to the terminal default.
|
|
3419
|
+
def with_fg: ((Color | Symbol | Integer | ::Array[Integer])? fg) -> StyledString
|
|
2469
3420
|
|
|
2470
3421
|
def inspect: () -> String
|
|
2471
3422
|
|
|
@@ -2481,10 +3432,11 @@ module Tuile
|
|
|
2481
3432
|
|
|
2482
3433
|
# _@param_ `color`
|
|
2483
3434
|
#
|
|
2484
|
-
# _@param_ `
|
|
3435
|
+
# _@param_ `target` — `:fg` or `:bg`.
|
|
2485
3436
|
#
|
|
2486
|
-
# _@
|
|
2487
|
-
|
|
3437
|
+
# _@return_ — SGR codes; `[39]` / `[49]` for the "default" reset
|
|
3438
|
+
# when `color` is `nil`, otherwise delegated to {Color#sgr_codes}.
|
|
3439
|
+
def color_codes: (Color? color, target: Symbol) -> ::Array[Integer]
|
|
2488
3440
|
|
|
2489
3441
|
# _@param_ `start_or_range`
|
|
2490
3442
|
#
|
|
@@ -2537,18 +3489,16 @@ module Tuile
|
|
|
2537
3489
|
class ParseError < Tuile::Error
|
|
2538
3490
|
end
|
|
2539
3491
|
|
|
2540
|
-
# A frozen value type describing the visual style of a {Span}.
|
|
2541
|
-
#
|
|
2542
|
-
#
|
|
2543
|
-
#
|
|
2544
|
-
#
|
|
2545
|
-
# - an Integer 0..255 — 256-color palette index (SGR 38;5;N / 48;5;N)
|
|
2546
|
-
# - an `[r, g, b]` Array of three 0..255 Integers — 24-bit RGB
|
|
3492
|
+
# A frozen value type describing the visual style of a {Span}. Colors are
|
|
3493
|
+
# stored as {Color} instances (or `nil` for the terminal default); inputs
|
|
3494
|
+
# to {.new} and {#merge} are coerced via {Color.coerce}, so the four
|
|
3495
|
+
# accepted color forms — `nil`, Symbol, Integer 0..255, RGB Array — work
|
|
3496
|
+
# transparently.
|
|
2547
3497
|
#
|
|
2548
3498
|
# @!attribute [r] fg
|
|
2549
|
-
# @return [
|
|
3499
|
+
# @return [Color, nil]
|
|
2550
3500
|
# @!attribute [r] bg
|
|
2551
|
-
# @return [
|
|
3501
|
+
# @return [Color, nil]
|
|
2552
3502
|
# @!attribute [r] bold
|
|
2553
3503
|
# @return [Boolean]
|
|
2554
3504
|
# @!attribute [r] italic
|
|
@@ -2556,12 +3506,11 @@ module Tuile
|
|
|
2556
3506
|
# @!attribute [r] underline
|
|
2557
3507
|
# @return [Boolean]
|
|
2558
3508
|
class Style
|
|
2559
|
-
COLOR_SYMBOLS: ::Array[Symbol]
|
|
2560
3509
|
DEFAULT: Style
|
|
2561
3510
|
|
|
2562
|
-
# _@param_ `fg`
|
|
3511
|
+
# _@param_ `fg` — coerced via {Color.coerce}.
|
|
2563
3512
|
#
|
|
2564
|
-
# _@param_ `bg`
|
|
3513
|
+
# _@param_ `bg` — coerced via {Color.coerce}.
|
|
2565
3514
|
#
|
|
2566
3515
|
# _@param_ `bold`
|
|
2567
3516
|
#
|
|
@@ -2569,18 +3518,13 @@ module Tuile
|
|
|
2569
3518
|
#
|
|
2570
3519
|
# _@param_ `underline`
|
|
2571
3520
|
def self.new: (
|
|
2572
|
-
?fg: (Symbol | Integer | ::Array[Integer])?,
|
|
2573
|
-
?bg: (Symbol | Integer | ::Array[Integer])?,
|
|
3521
|
+
?fg: (Color | Symbol | Integer | ::Array[Integer])?,
|
|
3522
|
+
?bg: (Color | Symbol | Integer | ::Array[Integer])?,
|
|
2574
3523
|
?bold: bool,
|
|
2575
3524
|
?italic: bool,
|
|
2576
3525
|
?underline: bool
|
|
2577
3526
|
) -> Style
|
|
2578
3527
|
|
|
2579
|
-
# _@param_ `color`
|
|
2580
|
-
#
|
|
2581
|
-
# _@param_ `which`
|
|
2582
|
-
def self.validate_color!: (Object color, Symbol which) -> void
|
|
2583
|
-
|
|
2584
3528
|
def default?: () -> bool
|
|
2585
3529
|
|
|
2586
3530
|
# Returns a new {Style} with the given attributes overridden.
|
|
@@ -2588,9 +3532,9 @@ module Tuile
|
|
|
2588
3532
|
# _@param_ `overrides`
|
|
2589
3533
|
def merge: (**::Hash[Symbol, Object] overrides) -> Style
|
|
2590
3534
|
|
|
2591
|
-
attr_reader fg:
|
|
3535
|
+
attr_reader fg: Color?
|
|
2592
3536
|
|
|
2593
|
-
attr_reader bg:
|
|
3537
|
+
attr_reader bg: Color?
|
|
2594
3538
|
|
|
2595
3539
|
attr_reader bold: bool
|
|
2596
3540
|
|
|
@@ -2656,6 +3600,8 @@ module Tuile
|
|
|
2656
3600
|
# A "synchronous" event queue – no loop is run, submitted blocks are run right
|
|
2657
3601
|
# away and submitted events are thrown away. Intended for testing only.
|
|
2658
3602
|
class FakeEventQueue
|
|
3603
|
+
def initialize: () -> void
|
|
3604
|
+
|
|
2659
3605
|
def locked?: () -> bool
|
|
2660
3606
|
|
|
2661
3607
|
def stop: () -> void
|
|
@@ -2668,6 +3614,127 @@ module Tuile
|
|
|
2668
3614
|
|
|
2669
3615
|
# _@param_ `event`
|
|
2670
3616
|
def post: (Object event) -> void
|
|
3617
|
+
|
|
3618
|
+
# Mirrors {EventQueue#tick} but timeless: returns a {FakeTicker} that
|
|
3619
|
+
# only fires when a test calls {#tick_once}. The `fps` argument is
|
|
3620
|
+
# validated the same way the real queue validates it, then discarded —
|
|
3621
|
+
# the fake has no clock, so frame cadence is up to the test.
|
|
3622
|
+
#
|
|
3623
|
+
# _@param_ `fps` — firings per second, must be positive. Validated for parity with {EventQueue#tick}; otherwise unused.
|
|
3624
|
+
def tick: (Numeric fps) ?{ (Integer tick) -> void } -> FakeTicker
|
|
3625
|
+
|
|
3626
|
+
# Test helper: fires every live ticker's user block once and prunes
|
|
3627
|
+
# cancelled tickers. No-op when no tickers are registered. Pumps once
|
|
3628
|
+
# per call regardless of any ticker's fps — the fake has no clock, so
|
|
3629
|
+
# tests pump N frames by calling this N times.
|
|
3630
|
+
def tick_once: () -> void
|
|
3631
|
+
|
|
3632
|
+
# Handle returned by {FakeEventQueue#tick}. Mirrors the public surface of
|
|
3633
|
+
# {EventQueue::Ticker} (`cancel`, `cancelled?`) but does not auto-fire —
|
|
3634
|
+
# the host {FakeEventQueue} drives firing via {FakeEventQueue#tick_once}.
|
|
3635
|
+
class FakeTicker
|
|
3636
|
+
# _@param_ `block` — called as `block.call(tick_count)` on each {#fire}.
|
|
3637
|
+
def initialize: (Proc block) -> void
|
|
3638
|
+
|
|
3639
|
+
# _@return_ — true once {#cancel} has been called.
|
|
3640
|
+
def cancelled?: () -> bool
|
|
3641
|
+
|
|
3642
|
+
# Marks the ticker cancelled. Idempotent. Subsequent {#fire} calls are
|
|
3643
|
+
# no-ops; {FakeEventQueue#tick_once} also prunes the ticker on its next
|
|
3644
|
+
# pass.
|
|
3645
|
+
def cancel: () -> void
|
|
3646
|
+
|
|
3647
|
+
# Invokes the user block with the current tick counter, then advances.
|
|
3648
|
+
# No-op when {#cancelled?}. Typically driven by
|
|
3649
|
+
# {FakeEventQueue#tick_once}; safe to call directly from a test that
|
|
3650
|
+
# wants to drive a single ticker.
|
|
3651
|
+
def fire: () -> void
|
|
3652
|
+
end
|
|
3653
|
+
end
|
|
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?
|
|
2671
3738
|
end
|
|
2672
3739
|
|
|
2673
3740
|
# A vertical scrollbar that computes which character to draw at each row.
|