tuile 0.8.0 → 0.10.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 +47 -0
- data/DECISIONS.md +1961 -0
- data/README.md +82 -48
- data/book/01-first-app.md +186 -0
- data/book/02-repaint.md +177 -0
- data/book/03-layout.md +379 -0
- data/book/04-event-loop.md +295 -0
- data/book/05-focus.md +219 -0
- data/book/06-theming.md +302 -0
- data/book/07-components.md +585 -0
- data/book/08-testing.md +199 -0
- data/book/09-styled-text.md +132 -0
- data/book/README.md +85 -0
- data/examples/hello_world.rb +1 -2
- data/examples/sampler.rb +435 -20
- data/ideas/new-components.md +109 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +113 -43
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/button.rb +25 -29
- data/lib/tuile/component/checkbox.rb +133 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +281 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/info_window.rb +4 -2
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +20 -25
- data/lib/tuile/component/layout.rb +3 -26
- data/lib/tuile/component/list.rb +8 -33
- data/lib/tuile/component/list_dropdown.rb +106 -0
- data/lib/tuile/component/log_window.rb +0 -14
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +70 -79
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -137
- data/lib/tuile/component/window.rb +88 -121
- data/lib/tuile/component.rb +246 -142
- data/lib/tuile/event_queue.rb +39 -21
- data/lib/tuile/fake_event_queue.rb +32 -7
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +42 -0
- data/lib/tuile/screen.rb +210 -109
- data/lib/tuile/screen_pane.rb +56 -44
- data/lib/tuile/styled_string.rb +112 -83
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2291 -890
- metadata +28 -9
- data/ideas/back-buffer.md +0 -217
- data/lib/tuile/sizing.rb +0 -59
data/sig/tuile.rbs
CHANGED
|
@@ -226,16 +226,10 @@ module Tuile
|
|
|
226
226
|
# Color.coerce(nil) # nil → nil
|
|
227
227
|
# ```
|
|
228
228
|
#
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
# only {Color} instances, where `Color.palette(130)` documents itself in
|
|
234
|
-
# a way the bare `130` (palette index? RGB channel?) does not.
|
|
235
|
-
#
|
|
236
|
-
# {#to_ansi} renders a full SGR escape (`"\e[31m"`); {#sgr_codes} returns the
|
|
237
|
-
# raw numeric codes so callers (notably {StyledString}) can combine them with
|
|
238
|
-
# other SGR attributes in a single sequence.
|
|
229
|
+
# {.coerce} is the lenient entry point (raw forms plus `nil`); the named
|
|
230
|
+
# factories and constants are the strict, self-documenting path for
|
|
231
|
+
# declaration sites — see the book's chapter 6 for why theme colors take
|
|
232
|
+
# {Color} instances only.
|
|
239
233
|
class Color
|
|
240
234
|
COLOR_SYMBOLS: ::Array[Symbol]
|
|
241
235
|
PALETTE_NAMES: ::Hash[Symbol, Integer]
|
|
@@ -321,67 +315,52 @@ module Tuile
|
|
|
321
315
|
attr_reader y: Integer
|
|
322
316
|
end
|
|
323
317
|
|
|
324
|
-
# A set of semantic colors the built-in components read when painting.
|
|
325
|
-
#
|
|
326
|
-
#
|
|
327
|
-
#
|
|
328
|
-
#
|
|
318
|
+
# A set of semantic colors the built-in components read when painting. The
|
|
319
|
+
# current theme lives at {Screen#theme}; components must look it up at paint
|
|
320
|
+
# time (inside `repaint`) rather than caching values, so a {Screen#theme=}
|
|
321
|
+
# restyles everything via one invalidate-everything pass. Book ch6 is the
|
|
322
|
+
# concept in full (why accents-only, dark/light, live OS flips).
|
|
329
323
|
#
|
|
330
|
-
# The
|
|
331
|
-
# {#
|
|
332
|
-
#
|
|
333
|
-
# role) and reset:
|
|
324
|
+
# The rendering helpers — {#active_bg}, {#active_border}, {#input_bg},
|
|
325
|
+
# {#hint} — wrap a plain string in the token's SGR color (on the channel
|
|
326
|
+
# appropriate for the token's role) and reset:
|
|
334
327
|
#
|
|
335
328
|
# screen.theme.active_bg("[ Ok ]") # => "\e[48;5;59m[ Ok ]\e[0m"
|
|
336
329
|
# screen.theme.hint("quit") # => "\e[38;5;109mquit\e[0m"
|
|
337
330
|
#
|
|
338
|
-
#
|
|
339
|
-
#
|
|
340
|
-
#
|
|
341
|
-
#
|
|
342
|
-
#
|
|
343
|
-
# `with_bg(theme.active_bg_color)`). Rule of thumb: plain chrome text →
|
|
344
|
-
# helper; structured text → `*_color` reader + {StyledString}.
|
|
331
|
+
# Content passes through verbatim (so it may carry other escapes). For
|
|
332
|
+
# span-aware styling — a token applied to a {StyledString} without flattening
|
|
333
|
+
# its per-span colors — use the `*_color` readers instead
|
|
334
|
+
# (`with_bg(theme.active_bg_color)`). Rule of thumb: plain chrome → helper;
|
|
335
|
+
# structured text → `*_color` reader + {StyledString}.
|
|
345
336
|
#
|
|
346
|
-
# Two built-in themes
|
|
347
|
-
#
|
|
348
|
-
#
|
|
337
|
+
# Two built-in themes ship: {DARK} (default) and {LIGHT}. A custom one is one
|
|
338
|
+
# `with` away, and every token must be a {Color} instance — not the lenient
|
|
339
|
+
# {Color.coerce} forms, since a theme is declared once so the verbosity
|
|
340
|
+
# self-documents:
|
|
349
341
|
#
|
|
350
342
|
# screen.theme = Theme::DARK.with(active_border_color: Color::CYAN)
|
|
351
343
|
#
|
|
352
|
-
# Tokens deliberately cover only the *accents* Tuile paints. Everything
|
|
353
|
-
# else inherits the terminal's own default foreground/background, which
|
|
354
|
-
# already matches the user's terminal theme perfectly — that's why there
|
|
355
|
-
# is no global `bg`/`fg` token.
|
|
356
|
-
#
|
|
357
|
-
# Every token is a {Color} — and must be passed as one. Unlike the
|
|
358
|
-
# lenient {Color.coerce} call sites elsewhere in the framework, a theme
|
|
359
|
-
# is declared once per app, so it takes only {Color} instances: at a
|
|
360
|
-
# declaration site `Color.palette(130)` documents itself in a way the
|
|
361
|
-
# bare `130` does not (palette index? RGB channel?) — and the named
|
|
362
|
-
# palette constants (`Color::DARK_ORANGE3` *is* 130; see
|
|
363
|
-
# {Color::PALETTE_NAMES}) go one step further.
|
|
364
|
-
#
|
|
365
344
|
# ## App-specific tokens
|
|
366
345
|
#
|
|
367
|
-
#
|
|
368
|
-
#
|
|
369
|
-
# {#
|
|
370
|
-
#
|
|
346
|
+
# An app carries its own colors in {#custom} (frozen `Hash{Symbol => Color}`).
|
|
347
|
+
# Look them up with {#[]} (fail-fast on typos) and render with the generic
|
|
348
|
+
# {#fg} / {#bg} helpers; subclass for semantic readers (`Data#with` keeps the
|
|
349
|
+
# subclass). Pair dark/light variants in a {ThemeDef} for {Screen#theme_def=}.
|
|
371
350
|
#
|
|
372
351
|
# theme = Theme::DARK.with(custom: { accent: Color::DARK_ORANGE })
|
|
373
352
|
# theme[:accent] # => Color, e.g. for StyledString#with_fg
|
|
374
353
|
# theme.fg(:accent, "NEW") # => "\e[38;5;208mNEW\e[0m"
|
|
375
354
|
#
|
|
376
|
-
# Apps wanting semantic readers can subclass — `Data#with` preserves the
|
|
377
|
-
# subclass, so an `AppTheme` stays an `AppTheme` through `with`:
|
|
378
|
-
#
|
|
379
355
|
# class AppTheme < Tuile::Theme
|
|
380
356
|
# def accent(text) = fg(:accent, text)
|
|
381
357
|
# end
|
|
382
358
|
#
|
|
383
|
-
#
|
|
384
|
-
# {
|
|
359
|
+
# For a color slot resolved *live* at paint — currently
|
|
360
|
+
# {Component#bg_color=} — assign a {Ref} instead of reading + rebuilding
|
|
361
|
+
# the token in {Component#on_theme_changed}; it tracks theme swaps on its
|
|
362
|
+
# own. Baked content colors ({Component::Label} text and friends) can't:
|
|
363
|
+
# they live in a frozen {StyledString} and still need the hook.
|
|
385
364
|
#
|
|
386
365
|
# @!attribute [r] active_bg_color
|
|
387
366
|
# Background highlight of the component the user is interacting with:
|
|
@@ -410,6 +389,7 @@ module Tuile
|
|
|
410
389
|
# the tokens.
|
|
411
390
|
# @return [Hash{Symbol => Color}]
|
|
412
391
|
class Theme
|
|
392
|
+
CHROME_TOKENS: ::Array[Symbol]
|
|
413
393
|
DARK: Theme
|
|
414
394
|
LIGHT: Theme
|
|
415
395
|
|
|
@@ -435,6 +415,18 @@ module Tuile
|
|
|
435
415
|
# _@param_ `token`
|
|
436
416
|
def []: (Symbol token) -> Color
|
|
437
417
|
|
|
418
|
+
# _@param_ `name` — a token name.
|
|
419
|
+
#
|
|
420
|
+
# _@return_ — true iff `name` is a built-in chrome token (see
|
|
421
|
+
# {CHROME_TOKENS}) rather than a {#custom} one.
|
|
422
|
+
def self.chrome_token?: (Symbol name) -> bool
|
|
423
|
+
|
|
424
|
+
# Builds a {Ref} — a live theme reference for a late-resolved color slot
|
|
425
|
+
# like {Component#bg_color=}. Sugar for `Theme::Ref.new(name)`.
|
|
426
|
+
#
|
|
427
|
+
# _@param_ `name` — a built-in chrome token ({#input_bg_color} etc.) or a {#custom} token name.
|
|
428
|
+
def self.ref: (Symbol name) -> Ref
|
|
429
|
+
|
|
438
430
|
# Renders `text` in the foreground color of the app-specific `token`
|
|
439
431
|
# — the generic counterpart of {#hint} for {#custom} tokens.
|
|
440
432
|
#
|
|
@@ -526,6 +518,34 @@ module Tuile
|
|
|
526
518
|
# lookups (it fail-fasts on typos); read this directly to enumerate
|
|
527
519
|
# the tokens.
|
|
528
520
|
attr_reader custom: ::Hash[Symbol, Color]
|
|
521
|
+
|
|
522
|
+
# A live reference to a theme token, resolved against the current theme at
|
|
523
|
+
# paint time rather than baked to a concrete {Color}. Assign one where a
|
|
524
|
+
# slot is resolved late — currently {Component#bg_color=} — and it follows
|
|
525
|
+
# light/dark flips with no {Component#on_theme_changed} hook:
|
|
526
|
+
#
|
|
527
|
+
# panel.bg_color = Tuile::Theme.ref(:panel_bg) # a #custom token
|
|
528
|
+
# dropdown.bg_color = Tuile::Theme.ref(:input_bg_color) # built-in chrome
|
|
529
|
+
#
|
|
530
|
+
# The name may be a built-in chrome token ({CHROME_TOKENS}) or a {#custom}
|
|
531
|
+
# one; a chrome name takes precedence on the (pathological) collision. This
|
|
532
|
+
# does *not* add a global bg/fg token — it only lets a slot point at a
|
|
533
|
+
# color the theme *already* carries, resolved the same way framework chrome
|
|
534
|
+
# already resolves it.
|
|
535
|
+
#
|
|
536
|
+
# Distinct from {Color.coerce}'s symbol support, which names one of the 16
|
|
537
|
+
# ANSI colors and yields a fixed {Color}; a Ref names a *theme* token and
|
|
538
|
+
# re-reads it each paint.
|
|
539
|
+
#
|
|
540
|
+
# Immutable.
|
|
541
|
+
class Ref
|
|
542
|
+
# Resolves to the concrete {Color} `name` maps to in `theme` — a built-in
|
|
543
|
+
# chrome reader when `name` is one ({Theme.chrome_token?}), else a
|
|
544
|
+
# {#custom} token.
|
|
545
|
+
#
|
|
546
|
+
# _@param_ `theme`
|
|
547
|
+
def resolve: (Theme theme) -> Color
|
|
548
|
+
end
|
|
529
549
|
end
|
|
530
550
|
|
|
531
551
|
# An in-memory grid of styled cells mirroring the terminal screen. This is
|
|
@@ -535,7 +555,7 @@ module Tuile
|
|
|
535
555
|
# string needed to bring a terminal — one that already matches the buffer's
|
|
536
556
|
# state as of the previous flush — up to date. Only cells that actually
|
|
537
557
|
# changed are emitted, so nothing flickers regardless of terminal/multiplexer
|
|
538
|
-
# synchronized-output support.
|
|
558
|
+
# synchronized-output support.
|
|
539
559
|
#
|
|
540
560
|
# Coordinates are 0-based `(x, y)` = `(column, row)`, matching
|
|
541
561
|
# {Component#rect} and `TTY::Cursor.move_to`.
|
|
@@ -548,17 +568,10 @@ module Tuile
|
|
|
548
568
|
# size. There is deliberately no per-frame whole-buffer clear or copy;
|
|
549
569
|
# un-touched cells retain the previous frame's value.
|
|
550
570
|
#
|
|
551
|
-
#
|
|
552
|
-
# cell** (O(1) set, no `Set` bucket math, no separate array), a per-row
|
|
553
|
-
# boolean so {#flush} scans only the rows that changed, and one global flag
|
|
554
|
-
# so {#dirty?} and the "nothing changed" early-out are O(1). {#flush} clears
|
|
555
|
-
# every flag it consumes.
|
|
556
|
-
#
|
|
557
|
-
# Cells are **mutable and pre-allocated**: the grid builds its {Cell}s once
|
|
571
|
+
# Cells are **mutable and pre-allocated** — the grid builds its {Cell}s once
|
|
558
572
|
# (at construction and {#resize}) and rewrites them in place, so a normal
|
|
559
|
-
# paint allocates nothing per cell. That
|
|
560
|
-
# object
|
|
561
|
-
# space in the default style.
|
|
573
|
+
# paint allocates nothing per cell. That's why {Cell} is a plain mutable
|
|
574
|
+
# object, not a frozen value type.
|
|
562
575
|
#
|
|
563
576
|
# ## Wide characters
|
|
564
577
|
#
|
|
@@ -569,6 +582,15 @@ module Tuile
|
|
|
569
582
|
# grid never holds a dangling continuation or a headless one.
|
|
570
583
|
class Buffer
|
|
571
584
|
DEFAULT_STYLE: StyledString::Style
|
|
585
|
+
WIDTH_CACHE: ::Hash[String, Integer]
|
|
586
|
+
|
|
587
|
+
# Memoized {Unicode::DisplayWidth.of}. Use this for every paint-path width
|
|
588
|
+
# lookup instead of calling the gem directly.
|
|
589
|
+
#
|
|
590
|
+
# _@param_ `grapheme` — one grapheme cluster.
|
|
591
|
+
#
|
|
592
|
+
# _@return_ — its display width in columns (0 for combining marks).
|
|
593
|
+
def self.display_width: (String grapheme) -> Integer
|
|
572
594
|
|
|
573
595
|
# _@param_ `size` — grid dimensions in columns × rows.
|
|
574
596
|
def initialize: (Size size) -> void
|
|
@@ -609,9 +631,8 @@ module Tuile
|
|
|
609
631
|
) -> void
|
|
610
632
|
|
|
611
633
|
# Writes a {StyledString} starting at `(x, y)`, advancing by each grapheme's
|
|
612
|
-
# display width and clipping at the right edge.
|
|
613
|
-
#
|
|
614
|
-
# paint. Newlines in the string are not handled — pass one physical line.
|
|
634
|
+
# display width and clipping at the right edge. Newlines are not handled —
|
|
635
|
+
# pass one physical line.
|
|
615
636
|
#
|
|
616
637
|
# _@param_ `x` — starting column.
|
|
617
638
|
#
|
|
@@ -693,6 +714,29 @@ module Tuile
|
|
|
693
714
|
# tests asserting styled output.
|
|
694
715
|
def region_ansi: (Rect rect) -> ::Array[String]
|
|
695
716
|
|
|
717
|
+
# Core of {#set_char} with the grapheme's display width already known.
|
|
718
|
+
# {#set_line} computes each width once while advancing the column and passes
|
|
719
|
+
# it straight through, so the paint hot path measures every grapheme exactly
|
|
720
|
+
# once (and that once is a {.display_width} memo read). See {#set_char} for
|
|
721
|
+
# the wide-glyph / clipping / out-of-bounds contract.
|
|
722
|
+
#
|
|
723
|
+
# _@param_ `x` — column.
|
|
724
|
+
#
|
|
725
|
+
# _@param_ `y` — row.
|
|
726
|
+
#
|
|
727
|
+
# _@param_ `grapheme` — one grapheme cluster.
|
|
728
|
+
#
|
|
729
|
+
# _@param_ `w` — `grapheme`'s display width (0, 1, or 2).
|
|
730
|
+
#
|
|
731
|
+
# _@param_ `style`
|
|
732
|
+
def put_char: (
|
|
733
|
+
Integer x,
|
|
734
|
+
Integer y,
|
|
735
|
+
String grapheme,
|
|
736
|
+
Integer w,
|
|
737
|
+
StyledString::Style style
|
|
738
|
+
) -> void
|
|
739
|
+
|
|
696
740
|
# (Re)allocates a blank grid of `size` with clean dirty state. Callers
|
|
697
741
|
# follow with {#mark_all_dirty} when the terminal doesn't match the new
|
|
698
742
|
# grid — construction and {#resize} both do.
|
|
@@ -748,13 +792,27 @@ module Tuile
|
|
|
748
792
|
StyledString::Style style
|
|
749
793
|
) -> void
|
|
750
794
|
|
|
751
|
-
# If `(x, y)`
|
|
752
|
-
#
|
|
795
|
+
# If `(x, y)` holds a continuation, blanks the head of the glyph it belongs to
|
|
796
|
+
# and every continuation up to — but not including — `x`. Called before a
|
|
797
|
+
# write lands on `x`, so the glyph reaching into `x` isn't left headless.
|
|
798
|
+
# Walks left rather than assuming the head sits at `x - 1`: a glyph may be
|
|
799
|
+
# wider than two columns, so its tail can run several cells.
|
|
800
|
+
#
|
|
801
|
+
# _@param_ `x` — column
|
|
802
|
+
#
|
|
803
|
+
# _@param_ `y` — row
|
|
804
|
+
def blank_left_partner: (Integer x, Integer y) -> void
|
|
805
|
+
|
|
806
|
+
# Blanks the run of continuations immediately right of `(x, y)` — the tail of
|
|
807
|
+
# a glyph whose head is at or before `x`, and which the write landing on `x`
|
|
808
|
+
# is about to decapitate. A continuation always belongs to the nearest glyph
|
|
809
|
+
# on its left, so the empty-grapheme test is exact — and cheaper than
|
|
810
|
+
# re-measuring that glyph's width.
|
|
753
811
|
#
|
|
754
812
|
# _@param_ `x` — column
|
|
755
813
|
#
|
|
756
814
|
# _@param_ `y` — row
|
|
757
|
-
def
|
|
815
|
+
def blank_right_partner: (Integer x, Integer y) -> void
|
|
758
816
|
|
|
759
817
|
attr_reader width: Integer
|
|
760
818
|
|
|
@@ -808,26 +866,57 @@ module Tuile
|
|
|
808
866
|
end
|
|
809
867
|
end
|
|
810
868
|
|
|
811
|
-
# The
|
|
869
|
+
# The process-singleton runtime: one {Screen} per app, reached through
|
|
870
|
+
# {Screen.instance}. It owns everything the UI needs to exist — the
|
|
871
|
+
# {#event_queue}, the UI lock, the invalidation set, the terminal IO, the
|
|
872
|
+
# back {#buffer}, the {#theme}/{#theme_def}, the {#focused} component, the
|
|
873
|
+
# global-shortcut registry, and the single {ScreenPane} under which *all*
|
|
874
|
+
# UI lives. Construct one with {Screen.new} (or {Screen.fake} in tests),
|
|
875
|
+
# tear it down with {Screen.close}.
|
|
876
|
+
#
|
|
877
|
+
# ## The component tree
|
|
812
878
|
#
|
|
813
|
-
#
|
|
879
|
+
# Everything on screen hangs off {#pane} (a {ScreenPane}): the tiled
|
|
880
|
+
# {#content} (set via {#content=}, filling the whole terminal and laying
|
|
881
|
+
# out its own children), the modal/overlay {#popups} stack (opened via
|
|
882
|
+
# {Component::Popup#open}, drawn on top of the content), and the bottom
|
|
883
|
+
# status bar. Popups are *not* sized from their content — each carries its
|
|
884
|
+
# own top-down {Component::Popup#size} — and they deliberately overdraw the
|
|
885
|
+
# content without clipping.
|
|
814
886
|
#
|
|
815
|
-
#
|
|
816
|
-
# the event queue.
|
|
887
|
+
# ## Repaint model
|
|
817
888
|
#
|
|
818
|
-
#
|
|
819
|
-
#
|
|
820
|
-
#
|
|
889
|
+
# Components never draw to the terminal directly. They call
|
|
890
|
+
# {Component#invalidate} to mark themselves dirty, and when they do paint
|
|
891
|
+
# they write styled cells into {#buffer}. Once the event loop drains its
|
|
892
|
+
# queue, {#repaint} walks the invalidated set in z-order, has each
|
|
893
|
+
# component paint into the buffer, then flushes the buffer's *minimal diff*
|
|
894
|
+
# (only cells that changed) to the terminal in one synchronized-output
|
|
895
|
+
# batch — which is what keeps repaint flicker-free and coalesces many
|
|
896
|
+
# invalidations into a single frame per tick. See the book (ch. 2) for the
|
|
897
|
+
# why.
|
|
821
898
|
#
|
|
822
|
-
#
|
|
823
|
-
# auto-size to their wrapped content and are drawn centered over the
|
|
824
|
-
# tiled content.
|
|
899
|
+
# ## Thread-safety
|
|
825
900
|
#
|
|
826
|
-
#
|
|
827
|
-
#
|
|
828
|
-
#
|
|
829
|
-
#
|
|
901
|
+
# **UI-thread-confined**, where "the UI thread" changes hands once: it is the
|
|
902
|
+
# loop's thread while {#run_event_loop} is in progress, and the thread that
|
|
903
|
+
# *created* the screen whenever no loop is running ({#state} `:idle`). So an
|
|
904
|
+
# app builds its tree on its own thread, hands ownership to the loop, and
|
|
905
|
+
# gets it back for teardown — the loop needn't run on the creating thread.
|
|
906
|
+
# *All* UI mutations — {#content=}, {#focused=}, {#theme=},
|
|
907
|
+
# {Component#invalidate}, `rect=`, … — obey it via {#check_locked}.
|
|
908
|
+
#
|
|
909
|
+
# A worker marshals back with `screen.event_queue.submit { … }`, which runs
|
|
910
|
+
# the block only while a loop is draining the queue — outside `:running` it
|
|
911
|
+
# silently never fires. Terminal resize, key/mouse input and OS color-scheme
|
|
912
|
+
# flips arrive as events on that same queue.
|
|
913
|
+
#
|
|
914
|
+
# The singleton slot survives subclassing (`FakeScreen < Screen`), so
|
|
915
|
+
# {FakeScreen} — which captures output in memory — is what
|
|
916
|
+
# {Screen.instance} returns under test.
|
|
830
917
|
class Screen
|
|
918
|
+
EDITING_KEYS: ::Array[String]
|
|
919
|
+
|
|
831
920
|
# rubocop:disable Style/ClassVars
|
|
832
921
|
def initialize: () -> void
|
|
833
922
|
|
|
@@ -844,8 +933,19 @@ module Tuile
|
|
|
844
933
|
# to {ScreenPane}). The array must not be modified!
|
|
845
934
|
def popups: () -> ::Array[Component]
|
|
846
935
|
|
|
847
|
-
#
|
|
848
|
-
#
|
|
936
|
+
# `:idle` covers *both* ends of the screen's life — before the first
|
|
937
|
+
# {#run_event_loop} and after it returns — and a screen may cycle
|
|
938
|
+
# `:idle` → `:running` → `:idle` repeatedly. `:closed` is terminal.
|
|
939
|
+
#
|
|
940
|
+
# _@return_ — `:idle` (no event loop running), `:running` (a
|
|
941
|
+
# {#run_event_loop} is in progress) or `:closed` (after {#close}).
|
|
942
|
+
def state: () -> Symbol
|
|
943
|
+
|
|
944
|
+
# Raises unless the calling thread currently owns the UI (see the
|
|
945
|
+
# class-level threading contract).
|
|
946
|
+
#
|
|
947
|
+
# screen.check_locked # from a worker: raises; wrap the work in
|
|
948
|
+
# # screen.event_queue.submit { ... } instead
|
|
849
949
|
def check_locked: () -> void
|
|
850
950
|
|
|
851
951
|
# Clears the TTY screen.
|
|
@@ -879,8 +979,13 @@ module Tuile
|
|
|
879
979
|
# _@param_ `window`
|
|
880
980
|
def add_popup: (Component::Popup window) -> void
|
|
881
981
|
|
|
882
|
-
# Runs event loop
|
|
883
|
-
#
|
|
982
|
+
# Runs the event loop on the calling thread, taking over stdin (raw mode,
|
|
983
|
+
# echo off): keys and mouse events are dispatched via {#handle_key} /
|
|
984
|
+
# {#handle_mouse}, and the loop repaints once per drained tick. Returns
|
|
985
|
+
# when `q` or ESC is pressed unhandled. Restores terminal state on exit.
|
|
986
|
+
#
|
|
987
|
+
# For the duration this thread owns the UI ({#state} is `:running`);
|
|
988
|
+
# ownership reverts to the creating thread once it returns.
|
|
884
989
|
#
|
|
885
990
|
# _@param_ `capture_mouse` — when true (default), enables xterm mouse tracking so clicks and scroll wheel arrive as {MouseEvent}s and feed {Component#handle_mouse}. When false, no tracking escape sequence is written: the terminal keeps its native click handling, which is what you want if the app benefits more from select-to-copy than from click-to-focus. Components' `handle_mouse` is simply never invoked from the loop in that mode (the terminal stops sending the bytes).
|
|
886
991
|
def run_event_loop: (?capture_mouse: bool) -> void
|
|
@@ -898,31 +1003,23 @@ module Tuile
|
|
|
898
1003
|
# _@return_ — true if focus moved.
|
|
899
1004
|
def focus_previous: () -> bool
|
|
900
1005
|
|
|
901
|
-
# Registers an app-level keyboard shortcut
|
|
902
|
-
#
|
|
903
|
-
#
|
|
904
|
-
#
|
|
905
|
-
#
|
|
906
|
-
#
|
|
907
|
-
#
|
|
908
|
-
#
|
|
909
|
-
#
|
|
910
|
-
#
|
|
911
|
-
#
|
|
912
|
-
#
|
|
913
|
-
#
|
|
914
|
-
#
|
|
915
|
-
#
|
|
916
|
-
#
|
|
917
|
-
#
|
|
918
|
-
# style stay consistent with whatever the host app uses elsewhere). The
|
|
919
|
-
# framework splices it in like any other status hint: in the tiled case,
|
|
920
|
-
# right after `q quit` and before the active window's own hint; while a
|
|
921
|
-
# popup is open, only hints from `over_popups: true` shortcuts are
|
|
922
|
-
# shown, and they're prepended before the popup's `q Close`.
|
|
923
|
-
#
|
|
924
|
-
# Example — open a log popup with Ctrl+L from anywhere, even while a
|
|
925
|
-
# popup is already on screen:
|
|
1006
|
+
# Registers an app-level keyboard shortcut: when `key` arrives, the block
|
|
1007
|
+
# runs on the event-loop thread (free to mutate UI) before the key reaches
|
|
1008
|
+
# any component. Re-registering a key replaces its binding.
|
|
1009
|
+
#
|
|
1010
|
+
# This registry is the *only* keyboard mechanism above the component tree,
|
|
1011
|
+
# and nothing suppresses it — so it accepts only keys no widget can need.
|
|
1012
|
+
# Three groups raise at registration rather than misbehaving at runtime:
|
|
1013
|
+
#
|
|
1014
|
+
# - **Printable keys** — they'd hijack typing into a
|
|
1015
|
+
# {Component::TextField}. A scope-wide one-key binding belongs on the
|
|
1016
|
+
# scope root's own `handle_key`, where a focused field consumes it first
|
|
1017
|
+
# (see {ScreenPane#handle_key}).
|
|
1018
|
+
# - **TAB / SHIFT_TAB** — {#handle_key} intercepts them for focus
|
|
1019
|
+
# navigation before the registry is consulted, so a binding would never
|
|
1020
|
+
# fire.
|
|
1021
|
+
# - **{EDITING_KEYS}** — `ENTER`, `BACKSPACE`, `DELETE` and the arrows,
|
|
1022
|
+
# which every editable widget needs.
|
|
926
1023
|
#
|
|
927
1024
|
# screen.register_global_shortcut(Keys::CTRL_L,
|
|
928
1025
|
# over_popups: true,
|
|
@@ -930,11 +1027,11 @@ module Tuile
|
|
|
930
1027
|
# log_popup.open
|
|
931
1028
|
# end
|
|
932
1029
|
#
|
|
933
|
-
# _@param_ `key` — unprintable key (e.g. {Keys::CTRL_L}, {Keys::ESC}
|
|
1030
|
+
# _@param_ `key` — unprintable key (e.g. {Keys::CTRL_L}, {Keys::ESC}).
|
|
934
1031
|
#
|
|
935
|
-
# _@param_ `over_popups` — when true, fires even while a modal popup is open (pre-empting the popup
|
|
1032
|
+
# _@param_ `over_popups` — when true, fires even while a modal popup is open (pre-empting the popup); when false (default), suppressed while any popup is open so the popup gets the key.
|
|
936
1033
|
#
|
|
937
|
-
# _@param_ `hint` — preformatted status-bar hint
|
|
1034
|
+
# _@param_ `hint` — preformatted status-bar hint; nil (default) is silent. Colors are baked in — re-register after a {#theme=} to recolor.
|
|
938
1035
|
def register_global_shortcut: (String key, ?over_popups: bool, ?hint: String?) -> void
|
|
939
1036
|
|
|
940
1037
|
# Removes a shortcut previously installed by {#register_global_shortcut}.
|
|
@@ -976,6 +1073,9 @@ module Tuile
|
|
|
976
1073
|
# return the same object.
|
|
977
1074
|
def self.fake: () -> FakeScreen
|
|
978
1075
|
|
|
1076
|
+
# Tears the screen down and vacates the singleton slot, moving {#state} to
|
|
1077
|
+
# the terminal `:closed`. Unmounts the tree first, so every component gets
|
|
1078
|
+
# its {Component#on_detached}. Idempotent.
|
|
979
1079
|
def close: () -> void
|
|
980
1080
|
|
|
981
1081
|
def self.close: () -> void
|
|
@@ -991,7 +1091,10 @@ module Tuile
|
|
|
991
1091
|
def print: (*String args) -> void
|
|
992
1092
|
|
|
993
1093
|
# Repaints the screen; tries to be as effective as possible, by only
|
|
994
|
-
# considering invalidated
|
|
1094
|
+
# considering invalidated components and flushing just the changed cells
|
|
1095
|
+
# of {#buffer}. Called once per event-loop tick (on {EventQueue::EmptyQueueEvent});
|
|
1096
|
+
# components should {Component#invalidate} and let the loop coalesce rather
|
|
1097
|
+
# than call this directly.
|
|
995
1098
|
def repaint: () -> void
|
|
996
1099
|
|
|
997
1100
|
# Returns the absolute screen coordinates where the hardware cursor should
|
|
@@ -1039,9 +1142,10 @@ module Tuile
|
|
|
1039
1142
|
# _@param_ `str`
|
|
1040
1143
|
def emit: (String str) -> void
|
|
1041
1144
|
|
|
1042
|
-
#
|
|
1043
|
-
#
|
|
1044
|
-
#
|
|
1145
|
+
# Resizes {#buffer} and {#pane} to the current {#size}, invalidates the
|
|
1146
|
+
# whole tree and repaints. Run whenever the terminal size changes (the
|
|
1147
|
+
# {EventQueue::TTYSizeEvent} path) and once at startup via the first
|
|
1148
|
+
# {#content=}.
|
|
1045
1149
|
def layout: () -> void
|
|
1046
1150
|
|
|
1047
1151
|
# A key has been pressed on the keyboard. Handle it, or forward to active
|
|
@@ -1049,17 +1153,15 @@ module Tuile
|
|
|
1049
1153
|
#
|
|
1050
1154
|
# Dispatch order:
|
|
1051
1155
|
# 1. Tab / Shift+Tab — reserved focus navigation, intercepted before
|
|
1052
|
-
# anything else so a focused {Component::TextField} (which
|
|
1053
|
-
#
|
|
1054
|
-
# doesn't trap them.
|
|
1156
|
+
# anything else so a focused {Component::TextField} (which swallows
|
|
1157
|
+
# printable keys) can't trap them.
|
|
1055
1158
|
# 2. App-level shortcuts from {#register_global_shortcut}. An entry
|
|
1056
1159
|
# registered with `over_popups: true` always fires; one with the
|
|
1057
1160
|
# default `over_popups: false` fires only when no modal popup is open
|
|
1058
1161
|
# (otherwise the modal popup receives the key normally). A non-modal
|
|
1059
1162
|
# overlay doesn't suppress global shortcuts.
|
|
1060
|
-
# 3. {ScreenPane#handle_key}
|
|
1061
|
-
#
|
|
1062
|
-
# it up the focus chain.
|
|
1163
|
+
# 3. {ScreenPane#handle_key} — delivery to {#focused}, bubbling up the
|
|
1164
|
+
# focus chain to the scope root.
|
|
1063
1165
|
#
|
|
1064
1166
|
# _@param_ `key`
|
|
1065
1167
|
#
|
|
@@ -1076,6 +1178,9 @@ module Tuile
|
|
|
1076
1178
|
# _@return_ — the structural root of the component tree.
|
|
1077
1179
|
attr_reader pane: ScreenPane
|
|
1078
1180
|
|
|
1181
|
+
# _@return_ — `:light` or `:dark`
|
|
1182
|
+
attr_reader color_scheme: Symbol
|
|
1183
|
+
|
|
1079
1184
|
# _@return_ — the back buffer components paint into
|
|
1080
1185
|
# ({Buffer#set_line} / {Buffer#fill} / {Buffer#set_char}).
|
|
1081
1186
|
attr_reader buffer: Buffer
|
|
@@ -1141,50 +1246,35 @@ module Tuile
|
|
|
1141
1246
|
end
|
|
1142
1247
|
end
|
|
1143
1248
|
|
|
1144
|
-
# A
|
|
1145
|
-
#
|
|
1146
|
-
#
|
|
1249
|
+
# A width/height ratio, each a float in `0.0..1.0` — the single relational
|
|
1250
|
+
# sizing primitive in Tuile, scoped to one job: sizing a {Component::Popup}
|
|
1251
|
+
# against the screen (a popup has no siblings competing for space, so "half
|
|
1252
|
+
# the screen, centered" beats a hard-coded cell count that breaks on the next
|
|
1253
|
+
# terminal size). It is deliberately *not* a general layout primitive — tiled
|
|
1254
|
+
# components get explicit integer rects computed by their parent's `rect=`.
|
|
1255
|
+
# See book ch3 for the layout model.
|
|
1147
1256
|
#
|
|
1148
|
-
#
|
|
1257
|
+
# Resolve it against a reference {Size} (the screen) to get concrete integer
|
|
1258
|
+
# cells:
|
|
1149
1259
|
#
|
|
1150
|
-
#
|
|
1151
|
-
# - {WRAP_CONTENT} — take the component's natural extent (its
|
|
1152
|
-
# {Component#content_size}), clamped to the slot;
|
|
1153
|
-
# - {.fixed} — take exactly the given number of cells, clamped to the slot.
|
|
1260
|
+
# Fraction::HALF.resolve(Size.new(80, 24)) # => 40x12
|
|
1154
1261
|
#
|
|
1155
|
-
#
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
# i.e. the component becomes invisible. Use {.fixed} or {FILL} for those.
|
|
1160
|
-
#
|
|
1161
|
-
# @!attribute [r] mode
|
|
1162
|
-
# @return [Symbol] `:fill`, `:wrap_content` or `:fixed`.
|
|
1163
|
-
# @!attribute [r] amount
|
|
1164
|
-
# @return [Integer, nil] the cell count for `:fixed`; `nil` otherwise.
|
|
1165
|
-
class Sizing
|
|
1166
|
-
FILL: Sizing
|
|
1167
|
-
WRAP_CONTENT: Sizing
|
|
1262
|
+
# Integer arguments are coerced to float, so `Fraction.new(1, 1) == FULL`.
|
|
1263
|
+
class Fraction
|
|
1264
|
+
HALF: Fraction
|
|
1265
|
+
FULL: Fraction
|
|
1168
1266
|
|
|
1169
|
-
# _@param_ `
|
|
1267
|
+
# _@param_ `width` — fraction of the reference width, `0.0..1.0`.
|
|
1170
1268
|
#
|
|
1171
|
-
# _@
|
|
1172
|
-
def
|
|
1269
|
+
# _@param_ `height` — fraction of the reference height, `0.0..1.0`.
|
|
1270
|
+
def initialize: (width: Numeric, height: Numeric) -> void
|
|
1173
1271
|
|
|
1174
|
-
# Resolves
|
|
1175
|
-
#
|
|
1176
|
-
#
|
|
1272
|
+
# Resolves this fraction against a reference size, rounding each axis to the
|
|
1273
|
+
# nearest cell and flooring at 1 — so a fraction never yields a zero-size
|
|
1274
|
+
# result on a tiny terminal.
|
|
1177
1275
|
#
|
|
1178
|
-
# _@param_ `
|
|
1179
|
-
|
|
1180
|
-
# _@return_ — the resolved extent, always in `0..available`.
|
|
1181
|
-
def resolve: (Integer available, Integer content) -> Integer
|
|
1182
|
-
|
|
1183
|
-
# _@return_ — `:fill`, `:wrap_content` or `:fixed`.
|
|
1184
|
-
attr_reader mode: Symbol
|
|
1185
|
-
|
|
1186
|
-
# _@return_ — the cell count for `:fixed`; `nil` otherwise.
|
|
1187
|
-
attr_reader amount: Integer?
|
|
1276
|
+
# _@param_ `reference` — the size to take a fraction of (usually the screen).
|
|
1277
|
+
def resolve: (Size reference) -> Size
|
|
1188
1278
|
end
|
|
1189
1279
|
|
|
1190
1280
|
# A UI component which is positioned on the screen and draws characters into
|
|
@@ -1203,56 +1293,35 @@ module Tuile
|
|
|
1203
1293
|
# Focuses this component. Equivalent to `screen.focused = self`.
|
|
1204
1294
|
def focus: () -> void
|
|
1205
1295
|
|
|
1206
|
-
#
|
|
1207
|
-
#
|
|
1208
|
-
#
|
|
1209
|
-
#
|
|
1210
|
-
#
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
#
|
|
1214
|
-
#
|
|
1215
|
-
#
|
|
1216
|
-
#
|
|
1217
|
-
#
|
|
1218
|
-
#
|
|
1219
|
-
#
|
|
1220
|
-
#
|
|
1221
|
-
#
|
|
1222
|
-
# Subclasses that paint their entire rect themselves (e.g. {Window}'s
|
|
1223
|
-
# border draws over the area the default would clear; {Component::List}
|
|
1224
|
-
# explicitly paints every row) may skip super and take full
|
|
1225
|
-
# responsibility for {#rect}. Everything else should call super.
|
|
1226
|
-
#
|
|
1227
|
-
# A component must not draw outside of {#rect}.
|
|
1228
|
-
#
|
|
1229
|
-
# Only called when the component is attached.
|
|
1296
|
+
# _@return_ — the background actually painted: this component's own
|
|
1297
|
+
# {#bg_color} if set (a {Theme::Ref} resolved against the current theme),
|
|
1298
|
+
# else the nearest ancestor's, else `nil` (terminal default). Resolved at
|
|
1299
|
+
# paint time — never cached, so the subtree tracks both an ancestor's
|
|
1300
|
+
# {#bg_color=} and a {Screen#theme=} on its next repaint.
|
|
1301
|
+
def effective_bg_color: () -> Color?
|
|
1302
|
+
|
|
1303
|
+
# Repaints the component. The default does the bookkeeping most components
|
|
1304
|
+
# need: it clears the background, and for a container whose children leave
|
|
1305
|
+
# gaps in {#rect} it re-invalidates those children so they repaint over the
|
|
1306
|
+
# cleared area (what makes mixed-width form layouts safe). A container whose
|
|
1307
|
+
# children fully tile {#rect} is left alone — the children cover everything.
|
|
1308
|
+
#
|
|
1309
|
+
# Call `super` from your own `repaint` to inherit this. Skip it only if you
|
|
1310
|
+
# paint the whole {#rect} yourself ({Window}'s border, {Component::List}'s
|
|
1311
|
+
# row-by-row paint). Never draw outside {#rect}. Only called when attached.
|
|
1230
1312
|
def repaint: () -> void
|
|
1231
1313
|
|
|
1232
|
-
# Called when a
|
|
1233
|
-
#
|
|
1234
|
-
#
|
|
1235
|
-
#
|
|
1236
|
-
#
|
|
1237
|
-
# Dispatch is owned by {ScreenPane#handle_key}: a {#key_shortcut} match
|
|
1238
|
-
# anywhere in the active scope is captured first (suppressed while a
|
|
1239
|
-
# cursor-owner is mid-edit), then the key is delivered to {Screen#focused}
|
|
1240
|
-
# and bubbles up its ancestor chain until some component handles it. A
|
|
1241
|
-
# component therefore only ever receives keys when it is on the focus chain
|
|
1242
|
-
# — or when app code hands it a key directly — so it acts on the key alone
|
|
1243
|
-
# and must never gate on its own {#active?} state.
|
|
1314
|
+
# Called when a key is pressed; override to act on keys you care about (the
|
|
1315
|
+
# default reports every key unhandled). A component only receives keys while
|
|
1316
|
+
# it's on the focus chain — or when app code hands it one directly — so act
|
|
1317
|
+
# on the key alone and never gate on your own {#active?} state. See book ch5
|
|
1318
|
+
# for how a keystroke is routed to reach here.
|
|
1244
1319
|
#
|
|
1245
1320
|
# _@param_ `_key` — a key.
|
|
1246
1321
|
#
|
|
1247
1322
|
# _@return_ — true if the key was handled, false if not.
|
|
1248
1323
|
def handle_key: (String _key) -> bool
|
|
1249
1324
|
|
|
1250
|
-
# _@param_ `key` — keyboard key to look up.
|
|
1251
|
-
#
|
|
1252
|
-
# _@return_ — the component whose {#key_shortcut} matches `key`,
|
|
1253
|
-
# or nil.
|
|
1254
|
-
def find_shortcut_component: (String key) -> Component?
|
|
1255
|
-
|
|
1256
1325
|
# Handles mouse event. Default implementation focuses this component when
|
|
1257
1326
|
# clicked (if {#focusable?}).
|
|
1258
1327
|
#
|
|
@@ -1268,16 +1337,11 @@ module Tuile
|
|
|
1268
1337
|
|
|
1269
1338
|
# Whether this component is a valid focus target. `false` by default —
|
|
1270
1339
|
# passive components like {Label} are decoration and don't accept focus.
|
|
1271
|
-
# The flag gates click-to-focus
|
|
1272
|
-
#
|
|
1273
|
-
#
|
|
1274
|
-
#
|
|
1275
|
-
#
|
|
1276
|
-
#
|
|
1277
|
-
# See also {#tab_stop?}: focusable controls _can_ receive focus (via click
|
|
1278
|
-
# or programmatic assignment), but only tab stops participate in Tab /
|
|
1279
|
-
# Shift+Tab cycling. Containers like {Window} and {Popup} are focusable
|
|
1280
|
-
# (so a click on chrome lands focus) but are not tab stops.
|
|
1340
|
+
# The flag gates click-to-focus and the container focus-cascade. Independent
|
|
1341
|
+
# from {#active?}: every component carries the active flag, but only
|
|
1342
|
+
# focusable ones can become a focus target that puts themselves and their
|
|
1343
|
+
# ancestors on the active chain. Focusable is broader than {#tab_stop?} —
|
|
1344
|
+
# a {Window} is focusable (a click on chrome lands focus) but not a tab stop.
|
|
1281
1345
|
#
|
|
1282
1346
|
# _@return_ — true if this component can be focused.
|
|
1283
1347
|
def focusable?: () -> bool
|
|
@@ -1298,20 +1362,23 @@ module Tuile
|
|
|
1298
1362
|
# _@return_ — the root component of this component hierarchy.
|
|
1299
1363
|
def root: () -> Component
|
|
1300
1364
|
|
|
1301
|
-
# List of child components, defaults to an empty array.
|
|
1302
|
-
#
|
|
1303
|
-
# _@return_ — child components. Must not be mutated! May be
|
|
1304
|
-
# empty.
|
|
1305
|
-
def children: () -> ::Array[Component]
|
|
1306
|
-
|
|
1307
1365
|
# Calls block for this component and for every descendant component.
|
|
1308
1366
|
def on_tree: () ?{ (Component component) -> void } -> void
|
|
1309
1367
|
|
|
1310
1368
|
# Called when the component receives focus.
|
|
1311
1369
|
def on_focus: () -> void
|
|
1312
1370
|
|
|
1313
|
-
#
|
|
1314
|
-
#
|
|
1371
|
+
# Whether this component's tree is mounted on a UI, {ScreenPane} being the
|
|
1372
|
+
# root of every displayed tree.
|
|
1373
|
+
#
|
|
1374
|
+
# A property of the parent chain alone — no {Screen} is consulted, so
|
|
1375
|
+
# assembling a tree needs no screen in the process at all:
|
|
1376
|
+
#
|
|
1377
|
+
# layout = Component::Layout::Absolute.new
|
|
1378
|
+
# layout.add(label) # legal with no Screen; neither is attached yet
|
|
1379
|
+
# screen.content = layout # now both are
|
|
1380
|
+
#
|
|
1381
|
+
# _@return_ — true if {#root} is a {ScreenPane}.
|
|
1315
1382
|
def attached?: () -> bool
|
|
1316
1383
|
|
|
1317
1384
|
# Called by container components after `child` has been detached from
|
|
@@ -1324,20 +1391,6 @@ module Tuile
|
|
|
1324
1391
|
# _@param_ `child` — the just-detached child.
|
|
1325
1392
|
def on_child_removed: (Component child) -> void
|
|
1326
1393
|
|
|
1327
|
-
# Called by a child component whose {#content_size} just changed (fired
|
|
1328
|
-
# from the child's {#content_size=}). Does nothing by default — a plain
|
|
1329
|
-
# container is not size-coupled to its children. Containers that derive
|
|
1330
|
-
# their own natural size or child layout from a child's natural size
|
|
1331
|
-
# override this (e.g. {Component::Window} re-lays-out a
|
|
1332
|
-
# {Sizing::WRAP_CONTENT} footer and recomputes its own size from content;
|
|
1333
|
-
# {Component::Popup} re-self-sizes). If the receiver's own
|
|
1334
|
-
# {#content_size} changes as a consequence, its {#content_size=} notifies
|
|
1335
|
-
# *its* parent in turn — so the event bubbles exactly as far as geometry
|
|
1336
|
-
# keeps changing, and stops where it doesn't.
|
|
1337
|
-
#
|
|
1338
|
-
# _@param_ `child` — the resized direct child.
|
|
1339
|
-
def on_child_content_size_changed: (Component child) -> void
|
|
1340
|
-
|
|
1341
1394
|
# Where the hardware terminal cursor should sit when this component is the
|
|
1342
1395
|
# cursor owner. Returns `nil` to indicate the cursor should be hidden. The
|
|
1343
1396
|
# {Screen} positions the hardware cursor after each repaint cycle by
|
|
@@ -1351,17 +1404,85 @@ module Tuile
|
|
|
1351
1404
|
# topmost popup. Empty by default; override to advertise shortcuts.
|
|
1352
1405
|
def keyboard_hint: () -> String
|
|
1353
1406
|
|
|
1354
|
-
#
|
|
1355
|
-
#
|
|
1356
|
-
#
|
|
1357
|
-
#
|
|
1358
|
-
#
|
|
1359
|
-
|
|
1407
|
+
# Adopts `child`: places it in {#children} and wires its parent pointer.
|
|
1408
|
+
#
|
|
1409
|
+
# add_child(@status_bar) # paints last
|
|
1410
|
+
# add_child(popup, at: @children.index(@status_bar)) # …just before it
|
|
1411
|
+
#
|
|
1412
|
+
# _@param_ `child` — must not already have a parent.
|
|
1413
|
+
#
|
|
1414
|
+
# _@param_ `at` — index to insert at; appends when nil.
|
|
1415
|
+
def add_child: (Component child, ?at: Integer?) -> void
|
|
1416
|
+
|
|
1417
|
+
# Drops `child` and notifies {#on_child_removed}.
|
|
1418
|
+
#
|
|
1419
|
+
# _@param_ `child`
|
|
1420
|
+
def remove_child: (Component child) -> void
|
|
1421
|
+
|
|
1422
|
+
# Drops `child` *without* notifying — for a container swapping a named slot,
|
|
1423
|
+
# which owes the {#on_child_removed} call once the new occupant is wired:
|
|
1424
|
+
#
|
|
1425
|
+
# detach_child(old)
|
|
1426
|
+
# @content = new
|
|
1427
|
+
# add_child(new, at: 0)
|
|
1428
|
+
# on_child_removed(old) # focus repair cascades into the *new* content
|
|
1429
|
+
#
|
|
1430
|
+
# The child leaves {#children} before its pointer is cleared, so nothing
|
|
1431
|
+
# observes a child whose parent has disowned it while still listing it.
|
|
1432
|
+
#
|
|
1433
|
+
# _@param_ `child`
|
|
1434
|
+
def detach_child: (Component child) -> void
|
|
1360
1435
|
|
|
1361
|
-
#
|
|
1362
|
-
#
|
|
1363
|
-
#
|
|
1364
|
-
|
|
1436
|
+
# Called once this component's tree has been mounted on a {ScreenPane},
|
|
1437
|
+
# i.e. when {#attached?} flips to true — the place to acquire whatever is
|
|
1438
|
+
# supposed to live for exactly as long as the component is on screen:
|
|
1439
|
+
#
|
|
1440
|
+
# def on_attached
|
|
1441
|
+
# @ticker = screen.event_queue.tick_fps(10) { advance }
|
|
1442
|
+
# end
|
|
1443
|
+
#
|
|
1444
|
+
# def on_detached
|
|
1445
|
+
# @ticker&.cancel
|
|
1446
|
+
# @ticker = nil
|
|
1447
|
+
# end
|
|
1448
|
+
#
|
|
1449
|
+
# `on_attached` starts what `on_detached` stops; both must be cheap and
|
|
1450
|
+
# idempotent, since a component moved between parents is genuinely detached
|
|
1451
|
+
# in between and gets both, in that order. Whatever you acquire here you
|
|
1452
|
+
# must release in {#on_detached} — nothing else will. Not a destructor:
|
|
1453
|
+
# process teardown does *not* fire {#on_detached}.
|
|
1454
|
+
#
|
|
1455
|
+
# {#invalidate} needs no guard: {#attached?} is already true here (and
|
|
1456
|
+
# already false in {#on_detached}, where it no-ops). Do not read {#rect} —
|
|
1457
|
+
# a parent assigns it *after* wiring, so it is still stale. Runs on the
|
|
1458
|
+
# thread that owns the UI.
|
|
1459
|
+
def on_attached: () -> void
|
|
1460
|
+
|
|
1461
|
+
# Mirror of {#on_attached}, called once the tree has been unmounted — see
|
|
1462
|
+
# there for the contract. Two things are still mid-flight when it runs, both
|
|
1463
|
+
# deliberate: {Screen#focused} may still point into this subtree (repair
|
|
1464
|
+
# happens after), and the ex-parent's own bookkeeping may not be finished.
|
|
1465
|
+
# So release resources here and don't inspect the tree around you.
|
|
1466
|
+
def on_detached: () -> void
|
|
1467
|
+
|
|
1468
|
+
# Walks self-then-children calling one lifecycle hook, delivering at most one
|
|
1469
|
+
# call per component per transition however the hooks mutate the tree. Two
|
|
1470
|
+
# guards, because a hook runs *before* its own children are visited:
|
|
1471
|
+
#
|
|
1472
|
+
# - the **snapshot** covers a child a hook *adds* — it isn't in `kids`, and
|
|
1473
|
+
# fires exactly once through its own `parent=`;
|
|
1474
|
+
# - the **state re-check** covers a child a hook *removes*. Matching on
|
|
1475
|
+
# current attachedness rather than on `parent.equal?(self)`: a child pulled
|
|
1476
|
+
# out during a detach walk is *already* detached, so its own `parent=` saw
|
|
1477
|
+
# no transition and stayed silent — a parentage check would skip it too and
|
|
1478
|
+
# it would never hear `on_detached` at all. The reverse case (pulled out
|
|
1479
|
+
# during an *attach* walk) gets `on_detached` from its own `parent=` and no
|
|
1480
|
+
# `on_attached`, which is why the hooks are required to be idempotent: an
|
|
1481
|
+
# unpaired detach releases nothing, whereas firing `on_attached` at a
|
|
1482
|
+
# component that is no longer attached would start a ticker nothing stops.
|
|
1483
|
+
#
|
|
1484
|
+
# _@param_ `attached` — true to fire {#on_attached}, false for {#on_detached}.
|
|
1485
|
+
def fire_lifecycle: (bool attached) -> void
|
|
1365
1486
|
|
|
1366
1487
|
# Called whenever the component width changes. Does nothing by default.
|
|
1367
1488
|
def on_width_changed: () -> void
|
|
@@ -1385,60 +1506,93 @@ module Tuile
|
|
|
1385
1506
|
# Children with empty rects contribute zero, since they paint nothing.
|
|
1386
1507
|
def children_tile_rect?: () -> bool
|
|
1387
1508
|
|
|
1388
|
-
# Clears the background:
|
|
1389
|
-
#
|
|
1390
|
-
|
|
1509
|
+
# Clears the background: fills every cell with a blank in the
|
|
1510
|
+
# {#effective_bg_color} (the terminal default when none is inherited).
|
|
1511
|
+
#
|
|
1512
|
+
# A component that paints part of its {#rect} itself passes just the part it
|
|
1513
|
+
# *doesn't* — blanking a cell it is about to overwrite anyway makes that cell
|
|
1514
|
+
# dirty, and {Buffer#flush} then re-emits it even though nothing visibly
|
|
1515
|
+
# changed.
|
|
1516
|
+
#
|
|
1517
|
+
# _@param_ `area` — the region to blank; defaults to the whole {#rect}.
|
|
1518
|
+
def clear_background: (?Rect area) -> void
|
|
1519
|
+
|
|
1520
|
+
# {Buffer#set_line} wrapper that fills {#effective_bg_color} behind any span
|
|
1521
|
+
# with no bg of its own (via {StyledString#under_bg}), so an inherited
|
|
1522
|
+
# {#bg_color} shows through the content a component paints. A no-op layer
|
|
1523
|
+
# when nothing is inherited. Self-painters (those skipping the {#repaint}
|
|
1524
|
+
# auto-clear) paint through this instead of {Screen#buffer} directly.
|
|
1525
|
+
#
|
|
1526
|
+
# _@param_ `x` — starting column.
|
|
1527
|
+
#
|
|
1528
|
+
# _@param_ `y` — row.
|
|
1529
|
+
#
|
|
1530
|
+
# _@param_ `styled`
|
|
1531
|
+
def draw_line: (Integer x, Integer y, StyledString styled) -> void
|
|
1532
|
+
|
|
1533
|
+
# {#draw_line}'s single-grapheme counterpart: writes `grapheme` at `(x, y)`,
|
|
1534
|
+
# filling {#effective_bg_color} when `style` carries no bg of its own.
|
|
1535
|
+
#
|
|
1536
|
+
# _@param_ `x` — column.
|
|
1537
|
+
#
|
|
1538
|
+
# _@param_ `y` — row.
|
|
1539
|
+
#
|
|
1540
|
+
# _@param_ `grapheme` — one grapheme cluster.
|
|
1541
|
+
#
|
|
1542
|
+
# _@param_ `style`
|
|
1543
|
+
def draw_char: (
|
|
1544
|
+
Integer x,
|
|
1545
|
+
Integer y,
|
|
1546
|
+
String grapheme,
|
|
1547
|
+
?StyledString::Style style
|
|
1548
|
+
) -> void
|
|
1391
1549
|
|
|
1392
1550
|
# _@return_ — the rectangle the component occupies on screen.
|
|
1393
1551
|
attr_accessor rect: Rect
|
|
1394
1552
|
|
|
1395
|
-
#
|
|
1396
|
-
#
|
|
1397
|
-
#
|
|
1398
|
-
|
|
1553
|
+
# _@return_ — this component's own background — the
|
|
1554
|
+
# value as set, so a {Theme::Ref} comes back unresolved; `nil` when unset,
|
|
1555
|
+
# in which case it inherits from the parent (see {#effective_bg_color}),
|
|
1556
|
+
# ultimately the terminal default. {#effective_bg_color} is the resolved
|
|
1557
|
+
# {Color} to paint.
|
|
1558
|
+
attr_accessor bg_color: (Color | Theme::Ref | Symbol | Integer | ::Array[Integer])?
|
|
1399
1559
|
|
|
1400
1560
|
# _@return_ — the parent component or nil if the component has
|
|
1401
1561
|
# no parent.
|
|
1402
1562
|
attr_accessor parent: Component?
|
|
1403
1563
|
|
|
1564
|
+
# Child components in paint order (siblings left to right, earlier ones
|
|
1565
|
+
# painted under later ones), maintained by {#add_child} / {#remove_child}.
|
|
1566
|
+
#
|
|
1567
|
+
# Not meant to be overridden: a container that computed this from its own
|
|
1568
|
+
# slots could disagree with the parent pointers, and {#attached?} walks the
|
|
1569
|
+
# chain while subtree walks use this list. Named slots are readers *over*
|
|
1570
|
+
# the array (`Window#footer`), never a second copy of it.
|
|
1571
|
+
#
|
|
1572
|
+
# _@return_ — child components. Must not be mutated by
|
|
1573
|
+
# callers! May be empty.
|
|
1574
|
+
attr_reader children: ::Array[Component]
|
|
1575
|
+
|
|
1404
1576
|
# Called on every attached component (pre-order, popups included) when
|
|
1405
|
-
# {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=}
|
|
1406
|
-
#
|
|
1407
|
-
#
|
|
1408
|
-
#
|
|
1409
|
-
#
|
|
1410
|
-
#
|
|
1411
|
-
#
|
|
1412
|
-
#
|
|
1413
|
-
#
|
|
1414
|
-
# them here, re-running the same code that rendered them initially.
|
|
1415
|
-
#
|
|
1416
|
-
# Runs on the UI thread; {Screen#theme} already returns the new theme.
|
|
1417
|
-
# Mutating content (`text=`, `lines=`, …) is safe — repaint coalesces per
|
|
1418
|
-
# event-loop tick. Do not assign {Screen#theme=} from inside the hook.
|
|
1419
|
-
#
|
|
1420
|
-
# Subclasses overriding this should call `super` so an assigned
|
|
1577
|
+
# {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=} and on
|
|
1578
|
+
# OS appearance flips. The hook exists for app *content* whose colors were
|
|
1579
|
+
# baked in from the old theme (a {Label#text} / {List#lines} {StyledString}
|
|
1580
|
+
# styled with `theme[:accent]`); rebuild it here by re-running the code that
|
|
1581
|
+
# rendered it. See book ch6 for why built-in accents need no such handling.
|
|
1582
|
+
#
|
|
1583
|
+
# Runs on the UI thread with {Screen#theme} already updated, so mutating
|
|
1584
|
+
# content (`text=`, `lines=`, …) is safe. Do not assign {Screen#theme=}
|
|
1585
|
+
# here. Subclasses overriding this must call `super` so an assigned
|
|
1421
1586
|
# {#on_theme_changed=} listener keeps firing.
|
|
1422
1587
|
attr_accessor on_theme_changed: Proc?
|
|
1423
1588
|
|
|
1424
|
-
# The {Size} big enough to show the entire component contents without
|
|
1425
|
-
# scrolling. Plain components have no intrinsic content and report
|
|
1426
|
-
# {Size::ZERO}; content-bearing components (e.g. {Label}, {List},
|
|
1427
|
-
# {TextView}, {Window}) maintain it eagerly via {#content_size=} from
|
|
1428
|
-
# their mutators, so reads are O(1). Used by callers like
|
|
1429
|
-
# {Component::Popup} to auto-size to whatever content was assigned,
|
|
1430
|
-
# regardless of its concrete type, and by {Sizing::WRAP_CONTENT} slots.
|
|
1431
|
-
attr_accessor content_size: Size
|
|
1432
|
-
|
|
1433
1589
|
# A scrollable list of items with cursor support.
|
|
1434
1590
|
#
|
|
1435
1591
|
# Items are modeled as {StyledString}s and painted directly into the
|
|
1436
1592
|
# component's {#rect}. Lines wider than the viewport are ellipsized via
|
|
1437
|
-
# {StyledString#ellipsize}
|
|
1438
|
-
#
|
|
1439
|
-
#
|
|
1440
|
-
# via {#top_line}; the list can also automatically scroll to the bottom
|
|
1441
|
-
# if {#auto_scroll} is enabled.
|
|
1593
|
+
# {StyledString#ellipsize} with span styles preserved across the cut.
|
|
1594
|
+
# Vertical scrolling is via {#top_line}; enable {#auto_scroll} to keep the
|
|
1595
|
+
# bottom in view.
|
|
1442
1596
|
#
|
|
1443
1597
|
# Cursor is supported; call {#cursor=} to change cursor behavior. The
|
|
1444
1598
|
# cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the
|
|
@@ -1528,7 +1682,10 @@ module Tuile
|
|
|
1528
1682
|
# Skips the {Component#repaint} default's auto-clear: every row of
|
|
1529
1683
|
# {#rect} is painted below (with blank padding past the last item),
|
|
1530
1684
|
# so the parent contract — "fully draw over your rect" — is met
|
|
1531
|
-
# without an upfront wipe.
|
|
1685
|
+
# without an upfront wipe. Rows go through {Component#draw_line}, so
|
|
1686
|
+
# content *and* blank filler inherit {Component#effective_bg_color}
|
|
1687
|
+
# (a {#bg_color} set here or on an ancestor); the cursor row's
|
|
1688
|
+
# {Theme#active_bg_color} highlight composes on top of it.
|
|
1532
1689
|
def repaint: () -> void
|
|
1533
1690
|
|
|
1534
1691
|
# Rebuilds pre-padded lines when the wrap width changes. The wrap width
|
|
@@ -1540,19 +1697,6 @@ module Tuile
|
|
|
1540
1697
|
# is one, so the list snaps to the bottom on first paint.
|
|
1541
1698
|
def on_width_changed: () -> void
|
|
1542
1699
|
|
|
1543
|
-
# Natural size from scratch: longest line's display width plus the two
|
|
1544
|
-
# single-space gutters {#pad_to_row} adds, × line count. An empty list
|
|
1545
|
-
# is {Size::ZERO} (no gutters for no content).
|
|
1546
|
-
def compute_content_size: () -> Size
|
|
1547
|
-
|
|
1548
|
-
# Incremental {#content_size} update for appends: folds just the
|
|
1549
|
-
# appended lines into the running maximum, keeping {#add_lines}
|
|
1550
|
-
# O(appended) instead of re-scanning the whole list (LogWindow appends
|
|
1551
|
-
# a line per log statement).
|
|
1552
|
-
#
|
|
1553
|
-
# _@param_ `appended` — the just-appended lines (already concatenated onto {@lines}).
|
|
1554
|
-
def grow_content_size: (::Array[StyledString] appended) -> void
|
|
1555
|
-
|
|
1556
1700
|
# Coerces and flattens a list of input entries into trimmed
|
|
1557
1701
|
# {StyledString} lines. Each entry becomes a {StyledString} (String
|
|
1558
1702
|
# via {StyledString.parse}, StyledString passed through, anything else
|
|
@@ -1850,23 +1994,20 @@ module Tuile
|
|
|
1850
1994
|
# embedded ANSI is honored) or a {StyledString} directly. {#text}
|
|
1851
1995
|
# always returns the {StyledString}.
|
|
1852
1996
|
class Label < Component
|
|
1853
|
-
|
|
1997
|
+
# _@param_ `text` — initial text, coerced the same way {#text=} coerces it (a `String` is parsed via {StyledString.parse}; `nil` is an empty label). Equivalent to constructing empty and assigning {#text=}.
|
|
1998
|
+
def initialize: (?(String | StyledString)? text) -> void
|
|
1854
1999
|
|
|
1855
2000
|
# Paints the text into {#rect}.
|
|
1856
2001
|
#
|
|
1857
2002
|
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
1858
2003
|
# painted explicitly (with pre-padded blanks past the last line), so
|
|
1859
2004
|
# the "fully draw over your rect" contract is met without an upfront
|
|
1860
|
-
# wipe.
|
|
2005
|
+
# wipe. Rows go through {Component#draw_line}, so the padding and blank
|
|
2006
|
+
# rows inherit {Component#effective_bg_color} when {#bg} is unset.
|
|
1861
2007
|
def repaint: () -> void
|
|
1862
2008
|
|
|
1863
2009
|
def on_width_changed: () -> void
|
|
1864
2010
|
|
|
1865
|
-
# Natural size: longest hard-line's display width × number of hard
|
|
1866
|
-
# lines. Computed on the *unclipped* text — sizing is intrinsic to the
|
|
1867
|
-
# content, not the viewport. Empty text yields {Size::ZERO}.
|
|
1868
|
-
def compute_content_size: () -> Size
|
|
1869
|
-
|
|
1870
2011
|
# Recomputes {@clipped_lines} for the current text and rect width.
|
|
1871
2012
|
# Each line is ellipsized to fit and padded with trailing spaces out to
|
|
1872
2013
|
# the full width, so {#repaint} is just a lookup + {Buffer#set_line} per
|
|
@@ -1887,26 +2028,35 @@ module Tuile
|
|
|
1887
2028
|
# {StyledString}.
|
|
1888
2029
|
attr_accessor text: (StyledString | String)?
|
|
1889
2030
|
|
|
1890
|
-
# _@return_ — background
|
|
1891
|
-
#
|
|
1892
|
-
#
|
|
2031
|
+
# _@return_ — a local background laid over *every* span and the
|
|
2032
|
+
# row padding (via {StyledString#with_bg}), overriding the text's own
|
|
2033
|
+
# span bgs — stronger than the inherited {#bg_color}. `nil` (default)
|
|
2034
|
+
# keeps each span's bg and lets the inherited {#effective_bg_color}
|
|
2035
|
+
# fill the rest.
|
|
1893
2036
|
attr_accessor bg: (Color | Symbol | Integer | ::Array[Integer])?
|
|
1894
2037
|
end
|
|
1895
2038
|
|
|
1896
2039
|
# An overlay that wraps any {Component} as its content. Popup itself
|
|
1897
2040
|
# paints nothing — it's a transparent host that handles its lifecycle
|
|
1898
|
-
# ({#open} / {#close} / {#open?}, ESC/q to close) and
|
|
1899
|
-
#
|
|
2041
|
+
# ({#open} / {#close} / {#open?}, ESC/q to close) and holds a top-down
|
|
2042
|
+
# {#size} the {Screen} applies.
|
|
2043
|
+
#
|
|
2044
|
+
# The popup does *not* size itself to its content. Its box is declared by
|
|
2045
|
+
# {#size} — a {Fraction} (resolved against the screen every layout pass, so
|
|
2046
|
+
# it tracks resize) or an absolute {Size} (clamped to the screen). The
|
|
2047
|
+
# default is {Fraction::HALF}: half the screen, centered. The wrapped
|
|
2048
|
+
# content then fills that box and handles its own overflow by wrapping and
|
|
2049
|
+
# scrolling, so use content that can — a {Component::TextView} or
|
|
2050
|
+
# {Component::TextArea} — for anything longer than fits. A
|
|
2051
|
+
# {Component::Label} only truncates.
|
|
1900
2052
|
#
|
|
1901
2053
|
# Modal by default: it centers on the screen, grabs focus, eats keys, and
|
|
1902
2054
|
# blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
|
|
1903
|
-
# that floats above the content
|
|
1904
|
-
#
|
|
1905
|
-
#
|
|
1906
|
-
#
|
|
1907
|
-
#
|
|
1908
|
-
# input, an {Component::TextInput#on_change} listener refills the list, and
|
|
1909
|
-
# an {Component::TextInput#on_key} interceptor forwards Up/Down/Enter to it.
|
|
2055
|
+
# that floats above the content without taking focus or capturing input —
|
|
2056
|
+
# the caller positions it (via {#rect=}), sizes it, and drives it from app
|
|
2057
|
+
# code. That's the building block for an autocomplete/slash-command list
|
|
2058
|
+
# anchored to a text field's caret: typing keeps focus in the input while
|
|
2059
|
+
# the caller refills and drives the overlay.
|
|
1910
2060
|
#
|
|
1911
2061
|
# The wrapped content fills the popup's full {#rect}; if you want a frame
|
|
1912
2062
|
# and caption, wrap a {Component::Window} (or any subclass — including
|
|
@@ -1919,17 +2069,20 @@ module Tuile
|
|
|
1919
2069
|
# Bare content also works (a {Component::Label}, a {Component::List}…), in
|
|
1920
2070
|
# which case the popup is borderless.
|
|
1921
2071
|
#
|
|
1922
|
-
# `q` and ESC close the popup
|
|
1923
|
-
# the
|
|
1924
|
-
#
|
|
1925
|
-
#
|
|
2072
|
+
# `q` and ESC close the popup — handled here, at the top of the popup's own
|
|
2073
|
+
# subtree, so the key only arrives after every component on the focus chain
|
|
2074
|
+
# declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
|
|
2075
|
+
# nested {Component::TextField} doesn't dismiss the popup: the field
|
|
2076
|
+
# consumes it first.
|
|
1926
2077
|
class Popup < Component
|
|
1927
2078
|
include Tuile::Component::HasContent
|
|
1928
2079
|
|
|
1929
|
-
# _@param_ `content` — initial content; can be set later via {#content=}.
|
|
2080
|
+
# _@param_ `content` — initial content; can be set later via {#content=}. The content fills the popup's {#rect}; it does not determine the popup's size.
|
|
1930
2081
|
#
|
|
1931
2082
|
# _@param_ `modal` — true (default) for a centered, focus-grabbing, input-capturing modal; false for a non-modal overlay the caller positions and drives (see the class docs).
|
|
1932
|
-
|
|
2083
|
+
#
|
|
2084
|
+
# _@param_ `size` — the popup's size, applied top-down. A {Fraction} is resolved against the screen each layout pass; a {Size} is clamped to the screen. Defaults to {Fraction::HALF}.
|
|
2085
|
+
def initialize: (?content: Component?, ?modal: bool, ?size: (Size | Fraction)) -> void
|
|
1933
2086
|
|
|
1934
2087
|
# _@return_ — whether this popup is modal. See {#initialize}.
|
|
1935
2088
|
def modal?: () -> bool
|
|
@@ -1947,17 +2100,20 @@ module Tuile
|
|
|
1947
2100
|
# _@param_ `new_rect`
|
|
1948
2101
|
def rect=: (Rect new_rect) -> void
|
|
1949
2102
|
|
|
1950
|
-
# Mounts this popup on the {Screen}
|
|
1951
|
-
#
|
|
1952
|
-
# grown or shrunk while closed picks up the new size.
|
|
2103
|
+
# Mounts this popup on the {Screen}, re-resolving its {#size} against the
|
|
2104
|
+
# current screen first.
|
|
1953
2105
|
def open: () -> void
|
|
1954
2106
|
|
|
1955
2107
|
# Constructs and opens a popup in one call.
|
|
1956
2108
|
#
|
|
1957
2109
|
# _@param_ `content`
|
|
1958
2110
|
#
|
|
2111
|
+
# _@param_ `modal` — see {#initialize}.
|
|
2112
|
+
#
|
|
2113
|
+
# _@param_ `size` — see {#initialize}.
|
|
2114
|
+
#
|
|
1959
2115
|
# _@return_ — the opened popup.
|
|
1960
|
-
def self.open: (?content: Component?) -> Popup
|
|
2116
|
+
def self.open: (?content: Component?, ?modal: bool, ?size: (Size | Fraction)) -> Popup
|
|
1961
2117
|
|
|
1962
2118
|
# Removes this popup from the {Screen}. No-op if not currently open.
|
|
1963
2119
|
def close: () -> void
|
|
@@ -1965,38 +2121,21 @@ module Tuile
|
|
|
1965
2121
|
# _@return_ — true if this popup is currently mounted on the screen.
|
|
1966
2122
|
def open?: () -> bool
|
|
1967
2123
|
|
|
1968
|
-
#
|
|
1969
|
-
#
|
|
1970
|
-
#
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
#
|
|
1974
|
-
# Defers to the content's {Component#popup_max_height} advice when it
|
|
1975
|
-
# gives one, else defaults to 12. Override in a subclass to allow
|
|
1976
|
-
# taller popups regardless of content.
|
|
1977
|
-
def max_height: () -> Integer
|
|
1978
|
-
|
|
1979
|
-
# _@return_ — min height the popup occupies even when its content
|
|
1980
|
-
# is shorter. Defers to the content's {Component#popup_min_height}
|
|
1981
|
-
# advice when it gives one, else defaults to 0 (size purely to
|
|
1982
|
-
# content) — so a {Component::LogWindow} stays readable while only a
|
|
1983
|
-
# few lines are in without callers wiring up a subclass. Override in a
|
|
1984
|
-
# subclass to keep any popup from collapsing to a couple of rows.
|
|
1985
|
-
# Capped at the same 4/5-of-screen ceiling {#update_rect} applies.
|
|
1986
|
-
def min_height: () -> Integer
|
|
1987
|
-
|
|
1988
|
-
# Sets the popup's content and auto-sizes the popup to fit.
|
|
2124
|
+
# Re-resolves {#size} against the current screen and repositions the popup
|
|
2125
|
+
# *itself* (this is not laying out content — the popup's own rect): a
|
|
2126
|
+
# modal popup recenters; a non-modal overlay keeps its caller-assigned
|
|
2127
|
+
# top-left (only its size follows the screen). Called on {#open}, on
|
|
2128
|
+
# {#size=}, and by the screen's layout pass (so a {Fraction} size tracks
|
|
2129
|
+
# SIGWINCH).
|
|
1989
2130
|
#
|
|
1990
|
-
#
|
|
1991
|
-
|
|
2131
|
+
# The final rect is computed and assigned in one step rather than sizing
|
|
2132
|
+
# at the origin and then centering: the intermediate origin rect rarely
|
|
2133
|
+
# covers the previous one, which would make {#rect=}'s shrink/move
|
|
2134
|
+
# detection fire a full repaint on every resize.
|
|
2135
|
+
def reposition: () -> void
|
|
1992
2136
|
|
|
1993
|
-
#
|
|
1994
|
-
|
|
1995
|
-
# `add_line`, or a nested {Window} whose own content grew (the window
|
|
1996
|
-
# recomputes its {Component#content_size} and the change bubbles here).
|
|
1997
|
-
#
|
|
1998
|
-
# _@param_ `_child`
|
|
1999
|
-
def on_child_content_size_changed: (Component _child) -> void
|
|
2137
|
+
# Recenters the popup on the screen, preserving its current width/height.
|
|
2138
|
+
def center: () -> void
|
|
2000
2139
|
|
|
2001
2140
|
# Hint for the status bar: own "q Close" plus the wrapped content's hint.
|
|
2002
2141
|
def keyboard_hint: () -> String
|
|
@@ -2015,21 +2154,13 @@ module Tuile
|
|
|
2015
2154
|
# _@param_ `content`
|
|
2016
2155
|
def layout: (Component content) -> void
|
|
2017
2156
|
|
|
2018
|
-
# Recompute width/height from {#content}'s natural size and recenter
|
|
2019
|
-
# if currently open. Called whenever content is (re)assigned.
|
|
2020
|
-
#
|
|
2021
|
-
# Computes the final (centered) rect and assigns it in one step rather
|
|
2022
|
-
# than positioning at the origin and then centering: the intermediate
|
|
2023
|
-
# origin rect rarely covers the previous one, which would make
|
|
2024
|
-
# {#rect=}'s shrink/move detection fire a full repaint on every resize.
|
|
2025
|
-
def update_rect: () -> void
|
|
2026
|
-
|
|
2027
2157
|
# _@param_ `event`
|
|
2028
2158
|
def handle_mouse: (MouseEvent event) -> void
|
|
2029
2159
|
|
|
2030
|
-
def children: () -> ::Array[Component]
|
|
2031
|
-
|
|
2032
2160
|
def on_focus: () -> void
|
|
2161
|
+
|
|
2162
|
+
# _@return_ — the popup's declared size. See {#size=}.
|
|
2163
|
+
attr_accessor size: (Size | Fraction)
|
|
2033
2164
|
end
|
|
2034
2165
|
|
|
2035
2166
|
# A clickable button. Activated by Enter, Space, or a left mouse click;
|
|
@@ -2042,10 +2173,14 @@ module Tuile
|
|
|
2042
2173
|
# {Component#handle_mouse}.
|
|
2043
2174
|
#
|
|
2044
2175
|
# Assign a {#rect} (typically by the surrounding {Layout}) wide enough to
|
|
2045
|
-
# show `[ caption ]
|
|
2176
|
+
# show `[ caption ]` — that natural width is `caption.display_width + 4`.
|
|
2177
|
+
# A narrower {#rect} truncates the label with an ellipsis; a wider one leaves
|
|
2178
|
+
# a tail that focuses but doesn't activate (see {#extent}).
|
|
2046
2179
|
class Button < Component
|
|
2047
|
-
|
|
2048
|
-
|
|
2180
|
+
include Tuile::Component::HasCaption
|
|
2181
|
+
|
|
2182
|
+
# _@param_ `caption` — the button's label, coerced the same way {HasCaption#caption=} coerces it.
|
|
2183
|
+
def initialize: (?(String | StyledString)? caption) -> void
|
|
2049
2184
|
|
|
2050
2185
|
def focusable?: () -> bool
|
|
2051
2186
|
|
|
@@ -2054,16 +2189,35 @@ module Tuile
|
|
|
2054
2189
|
# _@param_ `key`
|
|
2055
2190
|
def handle_key: (String key) -> bool
|
|
2056
2191
|
|
|
2192
|
+
# The cells the button actually paints: one row, `caption.display_width + 4`
|
|
2193
|
+
# columns, clipped to {#rect}. Both the focus highlight and the click hit
|
|
2194
|
+
# test use it, so a click on the blank tail of an over-wide rect — or on a
|
|
2195
|
+
# lower row, when the rect is taller than one — does not fire {#on_click}.
|
|
2196
|
+
# It still *focuses*: {Component#handle_mouse}'s click-to-focus is ungated
|
|
2197
|
+
# by geometry. Same rule as {Checkbox#extent}, which documents the two
|
|
2198
|
+
# traps behind it.
|
|
2199
|
+
def extent: () -> Rect
|
|
2200
|
+
|
|
2201
|
+
# Fires {#on_click} on a left click within {#extent}; `super` runs first, so
|
|
2202
|
+
# a click anywhere in {#rect} still focuses.
|
|
2203
|
+
#
|
|
2057
2204
|
# _@param_ `event`
|
|
2058
2205
|
def handle_mouse: (MouseEvent event) -> void
|
|
2059
2206
|
|
|
2060
2207
|
def repaint: () -> void
|
|
2061
2208
|
|
|
2062
|
-
#
|
|
2063
|
-
|
|
2209
|
+
# Read through *this* method, never `@caption` — the ivar stays nil until
|
|
2210
|
+
# the first non-empty set ({#caption=} short-circuits when unchanged).
|
|
2211
|
+
#
|
|
2212
|
+
# _@return_ — the caption; empty when never set.
|
|
2213
|
+
def caption: () -> StyledString
|
|
2064
2214
|
|
|
2065
|
-
#
|
|
2066
|
-
|
|
2215
|
+
# Sets the caption and invalidates the component. No-op when unchanged. A
|
|
2216
|
+
# `String` is parsed via {StyledString.parse} (embedded ANSI is honored);
|
|
2217
|
+
# a {StyledString} is used as-is; `nil` clears it.
|
|
2218
|
+
#
|
|
2219
|
+
# _@param_ `new_caption`
|
|
2220
|
+
def caption=: ((String | StyledString)? new_caption) -> void
|
|
2067
2221
|
|
|
2068
2222
|
# Callback fired when the button is activated (Enter, Space, or
|
|
2069
2223
|
# left-click). The callable receives no arguments.
|
|
@@ -2081,10 +2235,6 @@ module Tuile
|
|
|
2081
2235
|
# the background is cleared and children are re-invalidated so they
|
|
2082
2236
|
# paint over a clean surface.
|
|
2083
2237
|
class Layout < Component
|
|
2084
|
-
def initialize: () -> void
|
|
2085
|
-
|
|
2086
|
-
def children: () -> ::Array[Component]
|
|
2087
|
-
|
|
2088
2238
|
# Layouts are focusable containers — like {Window} and {Popup}, they
|
|
2089
2239
|
# don't accept input themselves but they need to participate in the
|
|
2090
2240
|
# {HasContent} focus cascade so a Popup wrapping a Layout wrapping a
|
|
@@ -2103,8 +2253,6 @@ module Tuile
|
|
|
2103
2253
|
# _@param_ `child`
|
|
2104
2254
|
def remove: (Component child) -> void
|
|
2105
2255
|
|
|
2106
|
-
def content_size: () -> Size
|
|
2107
|
-
|
|
2108
2256
|
# Dispatches the event to the child under the mouse cursor.
|
|
2109
2257
|
#
|
|
2110
2258
|
# _@param_ `event`
|
|
@@ -2129,14 +2277,13 @@ module Tuile
|
|
|
2129
2277
|
# by {Component#invalidate}; subclasses don't need to re-check.)
|
|
2130
2278
|
class Window < Component
|
|
2131
2279
|
include Tuile::Component::HasContent
|
|
2280
|
+
include Tuile::Component::HasCaption
|
|
2132
2281
|
|
|
2133
|
-
# _@param_ `caption`
|
|
2134
|
-
def initialize: (?String caption) -> void
|
|
2282
|
+
# _@param_ `caption` — the border title, coerced the same way {HasCaption#caption=} coerces it.
|
|
2283
|
+
def initialize: (?(String | StyledString)? caption) -> void
|
|
2135
2284
|
|
|
2136
2285
|
def focusable?: () -> bool
|
|
2137
2286
|
|
|
2138
|
-
def children: () -> ::Array[Component]
|
|
2139
|
-
|
|
2140
2287
|
# _@param_ `event`
|
|
2141
2288
|
def handle_mouse: (MouseEvent event) -> void
|
|
2142
2289
|
|
|
@@ -2146,22 +2293,6 @@ module Tuile
|
|
|
2146
2293
|
# _@param_ `value`
|
|
2147
2294
|
def scrollbar=: (bool value) -> void
|
|
2148
2295
|
|
|
2149
|
-
# Sets the new content. Also recomputes the window's natural size.
|
|
2150
|
-
#
|
|
2151
|
-
# _@param_ `new_content`
|
|
2152
|
-
def content=: (Component? new_content) -> void
|
|
2153
|
-
|
|
2154
|
-
# Re-lays-out a {Sizing::WRAP_CONTENT} footer when the footer's natural
|
|
2155
|
-
# size changes, and folds a content resize into the window's own
|
|
2156
|
-
# natural size (whose change then bubbles to the window's parent — e.g.
|
|
2157
|
-
# a {Popup} re-self-sizes). The footer deliberately does *not*
|
|
2158
|
-
# participate in the window's {#content_size}: it is decoration
|
|
2159
|
-
# overlaying the border, and must not drive the window's size — if it
|
|
2160
|
-
# doesn't fit, it is clipped to the inner width.
|
|
2161
|
-
#
|
|
2162
|
-
# _@param_ `child`
|
|
2163
|
-
def on_child_content_size_changed: (Component child) -> void
|
|
2164
|
-
|
|
2165
2296
|
# Fully repaints the window: both frame and contents.
|
|
2166
2297
|
#
|
|
2167
2298
|
# Window deliberately paints over its entire rect (border around the
|
|
@@ -2174,167 +2305,599 @@ module Tuile
|
|
|
2174
2305
|
# cycle.
|
|
2175
2306
|
def repaint: () -> void
|
|
2176
2307
|
|
|
2177
|
-
# _@param_ `key`
|
|
2178
|
-
def key_shortcut=: (String? key) -> void
|
|
2179
|
-
|
|
2180
2308
|
# _@param_ `content`
|
|
2181
2309
|
def layout: (Component content) -> void
|
|
2182
2310
|
|
|
2183
|
-
# Paints the window border
|
|
2184
|
-
#
|
|
2185
|
-
#
|
|
2311
|
+
# Paints the window border via {Component#draw_line}/{Component#draw_char},
|
|
2312
|
+
# so the border cells inherit {Component#effective_bg_color} — a
|
|
2313
|
+
# {Component#bg_color} on the window tints border and content alike. Both
|
|
2314
|
+
# border lines are clipped by *display* width, so no caption overflows the
|
|
2315
|
+
# box; when the window is active the whole border — the caption's own
|
|
2316
|
+
# colors included — is drawn in {Theme#active_border_color}.
|
|
2186
2317
|
def repaint_border: () -> void
|
|
2187
2318
|
|
|
2188
|
-
#
|
|
2189
|
-
#
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
#
|
|
2193
|
-
#
|
|
2194
|
-
#
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
#
|
|
2200
|
-
#
|
|
2201
|
-
#
|
|
2202
|
-
#
|
|
2319
|
+
# Builds the top border line: corners, {#caption} embedded at its own
|
|
2320
|
+
# width, dashes filling the remainder. The caption keeps its own styling
|
|
2321
|
+
# unless `fg` is set — an active window's border claims it.
|
|
2322
|
+
#
|
|
2323
|
+
# _@param_ `inner_w` — the border's interior width.
|
|
2324
|
+
#
|
|
2325
|
+
# _@param_ `fg` — the active-border color, or nil when inactive.
|
|
2326
|
+
def top_border: (Integer inner_w, Color? fg) -> StyledString
|
|
2327
|
+
|
|
2328
|
+
# Builds the bottom border line. The corners take the border color; the
|
|
2329
|
+
# interior is plain dashes when a {#footer} component occupies the row
|
|
2330
|
+
# (it overpaints them) or when there's no chrome, otherwise it carries
|
|
2331
|
+
# {#footer_text} embedded at its own width — keeping the text's own
|
|
2332
|
+
# styling — with dashes filling the remainder up to the inner width.
|
|
2333
|
+
#
|
|
2334
|
+
# _@param_ `inner_w` — the border's interior width.
|
|
2335
|
+
#
|
|
2336
|
+
# _@param_ `fg` — the active-border color, or nil when inactive.
|
|
2337
|
+
def bottom_border: (Integer inner_w, Color? fg) -> StyledString
|
|
2338
|
+
|
|
2339
|
+
# Positions the footer over the bottom border row, spanning the full
|
|
2340
|
+
# inner width (the only dimension a bottom-row widget needs — the window
|
|
2341
|
+
# already knows it).
|
|
2203
2342
|
def layout_footer: () -> void
|
|
2204
2343
|
|
|
2344
|
+
# Read through *this* method, never `@caption` — the ivar stays nil until
|
|
2345
|
+
# the first non-empty set ({#caption=} short-circuits when unchanged).
|
|
2346
|
+
#
|
|
2347
|
+
# _@return_ — the caption; empty when never set.
|
|
2348
|
+
def caption: () -> StyledString
|
|
2349
|
+
|
|
2350
|
+
# Sets the caption and invalidates the component. No-op when unchanged. A
|
|
2351
|
+
# `String` is parsed via {StyledString.parse} (embedded ANSI is honored);
|
|
2352
|
+
# a {StyledString} is used as-is; `nil` clears it.
|
|
2353
|
+
#
|
|
2354
|
+
# _@param_ `new_caption`
|
|
2355
|
+
def caption=: ((String | StyledString)? new_caption) -> void
|
|
2356
|
+
|
|
2205
2357
|
def on_focus: () -> void
|
|
2206
2358
|
|
|
2207
|
-
# _@return_ — optional component
|
|
2208
|
-
# row.
|
|
2359
|
+
# _@return_ — optional focusable component occupying the
|
|
2360
|
+
# bottom border row, always spanning the full inner width.
|
|
2209
2361
|
attr_accessor footer: Component?
|
|
2210
2362
|
|
|
2211
|
-
# _@return_ —
|
|
2212
|
-
#
|
|
2213
|
-
#
|
|
2214
|
-
attr_accessor
|
|
2215
|
-
|
|
2216
|
-
# _@return_ — the current caption, empty by default.
|
|
2217
|
-
attr_accessor caption: String
|
|
2363
|
+
# _@return_ — optional chrome embedded into the bottom border
|
|
2364
|
+
# line, mirroring {#caption} on the top line. Empty by default; hidden
|
|
2365
|
+
# whenever a {#footer} component is present.
|
|
2366
|
+
attr_accessor footer_text: (StyledString | String)?
|
|
2218
2367
|
end
|
|
2219
2368
|
|
|
2220
|
-
# A
|
|
2369
|
+
# A boolean input on one row. Space or a left click toggles it:
|
|
2221
2370
|
#
|
|
2222
|
-
#
|
|
2223
|
-
#
|
|
2224
|
-
# doesn't fit vertically is reached by scrolling: {#top_display_row}
|
|
2225
|
-
# follows the caret so the line being edited stays visible. There is no
|
|
2226
|
-
# horizontal scrolling.
|
|
2371
|
+
# [x] Enable syslog forwarding
|
|
2372
|
+
# [ ] Enable syslog forwarding
|
|
2227
2373
|
#
|
|
2228
|
-
#
|
|
2229
|
-
#
|
|
2230
|
-
#
|
|
2231
|
-
#
|
|
2374
|
+
# cb = Component::Checkbox.new("Enable syslog forwarding", value: true)
|
|
2375
|
+
# cb.on_value_change = ->(on) { config.syslog = on }
|
|
2376
|
+
# cb.toggle # unchecks it, firing the listener with false
|
|
2377
|
+
# cb.checked? # => false
|
|
2232
2378
|
#
|
|
2233
|
-
#
|
|
2234
|
-
#
|
|
2235
|
-
#
|
|
2236
|
-
|
|
2237
|
-
|
|
2379
|
+
# {#value} is the canonical seam ({HasValue}), always `true`/`false` and
|
|
2380
|
+
# never `nil`; {#checked?} / {#checked=} / {#toggle} are the domain-word face
|
|
2381
|
+
# over it — one piece of state, four names. Unchecked is the
|
|
2382
|
+
# {#empty_value}, so a fresh checkbox is {HasValue#empty? empty} and
|
|
2383
|
+
# {HasValue#clear} unchecks.
|
|
2384
|
+
#
|
|
2385
|
+
# Space toggles. Enter is unhandled — unlike {Button} — simply because a
|
|
2386
|
+
# checkbox has no default action to confirm, so it bubbles to an ancestor;
|
|
2387
|
+
# treat that as this widget declining a key, not as a guarantee the framework
|
|
2388
|
+
# makes (a {TextArea} claims Enter for newline, and a checkable row in a
|
|
2389
|
+
# {Component::List} toggles on it).
|
|
2390
|
+
#
|
|
2391
|
+
# A tab stop, so Tab lands on it, and the widget highlights while on the focus
|
|
2392
|
+
# chain. Assign a {#rect} (typically from the surrounding {Layout}) at least
|
|
2393
|
+
# `caption.display_width + 4` wide; a narrower one ellipsizes the caption, a
|
|
2394
|
+
# wider one leaves a dead tail — see {#extent}.
|
|
2395
|
+
#
|
|
2396
|
+
# == Implementation details
|
|
2397
|
+
# The glyphs are a house convention rather than constants: three columns plus
|
|
2398
|
+
# a trailing space (`[x] `, `[ ] `), ASCII because `☑`/`☐` are absent from
|
|
2399
|
+
# most monospace fonts and the fallback glyph bleeds over its cell. A widget
|
|
2400
|
+
# painting checkbox-like rows without instantiating a Checkbox — checkable
|
|
2401
|
+
# rows in a {Component::List} — repeats those literals to match.
|
|
2402
|
+
class Checkbox < Component
|
|
2403
|
+
include Tuile::Component::HasValue
|
|
2404
|
+
include Tuile::Component::HasCaption
|
|
2238
2405
|
|
|
2239
|
-
|
|
2406
|
+
# _@param_ `caption` — the label, coerced as {HasCaption#caption=} coerces it.
|
|
2407
|
+
#
|
|
2408
|
+
# _@param_ `value` — initial state. Assigned through {#value=}, which also seeds the backing ivar — an unseeded checkbox would read `nil` and so report itself non-{HasValue#empty? empty} while fresh.
|
|
2409
|
+
def initialize: (?(String | StyledString)? caption, ?value: bool) -> void
|
|
2410
|
+
|
|
2411
|
+
def tab_stop?: () -> bool
|
|
2412
|
+
|
|
2413
|
+
# _@return_ — `false` — {HasValue#empty?} means unchecked.
|
|
2414
|
+
def empty_value: () -> bool
|
|
2415
|
+
|
|
2416
|
+
# Coerces to `true`/`false` before storing, so the two-state invariant holds
|
|
2417
|
+
# whatever a caller assigns — and `cb.value = nil` on a fresh checkbox is
|
|
2418
|
+
# the no-op it looks like rather than a spurious change event.
|
|
2419
|
+
#
|
|
2420
|
+
# _@param_ `new_value` — anything; truthiness decides.
|
|
2421
|
+
def value=: (Object new_value) -> void
|
|
2422
|
+
|
|
2423
|
+
# _@return_ — {#value} under its domain word — `license.checked?`
|
|
2424
|
+
# reads better than `license.value`. Not a second piece of state.
|
|
2425
|
+
def checked?: () -> bool
|
|
2426
|
+
|
|
2427
|
+
# {#value=} under its domain word. A delegator rather than an `alias`, so it
|
|
2428
|
+
# keeps routing through the one write path even if a subclass overrides
|
|
2429
|
+
# {#value=} (an `alias` would freeze this onto the body defined here).
|
|
2430
|
+
#
|
|
2431
|
+
# _@param_ `new_value` — anything; truthiness decides.
|
|
2432
|
+
def checked=: (Object new_value) -> void
|
|
2433
|
+
|
|
2434
|
+
# Flips {#value}.
|
|
2435
|
+
def toggle: () -> void
|
|
2436
|
+
|
|
2437
|
+
# The cells the widget actually paints: one row, `caption.display_width + 4`
|
|
2438
|
+
# columns, clipped to {#rect}. A form column routinely hands a checkbox a
|
|
2439
|
+
# 40-column rect for a 22-column `[ ] Enable syslog forwarding` — the extent
|
|
2440
|
+
# is those 22 columns.
|
|
2441
|
+
#
|
|
2442
|
+
# Both the focus highlight and the click hit test use it, so a click on the
|
|
2443
|
+
# blank tail — or on a lower row, when the rect is taller than one — does
|
|
2444
|
+
# not toggle. It still *focuses*: {Component#handle_mouse}'s click-to-focus
|
|
2445
|
+
# is ungated by geometry, and the tail is the field's own row.
|
|
2446
|
+
#
|
|
2447
|
+
# The extent ignores {Component#bg_color}: an inherited tint paints the dead
|
|
2448
|
+
# tail, but a hit test that silently widened with a background would be a
|
|
2449
|
+
# mode switch invisible in the code and untestable by inspection.
|
|
2450
|
+
def extent: () -> Rect
|
|
2451
|
+
|
|
2452
|
+
# Toggles on Space. Every other key — Enter included — is left unhandled so
|
|
2453
|
+
# it bubbles to an ancestor.
|
|
2454
|
+
#
|
|
2455
|
+
# _@param_ `key`
|
|
2456
|
+
def handle_key: (String key) -> bool
|
|
2240
2457
|
|
|
2458
|
+
# Toggles on a left click within {#extent}; `super` runs first, so a click
|
|
2459
|
+
# anywhere in {#rect} still focuses.
|
|
2460
|
+
#
|
|
2241
2461
|
# _@param_ `event`
|
|
2242
2462
|
def handle_mouse: (MouseEvent event) -> void
|
|
2243
2463
|
|
|
2244
2464
|
def repaint: () -> void
|
|
2245
2465
|
|
|
2246
|
-
|
|
2466
|
+
# Read through *this* method, never `@caption` — the ivar stays nil until
|
|
2467
|
+
# the first non-empty set ({#caption=} short-circuits when unchanged).
|
|
2468
|
+
#
|
|
2469
|
+
# _@return_ — the caption; empty when never set.
|
|
2470
|
+
def caption: () -> StyledString
|
|
2247
2471
|
|
|
2248
|
-
|
|
2472
|
+
# Sets the caption and invalidates the component. No-op when unchanged. A
|
|
2473
|
+
# `String` is parsed via {StyledString.parse} (embedded ANSI is honored);
|
|
2474
|
+
# a {StyledString} is used as-is; `nil` clears it.
|
|
2475
|
+
#
|
|
2476
|
+
# _@param_ `new_caption`
|
|
2477
|
+
def caption=: ((String | StyledString)? new_caption) -> void
|
|
2249
2478
|
|
|
2250
|
-
# _@
|
|
2251
|
-
def
|
|
2479
|
+
# _@return_ — the current value; `nil` until first set.
|
|
2480
|
+
def value: () -> Object
|
|
2252
2481
|
|
|
2253
|
-
|
|
2482
|
+
# _@return_ — true iff {#value} equals {#empty_value}.
|
|
2483
|
+
def empty?: () -> bool
|
|
2254
2484
|
|
|
2255
|
-
#
|
|
2256
|
-
|
|
2257
|
-
def display_rows: () -> ::Array[::Hash[Symbol, Integer]]
|
|
2485
|
+
# Resets {#value} to {#empty_value}.
|
|
2486
|
+
def clear: () -> void
|
|
2258
2487
|
|
|
2259
|
-
#
|
|
2260
|
-
#
|
|
2261
|
-
#
|
|
2262
|
-
#
|
|
2263
|
-
|
|
2488
|
+
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
2489
|
+
# a read-only display field could override back to `false`. Only
|
|
2490
|
+
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
2491
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
2492
|
+
# `D-integer-field`).
|
|
2493
|
+
def focusable?: () -> bool
|
|
2494
|
+
end
|
|
2264
2495
|
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2496
|
+
# A text field with a filtering dropdown: type to narrow the candidates,
|
|
2497
|
+
# arrow to move the highlight, Enter (or click) to accept. Its {#value} is
|
|
2498
|
+
# the *selected item* — of whatever type the items are — not the display
|
|
2499
|
+
# string, so a combo over domain objects hands back the object:
|
|
2500
|
+
#
|
|
2501
|
+
# combo = Component::ComboBox.new
|
|
2502
|
+
# combo.items = User.all # Array of any type
|
|
2503
|
+
# combo.item_label = ->(u) { u.full_name } # item -> shown text; default :to_s
|
|
2504
|
+
# combo.on_value_change = ->(u) { open(u) } # fires on commit, with the item
|
|
2505
|
+
# combo.value = some_user # selects it; field shows its label
|
|
2506
|
+
#
|
|
2507
|
+
# It's the assembly you'd otherwise wire by hand — a {TextField} plus a
|
|
2508
|
+
# non-modal {Popup} over a {List} — promoted to one component. Give it a
|
|
2509
|
+
# single-row {#rect}; it paints the field across that row with a `▾` in the
|
|
2510
|
+
# last column and floats the dropdown above or below.
|
|
2511
|
+
#
|
|
2512
|
+
# == The two values
|
|
2513
|
+
# {#value} (the committed selection) and the field's typed text (a transient
|
|
2514
|
+
# *query*) are deliberately distinct. Keystrokes move the query and refilter
|
|
2515
|
+
# the list; only Enter/click commits, and only a commit changes {#value} and
|
|
2516
|
+
# fires {#on_value_change}. An uncommitted query reverts to the current
|
|
2517
|
+
# value's label when the dropdown is dismissed (ESC) or the combo loses
|
|
2518
|
+
# focus. Selecting by list index (not by matching the label back) is what
|
|
2519
|
+
# lets two items share a label and still resolve to the right object.
|
|
2520
|
+
#
|
|
2521
|
+
# The dropdown is a {ListDropdown}, tinted to read as a floating panel; see
|
|
2522
|
+
# it for the theming knob.
|
|
2523
|
+
#
|
|
2524
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
2525
|
+
class ComboBox < Component
|
|
2526
|
+
include Tuile::Component::HasContent
|
|
2527
|
+
include Tuile::Component::HasValue
|
|
2528
|
+
MAX_VISIBLE_ROWS: Integer
|
|
2529
|
+
|
|
2530
|
+
# _@param_ `items` — the candidate items (any type); also settable via {#items=}.
|
|
2531
|
+
def initialize: (?items: ::Array[untyped]) -> void
|
|
2532
|
+
|
|
2533
|
+
# Selects `new_value` programmatically: updates the field to its label
|
|
2534
|
+
# *without* opening the dropdown, then fires {#on_value_change}. `nil`
|
|
2535
|
+
# clears the selection (blank field). The value need not be in {#items}.
|
|
2272
2536
|
#
|
|
2273
|
-
# _@param_ `
|
|
2537
|
+
# _@param_ `new_value`
|
|
2538
|
+
def value=: (Object new_value) -> void
|
|
2539
|
+
|
|
2540
|
+
# _@return_ — the field's caret position (the combo delegates the
|
|
2541
|
+
# hardware cursor to its field).
|
|
2542
|
+
def cursor_position: () -> Point?
|
|
2543
|
+
|
|
2544
|
+
def keyboard_hint: () -> String
|
|
2545
|
+
|
|
2546
|
+
# Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
|
|
2547
|
+
# field via {#layout}.
|
|
2274
2548
|
#
|
|
2275
|
-
# _@
|
|
2276
|
-
def
|
|
2549
|
+
# _@param_ `new_rect`
|
|
2550
|
+
def rect=: (Rect new_rect) -> void
|
|
2277
2551
|
|
|
2278
|
-
#
|
|
2552
|
+
# Closes the dropdown and reverts an uncommitted query when the combo
|
|
2553
|
+
# leaves the focus chain — so tabbing away doesn't strand an open menu or
|
|
2554
|
+
# a half-typed filter. Safe against re-entrancy: focus never sits inside
|
|
2555
|
+
# the (non-focusable) {ListDropdown}, so closing the overlay repairs no
|
|
2556
|
+
# focus.
|
|
2279
2557
|
#
|
|
2280
|
-
# _@
|
|
2281
|
-
def
|
|
2558
|
+
# _@param_ `flag`
|
|
2559
|
+
def active=: (bool flag) -> void
|
|
2282
2560
|
|
|
2283
|
-
# _@param_ `
|
|
2284
|
-
def
|
|
2561
|
+
# _@param_ `event`
|
|
2562
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
2285
2563
|
|
|
2286
|
-
def
|
|
2564
|
+
def repaint: () -> void
|
|
2287
2565
|
|
|
2288
|
-
|
|
2566
|
+
# Field spans the row bar the last column, which the `▾` occupies
|
|
2567
|
+
# ({HasContent} layout hook).
|
|
2568
|
+
#
|
|
2569
|
+
# _@param_ `field`
|
|
2570
|
+
def layout: (Component field) -> void
|
|
2289
2571
|
|
|
2290
|
-
#
|
|
2572
|
+
# The field's key interceptor: while the dropdown is open forwards movement
|
|
2573
|
+
# to it ({ListDropdown#move}), commits on Enter ({ListDropdown#choose}),
|
|
2574
|
+
# and dismisses on ESC (reverting the query); opens it on Down or Enter
|
|
2575
|
+
# when closed. Everything else (printable keys, editing) falls through to
|
|
2576
|
+
# the field, whose {TextField#on_change} refilters.
|
|
2291
2577
|
#
|
|
2292
|
-
# _@
|
|
2293
|
-
|
|
2578
|
+
# _@param_ `key`
|
|
2579
|
+
#
|
|
2580
|
+
# _@return_ — true if consumed.
|
|
2581
|
+
def field_key: (String key) -> bool
|
|
2294
2582
|
|
|
2295
|
-
#
|
|
2296
|
-
|
|
2583
|
+
# Recomputes the matches for the current query, opening the dropdown when
|
|
2584
|
+
# there are any (and preselecting the current value's row) or closing it
|
|
2585
|
+
# when there are none.
|
|
2586
|
+
def refill: () -> void
|
|
2297
2587
|
|
|
2298
|
-
#
|
|
2299
|
-
|
|
2300
|
-
|
|
2588
|
+
# Items whose label contains `query` (case-insensitive). A query still
|
|
2589
|
+
# equal to the current value's label — the resting state, or a fresh
|
|
2590
|
+
# open — is treated as "show everything", so Down opens the full list.
|
|
2591
|
+
#
|
|
2592
|
+
# _@param_ `query`
|
|
2593
|
+
def matching: (String query) -> ::Array[untyped]
|
|
2301
2594
|
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
# lines wider than the viewport are word-wrapped via {StyledString#wrap}
|
|
2308
|
-
# (style spans are preserved across wrap boundaries — unlike the older
|
|
2309
|
-
# ANSI-as-bytes wrapping, color does *not* get dropped on continuation
|
|
2310
|
-
# rows). {#text=} accepts a {String} (parsed via {StyledString.parse},
|
|
2311
|
-
# so embedded ANSI is honored) or a {StyledString} directly; {#text}
|
|
2312
|
-
# always returns the {StyledString}.
|
|
2313
|
-
#
|
|
2314
|
-
# For incremental updates pick the right primitive: {#append} (aliased
|
|
2315
|
-
# as `<<`) is verbatim and stream-friendly — chunks are concatenated
|
|
2316
|
-
# straight onto the buffer, with embedded `\n` becoming hard breaks.
|
|
2317
|
-
# {#add_line} is the "log entry" convenience — it starts the content on
|
|
2318
|
-
# a fresh line by inserting a leading `\n` when the buffer is non-empty.
|
|
2319
|
-
# {#remove_last_n_lines} pops hard lines back off the tail — the
|
|
2320
|
-
# inverse of building up a region with {#append} / {#add_line}, so a
|
|
2321
|
-
# caller streaming reformattable content (e.g. partially-rendered
|
|
2322
|
-
# Markdown that may need to retract its last paragraph) can replace
|
|
2323
|
-
# the tail without rewriting the whole text. Turn on {#auto_scroll}
|
|
2324
|
-
# to keep the latest content in view.
|
|
2325
|
-
#
|
|
2326
|
-
# TextView is meant to be the content of a {Window} — focus indication and
|
|
2327
|
-
# keyboard-hint surfacing rely on the surrounding window chrome.
|
|
2328
|
-
class TextView < Component
|
|
2329
|
-
def initialize: () -> void
|
|
2595
|
+
# Commits the item at the menu's `index`: closes the dropdown and adopts
|
|
2596
|
+
# it as {#value} (which repaints the field with its label).
|
|
2597
|
+
#
|
|
2598
|
+
# _@param_ `index`
|
|
2599
|
+
def commit: (Integer index) -> void
|
|
2330
2600
|
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
#
|
|
2601
|
+
def open_menu: () -> void
|
|
2602
|
+
|
|
2603
|
+
def close_menu: () -> void
|
|
2604
|
+
|
|
2605
|
+
def revert_query: () -> void
|
|
2606
|
+
|
|
2607
|
+
# Sets the field's text without triggering a refilter — for programmatic
|
|
2608
|
+
# value changes and query reverts, which must not spring the dropdown.
|
|
2609
|
+
# Parks the caret at the end: `text=` only *clamps* the caret, so a
|
|
2610
|
+
# shorter query replaced by a longer label would otherwise strand it
|
|
2611
|
+
# mid-word (commit "Go", then pick "Kotlin" → caret after "Ko").
|
|
2612
|
+
#
|
|
2613
|
+
# _@param_ `text`
|
|
2614
|
+
def sync_field: (String text) -> void
|
|
2615
|
+
|
|
2616
|
+
# _@param_ `item`
|
|
2617
|
+
#
|
|
2618
|
+
# _@return_ — the plain-text label for `item`, or "" for nil.
|
|
2619
|
+
def display_for: (Object item) -> String
|
|
2620
|
+
|
|
2621
|
+
# Sizes and positions the dropdown against the field: full combo width,
|
|
2622
|
+
# `min(matches, 10)` rows, below the field — flipped above when it won't
|
|
2623
|
+
# fit beneath, clamped (with the list scrolling) when it fits neither.
|
|
2624
|
+
def anchor: () -> void
|
|
2625
|
+
|
|
2626
|
+
# _@return_ — the current value; `nil` until first set.
|
|
2627
|
+
def value: () -> Object
|
|
2628
|
+
|
|
2629
|
+
# _@return_ — true iff {#value} equals {#empty_value}.
|
|
2630
|
+
def empty?: () -> bool
|
|
2631
|
+
|
|
2632
|
+
# Resets {#value} to {#empty_value}.
|
|
2633
|
+
def clear: () -> void
|
|
2634
|
+
|
|
2635
|
+
# _@return_ — the value {#empty?}/{#clear} treat as empty; `nil`
|
|
2636
|
+
# unless an includer overrides it.
|
|
2637
|
+
def empty_value: () -> Object
|
|
2638
|
+
|
|
2639
|
+
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
2640
|
+
# a read-only display field could override back to `false`. Only
|
|
2641
|
+
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
2642
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
2643
|
+
# `D-integer-field`).
|
|
2644
|
+
def focusable?: () -> bool
|
|
2645
|
+
|
|
2646
|
+
def on_focus: () -> void
|
|
2647
|
+
|
|
2648
|
+
# _@return_ — the candidate items.
|
|
2649
|
+
attr_accessor items: ::Array[untyped]
|
|
2650
|
+
|
|
2651
|
+
# _@return_ — item -> shown label (a `String` or
|
|
2652
|
+
# {StyledString}); the field shows its `#to_s`, the list its styled form.
|
|
2653
|
+
attr_accessor item_label: (Proc | Method)
|
|
2654
|
+
end
|
|
2655
|
+
|
|
2656
|
+
# The value seam every input component shares: a settable/gettable {#value}
|
|
2657
|
+
# of *any* type, an {#on_value_change} listener, {#empty?}, and {#clear}. A
|
|
2658
|
+
# form (a future binder) drives a mix of field types uniformly through it,
|
|
2659
|
+
# not caring that a {TextField}'s value is a `String` while another field's
|
|
2660
|
+
# is a domain object.
|
|
2661
|
+
#
|
|
2662
|
+
# field.on_value_change = ->(v) { puts "now: #{v.inspect}" }
|
|
2663
|
+
# field.value = "hello" # fires the listener
|
|
2664
|
+
# field.clear # value = empty_value, fires again
|
|
2665
|
+
#
|
|
2666
|
+
# The default {#value=}/{#value} keep the value in `@value` and are enough
|
|
2667
|
+
# for a component with nothing more natural — you get a repaint and the
|
|
2668
|
+
# listener for free. An includer whose value lives elsewhere overrides both
|
|
2669
|
+
# ({AbstractStringField} backs them with its text buffer). Override {#empty_value}
|
|
2670
|
+
# when the empty sentinel isn't `nil` (a text field's is `""`).
|
|
2671
|
+
#
|
|
2672
|
+
# == Implementation details
|
|
2673
|
+
# Deliberately smaller than Vaadin's `HasValue`: read-only,
|
|
2674
|
+
# required-indicator, the from-client/old-value event payload, and
|
|
2675
|
+
# converters all belong to the not-yet-built form layer, not here.
|
|
2676
|
+
module HasValue
|
|
2677
|
+
# _@return_ — the current value; `nil` until first set.
|
|
2678
|
+
def value: () -> Object
|
|
2679
|
+
|
|
2680
|
+
# No-op (no repaint, no listener) when equal to the current value.
|
|
2681
|
+
#
|
|
2682
|
+
# _@param_ `new_value`
|
|
2683
|
+
def value=: (Object new_value) -> void
|
|
2684
|
+
|
|
2685
|
+
# _@return_ — true iff {#value} equals {#empty_value}.
|
|
2686
|
+
def empty?: () -> bool
|
|
2687
|
+
|
|
2688
|
+
# Resets {#value} to {#empty_value}.
|
|
2689
|
+
def clear: () -> void
|
|
2690
|
+
|
|
2691
|
+
# _@return_ — the value {#empty?}/{#clear} treat as empty; `nil`
|
|
2692
|
+
# unless an includer overrides it.
|
|
2693
|
+
def empty_value: () -> Object
|
|
2694
|
+
|
|
2695
|
+
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
2696
|
+
# a read-only display field could override back to `false`. Only
|
|
2697
|
+
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
2698
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
2699
|
+
# `D-integer-field`).
|
|
2700
|
+
def focusable?: () -> bool
|
|
2701
|
+
|
|
2702
|
+
# _@return_ — one-arg callable fired with the new value
|
|
2703
|
+
# whenever {#value} actually changes — never on a no-op set.
|
|
2704
|
+
attr_accessor on_value_change: (Proc | Method)?
|
|
2705
|
+
end
|
|
2706
|
+
|
|
2707
|
+
# A multi-line, word-wrapping text input.
|
|
2708
|
+
#
|
|
2709
|
+
# Sized by the caller — {#rect} is fixed; the area does not grow with
|
|
2710
|
+
# content. Text is wrapped to {Rect#width} columns and any text that
|
|
2711
|
+
# doesn't fit vertically is reached by scrolling: {#top_display_row}
|
|
2712
|
+
# follows the caret so the line being edited stays visible. There is no
|
|
2713
|
+
# horizontal scrolling.
|
|
2714
|
+
#
|
|
2715
|
+
# The caret is a logical index in `0..text.length`, always on a
|
|
2716
|
+
# grapheme-cluster boundary ({AbstractStringField}). When the caret falls
|
|
2717
|
+
# inside a whitespace run that was absorbed by a soft wrap, it displays
|
|
2718
|
+
# at the end of the previous row (which is visually identical to the
|
|
2719
|
+
# start of the next row in nearly all cases).
|
|
2720
|
+
#
|
|
2721
|
+
# Enter inserts a newline, as in a plain `<textarea>` or text editor; only
|
|
2722
|
+
# {#on_change} is wired. A pasted line break arrives as `\n`
|
|
2723
|
+
# ({Keys::CTRL_J}) rather than the `\r` a typed Enter sends, so both are
|
|
2724
|
+
# accepted — otherwise a multi-line paste would silently lose its
|
|
2725
|
+
# newlines.
|
|
2726
|
+
#
|
|
2727
|
+
# == Implementation details
|
|
2728
|
+
#
|
|
2729
|
+
# The same two axes {TextField} names apply, and the wrap straddles both: an
|
|
2730
|
+
# **index** counts characters into {#text} ({#caret}, a row's `start` and
|
|
2731
|
+
# `length`), a **column** counts terminal cells ({#rect}, a row's `columns`,
|
|
2732
|
+
# {#cursor_position}, a {MouseEvent}). A row therefore carries *both* counts,
|
|
2733
|
+
# and the wrap fills each row to a column budget while recording a character
|
|
2734
|
+
# span. Everything crossing between them goes through the inherited
|
|
2735
|
+
# `columns_of` and the private `chars_for_column`.
|
|
2736
|
+
#
|
|
2737
|
+
# The wrap walks **grapheme clusters**, not characters — a combining mark must
|
|
2738
|
+
# add no columns and must not be split from its base across a row break. Note
|
|
2739
|
+
# `"\r\n"` is a *single* cluster, so a hard break tests `end_with?("\n")`
|
|
2740
|
+
# rather than equality.
|
|
2741
|
+
class TextArea < Tuile::Component::AbstractStringField
|
|
2742
|
+
def initialize: () -> void
|
|
2743
|
+
|
|
2744
|
+
def cursor_position: () -> Point?
|
|
2745
|
+
|
|
2746
|
+
# _@param_ `event`
|
|
2747
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
2748
|
+
|
|
2749
|
+
def repaint: () -> void
|
|
2750
|
+
|
|
2751
|
+
def on_text_mutated: () -> void
|
|
2752
|
+
|
|
2753
|
+
def on_caret_mutated: () -> void
|
|
2754
|
+
|
|
2755
|
+
# _@param_ `key`
|
|
2756
|
+
def handle_text_input_key: (String key) -> bool
|
|
2757
|
+
|
|
2758
|
+
def on_width_changed: () -> void
|
|
2759
|
+
|
|
2760
|
+
# _@return_ — cached wrap of {#text} for the
|
|
2761
|
+
# current {Rect#width}. Each entry is `{start:, length:}`.
|
|
2762
|
+
def display_rows: () -> ::Array[::Hash[Symbol, Integer]]
|
|
2763
|
+
|
|
2764
|
+
# _@return_ — one entry per grapheme cluster of
|
|
2765
|
+
# {#text}: `{offset: <text-index>, text: <cluster>, width: <columns>}`.
|
|
2766
|
+
# Rebuilt per wrap and discarded — the wrap is what's cached.
|
|
2767
|
+
def cluster_table: () -> ::Array[::Hash[Symbol, Object]]
|
|
2768
|
+
|
|
2769
|
+
# _@param_ `cluster`
|
|
2770
|
+
#
|
|
2771
|
+
# _@return_ — true for a space or tab (each exactly one column).
|
|
2772
|
+
def blank?: (::Hash[Symbol, Object] cluster) -> bool
|
|
2773
|
+
|
|
2774
|
+
# _@param_ `cluster`
|
|
2775
|
+
#
|
|
2776
|
+
# _@return_ — true for a hard line break. Tests the suffix rather
|
|
2777
|
+
# than equality because `"\r\n"` is one grapheme cluster.
|
|
2778
|
+
def newline?: (::Hash[Symbol, Object] cluster) -> bool
|
|
2779
|
+
|
|
2780
|
+
# Greedy word-wrap, filling each row to a **column** budget while recording
|
|
2781
|
+
# the **character** span that produced it. Whitespace at a soft-wrap break
|
|
2782
|
+
# point is absorbed (not rendered on either row). A token wider than
|
|
2783
|
+
# {Rect#width} hard-wraps inside the token. Newlines force a hard break and
|
|
2784
|
+
# the wrap restarts on the next cluster.
|
|
2785
|
+
def compute_display_rows: () -> ::Array[::Hash[Symbol, Integer]]
|
|
2786
|
+
|
|
2787
|
+
# _@param_ `clusters`
|
|
2788
|
+
#
|
|
2789
|
+
# _@param_ `index` — cluster index of the word's first glyph.
|
|
2790
|
+
#
|
|
2791
|
+
# _@return_ — `[chars, columns, next_index]`
|
|
2792
|
+
# for the run of non-whitespace starting at `index`.
|
|
2793
|
+
def measure_word: (::Array[::Hash[Symbol, Object]] clusters, Integer index) -> [Integer, Integer, Integer]
|
|
2794
|
+
|
|
2795
|
+
# Splits a token too wide for a whole row, taking entire glyphs while they
|
|
2796
|
+
# fit. Consumes at least one glyph even when that single glyph is wider than
|
|
2797
|
+
# the row — otherwise the wrap would not terminate (the row would stay empty
|
|
2798
|
+
# and the same token be reconsidered forever). Such a row reports more
|
|
2799
|
+
# columns than the rect holds and {#padded_row} drops the glyph; a
|
|
2800
|
+
# 2-column glyph in a 1-column area is unpaintable either way.
|
|
2801
|
+
#
|
|
2802
|
+
# _@param_ `clusters`
|
|
2803
|
+
#
|
|
2804
|
+
# _@param_ `index`
|
|
2805
|
+
#
|
|
2806
|
+
# _@param_ `width` — column budget.
|
|
2807
|
+
#
|
|
2808
|
+
# _@return_ — `[chars, columns, next_index]`
|
|
2809
|
+
def hard_wrap: (::Array[::Hash[Symbol, Object]] clusters, Integer index, Integer width) -> [Integer, Integer, Integer]
|
|
2810
|
+
|
|
2811
|
+
# Trims trailing space/tab characters off a row's visible length so the
|
|
2812
|
+
# whitespace at a soft-wrap point is absorbed (not rendered) rather than
|
|
2813
|
+
# left at the end of the row. Without this, soft-wrapping `"foo bar"`
|
|
2814
|
+
# to width 4 would yield row 0 length 4 (`"foo "`) and the natural
|
|
2815
|
+
# end-of-row caret position would coincide with row 1's start.
|
|
2816
|
+
#
|
|
2817
|
+
# Both counts drop by one per trimmed character: a space and a tab each
|
|
2818
|
+
# measure exactly one column.
|
|
2819
|
+
#
|
|
2820
|
+
# _@param_ `row_start`
|
|
2821
|
+
#
|
|
2822
|
+
# _@param_ `row_chars`
|
|
2823
|
+
#
|
|
2824
|
+
# _@param_ `row_cols`
|
|
2825
|
+
#
|
|
2826
|
+
# _@return_ — `[row_chars, row_cols]`
|
|
2827
|
+
def trim_trailing_whitespace: (Integer row_start, Integer row_chars, Integer row_cols) -> [Integer, Integer]
|
|
2828
|
+
|
|
2829
|
+
# _@param_ `caret`
|
|
2830
|
+
#
|
|
2831
|
+
# _@return_ — `[row_index, column]` for `caret`.
|
|
2832
|
+
def caret_to_display: (Integer caret) -> [Integer, Integer]
|
|
2833
|
+
|
|
2834
|
+
# _@param_ `row`
|
|
2835
|
+
#
|
|
2836
|
+
# _@param_ `caret`
|
|
2837
|
+
#
|
|
2838
|
+
# _@return_ — `caret`'s column offset within `row`.
|
|
2839
|
+
def caret_column_in: (::Hash[Symbol, Integer] row, Integer caret) -> Integer
|
|
2840
|
+
|
|
2841
|
+
# _@param_ `row`
|
|
2842
|
+
#
|
|
2843
|
+
# _@param_ `column` — a column offset within `row`.
|
|
2844
|
+
#
|
|
2845
|
+
# _@return_ — characters from the row's start. A column landing in a
|
|
2846
|
+
# wide glyph's right half resolves past it, as a click does in
|
|
2847
|
+
# {TextField}.
|
|
2848
|
+
def chars_for_column: (::Hash[Symbol, Integer] row, Integer column) -> Integer
|
|
2849
|
+
|
|
2850
|
+
# _@param_ `row`
|
|
2851
|
+
#
|
|
2852
|
+
# _@return_ — the row's text padded to `rect.width` columns. A glyph
|
|
2853
|
+
# with no room left is dropped rather than half-painted.
|
|
2854
|
+
def padded_row: (::Hash[Symbol, Integer] row) -> String
|
|
2855
|
+
|
|
2856
|
+
# _@param_ `delta` — `+1` for down, `-1` for up.
|
|
2857
|
+
def move_caret_vertical: (Integer delta) -> void
|
|
2858
|
+
|
|
2859
|
+
def move_caret_to_row_start: () -> void
|
|
2860
|
+
|
|
2861
|
+
def move_caret_to_row_end: () -> void
|
|
2862
|
+
|
|
2863
|
+
# _@param_ `char`
|
|
2864
|
+
#
|
|
2865
|
+
# _@return_ — always true.
|
|
2866
|
+
def insert_char: (String char) -> bool
|
|
2867
|
+
|
|
2868
|
+
# Keeps the caret visible by scrolling vertically.
|
|
2869
|
+
def adjust_top_display_row: () -> void
|
|
2870
|
+
|
|
2871
|
+
# _@return_ — index of the topmost display row currently visible.
|
|
2872
|
+
attr_reader top_display_row: Integer
|
|
2873
|
+
end
|
|
2874
|
+
|
|
2875
|
+
# A read-only viewer for prose: chunks of formatted text that scroll
|
|
2876
|
+
# vertically. Shape-wise a hybrid between {Label} (string content via
|
|
2877
|
+
# {#text=}) and {List} (scroll keys, optional scrollbar, auto-scroll).
|
|
2878
|
+
#
|
|
2879
|
+
# Text is a {StyledString}: embedded `\n` are hard line breaks, longer lines
|
|
2880
|
+
# are word-wrapped via {StyledString#wrap} with style spans preserved across
|
|
2881
|
+
# wrap boundaries. {#text=} takes a {String} (parsed via {StyledString.parse},
|
|
2882
|
+
# honoring embedded ANSI) or a {StyledString}; {#text} always returns the
|
|
2883
|
+
# {StyledString}.
|
|
2884
|
+
#
|
|
2885
|
+
# Pick the right incremental primitive: {#append} (aliased `<<`) concatenates
|
|
2886
|
+
# a chunk verbatim onto the buffer (stream-friendly, `\n` → hard breaks);
|
|
2887
|
+
# {#add_line} starts the chunk on a fresh line (the "log entry" convenience);
|
|
2888
|
+
# {#remove_last_n_lines} pops hard lines off the tail, so a caller streaming
|
|
2889
|
+
# reformattable content can retract and rewrite it; {#replace} / {#insert}
|
|
2890
|
+
# splice a range in place. Turn on {#auto_scroll} to keep the latest content
|
|
2891
|
+
# in view.
|
|
2892
|
+
#
|
|
2893
|
+
# Meant to be the content of a {Window} — focus indication and keyboard-hint
|
|
2894
|
+
# surfacing rely on the surrounding window chrome.
|
|
2895
|
+
class TextView < Component
|
|
2896
|
+
def initialize: () -> void
|
|
2897
|
+
|
|
2898
|
+
# _@return_ — the current text (empty by default). Rebuilt
|
|
2899
|
+
# lazily on the first read after a mutation (O(total spans)), then
|
|
2900
|
+
# cached — repeated reads are O(1).
|
|
2338
2901
|
def text: () -> StyledString
|
|
2339
2902
|
|
|
2340
2903
|
# _@return_ — whether {#auto_scroll} is currently tailing. True
|
|
@@ -2372,19 +2935,12 @@ module Tuile
|
|
|
2372
2935
|
# _@return_ — true iff {#text} is empty (no hard lines).
|
|
2373
2936
|
def empty?: () -> bool
|
|
2374
2937
|
|
|
2375
|
-
# Appends `str` verbatim. Embedded `\n`
|
|
2376
|
-
#
|
|
2377
|
-
#
|
|
2378
|
-
#
|
|
2379
|
-
#
|
|
2380
|
-
#
|
|
2381
|
-
# For the "add an entry on a new line" pattern use {#add_line}.
|
|
2382
|
-
#
|
|
2383
|
-
# Cost is O(appended + width-of-current-last-hard-line) — the
|
|
2384
|
-
# previously last hard line is re-wrapped (because the extension may
|
|
2385
|
-
# cause it to wrap differently), any additional hard lines created by
|
|
2386
|
-
# embedded `\n` are wrapped fresh. The cached {#text} is invalidated
|
|
2387
|
-
# and rebuilt on demand.
|
|
2938
|
+
# Appends `str` verbatim. Embedded `\n` become hard line breaks; otherwise
|
|
2939
|
+
# the text is concatenated onto the current last hard line. Designed for
|
|
2940
|
+
# streaming use (feed each partial chunk straight in). Accepts the same
|
|
2941
|
+
# input forms as {#text=}; empty/`nil` is a no-op. For the "entry on a new
|
|
2942
|
+
# line" pattern use {#add_line}. Cost is O(appended + width of the last
|
|
2943
|
+
# hard line), which is re-wrapped since the extension may wrap differently.
|
|
2388
2944
|
#
|
|
2389
2945
|
# _@param_ `str`
|
|
2390
2946
|
def append: ((String | StyledString)? str) -> void
|
|
@@ -2404,56 +2960,30 @@ module Tuile
|
|
|
2404
2960
|
# _@param_ `str`
|
|
2405
2961
|
def add_line: ((String | StyledString)? str) -> void
|
|
2406
2962
|
|
|
2407
|
-
# Drops the last `n` hard lines from the buffer
|
|
2408
|
-
#
|
|
2409
|
-
#
|
|
2410
|
-
#
|
|
2411
|
-
#
|
|
2412
|
-
# by `append(new_tail)` to replace the damaged region in place.
|
|
2413
|
-
#
|
|
2414
|
-
# `n == 0` and the empty-buffer case are no-ops (no invalidation).
|
|
2415
|
-
# `n >= hard-line count` empties the buffer.
|
|
2416
|
-
#
|
|
2417
|
-
# Operates on **hard lines** (the `\n`-delimited entries the
|
|
2418
|
-
# buffer stores), not on wrapped physical rows — same granularity
|
|
2419
|
-
# as {#add_line}. Cost is O(rendered-rows of the popped lines).
|
|
2963
|
+
# Drops the last `n` hard lines from the buffer — the inverse of building
|
|
2964
|
+
# up a tail with {#append} / {#add_line}, so a caller can `remove` then
|
|
2965
|
+
# `append` to rewrite a damaged tail in place. Operates on **hard lines**
|
|
2966
|
+
# (the `\n`-delimited entries), not wrapped physical rows. `n == 0` and the
|
|
2967
|
+
# empty buffer are no-ops; `n >= hard-line count` empties the buffer.
|
|
2420
2968
|
#
|
|
2421
2969
|
# _@param_ `n` — number of hard lines to drop; must be >= 0.
|
|
2422
2970
|
def remove_last_n_lines: (Integer n) -> void
|
|
2423
2971
|
|
|
2424
|
-
# Replaces a contiguous range of hard lines with the parsed content
|
|
2425
|
-
#
|
|
2426
|
-
#
|
|
2427
|
-
#
|
|
2428
|
-
#
|
|
2429
|
-
#
|
|
2430
|
-
# `
|
|
2431
|
-
#
|
|
2432
|
-
# `
|
|
2433
|
-
#
|
|
2434
|
-
#
|
|
2435
|
-
#
|
|
2436
|
-
#
|
|
2437
|
-
#
|
|
2438
|
-
#
|
|
2439
|
-
# position — no lines are removed. {#insert} is a thin alias for
|
|
2440
|
-
# this case.
|
|
2441
|
-
#
|
|
2442
|
-
# Endpoints must be non-negative integers; `begin` may equal
|
|
2443
|
-
# `hard-line count` (insertion at the end), `end` may not exceed
|
|
2444
|
-
# `hard-line count - 1`. `nil` endpoints (beginless / endless ranges)
|
|
2445
|
-
# are not accepted.
|
|
2446
|
-
#
|
|
2447
|
-
# Cost is roughly `O(from + length + new content)`: the splice
|
|
2448
|
-
# updates only the affected slice of the physical-row buffer, using
|
|
2449
|
-
# the per-hard-line wrap-count cache to locate the starting offset
|
|
2450
|
-
# without re-wrapping preceding lines. Lines outside the splice are
|
|
2451
|
-
# never re-wrapped. {#top_line} is clamped if the new line count
|
|
2452
|
-
# puts it past the end; {#auto_scroll} pins it to the bottom as
|
|
2453
|
-
# usual. The call is a no-op (no invalidation) when the parsed
|
|
2454
|
-
# replacement equals the covered range (vacuously true for an empty
|
|
2455
|
-
# range plus empty replacement, so `replace(n...n, "")` is a cheap
|
|
2456
|
-
# no-op).
|
|
2972
|
+
# Replaces a contiguous range of hard lines with the parsed content of
|
|
2973
|
+
# `str` (parsed like {#text=}: `String` → {StyledString.parse}, `nil` →
|
|
2974
|
+
# empty, so `nil` deletes the range). Embedded `"\n"` yields multiple hard
|
|
2975
|
+
# lines, so one `replace` can grow or shrink the buffer. `range` selects
|
|
2976
|
+
# which hard lines to swap out:
|
|
2977
|
+
#
|
|
2978
|
+
# - an `Integer` `n` is shorthand for `n..n` (replace one existing line);
|
|
2979
|
+
# - a non-empty `Range` replaces those lines;
|
|
2980
|
+
# - an empty `Range` (e.g. `2...2`, or `size...size` at the end) is
|
|
2981
|
+
# *insertion* at that position — nothing removed. {#insert} aliases this.
|
|
2982
|
+
#
|
|
2983
|
+
# Splices in place — only the affected slice of the physical-row buffer is
|
|
2984
|
+
# touched, no preceding lines re-wrapped (cost O(from + length + new
|
|
2985
|
+
# content)). A no-op when the replacement equals the covered range, so
|
|
2986
|
+
# `replace(n...n, "")` is cheap.
|
|
2457
2987
|
#
|
|
2458
2988
|
# _@param_ `range` — hard-line indices to replace.
|
|
2459
2989
|
#
|
|
@@ -2489,6 +3019,8 @@ module Tuile
|
|
|
2489
3019
|
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
2490
3020
|
# painted explicitly (with padded blanks past the last line), so the
|
|
2491
3021
|
# "fully draw over your rect" contract is met without an upfront wipe.
|
|
3022
|
+
# Rows go through {Component#draw_line}, so content and blank rows inherit
|
|
3023
|
+
# {Component#effective_bg_color} (a {#bg_color} set here or on an ancestor).
|
|
2492
3024
|
def repaint: () -> void
|
|
2493
3025
|
|
|
2494
3026
|
# Rewraps the text on width changes. Wrap width depends on
|
|
@@ -2573,19 +3105,12 @@ module Tuile
|
|
|
2573
3105
|
# _@param_ `region`
|
|
2574
3106
|
def remove_region: (Region region) -> void
|
|
2575
3107
|
|
|
2576
|
-
# Adjusts region line counts after a {@hard_lines} splice that
|
|
2577
|
-
#
|
|
2578
|
-
#
|
|
2579
|
-
#
|
|
2580
|
-
#
|
|
2581
|
-
#
|
|
2582
|
-
# region that lost lines — that's the natural home for the
|
|
2583
|
-
# replacement content.
|
|
2584
|
-
# 2. Credit `added_count` to that region. For pure insertions (no
|
|
2585
|
-
# removal), there's no "first overlapping region" to pick from;
|
|
2586
|
-
# walk regions and credit the latest one starting at `from` (the
|
|
2587
|
-
# boundary tiebreaker matches the spatial-tail-routing of
|
|
2588
|
-
# {#append}). Past-the-end inserts fall back to the tail region.
|
|
3108
|
+
# Adjusts region line counts after a {@hard_lines} splice that removed
|
|
3109
|
+
# `removed_count` lines at `from` and inserted `added_count`. Subtracts
|
|
3110
|
+
# each region's overlap with the removed range, then credits the added
|
|
3111
|
+
# lines to the first region that lost lines. Pure insertions have no such
|
|
3112
|
+
# region — they credit the latest region starting at `from`, matching
|
|
3113
|
+
# {#append}'s spatial-tail routing (past-the-end falls back to the tail).
|
|
2589
3114
|
#
|
|
2590
3115
|
# _@param_ `from`
|
|
2591
3116
|
#
|
|
@@ -2660,9 +3185,6 @@ module Tuile
|
|
|
2660
3185
|
# reader when the cache is cold. Cost is O(total spans).
|
|
2661
3186
|
def build_text: () -> StyledString
|
|
2662
3187
|
|
|
2663
|
-
# _@return_ — {#content_size} computed from {@hard_lines}.
|
|
2664
|
-
def compute_content_size: () -> Size
|
|
2665
|
-
|
|
2666
3188
|
# _@return_ — column width available for wrapped text — viewport
|
|
2667
3189
|
# width minus the scrollbar gutter (when visible). `0` when {#rect}'s
|
|
2668
3190
|
# width is non-positive, which yields a degenerate "no wrap" result.
|
|
@@ -2721,13 +3243,6 @@ module Tuile
|
|
|
2721
3243
|
# bottom and tailing resumes. Default `false`.
|
|
2722
3244
|
attr_accessor auto_scroll: bool
|
|
2723
3245
|
|
|
2724
|
-
# _@return_ — longest hard-line's display width × number of hard
|
|
2725
|
-
# lines. Reported on the *unwrapped* text — wrap-aware sizing would
|
|
2726
|
-
# be circular (width depends on width). Empty text returns
|
|
2727
|
-
# `Size.new(0, 0)`. Maintained incrementally by {#text=} and
|
|
2728
|
-
# {#append}, so reads are O(1).
|
|
2729
|
-
attr_reader content_size: Size
|
|
2730
|
-
|
|
2731
3246
|
# A logical section of a {TextView}'s text — a contiguous run of
|
|
2732
3247
|
# hard lines the app wants to address as a unit (e.g. an LLM's
|
|
2733
3248
|
# "thinking" output vs. its assistant message). The view always
|
|
@@ -2775,14 +3290,9 @@ module Tuile
|
|
|
2775
3290
|
def text=: ((String | StyledString)? value) -> void
|
|
2776
3291
|
|
|
2777
3292
|
# Verbatim append into this region's tail. Same semantics as
|
|
2778
|
-
# {TextView#append} but scoped
|
|
2779
|
-
#
|
|
2780
|
-
#
|
|
2781
|
-
# is a no-op (but still raises when detached). When the region is
|
|
2782
|
-
# the spatial tail of the view, this uses the incremental
|
|
2783
|
-
# {TextView#append} path; mid-document regions splice the affected
|
|
2784
|
-
# slice of the physical-row buffer (lines outside the region are
|
|
2785
|
-
# not re-wrapped).
|
|
3293
|
+
# {TextView#append} but scoped: embedded `"\n"` creates new hard lines
|
|
3294
|
+
# within the region, other input extends the region's last hard line.
|
|
3295
|
+
# Empty / `nil` is a no-op (but still raises when detached).
|
|
2786
3296
|
#
|
|
2787
3297
|
# _@param_ `str`
|
|
2788
3298
|
def append: ((String | StyledString)? str) -> void
|
|
@@ -2853,129 +3363,1012 @@ module Tuile
|
|
|
2853
3363
|
|
|
2854
3364
|
def check_attached: () -> void
|
|
2855
3365
|
|
|
2856
|
-
# _@return_ — number of hard lines this region owns. Safe to
|
|
2857
|
-
# read on a detached region (no error raised).
|
|
2858
|
-
attr_accessor line_count: (Integer | untyped)
|
|
3366
|
+
# _@return_ — number of hard lines this region owns. Safe to
|
|
3367
|
+
# read on a detached region (no error raised).
|
|
3368
|
+
attr_accessor line_count: (Integer | untyped)
|
|
3369
|
+
end
|
|
3370
|
+
end
|
|
3371
|
+
|
|
3372
|
+
# Shows a log. Construct your logger pointed at a {LogWindow::IO} to route
|
|
3373
|
+
# log lines into this window:
|
|
3374
|
+
#
|
|
3375
|
+
# log_window = Tuile::Component::LogWindow.new
|
|
3376
|
+
# logger = Logger.new(Tuile::Component::LogWindow::IO.new(log_window))
|
|
3377
|
+
#
|
|
3378
|
+
# Any logger that writes formatted lines to an IO works the same way —
|
|
3379
|
+
# for example `TTY::Logger` configured with the `:console` handler and
|
|
3380
|
+
# `output: LogWindow::IO.new(window)`.
|
|
3381
|
+
class LogWindow < Tuile::Component::Window
|
|
3382
|
+
# _@param_ `caption`
|
|
3383
|
+
def initialize: (?String caption) -> void
|
|
3384
|
+
|
|
3385
|
+
# Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
|
|
3386
|
+
#
|
|
3387
|
+
# _@param_ `string` — the line (or multiple lines) to log.
|
|
3388
|
+
def log: (String? string) -> void
|
|
3389
|
+
|
|
3390
|
+
# IO-shaped adapter that forwards each log line to the owning {LogWindow}.
|
|
3391
|
+
# Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
|
|
3392
|
+
# call `output.puts`, e.g. `TTY::Logger`).
|
|
3393
|
+
class IO
|
|
3394
|
+
# _@param_ `window`
|
|
3395
|
+
def initialize: (LogWindow window) -> void
|
|
3396
|
+
|
|
3397
|
+
# _@param_ `string`
|
|
3398
|
+
def write: (String string) -> void
|
|
3399
|
+
|
|
3400
|
+
# _@param_ `string`
|
|
3401
|
+
def puts: (String string) -> void
|
|
3402
|
+
|
|
3403
|
+
# Stdlib `Logger` only treats an object as an IO target when it
|
|
3404
|
+
# responds to both {#write} and {#close}; otherwise it tries to
|
|
3405
|
+
# interpret it as a filename. This is a no-op.
|
|
3406
|
+
def close: () -> void
|
|
3407
|
+
end
|
|
3408
|
+
end
|
|
3409
|
+
|
|
3410
|
+
# A single-line text input with a real hardware caret, scrolling
|
|
3411
|
+
# horizontally to keep that caret in view:
|
|
3412
|
+
#
|
|
3413
|
+
# f = TextField.new
|
|
3414
|
+
# f.rect = Rect.new(0, 0, 6, 1) # six columns wide …
|
|
3415
|
+
# f.text = "hello world" # … eleven columns of text, so it scrolls
|
|
3416
|
+
# f.caret = 11 # paints "world " — left_column 6, cursor on the last column
|
|
3417
|
+
# f.caret = 0 # paints "hello " — left_column 0
|
|
3418
|
+
#
|
|
3419
|
+
# The field's width never bounds its contents — {#max_text_length} does, and
|
|
3420
|
+
# only for typing.
|
|
3421
|
+
#
|
|
3422
|
+
# == Implementation details
|
|
3423
|
+
#
|
|
3424
|
+
# Two axes run through this class and are *not* interchangeable:
|
|
3425
|
+
#
|
|
3426
|
+
# - an **index** counts characters into {#text} — {#caret},
|
|
3427
|
+
# {#max_text_length}, `text[i]`, every edit;
|
|
3428
|
+
# - a **column** counts terminal cells — {#rect}, {#left_column},
|
|
3429
|
+
# {#cursor_position}, a {MouseEvent}.
|
|
3430
|
+
#
|
|
3431
|
+
# They coincide only while every glyph is one column wide. A fullwidth CJK
|
|
3432
|
+
# char is two columns and a combining mark zero, so index 3 of `"日本語"` is
|
|
3433
|
+
# column 6. Every crossing goes through the private `column_at` / `index_at`
|
|
3434
|
+
# pair; adding an index to a column anywhere else is the bug those two exist
|
|
3435
|
+
# to prevent.
|
|
3436
|
+
#
|
|
3437
|
+
# Indices count characters while widths measure grapheme clusters, but the
|
|
3438
|
+
# caret never falls between the two: {AbstractStringField} keeps it on a
|
|
3439
|
+
# cluster boundary, so a column derived from it always names a real glyph
|
|
3440
|
+
# edge.
|
|
3441
|
+
#
|
|
3442
|
+
# What gets *painted* is {#display_text}, a third seam that is `text` itself
|
|
3443
|
+
# here and the mask in {PasswordField}. Every column measurement reads it, so
|
|
3444
|
+
# a subclass showing something else overrides that and never {#repaint} —
|
|
3445
|
+
# overriding the paint alone leaves the measurements on the buffer while the
|
|
3446
|
+
# cells show the substitute, and the two drift apart by a growing offset.
|
|
3447
|
+
class TextField < Tuile::Component::AbstractStringField
|
|
3448
|
+
def initialize: () -> void
|
|
3449
|
+
|
|
3450
|
+
def cursor_position: () -> Point?
|
|
3451
|
+
|
|
3452
|
+
# Places the caret at the clicked column. A click on the right half of a
|
|
3453
|
+
# wide glyph lands *after* it, as in any editor.
|
|
3454
|
+
#
|
|
3455
|
+
# _@param_ `event`
|
|
3456
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
3457
|
+
|
|
3458
|
+
def repaint: () -> void
|
|
3459
|
+
|
|
3460
|
+
# _@param_ `key`
|
|
3461
|
+
def handle_text_input_key: (String key) -> bool
|
|
3462
|
+
|
|
3463
|
+
def on_text_mutated: () -> void
|
|
3464
|
+
|
|
3465
|
+
def on_caret_mutated: () -> void
|
|
3466
|
+
|
|
3467
|
+
def on_width_changed: () -> void
|
|
3468
|
+
|
|
3469
|
+
# What the field paints in place of {#text}: one display character per
|
|
3470
|
+
# {#text} character, in order. `column_at` measures `display_text[0, i]` as
|
|
3471
|
+
# the rendering of `text[0, i]`, so an override that changes the character
|
|
3472
|
+
# count — or reorders — desynchronizes the caret from the display. Nothing
|
|
3473
|
+
# enforces it at runtime; a subclass pins it with a spec.
|
|
3474
|
+
#
|
|
3475
|
+
# _@return_ — {#text} itself, unless a subclass substitutes.
|
|
3476
|
+
def display_text: () -> String
|
|
3477
|
+
|
|
3478
|
+
# _@param_ `char`
|
|
3479
|
+
#
|
|
3480
|
+
# _@return_ — always true — a field at {#max_text_length} swallows the
|
|
3481
|
+
# key rather than declining it, so typing can never fall through to a
|
|
3482
|
+
# scope-wide binding.
|
|
3483
|
+
def insert: (String char) -> bool
|
|
3484
|
+
|
|
3485
|
+
# _@param_ `index` — a {#text} index in `0..text.length`.
|
|
3486
|
+
#
|
|
3487
|
+
# _@return_ — the column it sits at. An index landing inside a
|
|
3488
|
+
# grapheme cluster measures the whole cluster, putting the caret just
|
|
3489
|
+
# past it.
|
|
3490
|
+
def column_at: (Integer index) -> Integer
|
|
3491
|
+
|
|
3492
|
+
# _@param_ `column` — a text column (0 is the first glyph).
|
|
3493
|
+
#
|
|
3494
|
+
# _@return_ — the nearest {#text} index — a column falling in a wide
|
|
3495
|
+
# glyph's right half resolves past it.
|
|
3496
|
+
def index_at: (Integer column) -> Integer
|
|
3497
|
+
|
|
3498
|
+
# _@return_ — total display width of {#text}.
|
|
3499
|
+
def text_columns: () -> Integer
|
|
3500
|
+
|
|
3501
|
+
# _@return_ — the windowed text, padded with spaces to `rect.width`.
|
|
3502
|
+
# A wide glyph straddling the right edge is dropped rather than painted
|
|
3503
|
+
# as a half glyph.
|
|
3504
|
+
def visible_text: () -> String
|
|
3505
|
+
|
|
3506
|
+
# Scrolls the minimum needed to keep the caret's column visible.
|
|
3507
|
+
def adjust_left_column: () -> void
|
|
3508
|
+
|
|
3509
|
+
# Snapping *right* is the only safe direction, and not because it shows
|
|
3510
|
+
# more: the caret's own column is always a glyph boundary, so the next
|
|
3511
|
+
# boundary at or after `left_column` can never overshoot it. Snapping left
|
|
3512
|
+
# instead pulls the window's right edge inward, which strands the caret
|
|
3513
|
+
# outside it whenever wide glyphs exactly fill a narrow field.
|
|
3514
|
+
#
|
|
3515
|
+
# _@param_ `column`
|
|
3516
|
+
#
|
|
3517
|
+
# _@return_ — the smallest glyph-boundary column `>= column`, so the
|
|
3518
|
+
# window never opens on a wide glyph's right half.
|
|
3519
|
+
def snap_to_glyph_start: (Integer column) -> Integer
|
|
3520
|
+
|
|
3521
|
+
# Optional cap on {#text}'s length **in characters** — a wide glyph counts
|
|
3522
|
+
# once. Typing into a field already at the cap does nothing.
|
|
3523
|
+
#
|
|
3524
|
+
# Deliberately does not police {#text=}: lowering the cap under an existing
|
|
3525
|
+
# value leaves that value intact rather than silently trimming it.
|
|
3526
|
+
#
|
|
3527
|
+
# _@return_ — maximum characters, or nil for unbounded (default).
|
|
3528
|
+
attr_accessor max_text_length: Integer?
|
|
3529
|
+
|
|
3530
|
+
# _@return_ — text column drawn in the field's leftmost cell — the
|
|
3531
|
+
# horizontal scroll offset. Follows {#caret}, always on a glyph boundary.
|
|
3532
|
+
attr_reader left_column: Integer
|
|
3533
|
+
|
|
3534
|
+
# Optional callback fired when the UP arrow key is pressed. When set, UP
|
|
3535
|
+
# is consumed by the field; when nil, UP falls through to the parent
|
|
3536
|
+
# (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
|
|
3537
|
+
# since `k` is a printable character inserted into {#text}.
|
|
3538
|
+
#
|
|
3539
|
+
# _@return_ — no-arg callable, or nil.
|
|
3540
|
+
attr_accessor on_key_up: (Proc | Method)?
|
|
3541
|
+
|
|
3542
|
+
# Optional callback fired when the DOWN arrow key is pressed. When set,
|
|
3543
|
+
# DOWN is consumed by the field; when nil, DOWN falls through to the
|
|
3544
|
+
# parent (default behavior). Only triggered by {Keys::DOWN_ARROW}, not by
|
|
3545
|
+
# `j`, since `j` is a printable character inserted into {#text}.
|
|
3546
|
+
#
|
|
3547
|
+
# _@return_ — no-arg callable, or nil.
|
|
3548
|
+
attr_accessor on_key_down: (Proc | Method)?
|
|
3549
|
+
|
|
3550
|
+
# Optional callback fired when ENTER is pressed. When set, ENTER is
|
|
3551
|
+
# consumed by the field; when nil, ENTER falls through to the parent
|
|
3552
|
+
# (default behavior).
|
|
3553
|
+
#
|
|
3554
|
+
# _@return_ — no-arg callable, or nil.
|
|
3555
|
+
attr_accessor on_enter: (Proc | Method)?
|
|
3556
|
+
end
|
|
3557
|
+
|
|
3558
|
+
# The chrome text a component *wears* — a {Window}'s border title, a
|
|
3559
|
+
# {Button}'s label — as opposed to the value it *holds*.
|
|
3560
|
+
#
|
|
3561
|
+
# button.caption = "Submit"
|
|
3562
|
+
# window.caption = StyledString.styled("Settings", fg: Color::RED)
|
|
3563
|
+
#
|
|
3564
|
+
# Tuile's naming split, which decides what a new component gets:
|
|
3565
|
+
# **caption** is chrome, authored by the app; **text** is the value the
|
|
3566
|
+
# user edits (aliased to {HasValue#value} on {AbstractStringField}). A
|
|
3567
|
+
# component may carry both, hence two mixins.
|
|
3568
|
+
#
|
|
3569
|
+
# Includers own the *rendering* — clipping, width arithmetic, decoration
|
|
3570
|
+
# such as {Window}'s `[key]-` shortcut prefix; this holds only the text.
|
|
3571
|
+
#
|
|
3572
|
+
# == Implementation details
|
|
3573
|
+
# Being a mixin is what lets tree-walking code find "the {Button} captioned
|
|
3574
|
+
# Submit" via `is_a?(HasCaption)` plus a caption compare, rather than a
|
|
3575
|
+
# hardcoded list of classes that happen to respond to `caption`. Don't
|
|
3576
|
+
# collapse it back into per-class accessors.
|
|
3577
|
+
module HasCaption
|
|
3578
|
+
# Read through *this* method, never `@caption` — the ivar stays nil until
|
|
3579
|
+
# the first non-empty set ({#caption=} short-circuits when unchanged).
|
|
3580
|
+
#
|
|
3581
|
+
# _@return_ — the caption; empty when never set.
|
|
3582
|
+
def caption: () -> StyledString
|
|
3583
|
+
|
|
3584
|
+
# Sets the caption and invalidates the component. No-op when unchanged. A
|
|
3585
|
+
# `String` is parsed via {StyledString.parse} (embedded ANSI is honored);
|
|
3586
|
+
# a {StyledString} is used as-is; `nil` clears it.
|
|
3587
|
+
#
|
|
3588
|
+
# _@param_ `new_caption`
|
|
3589
|
+
def caption=: ((String | StyledString)? new_caption) -> void
|
|
3590
|
+
end
|
|
3591
|
+
|
|
3592
|
+
# A mixin interface for a component with one child tops. The host must
|
|
3593
|
+
# provide a protected `layout(content)` method which repositions the
|
|
3594
|
+
# content component; the mixin manages `@content` itself.
|
|
3595
|
+
module HasContent
|
|
3596
|
+
# _@param_ `event`
|
|
3597
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
3598
|
+
|
|
3599
|
+
# _@param_ `rect`
|
|
3600
|
+
def rect=: (Rect rect) -> void
|
|
3601
|
+
|
|
3602
|
+
def on_focus: () -> void
|
|
3603
|
+
|
|
3604
|
+
# _@return_ — the current content component.
|
|
3605
|
+
attr_accessor content: Component?
|
|
3606
|
+
end
|
|
3607
|
+
|
|
3608
|
+
# A {Window} preconfigured with a {List} of static lines. Useful for
|
|
3609
|
+
# showing read-only information.
|
|
3610
|
+
#
|
|
3611
|
+
# Usable tiled (just add to a {Layout}) or as a popup via {.open}, which
|
|
3612
|
+
# wraps it in a {Popup}.
|
|
3613
|
+
class InfoWindow < Tuile::Component::Window
|
|
3614
|
+
# _@param_ `caption`
|
|
3615
|
+
#
|
|
3616
|
+
# _@param_ `lines` — initial content; each entry may contain Rainbow formatting.
|
|
3617
|
+
def initialize: (?String caption, ?::Array[String] lines) -> void
|
|
3618
|
+
|
|
3619
|
+
# Opens the info window as a popup.
|
|
3620
|
+
#
|
|
3621
|
+
# _@param_ `caption`
|
|
3622
|
+
#
|
|
3623
|
+
# _@param_ `lines` — the content, may contain formatting.
|
|
3624
|
+
#
|
|
3625
|
+
# _@param_ `size` — the popup's size, applied top-down; the list wraps and scrolls within it. Defaults to {Fraction::HALF}.
|
|
3626
|
+
#
|
|
3627
|
+
# _@return_ — the opened popup.
|
|
3628
|
+
def self.open: (String caption, ::Array[String] lines, ?size: (Size | Fraction)) -> Popup
|
|
3629
|
+
end
|
|
3630
|
+
|
|
3631
|
+
# Single-select from a set of typed items, one row each. Arrows move a
|
|
3632
|
+
# cursor; Space, Enter or a left click selects the row under it:
|
|
3633
|
+
#
|
|
3634
|
+
# (*) Ascending
|
|
3635
|
+
# ( ) Descending <- cursor row, highlighted across the full width
|
|
3636
|
+
# ( ) Unsorted
|
|
3637
|
+
# ^ the composed {List}'s one-column gutter
|
|
3638
|
+
#
|
|
3639
|
+
# rg = Component::RadioGroup.new(items: %w[Ascending Descending Unsorted])
|
|
3640
|
+
# rg.value = "Descending" # or seed it via the ctor
|
|
3641
|
+
# rg.on_value_change = ->(order) { resort(order) }
|
|
3642
|
+
# rg.value # => "Descending"
|
|
3643
|
+
# rg.item_label = ->(o) { o.title } # default :to_s
|
|
3644
|
+
#
|
|
3645
|
+
# {#value} is **the selected item itself** — of whatever type {#items}
|
|
3646
|
+
# holds, never its label. `nil` means nothing is selected: that is the
|
|
3647
|
+
# initial state, and assigning it is the only way back, since Space on the
|
|
3648
|
+
# already-selected row is a no-op rather than a deselect.
|
|
3649
|
+
#
|
|
3650
|
+
# Composes rather than subclasses, like {ComboBox}: a {List} is its single
|
|
3651
|
+
# {HasContent} child, which is where the cursor, scrolling, the scrollbar
|
|
3652
|
+
# and per-row mouse hit-testing come from. `content` is that list, so an app
|
|
3653
|
+
# can tune it (`scrollbar_visibility`, `show_cursor_when_inactive`, …). Rows
|
|
3654
|
+
# beyond {#rect}'s height scroll; the inner list is the tab stop, not the
|
|
3655
|
+
# group.
|
|
3656
|
+
#
|
|
3657
|
+
# == The cursor is chrome
|
|
3658
|
+
# The cursor and the selection are two independent things, as in
|
|
3659
|
+
# {CheckboxGroup} — arrows roam without changing {#value}, so a listener
|
|
3660
|
+
# that resorts a pane fires once on intent instead of once per row crossed.
|
|
3661
|
+
# {#value=} therefore does *not* move the cursor. An app that wants it
|
|
3662
|
+
# parked on the selection parks it:
|
|
3663
|
+
#
|
|
3664
|
+
# rg.content.cursor = List::Cursor.new(position: rg.items.index(rg.value))
|
|
3665
|
+
#
|
|
3666
|
+
# {#items=} is the one thing that moves it, clamping it back into range.
|
|
3667
|
+
#
|
|
3668
|
+
# == +items+ is chrome; +value+ is authoritative
|
|
3669
|
+
# {#items=} changes only what is *presented*. It never touches {#value} and
|
|
3670
|
+
# never fires {HasValue#on_value_change}, and a selected item absent from
|
|
3671
|
+
# {#items} renders no marked row while surviving intact — so a form saved
|
|
3672
|
+
# without the user editing anything changes nothing silently. Keeping the
|
|
3673
|
+
# two in sync is the app's job. Same contract as {ComboBox#value} and
|
|
3674
|
+
# {CheckboxGroup#value}.
|
|
3675
|
+
#
|
|
3676
|
+
# == Implementation details
|
|
3677
|
+
# Two `==`-equal items share one selection, so selecting either marks both
|
|
3678
|
+
# rows; two *distinct* items that merely render the same label stay
|
|
3679
|
+
# independent, because a row resolves to an item by index.
|
|
3680
|
+
#
|
|
3681
|
+
# Rows are `(*) `/`( ) ` literals, mirroring {Checkbox}'s convention rather
|
|
3682
|
+
# than importing constants from it. ASCII deliberately: `(•)` would measure
|
|
3683
|
+
# two columns in a terminal configured for East-Asian-Ambiguous glyphs and
|
|
3684
|
+
# shift every row's text, which no test would catch.
|
|
3685
|
+
#
|
|
3686
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
3687
|
+
class RadioGroup < Component
|
|
3688
|
+
include Tuile::Component::HasContent
|
|
3689
|
+
include Tuile::Component::HasValue
|
|
3690
|
+
|
|
3691
|
+
# _@param_ `items` — the items to present, one row each; also settable via {#items=}.
|
|
3692
|
+
#
|
|
3693
|
+
# _@param_ `value` — the initially selected item. Seeds the backing ivar directly, so no listener fires and assignment order doesn't matter to a form helper.
|
|
3694
|
+
def initialize: (?items: ::Array[untyped], ?value: Object?) -> void
|
|
3695
|
+
|
|
3696
|
+
# Selects `new_value`, firing {HasValue#on_value_change} when it really
|
|
3697
|
+
# changed. The cursor stays where it is.
|
|
3698
|
+
#
|
|
3699
|
+
# _@param_ `new_value` — `nil` selects nothing; an item outside {#items} is kept but renders no marked row.
|
|
3700
|
+
def value=: (Object? new_value) -> void
|
|
3701
|
+
|
|
3702
|
+
# Selects the cursor row on Space. Nothing else is claimed: the composed
|
|
3703
|
+
# {List} — being the focused component — has already had its chance at the
|
|
3704
|
+
# key (its arrows, Home/End, PgUp/PgDn, ^U/^D and Enter), and whatever
|
|
3705
|
+
# neither of us wants bubbles on to an ancestor.
|
|
3706
|
+
#
|
|
3707
|
+
# _@param_ `key`
|
|
3708
|
+
def handle_key: (String key) -> bool
|
|
3709
|
+
|
|
3710
|
+
# Places the composed list across the whole rect ({HasContent} hook).
|
|
3711
|
+
#
|
|
3712
|
+
# _@param_ `list`
|
|
3713
|
+
def layout: (Component list) -> void
|
|
3714
|
+
|
|
3715
|
+
# Selects the item on row `index`; an index outside {#items} is ignored.
|
|
3716
|
+
#
|
|
3717
|
+
# _@param_ `index`
|
|
3718
|
+
def select_at: (Integer index) -> void
|
|
3719
|
+
|
|
3720
|
+
# Re-renders every row from the current items, labels and selection.
|
|
3721
|
+
def rebuild_rows: () -> void
|
|
3722
|
+
|
|
3723
|
+
# Pulls an over-range cursor back onto the last row (row 0 when there are
|
|
3724
|
+
# none). {List#lines=} leaves a stale cursor alone, which would strand it
|
|
3725
|
+
# off-content: no highlight, a dead Enter, and a Space that resolves to
|
|
3726
|
+
# `nil` and silently clears the selection.
|
|
3727
|
+
def clamp_cursor: () -> void
|
|
3728
|
+
|
|
3729
|
+
# _@param_ `item`
|
|
3730
|
+
#
|
|
3731
|
+
# _@return_ — whichever {StyledString#+} accepts on the
|
|
3732
|
+
# right — so a styled label keeps its spans and a plain one is parsed.
|
|
3733
|
+
def label_for: (Object item) -> (StyledString | String)
|
|
3734
|
+
|
|
3735
|
+
# _@return_ — the current value; `nil` until first set.
|
|
3736
|
+
def value: () -> Object
|
|
3737
|
+
|
|
3738
|
+
# _@return_ — true iff {#value} equals {#empty_value}.
|
|
3739
|
+
def empty?: () -> bool
|
|
3740
|
+
|
|
3741
|
+
# Resets {#value} to {#empty_value}.
|
|
3742
|
+
def clear: () -> void
|
|
3743
|
+
|
|
3744
|
+
# _@return_ — the value {#empty?}/{#clear} treat as empty; `nil`
|
|
3745
|
+
# unless an includer overrides it.
|
|
3746
|
+
def empty_value: () -> Object
|
|
3747
|
+
|
|
3748
|
+
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
3749
|
+
# a read-only display field could override back to `false`. Only
|
|
3750
|
+
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
3751
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
3752
|
+
# `D-integer-field`).
|
|
3753
|
+
def focusable?: () -> bool
|
|
3754
|
+
|
|
3755
|
+
# _@param_ `event`
|
|
3756
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
3757
|
+
|
|
3758
|
+
# _@param_ `rect`
|
|
3759
|
+
def rect=: (Rect rect) -> void
|
|
3760
|
+
|
|
3761
|
+
def on_focus: () -> void
|
|
3762
|
+
|
|
3763
|
+
# _@return_ — the presented items.
|
|
3764
|
+
attr_accessor items: ::Array[untyped]
|
|
3765
|
+
|
|
3766
|
+
# _@return_ — item -> row label (a `String`, {StyledString}, or
|
|
3767
|
+
# anything with `#to_s`); `:to_s` by default.
|
|
3768
|
+
attr_accessor item_label: (Proc | Method)
|
|
3769
|
+
end
|
|
3770
|
+
|
|
3771
|
+
# A one-row progress bar: a run of `█` growing left to right across {#rect},
|
|
3772
|
+
# over a `░` track.
|
|
3773
|
+
#
|
|
3774
|
+
# ████████░░░░░░░░░░░░
|
|
3775
|
+
#
|
|
3776
|
+
# bar = Component::ProgressBar.new(range: 0..files.size)
|
|
3777
|
+
# label = Component::Label.new
|
|
3778
|
+
# add(bar)
|
|
3779
|
+
# add(label)
|
|
3780
|
+
#
|
|
3781
|
+
# def rect=(new_rect) # the enclosing Layout positions both
|
|
3782
|
+
# super
|
|
3783
|
+
# bar.rect = Rect.new(rect.left, rect.top, rect.width, 1)
|
|
3784
|
+
# label.rect = Rect.new(rect.left, rect.top + 1, rect.width, 1)
|
|
3785
|
+
# end
|
|
3786
|
+
#
|
|
3787
|
+
# bar.value = done
|
|
3788
|
+
# label.text = "#{bar.percent}% — #{done}/#{files.size}"
|
|
3789
|
+
#
|
|
3790
|
+
# The bar paints no text of its own: put a {Label} beside it and feed it
|
|
3791
|
+
# {#percent} or {#fraction}, so the app words it ("42% — 3/7 files") and
|
|
3792
|
+
# places it freely. Display-only — not focusable, no keys, no mouse.
|
|
3793
|
+
#
|
|
3794
|
+
# While the total is still unknown, {#indeterminate=} swaps the fill for a
|
|
3795
|
+
# block sliding across the bar:
|
|
3796
|
+
#
|
|
3797
|
+
# ░░░░░░░████░░░░░░░░░
|
|
3798
|
+
#
|
|
3799
|
+
# Both endpoints are exact: the bar is full only at {#max} and empty only at
|
|
3800
|
+
# {#min}, so a full bar always means done. Assign a one-row {#rect}; a taller
|
|
3801
|
+
# one paints the bar on its first row and leaves the rest to the background.
|
|
3802
|
+
#
|
|
3803
|
+
# == Implementation details
|
|
3804
|
+
# The `█`/`░` pair is the same one {VerticalScrollBar} uses — East-Asian
|
|
3805
|
+
# Ambiguous and Neutral respectively, so under an ambiguous-as-wide terminal
|
|
3806
|
+
# the rendered length would vary with the fill level. Shipped anyway, per
|
|
3807
|
+
# `DECISIONS.md` `D-ambiguous-width`: a bar that rhymes with the scrollbar
|
|
3808
|
+
# beats a third convention, and if that bet is ever reversed both swap
|
|
3809
|
+
# together.
|
|
3810
|
+
class ProgressBar < Component
|
|
3811
|
+
DEFAULT_RANGE: ::Range[untyped]
|
|
3812
|
+
INDETERMINATE_FPS: Integer
|
|
3813
|
+
BLOCK_DIVISOR: Integer
|
|
3814
|
+
|
|
3815
|
+
# _@param_ `range` — initial {#range=}.
|
|
3816
|
+
#
|
|
3817
|
+
# _@param_ `value` — initial {#value=}; `nil` starts at the range's lower bound.
|
|
3818
|
+
#
|
|
3819
|
+
# _@param_ `indeterminate` — initial {#indeterminate=}.
|
|
3820
|
+
def initialize: (?range: ::Range[untyped], ?value: Numeric?, ?indeterminate: bool) -> void
|
|
3821
|
+
|
|
3822
|
+
# _@return_ — the scale {#value} is measured against.
|
|
3823
|
+
def range: () -> ::Range[untyped]
|
|
3824
|
+
|
|
3825
|
+
# Replaces the scale, re-clamping {#value} into it. `min == max` is legal
|
|
3826
|
+
# and reads as complete — a zero-length job has nothing outstanding — so
|
|
3827
|
+
# `bar.range = 0..files.size` needs no special case for an empty list.
|
|
3828
|
+
#
|
|
3829
|
+
# _@param_ `new_range` — inclusive; endpoints Numeric and finite.
|
|
3830
|
+
def range=: (::Range[untyped] new_range) -> void
|
|
3831
|
+
|
|
3832
|
+
# _@return_ — {#value} as `0.0..1.0`. `1.0` when the range is empty.
|
|
3833
|
+
def fraction: () -> Float
|
|
3834
|
+
|
|
3835
|
+
# _@return_ — {#fraction} as `0..100`, floored — `100` means done and
|
|
3836
|
+
# nothing else does, matching the painted bar exactly.
|
|
3837
|
+
def percent: () -> Integer
|
|
3838
|
+
|
|
3839
|
+
# _@return_ — whether the sliding-block animation is showing.
|
|
3840
|
+
def indeterminate?: () -> bool
|
|
3841
|
+
|
|
3842
|
+
# Switches between the fill and the sliding block. {#value} keeps working
|
|
3843
|
+
# while indeterminate — it is simply not painted — so switching back shows
|
|
3844
|
+
# the progress that accumulated meanwhile.
|
|
3845
|
+
#
|
|
3846
|
+
# The animation only runs while the bar is {Component#attached? attached},
|
|
3847
|
+
# and stops on detach. It also keeps the event loop awake at
|
|
3848
|
+
# {INDETERMINATE_FPS}, so turn it off (or remove the bar) when the job ends.
|
|
3849
|
+
#
|
|
3850
|
+
# _@param_ `flag` — coerced; truthiness decides.
|
|
3851
|
+
def indeterminate=: (bool flag) -> void
|
|
3852
|
+
|
|
3853
|
+
def on_attached: () -> void
|
|
3854
|
+
|
|
3855
|
+
def on_detached: () -> void
|
|
3856
|
+
|
|
3857
|
+
# Paints the bar on the first row of {#rect} and blanks the rest.
|
|
3858
|
+
#
|
|
3859
|
+
# Deliberately not `super`: {Component#repaint}'s default blanks the
|
|
3860
|
+
# *whole* rect, which dirties every cell of the bar's own row before it is
|
|
3861
|
+
# painted over — so {Buffer#flush} re-emits the entire row every frame
|
|
3862
|
+
# instead of the one or two cells that actually moved.
|
|
3863
|
+
def repaint: () -> void
|
|
3864
|
+
|
|
3865
|
+
# Filled cells out of `steps` — the rect width when painting, 100 for
|
|
3866
|
+
# {#percent}, so the bar and a {Label} showing the percentage can never
|
|
3867
|
+
# disagree about being done.
|
|
3868
|
+
#
|
|
3869
|
+
# _@param_ `steps`
|
|
3870
|
+
def scale: (Integer steps) -> Integer
|
|
3871
|
+
|
|
3872
|
+
# _@param_ `width` — columns available.
|
|
3873
|
+
#
|
|
3874
|
+
# _@return_ — the row, `width` glyphs wide.
|
|
3875
|
+
def glyphs: (Integer width) -> String
|
|
3876
|
+
|
|
3877
|
+
# Where the sliding block sits this frame: it enters at the left edge and
|
|
3878
|
+
# leaves at the right, one cell per frame, then loops. The period is one
|
|
3879
|
+
# short of `width + block` so at least one cell is always lit — a full
|
|
3880
|
+
# `width + block` blanks the bar for exactly one frame per cycle.
|
|
3881
|
+
#
|
|
3882
|
+
# _@param_ `width` — columns available.
|
|
3883
|
+
#
|
|
3884
|
+
# _@return_ — start column and length, clipped.
|
|
3885
|
+
def block_at: (Integer width) -> [Integer, Integer]
|
|
3886
|
+
|
|
3887
|
+
def resolved_bar_color: () -> Color?
|
|
3888
|
+
|
|
3889
|
+
# Brings the ticker in line with "animating and on screen". The sole writer
|
|
3890
|
+
# of `@ticker`, and idempotent, so the attach/detach hooks and
|
|
3891
|
+
# {#indeterminate=} are all the same call and a repeated `indeterminate =
|
|
3892
|
+
# true` cannot start a second one.
|
|
3893
|
+
def sync_ticker: () -> void
|
|
3894
|
+
|
|
3895
|
+
# _@return_ — lower bound of {#range}.
|
|
3896
|
+
attr_reader min: Float
|
|
3897
|
+
|
|
3898
|
+
# _@return_ — upper bound of {#range}.
|
|
3899
|
+
attr_reader max: Float
|
|
3900
|
+
|
|
3901
|
+
# _@return_ — the value as set, so a {Theme::Ref} comes back
|
|
3902
|
+
# unresolved. Both glyphs paint in it; `nil` (the default) is the
|
|
3903
|
+
# terminal's default foreground.
|
|
3904
|
+
attr_accessor bar_color: (Color | Theme::Ref | Symbol | Integer | ::Array[Integer])?
|
|
3905
|
+
|
|
3906
|
+
# _@return_ — the progress, clamped into {#range} when assigned — so
|
|
3907
|
+
# `bar.value = 999` on a `0..250` bar reads back as `250.0`.
|
|
3908
|
+
attr_accessor value: (Float | Numeric)
|
|
3909
|
+
end
|
|
3910
|
+
|
|
3911
|
+
# A single-line field whose {#value} is an `Integer` (or `nil` when empty).
|
|
3912
|
+
# The user may type only `0`–`9` and a single leading `-`; anything else is
|
|
3913
|
+
# silently rejected without moving the caret. Up/Down step the value by one
|
|
3914
|
+
# (an empty field counting as `0`). An empty or otherwise un-parseable
|
|
3915
|
+
# buffer reads back as `nil`:
|
|
3916
|
+
#
|
|
3917
|
+
# field = Component::IntegerField.new
|
|
3918
|
+
# field.on_value_change = ->(n) { puts n.inspect } # Integer or nil, per change
|
|
3919
|
+
# field.value = 42 # field shows "42"
|
|
3920
|
+
# field.value # => 42
|
|
3921
|
+
# field.clear # empties it; value => nil
|
|
3922
|
+
#
|
|
3923
|
+
# Like {ComboBox}, it *composes* a {TextField} (its single {HasContent}
|
|
3924
|
+
# child) rather than subclassing one — its face carries only the typed
|
|
3925
|
+
# {HasValue} value seam, never the widget's `String`-typed `text`. It's the
|
|
3926
|
+
# same wrapper shape as {ComboBox} minus the dropdown: a digit-filtered text
|
|
3927
|
+
# field re-exposed as a typed input. Give it a single-row {#rect}.
|
|
3928
|
+
#
|
|
3929
|
+
# == The value is a *derived parse* of the buffer
|
|
3930
|
+
# {#value} is `Integer(buffer, 10)` (or `nil`), recomputed on read — the
|
|
3931
|
+
# buffer is the single source of truth, {#value=} just writes it. So `"-"`
|
|
3932
|
+
# alone and `""` both read as `nil`, and `on_value_change` fires eagerly
|
|
3933
|
+
# once per real *value* change: typing `0`→`7` in `"07"` shifts the buffer
|
|
3934
|
+
# but not the value (`7`), so it does not fire. No normalization — a typed
|
|
3935
|
+
# `"007"` stays `"007"` on screen though its value is `7`.
|
|
3936
|
+
#
|
|
3937
|
+
# `min`/`max`, a `+` sign, and thousands separators are deliberately out of
|
|
3938
|
+
# scope (range and formatting are a forms concern).
|
|
3939
|
+
#
|
|
3940
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
3941
|
+
class IntegerField < Component
|
|
3942
|
+
include Tuile::Component::HasContent
|
|
3943
|
+
include Tuile::Component::HasValue
|
|
3944
|
+
|
|
3945
|
+
def initialize: () -> void
|
|
3946
|
+
|
|
3947
|
+
# _@return_ — the parsed buffer; `nil` when empty or not a
|
|
3948
|
+
# valid integer (e.g. a lone `"-"`).
|
|
3949
|
+
def value: () -> Integer?
|
|
3950
|
+
|
|
3951
|
+
# Writes `new_value` into the buffer and parks the caret at its end; fires
|
|
3952
|
+
# {#on_value_change} only if the value actually changed.
|
|
3953
|
+
#
|
|
3954
|
+
# _@param_ `new_value` — `nil` empties the field.
|
|
3955
|
+
def value=: (Integer? new_value) -> void
|
|
3956
|
+
|
|
3957
|
+
# `nil`, not `""`: an integer field with no parseable number is empty.
|
|
3958
|
+
def empty_value: () -> void
|
|
3959
|
+
|
|
3960
|
+
# _@return_ — the field's caret (the hardware cursor is delegated
|
|
3961
|
+
# to the inner field).
|
|
3962
|
+
def cursor_position: () -> Point?
|
|
3963
|
+
|
|
3964
|
+
# Fired when ENTER is pressed in the field; see {TextField#on_enter}.
|
|
3965
|
+
#
|
|
3966
|
+
# _@return_ — no-arg callable, or nil.
|
|
3967
|
+
def on_enter: () -> (Proc | Method)?
|
|
3968
|
+
|
|
3969
|
+
# _@param_ `callback`
|
|
3970
|
+
def on_enter=: ((Proc | Method)? callback) -> void
|
|
3971
|
+
|
|
3972
|
+
# Places the wrapped field across the whole rect ({HasContent} hook).
|
|
3973
|
+
#
|
|
3974
|
+
# _@param_ `field`
|
|
3975
|
+
def layout: (Component field) -> void
|
|
3976
|
+
|
|
3977
|
+
# The field's key interceptor, consulted *before* the field acts on the
|
|
3978
|
+
# key: Up/Down step the value; a printable key the field mustn't accept is
|
|
3979
|
+
# swallowed (so a rejected key never moves the caret); everything else —
|
|
3980
|
+
# digits, the leading sign, and all editing/navigation keys — falls
|
|
3981
|
+
# through.
|
|
3982
|
+
#
|
|
3983
|
+
# _@param_ `key`
|
|
3984
|
+
#
|
|
3985
|
+
# _@return_ — true to consume the key.
|
|
3986
|
+
def field_key: (String key) -> bool
|
|
3987
|
+
|
|
3988
|
+
# Nudges {#value} by `delta`, treating an empty/un-parseable field as `0`.
|
|
3989
|
+
#
|
|
3990
|
+
# _@param_ `delta`
|
|
3991
|
+
def step: (Integer delta) -> void
|
|
3992
|
+
|
|
3993
|
+
# A digit anywhere, or a `-` only as the very first character.
|
|
3994
|
+
#
|
|
3995
|
+
# _@param_ `char` — a single printable character.
|
|
3996
|
+
def accepts?: (String char) -> bool
|
|
3997
|
+
|
|
3998
|
+
# Re-emits {#on_value_change} with the freshly-parsed {#value}, but only
|
|
3999
|
+
# when it differs from the last one fired — so a buffer edit that leaves
|
|
4000
|
+
# the value unchanged (`"7"`→`"07"`) stays silent.
|
|
4001
|
+
def fire_if_changed: () -> void
|
|
4002
|
+
|
|
4003
|
+
# _@return_ — true iff {#value} equals {#empty_value}.
|
|
4004
|
+
def empty?: () -> bool
|
|
4005
|
+
|
|
4006
|
+
# Resets {#value} to {#empty_value}.
|
|
4007
|
+
def clear: () -> void
|
|
4008
|
+
|
|
4009
|
+
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
4010
|
+
# a read-only display field could override back to `false`. Only
|
|
4011
|
+
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
4012
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
4013
|
+
# `D-integer-field`).
|
|
4014
|
+
def focusable?: () -> bool
|
|
4015
|
+
|
|
4016
|
+
# _@param_ `event`
|
|
4017
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
4018
|
+
|
|
4019
|
+
# _@param_ `rect`
|
|
4020
|
+
def rect=: (Rect rect) -> void
|
|
4021
|
+
|
|
4022
|
+
def on_focus: () -> void
|
|
4023
|
+
end
|
|
4024
|
+
|
|
4025
|
+
# A borderless, tinted, non-focusable floating selection list — the dropdown
|
|
4026
|
+
# a text input drops open, drives by forwarding movement keys, and commits a
|
|
4027
|
+
# pick from: a non-modal {Popup} wrapping a {List} that never takes focus, so
|
|
4028
|
+
# the caret stays in the driving input while the caller refills the rows,
|
|
4029
|
+
# moves the highlight, and reads the pick.
|
|
4030
|
+
#
|
|
4031
|
+
# drop = Component::ListDropdown.new
|
|
4032
|
+
# drop.on_item_chosen = ->(index, _line) { commit(index) } # caller commits
|
|
4033
|
+
# # …then, per keystroke in the driving input's key handler:
|
|
4034
|
+
# drop.lines = matches.map { |m| render(m) } # caller filters + renders
|
|
4035
|
+
# drop.rect = Rect.new(...) # caller anchors + sizes it
|
|
4036
|
+
# drop.open
|
|
4037
|
+
# return true if drop.move(key) # Up/Down/PgUp/PgDn/^U/^D → list scroll
|
|
4038
|
+
# drop.choose if key == Keys::ENTER # commit the highlight
|
|
4039
|
+
#
|
|
4040
|
+
# It owns only what every such dropdown shares; everything that varies stays
|
|
4041
|
+
# with the driver: geometry/anchoring, filtering, row rendering, the commit
|
|
4042
|
+
# action, and ESC/Enter handling. ESC and Enter carry driver-specific tails
|
|
4043
|
+
# (ESC may revert a query; Enter may commit via {#choose} *or* via a separate
|
|
4044
|
+
# submit path), so {#move} claims neither — the driver calls {#choose} and
|
|
4045
|
+
# {#close} from its own branches.
|
|
4046
|
+
#
|
|
4047
|
+
# == Theming
|
|
4048
|
+
# Borderless, told apart from the content beneath by a background tint —
|
|
4049
|
+
# {Theme#input_bg_color} by default, assigned as a live {Theme::Ref} so it
|
|
4050
|
+
# tracks light/dark flips with no hook. Reassign {Component#bg_color=} for a
|
|
4051
|
+
# different tint (a `Theme.ref(:token)` keeps the flip-tracking).
|
|
4052
|
+
#
|
|
4053
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
4054
|
+
class ListDropdown < Tuile::Component::Popup
|
|
4055
|
+
MOVE_KEYS: ::Array[String]
|
|
4056
|
+
|
|
4057
|
+
def initialize: () -> void
|
|
4058
|
+
|
|
4059
|
+
# _@param_ `lines` — the rows to show; see {List#lines=}.
|
|
4060
|
+
def lines=: (::Array[untyped] lines) -> void
|
|
4061
|
+
|
|
4062
|
+
# _@return_ — the current rows.
|
|
4063
|
+
def lines: () -> ::Array[StyledString]
|
|
4064
|
+
|
|
4065
|
+
# _@param_ `proc` — commit callback; see {List#on_item_chosen}.
|
|
4066
|
+
def on_item_chosen=: ((Proc | Method)? proc) -> void
|
|
4067
|
+
|
|
4068
|
+
# _@param_ `cursor` — the highlight; see {List#cursor=}.
|
|
4069
|
+
def cursor=: (List::Cursor cursor) -> void
|
|
4070
|
+
|
|
4071
|
+
# _@return_ — the list's cursor (the current highlight).
|
|
4072
|
+
def cursor: () -> List::Cursor
|
|
4073
|
+
|
|
4074
|
+
# Forwards a cursor-movement key to the list. The driver calls this from
|
|
4075
|
+
# its own key handler; a truthy return means "consumed — stop here", falsy
|
|
4076
|
+
# means "not mine — proceed with normal editing/dispatch". Only {MOVE_KEYS}
|
|
4077
|
+
# are claimed, and only while open.
|
|
4078
|
+
#
|
|
4079
|
+
# _@param_ `key`
|
|
4080
|
+
#
|
|
4081
|
+
# _@return_ — true iff the key was consumed.
|
|
4082
|
+
def move: (String key) -> bool
|
|
4083
|
+
|
|
4084
|
+
# Commits the highlighted row by firing {List#on_item_chosen}, exactly as
|
|
4085
|
+
# pressing Enter on the focused list would — the driver calls this from its
|
|
4086
|
+
# own Enter branch.
|
|
4087
|
+
#
|
|
4088
|
+
# _@return_ — true iff a row was chosen (false when the cursor is
|
|
4089
|
+
# off-content).
|
|
4090
|
+
def choose: () -> bool
|
|
4091
|
+
|
|
4092
|
+
# The dropdown's {List}. Non-focusable on purpose: the driver forwards keys
|
|
4093
|
+
# while focus (and the caret) stay in its input, and a mouse click selects
|
|
4094
|
+
# an item without stealing focus — so the input never loses the cursor
|
|
4095
|
+
# mid-interaction.
|
|
4096
|
+
class Menu < Tuile::Component::List
|
|
4097
|
+
def focusable?: () -> bool
|
|
4098
|
+
|
|
4099
|
+
def tab_stop?: () -> bool
|
|
4100
|
+
end
|
|
4101
|
+
end
|
|
4102
|
+
|
|
4103
|
+
# A {Window} that lists options identified by single keyboard keys, asks
|
|
4104
|
+
# the user to pick one, and fires a callback with the picked key.
|
|
4105
|
+
#
|
|
4106
|
+
# Usable tiled (just add to a {Layout} and read picks via the block) or
|
|
4107
|
+
# as a popup via {.open}, which wraps it in a {Popup} that closes itself
|
|
4108
|
+
# after a pick. ESC / `q` close without firing the callback.
|
|
4109
|
+
class PickerWindow < Tuile::Component::Window
|
|
4110
|
+
MAX_ITEMS: Integer
|
|
4111
|
+
|
|
4112
|
+
# _@param_ `caption` — the window caption.
|
|
4113
|
+
#
|
|
4114
|
+
# _@param_ `options` — pairs of keyboard key and option caption. No Rainbow formatting must be used.
|
|
4115
|
+
def initialize: (String caption, ::Array[[String, String]] options) ?{ (String key) -> void } -> void
|
|
4116
|
+
|
|
4117
|
+
# Handles an option-key press. Reached by bubbling: the inner {List}
|
|
4118
|
+
# (the focused component) sees the key first and handles cursor/Enter
|
|
4119
|
+
# picks; anything it declines bubbles up here, where a key matching an
|
|
4120
|
+
# option's `key` picks that option.
|
|
4121
|
+
#
|
|
4122
|
+
# _@param_ `key`
|
|
4123
|
+
def handle_key: (String key) -> bool
|
|
4124
|
+
|
|
4125
|
+
def keyboard_hint: () -> String
|
|
4126
|
+
|
|
4127
|
+
# Opens a picker as a popup. Picking an option fires `block`, then
|
|
4128
|
+
# closes the popup; ESC / `q` close without firing `block`.
|
|
4129
|
+
#
|
|
4130
|
+
# _@param_ `caption`
|
|
4131
|
+
#
|
|
4132
|
+
# _@param_ `options`
|
|
4133
|
+
#
|
|
4134
|
+
# _@return_ — the wrapping popup.
|
|
4135
|
+
def self.open: (String caption, ::Array[[String, String]] options) ?{ (String key) -> void } -> Popup
|
|
4136
|
+
|
|
4137
|
+
# _@param_ `key`
|
|
4138
|
+
def select_option: (String key) -> void
|
|
4139
|
+
|
|
4140
|
+
# Callback invoked after the user picks an option (after the block
|
|
4141
|
+
# fires). The {Popup} returned by {.open} sets this to its own `close`.
|
|
4142
|
+
attr_accessor on_pick: Proc?
|
|
4143
|
+
|
|
4144
|
+
# One picker option.
|
|
4145
|
+
#
|
|
4146
|
+
# @!attribute [r] key
|
|
4147
|
+
# @return [String] the keyboard key that picks this option.
|
|
4148
|
+
# @!attribute [r] caption
|
|
4149
|
+
# @return [String] the option caption.
|
|
4150
|
+
class Option
|
|
4151
|
+
# _@return_ — the keyboard key that picks this option.
|
|
4152
|
+
attr_reader key: String
|
|
4153
|
+
|
|
4154
|
+
# _@return_ — the option caption.
|
|
4155
|
+
attr_reader caption: String
|
|
2859
4156
|
end
|
|
2860
4157
|
end
|
|
2861
4158
|
|
|
2862
|
-
#
|
|
2863
|
-
#
|
|
2864
|
-
#
|
|
2865
|
-
#
|
|
2866
|
-
#
|
|
2867
|
-
#
|
|
2868
|
-
#
|
|
2869
|
-
#
|
|
2870
|
-
#
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
4159
|
+
# Multi-select from a set of typed items, one checkable row each. Arrows move
|
|
4160
|
+
# a cursor; Space, Enter or a left click toggles the row under it:
|
|
4161
|
+
#
|
|
4162
|
+
# [x] Errors
|
|
4163
|
+
# [ ] Warnings <- cursor row, highlighted across the full width
|
|
4164
|
+
# [x] Info
|
|
4165
|
+
# ^ the composed {List}'s one-column gutter
|
|
4166
|
+
#
|
|
4167
|
+
# cg = Component::CheckboxGroup.new(items: %w[Errors Warnings Info])
|
|
4168
|
+
# cg.value = %w[Errors Info] # any Enumerable, stored as a Set
|
|
4169
|
+
# cg.on_value_change = ->(set) { filter(set) } # once per toggle
|
|
4170
|
+
# cg.value # => #<Set: {"Errors", "Info"}>
|
|
4171
|
+
# cg.item_label = ->(level) { level.name } # default :to_s
|
|
4172
|
+
#
|
|
4173
|
+
# {#value} is a **frozen `Set` of the selected items themselves** — of
|
|
4174
|
+
# whatever type {#items} holds, never their labels. Frozen so `cg.value <<
|
|
4175
|
+
# item` fails loudly rather than mutating the selection behind
|
|
4176
|
+
# {HasValue#on_value_change}'s back; assign a new set or an `Array` instead.
|
|
4177
|
+
# Treat it as *unordered*: it iterates in toggle order, so use
|
|
4178
|
+
# `cg.items & cg.value.to_a` when you need {#items} order.
|
|
4179
|
+
#
|
|
4180
|
+
# Composes rather than subclasses, like {ComboBox}: a {List} is its single
|
|
4181
|
+
# {HasContent} child, which is where the cursor, scrolling, the scrollbar and
|
|
4182
|
+
# per-row mouse hit-testing come from. `content` is that list, so an app can
|
|
4183
|
+
# tune it (`scrollbar_visibility`, `show_cursor_when_inactive`, …). Rows
|
|
4184
|
+
# beyond {#rect}'s height scroll; the inner list is the tab stop, not the
|
|
4185
|
+
# group.
|
|
4186
|
+
#
|
|
4187
|
+
# == +items+ is chrome; +value+ is authoritative
|
|
4188
|
+
# {#items=} changes only what is *presented*. It never touches {#value} and
|
|
4189
|
+
# never fires {HasValue#on_value_change}, and a selected item absent from
|
|
4190
|
+
# {#items} renders no checked row while surviving intact — so a form saved
|
|
4191
|
+
# without the user editing anything changes nothing silently. Keeping the two
|
|
4192
|
+
# in sync is the app's job: `cg.value &= cg.items.to_set` reconciles them.
|
|
4193
|
+
# Same contract as {ComboBox#value}, one item at a time.
|
|
4194
|
+
#
|
|
4195
|
+
# There is no select-all — neither a key nor a header row. An app that wants
|
|
4196
|
+
# one writes `cg.value = cg.items` behind its own affordance.
|
|
4197
|
+
#
|
|
4198
|
+
# == Implementation details
|
|
4199
|
+
# Items need stable `#hash`/`#eql?`, since the selection is a `Set`: an item
|
|
4200
|
+
# mutated after being selected becomes unfindable. Two `==`-equal items also
|
|
4201
|
+
# share one selection — their rows check and uncheck together — whereas two
|
|
4202
|
+
# *distinct* items that merely render the same label toggle independently,
|
|
4203
|
+
# because a row resolves to an item by index.
|
|
4204
|
+
#
|
|
4205
|
+
# Rows repeat {Checkbox}'s `[x] `/`[ ] ` glyph convention rather than
|
|
4206
|
+
# importing a constant from it.
|
|
4207
|
+
#
|
|
4208
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
4209
|
+
class CheckboxGroup < Component
|
|
4210
|
+
include Tuile::Component::HasContent
|
|
4211
|
+
include Tuile::Component::HasValue
|
|
4212
|
+
EMPTY_SELECTION: ::Set[untyped]
|
|
2874
4213
|
|
|
2875
|
-
#
|
|
2876
|
-
#
|
|
2877
|
-
#
|
|
2878
|
-
|
|
2879
|
-
def popup_min_height: () -> Integer
|
|
4214
|
+
# _@param_ `items` — the items to present, one row each; also settable via {#items=}.
|
|
4215
|
+
#
|
|
4216
|
+
# _@param_ `value` — the initial selection. Seeds the backing ivar directly, so no listener fires and assignment order doesn't matter to a form helper.
|
|
4217
|
+
def initialize: (?items: ::Array[untyped], ?value: ::Enumerable[untyped]?) -> void
|
|
2880
4218
|
|
|
2881
|
-
#
|
|
2882
|
-
#
|
|
2883
|
-
|
|
2884
|
-
# by {Component::Popup#max_height} when this window is a popup's content.
|
|
2885
|
-
def popup_max_height: () -> Integer
|
|
4219
|
+
# _@return_ — the frozen empty set — {HasValue#empty?} means nothing is
|
|
4220
|
+
# selected.
|
|
4221
|
+
def empty_value: () -> ::Set[untyped]
|
|
2886
4222
|
|
|
2887
|
-
#
|
|
4223
|
+
# Replaces the selection, firing {HasValue#on_value_change} when it really
|
|
4224
|
+
# changed. Stores a frozen `Set` *copy*, so a set the caller goes on
|
|
4225
|
+
# mutating can't reach in.
|
|
2888
4226
|
#
|
|
2889
|
-
# _@param_ `
|
|
2890
|
-
def
|
|
4227
|
+
# _@param_ `new_value` — `nil` selects nothing.
|
|
4228
|
+
def value=: (::Enumerable[untyped]? new_value) -> void
|
|
2891
4229
|
|
|
2892
|
-
#
|
|
2893
|
-
#
|
|
2894
|
-
#
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
4230
|
+
# Toggles the cursor row on Space. Nothing else is claimed: the composed
|
|
4231
|
+
# {List} — being the focused component — has already had its chance at the
|
|
4232
|
+
# key (its arrows, Home/End, PgUp/PgDn, ^U/^D and Enter), and whatever
|
|
4233
|
+
# neither of us wants bubbles on to an ancestor.
|
|
4234
|
+
#
|
|
4235
|
+
# _@param_ `key`
|
|
4236
|
+
def handle_key: (String key) -> bool
|
|
2898
4237
|
|
|
2899
|
-
|
|
2900
|
-
|
|
4238
|
+
# Places the composed list across the whole rect ({HasContent} hook).
|
|
4239
|
+
#
|
|
4240
|
+
# _@param_ `list`
|
|
4241
|
+
def layout: (Component list) -> void
|
|
2901
4242
|
|
|
2902
|
-
|
|
2903
|
-
|
|
4243
|
+
# Flips membership of the item on row `index`; an index outside {#items} is
|
|
4244
|
+
# ignored.
|
|
4245
|
+
#
|
|
4246
|
+
# _@param_ `index`
|
|
4247
|
+
def toggle_at: (Integer index) -> void
|
|
2904
4248
|
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
# interpret it as a filename. This is a no-op.
|
|
2908
|
-
def close: () -> void
|
|
2909
|
-
end
|
|
2910
|
-
end
|
|
4249
|
+
# Re-renders every row from the current items, labels and selection.
|
|
4250
|
+
def rebuild_rows: () -> void
|
|
2911
4251
|
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
# last char) is rejected.
|
|
2917
|
-
#
|
|
2918
|
-
# The caret is a logical index in `0..text.length`. The hardware cursor is
|
|
2919
|
-
# positioned by {Screen} after each repaint cycle when this component is
|
|
2920
|
-
# focused; see {Component#cursor_position}.
|
|
2921
|
-
class TextField < Tuile::Component::TextInput
|
|
2922
|
-
def initialize: () -> void
|
|
4252
|
+
# _@param_ `new_value`
|
|
4253
|
+
#
|
|
4254
|
+
# _@return_ — a frozen copy; `nil` becomes {#empty_value}.
|
|
4255
|
+
def coerce: (::Enumerable[untyped]? new_value) -> ::Set[untyped]
|
|
2923
4256
|
|
|
2924
|
-
|
|
4257
|
+
# _@param_ `item`
|
|
4258
|
+
#
|
|
4259
|
+
# _@return_ — whichever {StyledString#+} accepts on the
|
|
4260
|
+
# right — so a styled label keeps its spans and a plain one is parsed.
|
|
4261
|
+
def label_for: (Object item) -> (StyledString | String)
|
|
4262
|
+
|
|
4263
|
+
# _@return_ — the current value; `nil` until first set.
|
|
4264
|
+
def value: () -> Object
|
|
4265
|
+
|
|
4266
|
+
# _@return_ — true iff {#value} equals {#empty_value}.
|
|
4267
|
+
def empty?: () -> bool
|
|
4268
|
+
|
|
4269
|
+
# Resets {#value} to {#empty_value}.
|
|
4270
|
+
def clear: () -> void
|
|
4271
|
+
|
|
4272
|
+
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
4273
|
+
# a read-only display field could override back to `false`. Only
|
|
4274
|
+
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
4275
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
4276
|
+
# `D-integer-field`).
|
|
4277
|
+
def focusable?: () -> bool
|
|
2925
4278
|
|
|
2926
4279
|
# _@param_ `event`
|
|
2927
4280
|
def handle_mouse: (MouseEvent event) -> void
|
|
2928
4281
|
|
|
2929
|
-
|
|
4282
|
+
# _@param_ `rect`
|
|
4283
|
+
def rect=: (Rect rect) -> void
|
|
2930
4284
|
|
|
2931
|
-
|
|
2932
|
-
# their width.
|
|
2933
|
-
#
|
|
2934
|
-
# _@param_ `new_text`
|
|
2935
|
-
def preprocess_text: (String new_text) -> String
|
|
4285
|
+
def on_focus: () -> void
|
|
2936
4286
|
|
|
2937
|
-
# _@
|
|
2938
|
-
|
|
4287
|
+
# _@return_ — the presented items.
|
|
4288
|
+
attr_accessor items: ::Array[untyped]
|
|
2939
4289
|
|
|
2940
|
-
|
|
4290
|
+
# _@return_ — item -> row label (a `String`, {StyledString}, or
|
|
4291
|
+
# anything with `#to_s`); `:to_s` by default.
|
|
4292
|
+
attr_accessor item_label: (Proc | Method)
|
|
4293
|
+
end
|
|
4294
|
+
|
|
4295
|
+
# A {TextField} that paints one mask glyph per character instead of the
|
|
4296
|
+
# text. Editing, caret, clicks and horizontal scrolling are the field's,
|
|
4297
|
+
# unchanged:
|
|
4298
|
+
#
|
|
4299
|
+
# pf = Component::PasswordField.new
|
|
4300
|
+
# pf.rect = Rect.new(0, 0, 20, 1)
|
|
4301
|
+
# pf.value # => the plaintext String
|
|
4302
|
+
# pf.mask_char = "•" # default "*"
|
|
4303
|
+
# pf.revealed = true # show the plaintext, e.g. behind a Checkbox
|
|
4304
|
+
#
|
|
4305
|
+
# A password's value *is* its text, so this subclasses {TextField} rather
|
|
4306
|
+
# than composing one the way {IntegerField} does — the delta is presentation
|
|
4307
|
+
# only, and it lands entirely on {TextField#display_text}.
|
|
4308
|
+
#
|
|
4309
|
+
# == What it hides, and what it doesn't
|
|
4310
|
+
# The plaintext is an ordinary Ruby `String`: not pinned, not wiped, not
|
|
4311
|
+
# kept out of GC. Anything stronger needs a frozen-buffer type and the
|
|
4312
|
+
# cooperation of every consumer, which is out of scope for a widget.
|
|
4313
|
+
#
|
|
4314
|
+
# The mask shows the text's *length* — accepted, since a caret has to sit
|
|
4315
|
+
# somewhere. Its *word structure* is hidden: CTRL+LEFT / CTRL+RIGHT jump to
|
|
4316
|
+
# the ends while masked instead of hopping the spaces a watcher could then
|
|
4317
|
+
# read off the caret. They resume word-jumping when {#revealed}.
|
|
4318
|
+
#
|
|
4319
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
4320
|
+
class PasswordField < Tuile::Component::TextField
|
|
4321
|
+
def initialize: () -> void
|
|
2941
4322
|
|
|
2942
|
-
#
|
|
2943
|
-
def
|
|
4323
|
+
# _@return_ — {#revealed} in predicate form.
|
|
4324
|
+
def revealed?: () -> bool
|
|
4325
|
+
|
|
4326
|
+
# _@return_ — the mask, one glyph per character, unless {#revealed}.
|
|
4327
|
+
def display_text: () -> String
|
|
2944
4328
|
|
|
2945
4329
|
# _@param_ `char`
|
|
2946
|
-
def
|
|
4330
|
+
def single_cluster?: (String char) -> bool
|
|
2947
4331
|
|
|
2948
|
-
#
|
|
2949
|
-
|
|
2950
|
-
# (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
|
|
2951
|
-
# since `k` is a printable character inserted into {#text}.
|
|
2952
|
-
#
|
|
2953
|
-
# _@return_ — no-arg callable, or nil.
|
|
2954
|
-
attr_accessor on_key_up: (Proc | Method)?
|
|
4332
|
+
# _@return_ — caret target for CTRL+LEFT: the start, while masked.
|
|
4333
|
+
def word_left: () -> Integer
|
|
2955
4334
|
|
|
2956
|
-
#
|
|
2957
|
-
|
|
2958
|
-
# parent (default behavior). Only triggered by {Keys::DOWN_ARROW}, not by
|
|
2959
|
-
# `j`, since `j` is a printable character inserted into {#text}.
|
|
2960
|
-
#
|
|
2961
|
-
# _@return_ — no-arg callable, or nil.
|
|
2962
|
-
attr_accessor on_key_down: (Proc | Method)?
|
|
4335
|
+
# _@return_ — caret target for CTRL+RIGHT: the end, while masked.
|
|
4336
|
+
def word_right: () -> Integer
|
|
2963
4337
|
|
|
2964
|
-
#
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
#
|
|
2968
|
-
|
|
2969
|
-
attr_accessor on_enter: (Proc | Method)?
|
|
4338
|
+
# _@return_ — the glyph painted per character; `"*"` by default.
|
|
4339
|
+
attr_accessor mask_char: String
|
|
4340
|
+
|
|
4341
|
+
# _@return_ — whether the plaintext is shown; `false` by default.
|
|
4342
|
+
attr_accessor revealed: (bool | Object)
|
|
2970
4343
|
end
|
|
2971
4344
|
|
|
2972
|
-
# Abstract base for editable text components
|
|
4345
|
+
# Abstract base for the **String-valued** editable text components
|
|
4346
|
+
# ({TextField}, {TextArea}): a field whose {HasValue#value} *is* its text.
|
|
4347
|
+
# A field whose value is a different type (an `Integer`, a domain object)
|
|
4348
|
+
# *composes* one of these rather than subclassing it — subclassing would
|
|
4349
|
+
# drag this String-typed `text`/`value` seam onto its face alongside the
|
|
4350
|
+
# real typed one.
|
|
2973
4351
|
#
|
|
2974
4352
|
# Holds the shared state — a mutable {#text} buffer, a {#caret} index,
|
|
2975
4353
|
# {#on_change} and {#on_escape} callbacks — and the keyboard machinery
|
|
2976
4354
|
# that single-line and multi-line inputs both need: ESC handling,
|
|
2977
4355
|
# LEFT/RIGHT caret movement, CTRL+LEFT/CTRL+RIGHT word jumps, and the
|
|
2978
|
-
# `
|
|
4356
|
+
# `tab_stop?` flag (`focusable?` comes from {HasValue}).
|
|
4357
|
+
#
|
|
4358
|
+
# {#caret} counts *characters* into {#text} but may only sit *between*
|
|
4359
|
+
# grapheme clusters — the glyphs a terminal draws. Both write sites snap it
|
|
4360
|
+
# forward onto the enclosing cluster's end, and every edit steps by a whole
|
|
4361
|
+
# cluster:
|
|
4362
|
+
#
|
|
4363
|
+
# f.text = "e\u{0301}x" # a decomposed e-acute then "x": 3 chars, 2 glyphs
|
|
4364
|
+
# f.caret = 1 # into the middle of the e-acute …
|
|
4365
|
+
# f.caret # => 2, its end — where the caret already drew
|
|
4366
|
+
# f.handle_key(Keys::BACKSPACE)
|
|
4367
|
+
# f.text # => "x": the whole glyph went, not its accent
|
|
4368
|
+
#
|
|
4369
|
+
# Insertion stays character-native, so `String#insert` merges a typed
|
|
4370
|
+
# combining mark into its base; {#text=}'s snap covers the case where that
|
|
4371
|
+
# re-segments the text around the caret.
|
|
2979
4372
|
#
|
|
2980
4373
|
# Subclasses implement the layout-specific pieces ({#cursor_position},
|
|
2981
4374
|
# {#repaint}) and add their own keys (HOME/END, ENTER, UP/DOWN,
|
|
@@ -2992,13 +4385,22 @@ module Tuile
|
|
|
2992
4385
|
# - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
|
|
2993
4386
|
# effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
|
|
2994
4387
|
# keep the caret visible).
|
|
2995
|
-
class
|
|
4388
|
+
class AbstractStringField < Component
|
|
4389
|
+
include Tuile::Component::HasValue
|
|
4390
|
+
|
|
2996
4391
|
def initialize: () -> void
|
|
2997
4392
|
|
|
2998
|
-
#
|
|
2999
|
-
|
|
4393
|
+
# A text component's value *is* its text: {#value}/{#value=} are the
|
|
4394
|
+
# {HasValue} seam over the same buffer as {#text}/{#text=}, so a form can
|
|
4395
|
+
# drive it alongside typed fields. `text` stays the text-native name.
|
|
4396
|
+
def value: () -> String
|
|
3000
4397
|
|
|
3001
|
-
|
|
4398
|
+
# sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS
|
|
4399
|
+
# _@param_ `new_value`
|
|
4400
|
+
def value=: ((String | _ToS) new_value) -> void
|
|
4401
|
+
|
|
4402
|
+
# `""` (not `nil`): a text field is empty when its buffer is blank.
|
|
4403
|
+
def empty_value: () -> String
|
|
3002
4404
|
|
|
3003
4405
|
def tab_stop?: () -> bool
|
|
3004
4406
|
|
|
@@ -3029,6 +4431,17 @@ module Tuile
|
|
|
3029
4431
|
# _@return_ — possibly transformed text.
|
|
3030
4432
|
def preprocess_text: (String new_text) -> String
|
|
3031
4433
|
|
|
4434
|
+
# The one measurement primitive both inputs share: a caret index counts
|
|
4435
|
+
# characters, but every rect, cursor and click counts columns, and only
|
|
4436
|
+
# this converts between them.
|
|
4437
|
+
#
|
|
4438
|
+
# _@param_ `str`
|
|
4439
|
+
#
|
|
4440
|
+
# _@return_ — `str`'s width in terminal columns, measured per
|
|
4441
|
+
# grapheme cluster — so a combining mark adds nothing and a fullwidth
|
|
4442
|
+
# glyph adds two.
|
|
4443
|
+
def columns_of: (String str) -> Integer
|
|
4444
|
+
|
|
3032
4445
|
# Hook called after {#text} has been mutated, before invalidation /
|
|
3033
4446
|
# {#on_change}. Default no-op. Subclasses use this to invalidate caches
|
|
3034
4447
|
# ({TextArea}'s wrap cache) and update derived state.
|
|
@@ -3041,7 +4454,8 @@ module Tuile
|
|
|
3041
4454
|
|
|
3042
4455
|
# Dispatch hook for {#handle_key}. Handles ESC and the navigation keys
|
|
3043
4456
|
# that have identical semantics in single-line and multi-line inputs:
|
|
3044
|
-
# LEFT/RIGHT arrows,
|
|
4457
|
+
# LEFT/RIGHT arrows (one grapheme cluster per press, so a press always
|
|
4458
|
+
# moves), CTRL+LEFT/CTRL+RIGHT for word jumps. Subclasses
|
|
3045
4459
|
# override to add their own keys (HOME/END, UP/DOWN, ENTER, BACKSPACE/
|
|
3046
4460
|
# DELETE, printable insertion) and call `super` to fall back to the
|
|
3047
4461
|
# common navigation handling.
|
|
@@ -3051,10 +4465,31 @@ module Tuile
|
|
|
3051
4465
|
# _@return_ — true if the key was handled.
|
|
3052
4466
|
def handle_text_input_key: (String key) -> bool
|
|
3053
4467
|
|
|
4468
|
+
# Removes the whole grapheme cluster before the caret — one press, one
|
|
4469
|
+
# glyph, whatever it is built from (a ZWJ emoji family and a three-jamo
|
|
4470
|
+
# Hangul syllable each go whole).
|
|
3054
4471
|
def delete_before_caret: () -> void
|
|
3055
4472
|
|
|
4473
|
+
# Removes the whole grapheme cluster at the caret.
|
|
3056
4474
|
def delete_at_caret: () -> void
|
|
3057
4475
|
|
|
4476
|
+
# _@param_ `index` — a {#text} index in `0..text.length`.
|
|
4477
|
+
#
|
|
4478
|
+
# _@return_ — the smallest grapheme-cluster boundary `>= index`.
|
|
4479
|
+
def snap_to_cluster: (Integer index) -> Integer
|
|
4480
|
+
|
|
4481
|
+
# _@param_ `index`
|
|
4482
|
+
#
|
|
4483
|
+
# _@return_ — the greatest grapheme-cluster boundary `< index`, or
|
|
4484
|
+
# 0 at the start of the text.
|
|
4485
|
+
def cluster_boundary_before: (Integer index) -> Integer
|
|
4486
|
+
|
|
4487
|
+
# _@param_ `index`
|
|
4488
|
+
#
|
|
4489
|
+
# _@return_ — the smallest grapheme-cluster boundary `> index`, or
|
|
4490
|
+
# `text.length` at the end of the text.
|
|
4491
|
+
def cluster_boundary_after: (Integer index) -> Integer
|
|
4492
|
+
|
|
3058
4493
|
# Default {#on_escape} action: clear focus. Component deactivates; user
|
|
3059
4494
|
# can re-focus by clicking or tabbing back in.
|
|
3060
4495
|
def default_on_escape: () -> void
|
|
@@ -3069,10 +4504,24 @@ module Tuile
|
|
|
3069
4504
|
# end of the text if no further word exists.
|
|
3070
4505
|
def word_right: () -> Integer
|
|
3071
4506
|
|
|
4507
|
+
# _@return_ — true iff {#value} equals {#empty_value}.
|
|
4508
|
+
def empty?: () -> bool
|
|
4509
|
+
|
|
4510
|
+
# Resets {#value} to {#empty_value}.
|
|
4511
|
+
def clear: () -> void
|
|
4512
|
+
|
|
4513
|
+
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
4514
|
+
# a read-only display field could override back to `false`. Only
|
|
4515
|
+
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
4516
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
4517
|
+
# `D-integer-field`).
|
|
4518
|
+
def focusable?: () -> bool
|
|
4519
|
+
|
|
3072
4520
|
# _@return_ — current text contents.
|
|
3073
4521
|
attr_accessor text: String
|
|
3074
4522
|
|
|
3075
|
-
# _@return_ — caret index in `0..text.length
|
|
4523
|
+
# _@return_ — caret index in `0..text.length`, counting characters
|
|
4524
|
+
# and always on a grapheme-cluster boundary (see the class doc).
|
|
3076
4525
|
attr_accessor caret: Integer
|
|
3077
4526
|
|
|
3078
4527
|
# Optional callback fired whenever {#text} changes. Receives the new text
|
|
@@ -3106,101 +4555,6 @@ module Tuile
|
|
|
3106
4555
|
# _@return_ — no-arg callable, or nil.
|
|
3107
4556
|
attr_accessor on_escape: (Proc | Method)?
|
|
3108
4557
|
end
|
|
3109
|
-
|
|
3110
|
-
# A mixin interface for a component with one child tops. The host must
|
|
3111
|
-
# provide a protected `layout(content)` method which repositions the
|
|
3112
|
-
# content component; the mixin manages `@content` itself.
|
|
3113
|
-
module HasContent
|
|
3114
|
-
# _@param_ `event`
|
|
3115
|
-
def handle_mouse: (MouseEvent event) -> void
|
|
3116
|
-
|
|
3117
|
-
def children: () -> ::Array[Component]
|
|
3118
|
-
|
|
3119
|
-
# _@param_ `rect`
|
|
3120
|
-
def rect=: (Rect rect) -> void
|
|
3121
|
-
|
|
3122
|
-
def on_focus: () -> void
|
|
3123
|
-
|
|
3124
|
-
# _@return_ — the current content component.
|
|
3125
|
-
attr_accessor content: Component?
|
|
3126
|
-
end
|
|
3127
|
-
|
|
3128
|
-
# A {Window} preconfigured with a {List} of static lines. Useful for
|
|
3129
|
-
# showing read-only information.
|
|
3130
|
-
#
|
|
3131
|
-
# Usable tiled (just add to a {Layout}) or as a popup via {.open}, which
|
|
3132
|
-
# wraps it in a {Popup}.
|
|
3133
|
-
class InfoWindow < Tuile::Component::Window
|
|
3134
|
-
# _@param_ `caption`
|
|
3135
|
-
#
|
|
3136
|
-
# _@param_ `lines` — initial content; each entry may contain Rainbow formatting.
|
|
3137
|
-
def initialize: (?String caption, ?::Array[String] lines) -> void
|
|
3138
|
-
|
|
3139
|
-
# Opens the info window as a popup.
|
|
3140
|
-
#
|
|
3141
|
-
# _@param_ `caption`
|
|
3142
|
-
#
|
|
3143
|
-
# _@param_ `lines` — the content, may contain formatting.
|
|
3144
|
-
#
|
|
3145
|
-
# _@return_ — the opened popup.
|
|
3146
|
-
def self.open: (String caption, ::Array[String] lines) -> Popup
|
|
3147
|
-
end
|
|
3148
|
-
|
|
3149
|
-
# A {Window} that lists options identified by single keyboard keys, asks
|
|
3150
|
-
# the user to pick one, and fires a callback with the picked key.
|
|
3151
|
-
#
|
|
3152
|
-
# Usable tiled (just add to a {Layout} and read picks via the block) or
|
|
3153
|
-
# as a popup via {.open}, which wraps it in a {Popup} that closes itself
|
|
3154
|
-
# after a pick. ESC / `q` close without firing the callback.
|
|
3155
|
-
class PickerWindow < Tuile::Component::Window
|
|
3156
|
-
MAX_ITEMS: Integer
|
|
3157
|
-
|
|
3158
|
-
# _@param_ `caption` — the window caption.
|
|
3159
|
-
#
|
|
3160
|
-
# _@param_ `options` — pairs of keyboard key and option caption. No Rainbow formatting must be used.
|
|
3161
|
-
def initialize: (String caption, ::Array[[String, String]] options) ?{ (String key) -> void } -> void
|
|
3162
|
-
|
|
3163
|
-
# Handles an option-key press. Reached by bubbling: the inner {List}
|
|
3164
|
-
# (the focused component) sees the key first and handles cursor/Enter
|
|
3165
|
-
# picks; anything it declines bubbles up here, where a key matching an
|
|
3166
|
-
# option's `key` picks that option.
|
|
3167
|
-
#
|
|
3168
|
-
# _@param_ `key`
|
|
3169
|
-
def handle_key: (String key) -> bool
|
|
3170
|
-
|
|
3171
|
-
def keyboard_hint: () -> String
|
|
3172
|
-
|
|
3173
|
-
# Opens a picker as a popup. Picking an option fires `block`, then
|
|
3174
|
-
# closes the popup; ESC / `q` close without firing `block`.
|
|
3175
|
-
#
|
|
3176
|
-
# _@param_ `caption`
|
|
3177
|
-
#
|
|
3178
|
-
# _@param_ `options`
|
|
3179
|
-
#
|
|
3180
|
-
# _@return_ — the wrapping popup.
|
|
3181
|
-
def self.open: (String caption, ::Array[[String, String]] options) ?{ (String key) -> void } -> Popup
|
|
3182
|
-
|
|
3183
|
-
# _@param_ `key`
|
|
3184
|
-
def select_option: (String key) -> void
|
|
3185
|
-
|
|
3186
|
-
# Callback invoked after the user picks an option (after the block
|
|
3187
|
-
# fires). The {Popup} returned by {.open} sets this to its own `close`.
|
|
3188
|
-
attr_accessor on_pick: Proc?
|
|
3189
|
-
|
|
3190
|
-
# One picker option.
|
|
3191
|
-
#
|
|
3192
|
-
# @!attribute [r] key
|
|
3193
|
-
# @return [String] the keyboard key that picks this option.
|
|
3194
|
-
# @!attribute [r] caption
|
|
3195
|
-
# @return [String] the option caption.
|
|
3196
|
-
class Option
|
|
3197
|
-
# _@return_ — the keyboard key that picks this option.
|
|
3198
|
-
attr_reader key: String
|
|
3199
|
-
|
|
3200
|
-
# _@return_ — the option caption.
|
|
3201
|
-
attr_reader caption: String
|
|
3202
|
-
end
|
|
3203
|
-
end
|
|
3204
4558
|
end
|
|
3205
4559
|
|
|
3206
4560
|
# An app's theme definition: the {Theme} pair covering both terminal
|
|
@@ -3296,25 +4650,29 @@ module Tuile
|
|
|
3296
4650
|
# Awaits until the event queue is empty (all events have been processed).
|
|
3297
4651
|
def await_empty: () -> void
|
|
3298
4652
|
|
|
3299
|
-
# Schedules `block` to fire on the event-loop thread
|
|
3300
|
-
#
|
|
3301
|
-
#
|
|
3302
|
-
#
|
|
3303
|
-
#
|
|
3304
|
-
# The returned {Ticker} controls the schedule — call {Ticker#cancel} to
|
|
3305
|
-
# stop it.
|
|
4653
|
+
# Schedules `block` to fire on the event-loop thread every `seconds`,
|
|
4654
|
+
# passing a 0-based monotonically increasing tick counter — `tick(0.2)`
|
|
4655
|
+
# fires five times a second. Use it for periodic UI refresh (poll a status,
|
|
4656
|
+
# redraw a clock); for animation, {#tick_fps} reads more naturally. The
|
|
4657
|
+
# returned {Ticker} controls the schedule — {Ticker#cancel} stops it.
|
|
3306
4658
|
#
|
|
3307
4659
|
# **Errors:** if `block` raises, the {Ticker} cancels itself and the
|
|
3308
|
-
# exception flows through the normal event-loop error path
|
|
3309
|
-
# {Screen#on_error}
|
|
3310
|
-
#
|
|
4660
|
+
# exception flows through the normal event-loop error path
|
|
4661
|
+
# ({Screen#on_error} by default) — auto-cancel keeps a broken block from
|
|
4662
|
+
# spamming `on_error` at the tick rate.
|
|
4663
|
+
#
|
|
4664
|
+
# Tickers reuse `concurrent-ruby`'s shared timer thread, so adding more
|
|
4665
|
+
# tickers doesn't add threads.
|
|
3311
4666
|
#
|
|
3312
|
-
#
|
|
3313
|
-
|
|
3314
|
-
|
|
4667
|
+
# _@param_ `seconds` — interval between firings, must be positive. Fractional values are fine (`tick(0.05)` ⇒ ~20 firings a second).
|
|
4668
|
+
def tick: (Numeric seconds) ?{ (Integer tick) -> void } -> Ticker
|
|
4669
|
+
|
|
4670
|
+
# Frames-per-second convenience over {#tick}: `tick_fps(15)` is exactly
|
|
4671
|
+
# `tick(1.0 / 15)`. Reads naturally for animation (a `/-\|` spinner, a
|
|
4672
|
+
# progress pulse) where you think in frames, not intervals.
|
|
3315
4673
|
#
|
|
3316
|
-
# _@param_ `fps` — firings per second, must be positive. Fractional values are fine (`
|
|
3317
|
-
def
|
|
4674
|
+
# _@param_ `fps` — firings per second, must be positive. Fractional values are fine (`tick_fps(0.5)` ⇒ one firing every two seconds).
|
|
4675
|
+
def tick_fps: (Numeric fps) ?{ (Integer tick) -> void } -> Ticker
|
|
3318
4676
|
|
|
3319
4677
|
# Runs the event loop and blocks. Must be run from at most one thread at the
|
|
3320
4678
|
# same time. Blocks until some thread calls {#stop}. Calls block for all
|
|
@@ -3331,8 +4689,11 @@ module Tuile
|
|
|
3331
4689
|
# event-handler error, instead of bypassing it.
|
|
3332
4690
|
def run_loop: () ?{ (Object event) -> void } -> void
|
|
3333
4691
|
|
|
3334
|
-
# _@return_ — true if
|
|
3335
|
-
def
|
|
4692
|
+
# _@return_ — true if a {#run_loop} is in progress on *any* thread.
|
|
4693
|
+
def running?: () -> bool
|
|
4694
|
+
|
|
4695
|
+
# _@return_ — true if this thread is the one running {#run_loop}.
|
|
4696
|
+
def on_loop_thread?: () -> bool
|
|
3336
4697
|
|
|
3337
4698
|
# Stops ongoing {#run_loop}. The stop may not be immediate: {#run_loop} may
|
|
3338
4699
|
# process a bunch of events before terminating.
|
|
@@ -3435,10 +4796,10 @@ module Tuile
|
|
|
3435
4796
|
class Ticker
|
|
3436
4797
|
# _@param_ `event_queue` — queue to dispatch tick calls onto.
|
|
3437
4798
|
#
|
|
3438
|
-
# _@param_ `
|
|
4799
|
+
# _@param_ `interval` — seconds between firings (positive).
|
|
3439
4800
|
#
|
|
3440
4801
|
# _@param_ `block` — called as `block.call(tick_count)` on each fire.
|
|
3441
|
-
def initialize: (EventQueue event_queue, Numeric
|
|
4802
|
+
def initialize: (EventQueue event_queue, Numeric interval, Proc block) -> void
|
|
3442
4803
|
|
|
3443
4804
|
# _@return_ — true once {#cancel} has been called.
|
|
3444
4805
|
def cancelled?: () -> bool
|
|
@@ -3454,8 +4815,10 @@ module Tuile
|
|
|
3454
4815
|
end
|
|
3455
4816
|
end
|
|
3456
4817
|
|
|
3457
|
-
# Testing only — a screen which doesn't paint anything
|
|
3458
|
-
#
|
|
4818
|
+
# Testing only — a screen which doesn't paint anything, so the TTY running
|
|
4819
|
+
# the tests is not painted over. It runs no event loop, so
|
|
4820
|
+
# {Screen#check_locked} admits the thread that called {Screen.fake}: a spec
|
|
4821
|
+
# mutating the UI from a *spawned* thread raises, exactly as an app would.
|
|
3459
4822
|
#
|
|
3460
4823
|
# Intended for unit-testing individual components: instantiate a component,
|
|
3461
4824
|
# mutate it, and assert against {#prints} or {#invalidated?}. It does not
|
|
@@ -3474,9 +4837,9 @@ module Tuile
|
|
|
3474
4837
|
# assert_includes Screen.instance.prints.join, "hi"
|
|
3475
4838
|
# end
|
|
3476
4839
|
class FakeScreen < Tuile::Screen
|
|
3477
|
-
|
|
4840
|
+
EDITING_KEYS: ::Array[String]
|
|
3478
4841
|
|
|
3479
|
-
def
|
|
4842
|
+
def initialize: () -> void
|
|
3480
4843
|
|
|
3481
4844
|
def clear: () -> void
|
|
3482
4845
|
|
|
@@ -3579,16 +4942,18 @@ module Tuile
|
|
|
3579
4942
|
|
|
3580
4943
|
def focusable?: () -> bool
|
|
3581
4944
|
|
|
3582
|
-
# Children for tree traversal: content first, popups in stacking order,
|
|
3583
|
-
# status bar last.
|
|
3584
|
-
def children: () -> ::Array[Component]
|
|
3585
|
-
|
|
3586
4945
|
# Adds a popup and invalidates it for repaint. A modal popup is centered
|
|
3587
4946
|
# and grabs focus; a non-modal overlay ({Component::Popup#modal?} false) is
|
|
3588
4947
|
# left wherever the caller positions it and does *not* take focus, so the
|
|
3589
4948
|
# component that was focused keeps the cursor and keeps receiving keys —
|
|
3590
4949
|
# the overlay floats above the content, driven from app code.
|
|
3591
4950
|
#
|
|
4951
|
+
# The *whole subtree* is invalidated, not just the popup wrapper (which
|
|
4952
|
+
# paints nothing on its own): a reopened popup may land on cells that the
|
|
4953
|
+
# tiled content has since overpainted, and if its rect is unchanged from
|
|
4954
|
+
# last time its content components won't re-invalidate themselves — so
|
|
4955
|
+
# without this the popup's contents would stay blank on reopen.
|
|
4956
|
+
#
|
|
3592
4957
|
# _@param_ `window`
|
|
3593
4958
|
def add_popup: (Component::Popup window) -> void
|
|
3594
4959
|
|
|
@@ -3599,6 +4964,17 @@ module Tuile
|
|
|
3599
4964
|
# _@param_ `window`
|
|
3600
4965
|
def remove_popup: (Component window) -> void
|
|
3601
4966
|
|
|
4967
|
+
# Unmounts everything: each child is detached — firing {Component#on_detached}
|
|
4968
|
+
# down its subtree — and every slot is emptied. Terminal; the pane isn't
|
|
4969
|
+
# reusable afterwards, and {Screen#close} is its only caller.
|
|
4970
|
+
#
|
|
4971
|
+
# Deliberately not named `close` ({Component::Popup#close} already means
|
|
4972
|
+
# "remove *me* from the pane"), and deliberately not a generic
|
|
4973
|
+
# `Component#remove_all_children`: a slot container calling that would empty
|
|
4974
|
+
# `@children` while `#content` / `#footer` still pointed at detached
|
|
4975
|
+
# components, which is the desync the tree API exists to prevent.
|
|
4976
|
+
def detach_all: () -> void
|
|
4977
|
+
|
|
3602
4978
|
# _@param_ `window`
|
|
3603
4979
|
#
|
|
3604
4980
|
# _@return_ — true if this pane currently hosts the popup.
|
|
@@ -3617,27 +4993,30 @@ module Tuile
|
|
|
3617
4993
|
def rect=: (Rect new_rect) -> void
|
|
3618
4994
|
|
|
3619
4995
|
# Lays out content (full pane minus the bottom row) and the status bar
|
|
3620
|
-
# (bottom row).
|
|
3621
|
-
#
|
|
4996
|
+
# (bottom row). Each popup re-resolves its {Component::Popup#size} against
|
|
4997
|
+
# the new screen via {Component::Popup#reposition} — so a {Fraction} size
|
|
4998
|
+
# tracks resize — repositioning itself (modal popups recenter; non-modal
|
|
4999
|
+
# overlays keep the top-left their owner assigned).
|
|
3622
5000
|
def layout: () -> void
|
|
3623
5001
|
|
|
3624
5002
|
# Pane paints nothing itself; its children paint over the entire rect.
|
|
3625
5003
|
def repaint: () -> void
|
|
3626
5004
|
|
|
3627
|
-
#
|
|
3628
|
-
#
|
|
3629
|
-
#
|
|
3630
|
-
#
|
|
3631
|
-
#
|
|
3632
|
-
#
|
|
3633
|
-
#
|
|
3634
|
-
#
|
|
3635
|
-
#
|
|
3636
|
-
#
|
|
3637
|
-
#
|
|
3638
|
-
#
|
|
3639
|
-
#
|
|
3640
|
-
#
|
|
5005
|
+
# Delivers a key to {Screen#focused}, then bubbles it up the focus chain —
|
|
5006
|
+
# the first component whose `handle_key` returns true wins.
|
|
5007
|
+
#
|
|
5008
|
+
# Bubbling stops at the *scope* root: the topmost *modal* popup when one is
|
|
5009
|
+
# open, else the tiled {#content}. Focus that is nil or sits outside the
|
|
5010
|
+
# scope receives nothing, which is what keeps an open modal popup modal.
|
|
5011
|
+
# Non-modal overlays are never the scope: focus stays in the content
|
|
5012
|
+
# beneath them, and the overlay is driven by app code (which forwards keys
|
|
5013
|
+
# to it explicitly), so it doesn't appear in this path at all.
|
|
5014
|
+
#
|
|
5015
|
+
# Because an ancestor sees a key only after every descendant on the chain
|
|
5016
|
+
# declined it, the scope root is the natural home for scope-wide fallbacks
|
|
5017
|
+
# — a form's default button, or a layout's one-key jumps to its panes (a
|
|
5018
|
+
# focused {Component::TextField} consumes the key first, so typing is never
|
|
5019
|
+
# hijacked).
|
|
3641
5020
|
#
|
|
3642
5021
|
# _@param_ `key`
|
|
3643
5022
|
#
|
|
@@ -3703,14 +5082,11 @@ module Tuile
|
|
|
3703
5082
|
|
|
3704
5083
|
# An immutable string-with-styling, modeled as a sequence of {Span}s where
|
|
3705
5084
|
# each span carries a complete {Style} (`fg`, `bg`, `bold`, `italic`,
|
|
3706
|
-
# `underline`, `strikethrough`). Spans are non-overlapping and fully tile
|
|
3707
|
-
# character has exactly one resolved style, no overlay
|
|
3708
|
-
#
|
|
3709
|
-
#
|
|
3710
|
-
#
|
|
3711
|
-
# they never have to "figure out what SGR state is active at column N" —
|
|
3712
|
-
# the answer is just the containing span's `style`. The flip side is one
|
|
3713
|
-
# extra type to construct (or parse) before doing styled-text math.
|
|
5085
|
+
# `underline`, `strikethrough`). Spans are non-overlapping and fully tile
|
|
5086
|
+
# the string — every character has exactly one resolved style, no overlay
|
|
5087
|
+
# layers to merge, so the style at any column is just its span's `style`
|
|
5088
|
+
# rather than a replay of the SGR state machine. The book's chapter 9 is
|
|
5089
|
+
# the long-form *why* (spans vs. a `String` full of escape codes).
|
|
3714
5090
|
#
|
|
3715
5091
|
# ## Constructors
|
|
3716
5092
|
#
|
|
@@ -3734,31 +5110,17 @@ module Tuile
|
|
|
3734
5110
|
# ss.each_char_with_style { |ch, style| ... }
|
|
3735
5111
|
# ```
|
|
3736
5112
|
#
|
|
3737
|
-
# ## Rendering
|
|
3738
|
-
#
|
|
3739
|
-
# - `#to_s` — plain text, no SGR.
|
|
3740
|
-
# - `#to_ansi` — minimal-diff SGR rendering, ending with `\e[0m` only when
|
|
3741
|
-
# the last span carried a non-default style. Transitions to the default
|
|
3742
|
-
# style emit `\e[0m` (shorter than re-emitting every off-code).
|
|
3743
|
-
#
|
|
3744
5113
|
# ## Parser
|
|
3745
5114
|
#
|
|
3746
|
-
# {.parse} is strict by default
|
|
3747
|
-
#
|
|
3748
|
-
#
|
|
3749
|
-
#
|
|
3750
|
-
#
|
|
3751
|
-
#
|
|
3752
|
-
#
|
|
3753
|
-
# Pass `lenient: true` to instead **discard** everything the parser can't
|
|
3754
|
-
# model and keep going — recognized fg/bg/bold/italic/underline/strikethrough codes still
|
|
3755
|
-
# apply, and any unmodeled SGR code, malformed extended color, non-SGR CSI
|
|
3756
|
-
# (cursor moves, `\e[K`), OSC/DCS/string sequence, or stray escape is
|
|
3757
|
-
# silently dropped. This is the mode for piping in colored output you don't
|
|
3758
|
-
# control (e.g. `git --color` through a pager): "give me the colors, throw
|
|
3759
|
-
# the rest away." It is lossy by design — `parse(x, lenient: true)` does not
|
|
3760
|
-
# round-trip back to `x`.
|
|
5115
|
+
# {.parse} is strict by default — it recognizes only the SGR codes for
|
|
5116
|
+
# {Style}'s attributes (fg/bg/bold/italic/underline/strikethrough) and
|
|
5117
|
+
# raises {ParseError} on anything else, keeping the `parse(to_ansi(x)) == x`
|
|
5118
|
+
# round-trip honest. Pass `lenient: true` to instead discard everything it
|
|
5119
|
+
# can't model (unmodeled SGR, cursor moves, OSC/DCS, stray escapes) and keep
|
|
5120
|
+
# only the recognized colors — lossy by design, for piping in colored output
|
|
5121
|
+
# you don't control. See the book for the full rationale.
|
|
3761
5122
|
class StyledString
|
|
5123
|
+
EMOJI_WIDTH: Symbol
|
|
3762
5124
|
EMPTY: StyledString
|
|
3763
5125
|
|
|
3764
5126
|
# sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS
|
|
@@ -3786,7 +5148,6 @@ module Tuile
|
|
|
3786
5148
|
|
|
3787
5149
|
# Total display width in terminal columns, accounting for Unicode wide
|
|
3788
5150
|
# characters (fullwidth CJK = 2 columns, combining marks = 0, etc.).
|
|
3789
|
-
# Memoized — safe because spans are frozen and immutable.
|
|
3790
5151
|
def display_width: () -> Integer
|
|
3791
5152
|
|
|
3792
5153
|
def empty?: () -> bool
|
|
@@ -3799,7 +5160,7 @@ module Tuile
|
|
|
3799
5160
|
# emits `\e[0m` (one code) instead of the longer "turn each attribute
|
|
3800
5161
|
# off" form. Always closes with `\e[0m` when the last span carried a
|
|
3801
5162
|
# non-default style, so the styled run doesn't bleed into subsequent
|
|
3802
|
-
# output.
|
|
5163
|
+
# output.
|
|
3803
5164
|
def to_ansi: () -> String
|
|
3804
5165
|
|
|
3805
5166
|
# _@param_ `other`
|
|
@@ -3872,6 +5233,15 @@ module Tuile
|
|
|
3872
5233
|
# _@param_ `bg` — background color, coerced via {Color.coerce}. `nil` clears bg back to the terminal default.
|
|
3873
5234
|
def with_bg: ((Color | Symbol | Integer | ::Array[Integer])? bg) -> StyledString
|
|
3874
5235
|
|
|
5236
|
+
# Returns a copy with `bg` set **only on spans that have none**; a span with
|
|
5237
|
+
# an explicit bg is left untouched. The fill-unset counterpart of {#with_bg}
|
|
5238
|
+
# (which overrides every span) — it slides a background *under* the content,
|
|
5239
|
+
# so a log line keeps its red error-level bg while its plain text picks up an
|
|
5240
|
+
# inherited panel tint.
|
|
5241
|
+
#
|
|
5242
|
+
# _@param_ `bg` — background color, coerced via {Color.coerce}. `nil` returns `self` unchanged.
|
|
5243
|
+
def under_bg: ((Color | Symbol | Integer | ::Array[Integer])? bg) -> StyledString
|
|
5244
|
+
|
|
3875
5245
|
# Returns a new {StyledString} with `fg` applied to every span, preserving
|
|
3876
5246
|
# each span's text and other style attributes (`bg`, `bold`, `italic`,
|
|
3877
5247
|
# `underline`, `strikethrough`). The new fg overlays without dropping background colors or
|
|
@@ -3906,23 +5276,40 @@ module Tuile
|
|
|
3906
5276
|
# _@param_ `width`
|
|
3907
5277
|
def wrap_one: (StyledString hard_line, Integer width) -> ::Array[StyledString]
|
|
3908
5278
|
|
|
5279
|
+
# Splits into whitespace/word tokens by **grapheme cluster**, not character:
|
|
5280
|
+
# a cluster is the unit a terminal draws, so measuring its parts separately
|
|
5281
|
+
# would both mis-total an emoji sequence and let a wrap break a letter away
|
|
5282
|
+
# from its combining mark.
|
|
5283
|
+
#
|
|
3909
5284
|
# _@param_ `hard_line`
|
|
3910
5285
|
#
|
|
3911
|
-
# _@return_ — tokens shaped `[type,
|
|
3912
|
-
# `:space` or `:word`, `
|
|
3913
|
-
# (
|
|
5286
|
+
# _@return_ — tokens shaped `[type, glyphs, w]` where `type` is
|
|
5287
|
+
# `:space` or `:word`, `glyphs` is an `Array<[String, Style, Integer]>`
|
|
5288
|
+
# (grapheme cluster, style, display width), and `w` is the token's total
|
|
5289
|
+
# width.
|
|
3914
5290
|
def tokenize_for_wrap: (StyledString hard_line) -> ::Array[::Array[untyped]]
|
|
3915
5291
|
|
|
3916
|
-
#
|
|
5292
|
+
# Like {#each_char_with_style} but per grapheme cluster. A cluster spanning a
|
|
5293
|
+
# style boundary takes the style of its first span — pathological input, and
|
|
5294
|
+
# splitting the cluster to honor both styles would paint a headless mark.
|
|
5295
|
+
#
|
|
5296
|
+
# _@param_ `styled`
|
|
5297
|
+
def each_glyph_with_style: (StyledString styled) ?{ (String glyph, Style style) -> void } -> void
|
|
5298
|
+
|
|
5299
|
+
# _@param_ `glyphs` — `[grapheme cluster, style, width]` triples.
|
|
3917
5300
|
#
|
|
3918
5301
|
# _@param_ `width`
|
|
3919
5302
|
#
|
|
3920
|
-
# _@return_ — each inner Array is a `
|
|
3921
|
-
def
|
|
5303
|
+
# _@return_ — each inner Array is a `glyphs`-shaped chunk.
|
|
5304
|
+
def hard_break_glyphs: (::Array[::Array[untyped]] glyphs, Integer width) -> ::Array[::Array[::Array[untyped]]]
|
|
3922
5305
|
|
|
3923
|
-
# _@param_ `
|
|
3924
|
-
def
|
|
5306
|
+
# _@param_ `glyphs` — `[grapheme cluster, style, width]` triples.
|
|
5307
|
+
def glyphs_to_styled: (::Array[::Array[untyped]] glyphs) -> StyledString
|
|
3925
5308
|
|
|
5309
|
+
# Walks **grapheme clusters**, so a slice boundary can never fall inside one:
|
|
5310
|
+
# cutting a cluster would strand a combining mark with no base, which the
|
|
5311
|
+
# painter drops outright, silently losing the accent off a letter.
|
|
5312
|
+
#
|
|
3926
5313
|
# _@param_ `text`
|
|
3927
5314
|
#
|
|
3928
5315
|
# _@param_ `start_col`
|
|
@@ -3992,10 +5379,6 @@ module Tuile
|
|
|
3992
5379
|
# (`\e[0m`, one code) when `other` is the default style — shorter than
|
|
3993
5380
|
# turning each attribute off individually.
|
|
3994
5381
|
#
|
|
3995
|
-
# Shared by {StyledString#to_ansi} (diffing span-to-span from the default
|
|
3996
|
-
# style) and {Buffer}'s flush (diffing cell-to-cell against the style the
|
|
3997
|
-
# terminal currently holds), so both emit identical minimal sequences.
|
|
3998
|
-
#
|
|
3999
5382
|
# _@param_ `other` — the style to transition to.
|
|
4000
5383
|
def sgr_to: (Style other) -> String
|
|
4001
5384
|
|
|
@@ -4108,7 +5491,11 @@ module Tuile
|
|
|
4108
5491
|
class FakeEventQueue
|
|
4109
5492
|
def initialize: () -> void
|
|
4110
5493
|
|
|
4111
|
-
|
|
5494
|
+
# _@return_ — always false — {#run_loop} raises, so no loop ever runs.
|
|
5495
|
+
def running?: () -> bool
|
|
5496
|
+
|
|
5497
|
+
# _@return_ — always true.
|
|
5498
|
+
def on_loop_thread?: () -> bool
|
|
4112
5499
|
|
|
4113
5500
|
def stop: () -> void
|
|
4114
5501
|
|
|
@@ -4122,12 +5509,18 @@ module Tuile
|
|
|
4122
5509
|
def post: (Object event) -> void
|
|
4123
5510
|
|
|
4124
5511
|
# Mirrors {EventQueue#tick} but timeless: returns a {FakeTicker} that
|
|
4125
|
-
# only fires when a test calls {#tick_once}. The `
|
|
5512
|
+
# only fires when a test calls {#tick_once}. The `seconds` argument is
|
|
4126
5513
|
# validated the same way the real queue validates it, then discarded —
|
|
4127
5514
|
# the fake has no clock, so frame cadence is up to the test.
|
|
4128
5515
|
#
|
|
4129
|
-
# _@param_ `
|
|
4130
|
-
def tick: (Numeric
|
|
5516
|
+
# _@param_ `seconds` — interval between firings, must be positive. Validated for parity with {EventQueue#tick}; otherwise unused.
|
|
5517
|
+
def tick: (Numeric seconds) ?{ (Integer tick) -> void } -> FakeTicker
|
|
5518
|
+
|
|
5519
|
+
# Mirrors {EventQueue#tick_fps}: validates `fps` for parity, then delegates
|
|
5520
|
+
# to {#tick} (the fake discards the interval regardless).
|
|
5521
|
+
#
|
|
5522
|
+
# _@param_ `fps` — firings per second, must be positive.
|
|
5523
|
+
def tick_fps: (Numeric fps) ?{ (Integer tick) -> void } -> FakeTicker
|
|
4131
5524
|
|
|
4132
5525
|
# Test helper: fires every live ticker's user block once and prunes
|
|
4133
5526
|
# cancelled tickers. No-op when no tickers are registered. Pumps once
|
|
@@ -4135,6 +5528,14 @@ module Tuile
|
|
|
4135
5528
|
# tests pump N frames by calling this N times.
|
|
4136
5529
|
def tick_once: () -> void
|
|
4137
5530
|
|
|
5531
|
+
# Lets a spec assert that a component started a ticker, and — via
|
|
5532
|
+
# {FakeTicker#cancelled?} — that it cancelled one rather than merely
|
|
5533
|
+
# dropping it. Cancelled tickers stay here until the next {#tick_once}
|
|
5534
|
+
# prunes them.
|
|
5535
|
+
#
|
|
5536
|
+
# _@return_ — the registered tickers, in creation order.
|
|
5537
|
+
attr_reader tickers: ::Array[FakeTicker]
|
|
5538
|
+
|
|
4138
5539
|
# Handle returned by {FakeEventQueue#tick}. Mirrors the public surface of
|
|
4139
5540
|
# {EventQueue::Ticker} (`cancel`, `cancelled?`) but does not auto-fire —
|
|
4140
5541
|
# the host {FakeEventQueue} drives firing via {FakeEventQueue#tick_once}.
|