tuile 0.9.0 → 0.11.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 +81 -36
- data/DECISIONS.md +2566 -0
- data/README.md +37 -24
- data/book/03-layout.md +153 -8
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +84 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +458 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +22 -14
- data/examples/sampler.rb +632 -67
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +118 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +52 -51
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +134 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +263 -0
- data/lib/tuile/component/float_field.rb +161 -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/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- data/lib/tuile/component/layout/box.rb +316 -0
- data/lib/tuile/component/layout/horizontal.rb +40 -0
- data/lib/tuile/component/layout/vertical.rb +41 -0
- data/lib/tuile/component/layout.rb +149 -15
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +157 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/select.rb +251 -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 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- data/lib/tuile/styled_string.rb +125 -86
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +2962 -680
- metadata +25 -7
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
|
|
@@ -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
|
#
|
|
@@ -567,19 +580,6 @@ module Tuile
|
|
|
567
580
|
# nothing for, since the glyph itself advances the cursor two columns).
|
|
568
581
|
# Overwriting either half of a wide glyph blanks the orphaned half, so the
|
|
569
582
|
# grid never holds a dangling continuation or a headless one.
|
|
570
|
-
#
|
|
571
|
-
# ## Future direction
|
|
572
|
-
#
|
|
573
|
-
# Components paint through this drawing surface ({#set_line} / {#set_char})
|
|
574
|
-
# without knowing whether it is the one global buffer or a private one — that
|
|
575
|
-
# indirection is deliberate, so per-component back buffers plus a z-order
|
|
576
|
-
# compositor could drop in without touching component code. It is not worth
|
|
577
|
-
# doing yet: the diff already drops unchanged cells from the wire, and an
|
|
578
|
-
# occluded component that didn't change is never repainted at all, so a
|
|
579
|
-
# compositor would only save residual `repaint` CPU. It pays off in exactly
|
|
580
|
-
# one regime — high repeat-rate scroll (held arrow / mouse wheel) of a large
|
|
581
|
-
# component on a large screen, where re-rendering the content each repeat is
|
|
582
|
-
# the dominant cost.
|
|
583
583
|
class Buffer
|
|
584
584
|
DEFAULT_STYLE: StyledString::Style
|
|
585
585
|
WIDTH_CACHE: ::Hash[String, Integer]
|
|
@@ -631,9 +631,8 @@ module Tuile
|
|
|
631
631
|
) -> void
|
|
632
632
|
|
|
633
633
|
# Writes a {StyledString} starting at `(x, y)`, advancing by each grapheme's
|
|
634
|
-
# display width and clipping at the right edge.
|
|
635
|
-
#
|
|
636
|
-
# 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.
|
|
637
636
|
#
|
|
638
637
|
# _@param_ `x` — starting column.
|
|
639
638
|
#
|
|
@@ -793,21 +792,22 @@ module Tuile
|
|
|
793
792
|
StyledString::Style style
|
|
794
793
|
) -> void
|
|
795
794
|
|
|
796
|
-
# If `(x, y)` holds
|
|
797
|
-
#
|
|
798
|
-
#
|
|
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.
|
|
799
800
|
#
|
|
800
801
|
# _@param_ `x` — column
|
|
801
802
|
#
|
|
802
803
|
# _@param_ `y` — row
|
|
803
804
|
def blank_left_partner: (Integer x, Integer y) -> void
|
|
804
805
|
|
|
805
|
-
#
|
|
806
|
-
#
|
|
807
|
-
#
|
|
808
|
-
#
|
|
809
|
-
#
|
|
810
|
-
# the origin's width.
|
|
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.
|
|
811
811
|
#
|
|
812
812
|
# _@param_ `x` — column
|
|
813
813
|
#
|
|
@@ -866,26 +866,57 @@ module Tuile
|
|
|
866
866
|
end
|
|
867
867
|
end
|
|
868
868
|
|
|
869
|
-
# 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
|
|
878
|
+
#
|
|
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.
|
|
870
886
|
#
|
|
871
|
-
#
|
|
887
|
+
# ## Repaint model
|
|
872
888
|
#
|
|
873
|
-
#
|
|
874
|
-
#
|
|
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.
|
|
875
898
|
#
|
|
876
|
-
#
|
|
877
|
-
# content via {#content=}; the pane fills the entire terminal and is
|
|
878
|
-
# responsible for laying out its children.
|
|
899
|
+
# ## Thread-safety
|
|
879
900
|
#
|
|
880
|
-
#
|
|
881
|
-
#
|
|
882
|
-
#
|
|
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}.
|
|
883
908
|
#
|
|
884
|
-
#
|
|
885
|
-
#
|
|
886
|
-
#
|
|
887
|
-
#
|
|
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.
|
|
888
917
|
class Screen
|
|
918
|
+
EDITING_KEYS: ::Array[String]
|
|
919
|
+
|
|
889
920
|
# rubocop:disable Style/ClassVars
|
|
890
921
|
def initialize: () -> void
|
|
891
922
|
|
|
@@ -902,8 +933,19 @@ module Tuile
|
|
|
902
933
|
# to {ScreenPane}). The array must not be modified!
|
|
903
934
|
def popups: () -> ::Array[Component]
|
|
904
935
|
|
|
905
|
-
#
|
|
906
|
-
#
|
|
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
|
|
907
949
|
def check_locked: () -> void
|
|
908
950
|
|
|
909
951
|
# Clears the TTY screen.
|
|
@@ -937,8 +979,13 @@ module Tuile
|
|
|
937
979
|
# _@param_ `window`
|
|
938
980
|
def add_popup: (Component::Popup window) -> void
|
|
939
981
|
|
|
940
|
-
# Runs event loop
|
|
941
|
-
#
|
|
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.
|
|
942
989
|
#
|
|
943
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).
|
|
944
991
|
def run_event_loop: (?capture_mouse: bool) -> void
|
|
@@ -956,31 +1003,23 @@ module Tuile
|
|
|
956
1003
|
# _@return_ — true if focus moved.
|
|
957
1004
|
def focus_previous: () -> bool
|
|
958
1005
|
|
|
959
|
-
# Registers an app-level keyboard shortcut
|
|
960
|
-
#
|
|
961
|
-
#
|
|
962
|
-
#
|
|
963
|
-
#
|
|
964
|
-
#
|
|
965
|
-
#
|
|
966
|
-
#
|
|
967
|
-
#
|
|
968
|
-
#
|
|
969
|
-
#
|
|
970
|
-
#
|
|
971
|
-
#
|
|
972
|
-
#
|
|
973
|
-
#
|
|
974
|
-
#
|
|
975
|
-
#
|
|
976
|
-
# style stay consistent with whatever the host app uses elsewhere). The
|
|
977
|
-
# framework splices it in like any other status hint: in the tiled case,
|
|
978
|
-
# right after `q quit` and before the active window's own hint; while a
|
|
979
|
-
# popup is open, only hints from `over_popups: true` shortcuts are
|
|
980
|
-
# shown, and they're prepended before the popup's `q Close`.
|
|
981
|
-
#
|
|
982
|
-
# Example — open a log popup with Ctrl+L from anywhere, even while a
|
|
983
|
-
# 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.
|
|
984
1023
|
#
|
|
985
1024
|
# screen.register_global_shortcut(Keys::CTRL_L,
|
|
986
1025
|
# over_popups: true,
|
|
@@ -988,11 +1027,11 @@ module Tuile
|
|
|
988
1027
|
# log_popup.open
|
|
989
1028
|
# end
|
|
990
1029
|
#
|
|
991
|
-
# _@param_ `key` — unprintable key (e.g. {Keys::CTRL_L}, {Keys::ESC}
|
|
1030
|
+
# _@param_ `key` — unprintable key (e.g. {Keys::CTRL_L}, {Keys::ESC}).
|
|
992
1031
|
#
|
|
993
|
-
# _@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.
|
|
994
1033
|
#
|
|
995
|
-
# _@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.
|
|
996
1035
|
def register_global_shortcut: (String key, ?over_popups: bool, ?hint: String?) -> void
|
|
997
1036
|
|
|
998
1037
|
# Removes a shortcut previously installed by {#register_global_shortcut}.
|
|
@@ -1034,6 +1073,9 @@ module Tuile
|
|
|
1034
1073
|
# return the same object.
|
|
1035
1074
|
def self.fake: () -> FakeScreen
|
|
1036
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.
|
|
1037
1079
|
def close: () -> void
|
|
1038
1080
|
|
|
1039
1081
|
def self.close: () -> void
|
|
@@ -1049,7 +1091,10 @@ module Tuile
|
|
|
1049
1091
|
def print: (*String args) -> void
|
|
1050
1092
|
|
|
1051
1093
|
# Repaints the screen; tries to be as effective as possible, by only
|
|
1052
|
-
# 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.
|
|
1053
1098
|
def repaint: () -> void
|
|
1054
1099
|
|
|
1055
1100
|
# Returns the absolute screen coordinates where the hardware cursor should
|
|
@@ -1097,9 +1142,10 @@ module Tuile
|
|
|
1097
1142
|
# _@param_ `str`
|
|
1098
1143
|
def emit: (String str) -> void
|
|
1099
1144
|
|
|
1100
|
-
#
|
|
1101
|
-
#
|
|
1102
|
-
#
|
|
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=}.
|
|
1103
1149
|
def layout: () -> void
|
|
1104
1150
|
|
|
1105
1151
|
# A key has been pressed on the keyboard. Handle it, or forward to active
|
|
@@ -1107,17 +1153,15 @@ module Tuile
|
|
|
1107
1153
|
#
|
|
1108
1154
|
# Dispatch order:
|
|
1109
1155
|
# 1. Tab / Shift+Tab — reserved focus navigation, intercepted before
|
|
1110
|
-
# anything else so a focused {Component::TextField} (which
|
|
1111
|
-
#
|
|
1112
|
-
# doesn't trap them.
|
|
1156
|
+
# anything else so a focused {Component::TextField} (which swallows
|
|
1157
|
+
# printable keys) can't trap them.
|
|
1113
1158
|
# 2. App-level shortcuts from {#register_global_shortcut}. An entry
|
|
1114
1159
|
# registered with `over_popups: true` always fires; one with the
|
|
1115
1160
|
# default `over_popups: false` fires only when no modal popup is open
|
|
1116
1161
|
# (otherwise the modal popup receives the key normally). A non-modal
|
|
1117
1162
|
# overlay doesn't suppress global shortcuts.
|
|
1118
|
-
# 3. {ScreenPane#handle_key}
|
|
1119
|
-
#
|
|
1120
|
-
# it up the focus chain.
|
|
1163
|
+
# 3. {ScreenPane#handle_key} — delivery to {#focused}, bubbling up the
|
|
1164
|
+
# focus chain to the scope root.
|
|
1121
1165
|
#
|
|
1122
1166
|
# _@param_ `key`
|
|
1123
1167
|
#
|
|
@@ -1203,16 +1247,12 @@ module Tuile
|
|
|
1203
1247
|
end
|
|
1204
1248
|
|
|
1205
1249
|
# A width/height ratio, each a float in `0.0..1.0` — the single relational
|
|
1206
|
-
# sizing primitive in Tuile
|
|
1207
|
-
#
|
|
1208
|
-
#
|
|
1209
|
-
#
|
|
1210
|
-
#
|
|
1211
|
-
#
|
|
1212
|
-
# Tiled components are *not* sized this way: their parent computes explicit
|
|
1213
|
-
# integer rects in its own `rect=` and hands them down. `Fraction` is
|
|
1214
|
-
# deliberately scoped to {Component::Popup#size=} and is not a general layout
|
|
1215
|
-
# primitive.
|
|
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.
|
|
1216
1256
|
#
|
|
1217
1257
|
# Resolve it against a reference {Size} (the screen) to get concrete integer
|
|
1218
1258
|
# cells:
|
|
@@ -1253,56 +1293,35 @@ module Tuile
|
|
|
1253
1293
|
# Focuses this component. Equivalent to `screen.focused = self`.
|
|
1254
1294
|
def focus: () -> void
|
|
1255
1295
|
|
|
1256
|
-
#
|
|
1257
|
-
#
|
|
1258
|
-
#
|
|
1259
|
-
#
|
|
1260
|
-
#
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
#
|
|
1264
|
-
#
|
|
1265
|
-
#
|
|
1266
|
-
#
|
|
1267
|
-
#
|
|
1268
|
-
#
|
|
1269
|
-
#
|
|
1270
|
-
#
|
|
1271
|
-
#
|
|
1272
|
-
# Subclasses that paint their entire rect themselves (e.g. {Window}'s
|
|
1273
|
-
# border draws over the area the default would clear; {Component::List}
|
|
1274
|
-
# explicitly paints every row) may skip super and take full
|
|
1275
|
-
# responsibility for {#rect}. Everything else should call super.
|
|
1276
|
-
#
|
|
1277
|
-
# A component must not draw outside of {#rect}.
|
|
1278
|
-
#
|
|
1279
|
-
# 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.
|
|
1280
1312
|
def repaint: () -> void
|
|
1281
1313
|
|
|
1282
|
-
# Called when a
|
|
1283
|
-
#
|
|
1284
|
-
#
|
|
1285
|
-
#
|
|
1286
|
-
#
|
|
1287
|
-
# Dispatch is owned by {ScreenPane#handle_key}: a {#key_shortcut} match
|
|
1288
|
-
# anywhere in the active scope is captured first (suppressed while a
|
|
1289
|
-
# cursor-owner is mid-edit), then the key is delivered to {Screen#focused}
|
|
1290
|
-
# and bubbles up its ancestor chain until some component handles it. A
|
|
1291
|
-
# component therefore only ever receives keys when it is on the focus chain
|
|
1292
|
-
# — or when app code hands it a key directly — so it acts on the key alone
|
|
1293
|
-
# 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.
|
|
1294
1319
|
#
|
|
1295
1320
|
# _@param_ `_key` — a key.
|
|
1296
1321
|
#
|
|
1297
1322
|
# _@return_ — true if the key was handled, false if not.
|
|
1298
1323
|
def handle_key: (String _key) -> bool
|
|
1299
1324
|
|
|
1300
|
-
# _@param_ `key` — keyboard key to look up.
|
|
1301
|
-
#
|
|
1302
|
-
# _@return_ — the component whose {#key_shortcut} matches `key`,
|
|
1303
|
-
# or nil.
|
|
1304
|
-
def find_shortcut_component: (String key) -> Component?
|
|
1305
|
-
|
|
1306
1325
|
# Handles mouse event. Default implementation focuses this component when
|
|
1307
1326
|
# clicked (if {#focusable?}).
|
|
1308
1327
|
#
|
|
@@ -1318,16 +1337,11 @@ module Tuile
|
|
|
1318
1337
|
|
|
1319
1338
|
# Whether this component is a valid focus target. `false` by default —
|
|
1320
1339
|
# passive components like {Label} are decoration and don't accept focus.
|
|
1321
|
-
# The flag gates click-to-focus
|
|
1322
|
-
#
|
|
1323
|
-
#
|
|
1324
|
-
#
|
|
1325
|
-
#
|
|
1326
|
-
#
|
|
1327
|
-
# See also {#tab_stop?}: focusable controls _can_ receive focus (via click
|
|
1328
|
-
# or programmatic assignment), but only tab stops participate in Tab /
|
|
1329
|
-
# Shift+Tab cycling. Containers like {Window} and {Popup} are focusable
|
|
1330
|
-
# (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.
|
|
1331
1345
|
#
|
|
1332
1346
|
# _@return_ — true if this component can be focused.
|
|
1333
1347
|
def focusable?: () -> bool
|
|
@@ -1348,20 +1362,23 @@ module Tuile
|
|
|
1348
1362
|
# _@return_ — the root component of this component hierarchy.
|
|
1349
1363
|
def root: () -> Component
|
|
1350
1364
|
|
|
1351
|
-
# List of child components, defaults to an empty array.
|
|
1352
|
-
#
|
|
1353
|
-
# _@return_ — child components. Must not be mutated! May be
|
|
1354
|
-
# empty.
|
|
1355
|
-
def children: () -> ::Array[Component]
|
|
1356
|
-
|
|
1357
1365
|
# Calls block for this component and for every descendant component.
|
|
1358
1366
|
def on_tree: () ?{ (Component component) -> void } -> void
|
|
1359
1367
|
|
|
1360
1368
|
# Called when the component receives focus.
|
|
1361
1369
|
def on_focus: () -> void
|
|
1362
1370
|
|
|
1363
|
-
#
|
|
1364
|
-
#
|
|
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}.
|
|
1365
1382
|
def attached?: () -> bool
|
|
1366
1383
|
|
|
1367
1384
|
# Called by container components after `child` has been detached from
|
|
@@ -1387,6 +1404,86 @@ module Tuile
|
|
|
1387
1404
|
# topmost popup. Empty by default; override to advertise shortcuts.
|
|
1388
1405
|
def keyboard_hint: () -> String
|
|
1389
1406
|
|
|
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
|
|
1435
|
+
|
|
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
|
|
1486
|
+
|
|
1390
1487
|
# Called whenever the component width changes. Does nothing by default.
|
|
1391
1488
|
def on_width_changed: () -> void
|
|
1392
1489
|
|
|
@@ -1409,39 +1506,83 @@ module Tuile
|
|
|
1409
1506
|
# Children with empty rects contribute zero, since they paint nothing.
|
|
1410
1507
|
def children_tile_rect?: () -> bool
|
|
1411
1508
|
|
|
1412
|
-
# Clears the background:
|
|
1413
|
-
#
|
|
1414
|
-
|
|
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
|
|
1415
1549
|
|
|
1416
1550
|
# _@return_ — the rectangle the component occupies on screen.
|
|
1417
1551
|
attr_accessor rect: Rect
|
|
1418
1552
|
|
|
1419
|
-
#
|
|
1420
|
-
#
|
|
1421
|
-
#
|
|
1422
|
-
|
|
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])?
|
|
1423
1559
|
|
|
1424
1560
|
# _@return_ — the parent component or nil if the component has
|
|
1425
1561
|
# no parent.
|
|
1426
1562
|
attr_accessor parent: Component?
|
|
1427
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
|
+
|
|
1428
1576
|
# Called on every attached component (pre-order, popups included) when
|
|
1429
|
-
# {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=}
|
|
1430
|
-
#
|
|
1431
|
-
#
|
|
1432
|
-
#
|
|
1433
|
-
#
|
|
1434
|
-
#
|
|
1435
|
-
#
|
|
1436
|
-
#
|
|
1437
|
-
#
|
|
1438
|
-
# them here, re-running the same code that rendered them initially.
|
|
1439
|
-
#
|
|
1440
|
-
# Runs on the UI thread; {Screen#theme} already returns the new theme.
|
|
1441
|
-
# Mutating content (`text=`, `lines=`, …) is safe — repaint coalesces per
|
|
1442
|
-
# event-loop tick. Do not assign {Screen#theme=} from inside the hook.
|
|
1443
|
-
#
|
|
1444
|
-
# 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
|
|
1445
1586
|
# {#on_theme_changed=} listener keeps firing.
|
|
1446
1587
|
attr_accessor on_theme_changed: Proc?
|
|
1447
1588
|
|
|
@@ -1449,11 +1590,9 @@ module Tuile
|
|
|
1449
1590
|
#
|
|
1450
1591
|
# Items are modeled as {StyledString}s and painted directly into the
|
|
1451
1592
|
# component's {#rect}. Lines wider than the viewport are ellipsized via
|
|
1452
|
-
# {StyledString#ellipsize}
|
|
1453
|
-
#
|
|
1454
|
-
#
|
|
1455
|
-
# via {#top_line}; the list can also automatically scroll to the bottom
|
|
1456
|
-
# 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.
|
|
1457
1596
|
#
|
|
1458
1597
|
# Cursor is supported; call {#cursor=} to change cursor behavior. The
|
|
1459
1598
|
# cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the
|
|
@@ -1543,7 +1682,10 @@ module Tuile
|
|
|
1543
1682
|
# Skips the {Component#repaint} default's auto-clear: every row of
|
|
1544
1683
|
# {#rect} is painted below (with blank padding past the last item),
|
|
1545
1684
|
# so the parent contract — "fully draw over your rect" — is met
|
|
1546
|
-
# 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.
|
|
1547
1689
|
def repaint: () -> void
|
|
1548
1690
|
|
|
1549
1691
|
# Rebuilds pre-padded lines when the wrap width changes. The wrap width
|
|
@@ -1860,7 +2002,8 @@ module Tuile
|
|
|
1860
2002
|
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
1861
2003
|
# painted explicitly (with pre-padded blanks past the last line), so
|
|
1862
2004
|
# the "fully draw over your rect" contract is met without an upfront
|
|
1863
|
-
# 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.
|
|
1864
2007
|
def repaint: () -> void
|
|
1865
2008
|
|
|
1866
2009
|
def on_width_changed: () -> void
|
|
@@ -1885,9 +2028,11 @@ module Tuile
|
|
|
1885
2028
|
# {StyledString}.
|
|
1886
2029
|
attr_accessor text: (StyledString | String)?
|
|
1887
2030
|
|
|
1888
|
-
# _@return_ — background
|
|
1889
|
-
#
|
|
1890
|
-
#
|
|
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.
|
|
1891
2036
|
attr_accessor bg: (Color | Symbol | Integer | ::Array[Integer])?
|
|
1892
2037
|
end
|
|
1893
2038
|
|
|
@@ -1907,16 +2052,11 @@ module Tuile
|
|
|
1907
2052
|
#
|
|
1908
2053
|
# Modal by default: it centers on the screen, grabs focus, eats keys, and
|
|
1909
2054
|
# blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
|
|
1910
|
-
# that floats above the content
|
|
1911
|
-
#
|
|
1912
|
-
#
|
|
1913
|
-
#
|
|
1914
|
-
#
|
|
1915
|
-
# {Component::TextInput#on_change} listener refills the list, and an
|
|
1916
|
-
# {Component::TextInput#on_key} interceptor forwards Up/Down/Enter to it.
|
|
1917
|
-
# Such a caller owns the list data, so it sizes the overlay itself
|
|
1918
|
-
# (`overlay.size = Size.new(longest, [items.size, 8].min)`) — still
|
|
1919
|
-
# caller-decides, top-down.
|
|
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.
|
|
1920
2060
|
#
|
|
1921
2061
|
# The wrapped content fills the popup's full {#rect}; if you want a frame
|
|
1922
2062
|
# and caption, wrap a {Component::Window} (or any subclass — including
|
|
@@ -1929,10 +2069,11 @@ module Tuile
|
|
|
1929
2069
|
# Bare content also works (a {Component::Label}, a {Component::List}…), in
|
|
1930
2070
|
# which case the popup is borderless.
|
|
1931
2071
|
#
|
|
1932
|
-
# `q` and ESC close the popup
|
|
1933
|
-
# the
|
|
1934
|
-
#
|
|
1935
|
-
#
|
|
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.
|
|
1936
2077
|
class Popup < Component
|
|
1937
2078
|
include Tuile::Component::HasContent
|
|
1938
2079
|
|
|
@@ -2016,8 +2157,6 @@ module Tuile
|
|
|
2016
2157
|
# _@param_ `event`
|
|
2017
2158
|
def handle_mouse: (MouseEvent event) -> void
|
|
2018
2159
|
|
|
2019
|
-
def children: () -> ::Array[Component]
|
|
2020
|
-
|
|
2021
2160
|
def on_focus: () -> void
|
|
2022
2161
|
|
|
2023
2162
|
# _@return_ — the popup's declared size. See {#size=}.
|
|
@@ -2034,10 +2173,14 @@ module Tuile
|
|
|
2034
2173
|
# {Component#handle_mouse}.
|
|
2035
2174
|
#
|
|
2036
2175
|
# Assign a {#rect} (typically by the surrounding {Layout}) wide enough to
|
|
2037
|
-
# show `[ caption ]` — that natural width is `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}).
|
|
2038
2179
|
class Button < Component
|
|
2039
|
-
|
|
2040
|
-
|
|
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
|
|
2041
2184
|
|
|
2042
2185
|
def focusable?: () -> bool
|
|
2043
2186
|
|
|
@@ -2046,13 +2189,35 @@ module Tuile
|
|
|
2046
2189
|
# _@param_ `key`
|
|
2047
2190
|
def handle_key: (String key) -> bool
|
|
2048
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
|
+
#
|
|
2049
2204
|
# _@param_ `event`
|
|
2050
2205
|
def handle_mouse: (MouseEvent event) -> void
|
|
2051
2206
|
|
|
2052
2207
|
def repaint: () -> void
|
|
2053
2208
|
|
|
2054
|
-
#
|
|
2055
|
-
|
|
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
|
|
2214
|
+
|
|
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
|
|
2056
2221
|
|
|
2057
2222
|
# Callback fired when the button is activated (Enter, Space, or
|
|
2058
2223
|
# left-click). The callable receives no arguments.
|
|
@@ -2062,7 +2227,13 @@ module Tuile
|
|
|
2062
2227
|
end
|
|
2063
2228
|
|
|
2064
2229
|
# A layout doesn't paint anything by itself: its job is to position child
|
|
2065
|
-
# components.
|
|
2230
|
+
# components. Two families, both top-down (see book ch3):
|
|
2231
|
+
#
|
|
2232
|
+
# - {Absolute} — you override {Component#rect=} and compute every child's
|
|
2233
|
+
# rectangle yourself. Total control, and the base for anything unusual.
|
|
2234
|
+
# - {Box} / {Vertical} / {Horizontal} — you declare each child's extent as
|
|
2235
|
+
# a {Fixed}, {Percent} or {Expand} constraint and the layout does the
|
|
2236
|
+
# arithmetic. Sugar over the same `rect=` assignment, for the common case.
|
|
2066
2237
|
#
|
|
2067
2238
|
# Children that fully tile the layout's rect repaint themselves and
|
|
2068
2239
|
# cover everything; children that leave gaps (e.g. a form with widgets
|
|
@@ -2070,10 +2241,6 @@ module Tuile
|
|
|
2070
2241
|
# the background is cleared and children are re-invalidated so they
|
|
2071
2242
|
# paint over a clean surface.
|
|
2072
2243
|
class Layout < Component
|
|
2073
|
-
def initialize: () -> void
|
|
2074
|
-
|
|
2075
|
-
def children: () -> ::Array[Component]
|
|
2076
|
-
|
|
2077
2244
|
# Layouts are focusable containers — like {Window} and {Popup}, they
|
|
2078
2245
|
# don't accept input themselves but they need to participate in the
|
|
2079
2246
|
# {HasContent} focus cascade so a Popup wrapping a Layout wrapping a
|
|
@@ -2099,10 +2266,563 @@ module Tuile
|
|
|
2099
2266
|
|
|
2100
2267
|
def on_focus: () -> void
|
|
2101
2268
|
|
|
2269
|
+
# How much space a child gets along one axis of a {Box}: exactly {#cells},
|
|
2270
|
+
# clamped to whatever is still unassigned.
|
|
2271
|
+
#
|
|
2272
|
+
# add(prompt, Fixed[4]) # 4 rows in a Vertical
|
|
2273
|
+
# add(field, Fixed[1], cross: Fixed[30]) # 1 row, 30 columns wide
|
|
2274
|
+
#
|
|
2275
|
+
# `Fixed[0]` hides the child — it gets an empty rect and paints nothing.
|
|
2276
|
+
#
|
|
2277
|
+
# @!attribute [r] cells
|
|
2278
|
+
# @return [Integer] cell count along the axis.
|
|
2279
|
+
class Fixed
|
|
2280
|
+
# _@param_ `cells` — cell count along the axis; `>= 0`.
|
|
2281
|
+
def initialize: (cells: Integer) -> void
|
|
2282
|
+
|
|
2283
|
+
# _@return_ — cell count along the axis.
|
|
2284
|
+
attr_reader cells: Integer
|
|
2285
|
+
end
|
|
2286
|
+
|
|
2287
|
+
# A percentage of the space *available* along a {Box}'s axis — measured
|
|
2288
|
+
# after {Box#padding} and {Box#spacing} have come off, so two `Percent[50]`
|
|
2289
|
+
# children fit exactly rather than overflowing by the gap between them.
|
|
2290
|
+
#
|
|
2291
|
+
# add(left, Percent[60])
|
|
2292
|
+
# add(right, Percent[40])
|
|
2293
|
+
#
|
|
2294
|
+
# @!attribute [r] percent
|
|
2295
|
+
# @return [Numeric] percentage of the available extent, `0..100`.
|
|
2296
|
+
class Percent
|
|
2297
|
+
# _@param_ `percent` — percentage of the available extent, `0..100`.
|
|
2298
|
+
def initialize: (percent: Numeric) -> void
|
|
2299
|
+
|
|
2300
|
+
# _@return_ — percentage of the available extent, `0..100`.
|
|
2301
|
+
attr_reader percent: Numeric
|
|
2302
|
+
end
|
|
2303
|
+
|
|
2304
|
+
# A share of whatever a {Box} has left once its {Fixed} and {Percent}
|
|
2305
|
+
# children have taken theirs, split between the `Expand` children in
|
|
2306
|
+
# proportion to their weights:
|
|
2307
|
+
#
|
|
2308
|
+
# add(header, Fixed[1])
|
|
2309
|
+
# add(body, Expand[2]) # gets twice…
|
|
2310
|
+
# add(side, Expand[1]) # …what this one gets
|
|
2311
|
+
#
|
|
2312
|
+
# Main axis only — {Box#add} rejects one passed as `cross:`, where a child
|
|
2313
|
+
# has no siblings to compete with and so nothing for a weight to mean.
|
|
2314
|
+
#
|
|
2315
|
+
# @!attribute [r] weight
|
|
2316
|
+
# @return [Integer] relative share of the leftover space.
|
|
2317
|
+
class Expand
|
|
2318
|
+
# _@param_ `weight` — relative share; `>= 1`.
|
|
2319
|
+
def initialize: (weight: Integer) -> void
|
|
2320
|
+
|
|
2321
|
+
# _@return_ — relative share of the leftover space.
|
|
2322
|
+
attr_reader weight: Integer
|
|
2323
|
+
end
|
|
2324
|
+
|
|
2325
|
+
# Per-edge padding for a {Box}, in cells:
|
|
2326
|
+
#
|
|
2327
|
+
# Insets[top: 1] # one blank row above the children
|
|
2328
|
+
# Insets[top: 1, left: 2, right: 2] # unnamed edges default to 0
|
|
2329
|
+
# Insets.coerce(1) # uniform on all four edges
|
|
2330
|
+
#
|
|
2331
|
+
# Keyword-only: AWT and JavaFX order these same four numbers differently,
|
|
2332
|
+
# so a positional form would be a coin flip.
|
|
2333
|
+
#
|
|
2334
|
+
# @!attribute [r] top
|
|
2335
|
+
# @return [Integer] cells inset from the top edge.
|
|
2336
|
+
# @!attribute [r] right
|
|
2337
|
+
# @return [Integer] cells inset from the right edge.
|
|
2338
|
+
# @!attribute [r] bottom
|
|
2339
|
+
# @return [Integer] cells inset from the bottom edge.
|
|
2340
|
+
# @!attribute [r] left
|
|
2341
|
+
# @return [Integer] cells inset from the left edge.
|
|
2342
|
+
class Insets
|
|
2343
|
+
ZERO: Insets
|
|
2344
|
+
|
|
2345
|
+
# _@param_ `positional` — must be empty — see the class doc.
|
|
2346
|
+
#
|
|
2347
|
+
# _@param_ `kwargs` — any of `top:`/`right:`/`bottom:`/`left:`.
|
|
2348
|
+
def self.new: (*::Array[untyped] positional, **::Hash[Symbol, Integer] kwargs) -> Insets
|
|
2349
|
+
|
|
2350
|
+
# Needed because `Data`'s inherited `[]` never dispatches through a `new`
|
|
2351
|
+
# override, so the guard above alone would miss `Insets[1, 2, 3, 4]`.
|
|
2352
|
+
#
|
|
2353
|
+
# _@param_ `positional` — must be empty.
|
|
2354
|
+
#
|
|
2355
|
+
# _@param_ `kwargs` — any of `top:`/`right:`/`bottom:`/`left:`.
|
|
2356
|
+
def self.[]: (*::Array[untyped] positional, **::Hash[Symbol, Integer] kwargs) -> Insets
|
|
2357
|
+
|
|
2358
|
+
# _@param_ `value` — an Integer becomes a uniform inset.
|
|
2359
|
+
def self.coerce: ((Insets | Integer) value) -> Insets
|
|
2360
|
+
|
|
2361
|
+
# _@param_ `top` — cells inset from the top edge; `>= 0`.
|
|
2362
|
+
#
|
|
2363
|
+
# _@param_ `right` — cells inset from the right edge; `>= 0`.
|
|
2364
|
+
#
|
|
2365
|
+
# _@param_ `bottom` — cells inset from the bottom edge; `>= 0`.
|
|
2366
|
+
#
|
|
2367
|
+
# _@param_ `left` — cells inset from the left edge; `>= 0`.
|
|
2368
|
+
def initialize: (
|
|
2369
|
+
?_top: Integer,
|
|
2370
|
+
?right: Integer,
|
|
2371
|
+
?bottom: Integer,
|
|
2372
|
+
?left: Integer
|
|
2373
|
+
) -> void
|
|
2374
|
+
|
|
2375
|
+
# _@return_ — `left` + `right`.
|
|
2376
|
+
def horizontal: () -> Integer
|
|
2377
|
+
|
|
2378
|
+
# _@return_ — `top` + `bottom`.
|
|
2379
|
+
def vertical: () -> Integer
|
|
2380
|
+
|
|
2381
|
+
# _@return_ — cells inset from the top edge.
|
|
2382
|
+
attr_reader top: Integer
|
|
2383
|
+
|
|
2384
|
+
# _@return_ — cells inset from the right edge.
|
|
2385
|
+
attr_reader right: Integer
|
|
2386
|
+
|
|
2387
|
+
# _@return_ — cells inset from the bottom edge.
|
|
2388
|
+
attr_reader bottom: Integer
|
|
2389
|
+
|
|
2390
|
+
# _@return_ — cells inset from the left edge.
|
|
2391
|
+
attr_reader left: Integer
|
|
2392
|
+
end
|
|
2393
|
+
|
|
2102
2394
|
# Absolute layout. Extend this class, register any children, and
|
|
2103
2395
|
# override {Component#rect=} to reposition the children.
|
|
2104
2396
|
class Absolute < Layout
|
|
2105
2397
|
end
|
|
2398
|
+
|
|
2399
|
+
# Abstract base of the one-dimensional box layouts. Children are stacked
|
|
2400
|
+
# along a *main* axis in the order they were added, each getting the extent
|
|
2401
|
+
# its constraint asks for; across the *cross* axis they are sized one at a
|
|
2402
|
+
# time, since nothing competes with them there. {Vertical} and {Horizontal}
|
|
2403
|
+
# pick which axis is which.
|
|
2404
|
+
#
|
|
2405
|
+
# class LoginForm < Tuile::Component::Layout::Vertical
|
|
2406
|
+
# def initialize
|
|
2407
|
+
# super(spacing: 1, padding: Insets[top: 1])
|
|
2408
|
+
# add(@prompt = Tuile::Component::Label.new, Fixed[4])
|
|
2409
|
+
# add(@user = Tuile::Component::TextField.new, Fixed[1], cross: Fixed[30])
|
|
2410
|
+
# add(@log = Tuile::Component::TextView.new, Expand[1])
|
|
2411
|
+
# end
|
|
2412
|
+
# end
|
|
2413
|
+
#
|
|
2414
|
+
# The constraint names need no prefix inside a subclass — Ruby finds them on
|
|
2415
|
+
# `Layout`, an ancestor. Component classes are not on that chain and still do.
|
|
2416
|
+
#
|
|
2417
|
+
# Children pack from the start edge, so with no {Expand} among them the
|
|
2418
|
+
# slack is simply left at the end: there is no filler component to add.
|
|
2419
|
+
# Nest boxes to vary the gap — a `Vertical.new(spacing: 0)` inside a
|
|
2420
|
+
# `Vertical.new(spacing: 1)` groups two rows tightly within a looser stack.
|
|
2421
|
+
#
|
|
2422
|
+
# == Implementation details
|
|
2423
|
+
#
|
|
2424
|
+
# Every child-list mutation re-runs the whole pass, because in a box the
|
|
2425
|
+
# children move: removing one shifts everything after it, and adding one
|
|
2426
|
+
# shrinks every {Expand} share. ({Absolute} can skip this — there, siblings
|
|
2427
|
+
# are independent.)
|
|
2428
|
+
#
|
|
2429
|
+
# Main-axis resolution order, against
|
|
2430
|
+
# `available = extent - padding - spacing * (children - 1)`:
|
|
2431
|
+
#
|
|
2432
|
+
# 1. {Fixed} takes its cells, clamped to what is still unassigned.
|
|
2433
|
+
# 2. {Percent} takes its share *of `available`*, likewise clamped.
|
|
2434
|
+
# 3. {Expand} children split the residue by weight; the integer remainder
|
|
2435
|
+
# goes to the earliest of them, one cell each.
|
|
2436
|
+
#
|
|
2437
|
+
# So over-subscription starves in declaration order rather than raising:
|
|
2438
|
+
# a child with nothing left gets an empty rect and paints nothing. Padding
|
|
2439
|
+
# wider than the layout does the same to every child.
|
|
2440
|
+
class Box < Layout
|
|
2441
|
+
DEFAULT_PLACEMENT: ::Hash[Symbol, Object]
|
|
2442
|
+
ALIGNMENTS: ::Array[Symbol]
|
|
2443
|
+
|
|
2444
|
+
# _@param_ `spacing` — blank cells between adjacent children; `>= 0`.
|
|
2445
|
+
#
|
|
2446
|
+
# _@param_ `padding` — inset from this layout's own rect; an Integer is coerced to a uniform {Insets}.
|
|
2447
|
+
def initialize: (?spacing: Integer, ?padding: (Insets | Integer)) -> void
|
|
2448
|
+
|
|
2449
|
+
# Adds a child — or every element of an Enumerable, all with the same
|
|
2450
|
+
# constraints — and re-runs the layout.
|
|
2451
|
+
#
|
|
2452
|
+
# add(field, Fixed[1], cross: Fixed[30], align: :center)
|
|
2453
|
+
# add([ok, cancel], Fixed[1])
|
|
2454
|
+
#
|
|
2455
|
+
# _@param_ `child`
|
|
2456
|
+
#
|
|
2457
|
+
# _@param_ `main` — extent along the main axis.
|
|
2458
|
+
#
|
|
2459
|
+
# _@param_ `cross` — extent across it.
|
|
2460
|
+
#
|
|
2461
|
+
# _@param_ `align` — one of {ALIGNMENTS} — where a child narrower than the cross extent sits. {Vertical} / {Horizontal} say which edge `:start` is.
|
|
2462
|
+
def add: (
|
|
2463
|
+
(Component | ::Enumerable[Component]) child,
|
|
2464
|
+
?(Fixed | Percent | Expand) main,
|
|
2465
|
+
?cross: (Fixed | Percent),
|
|
2466
|
+
?align: Symbol
|
|
2467
|
+
) -> void
|
|
2468
|
+
|
|
2469
|
+
# Removes the child, forgets its constraints, and closes the gap it left
|
|
2470
|
+
# by re-running the layout.
|
|
2471
|
+
#
|
|
2472
|
+
# _@param_ `child`
|
|
2473
|
+
def remove: (Component child) -> void
|
|
2474
|
+
|
|
2475
|
+
# _@param_ `new_rect`
|
|
2476
|
+
def rect=: (Rect new_rect) -> void
|
|
2477
|
+
|
|
2478
|
+
# Recomputes and assigns every child's rect. Silent until this layout has
|
|
2479
|
+
# a rect of its own — {#add} runs during construction, long before a
|
|
2480
|
+
# parent assigns one.
|
|
2481
|
+
def relayout: () -> void
|
|
2482
|
+
|
|
2483
|
+
# _@return_ — {#rect} with {#padding} taken off each edge; may be
|
|
2484
|
+
# {Rect#empty? empty}.
|
|
2485
|
+
def inner_rect: () -> Rect
|
|
2486
|
+
|
|
2487
|
+
# _@param_ `inner` — {#inner_rect}, known non-empty.
|
|
2488
|
+
def place_children: (Rect inner) -> void
|
|
2489
|
+
|
|
2490
|
+
# _@param_ `inner` — {#inner_rect}.
|
|
2491
|
+
#
|
|
2492
|
+
# _@return_ — main-axis extent per child, in child order.
|
|
2493
|
+
def main_sizes: (Rect inner) -> ::Array[Integer]
|
|
2494
|
+
|
|
2495
|
+
# Splits `slack` between the {Expand} children by weight, writing the
|
|
2496
|
+
# results into `sizes`.
|
|
2497
|
+
#
|
|
2498
|
+
# _@param_ `sizes` — mutated in place.
|
|
2499
|
+
#
|
|
2500
|
+
# _@param_ `indices` — child indices carrying an {Expand}.
|
|
2501
|
+
#
|
|
2502
|
+
# _@param_ `slack` — cells left over; a negative value yields zeroes.
|
|
2503
|
+
def distribute_expand: (::Array[Integer] sizes, ::Array[Integer] indices, Integer slack) -> void
|
|
2504
|
+
|
|
2505
|
+
# _@param_ `child`
|
|
2506
|
+
#
|
|
2507
|
+
# _@param_ `available` — cross extent of {#inner_rect}.
|
|
2508
|
+
#
|
|
2509
|
+
# _@return_ — offset from `inner`'s start edge, and
|
|
2510
|
+
# extent, along the cross axis.
|
|
2511
|
+
def cross_placement: (Component child, Integer available) -> [Integer, Integer]
|
|
2512
|
+
|
|
2513
|
+
# _@param_ `align` — one of {ALIGNMENTS}.
|
|
2514
|
+
#
|
|
2515
|
+
# _@param_ `slack` — unused cells across the axis.
|
|
2516
|
+
def align_offset: (Symbol align, Integer slack) -> Integer
|
|
2517
|
+
|
|
2518
|
+
# _@param_ `extent`
|
|
2519
|
+
#
|
|
2520
|
+
# _@param_ `constraint`
|
|
2521
|
+
def percent_of: (Integer extent, Percent constraint) -> Integer
|
|
2522
|
+
|
|
2523
|
+
# _@param_ `child`
|
|
2524
|
+
#
|
|
2525
|
+
# _@return_ — the child's `main`/`cross`/`align`.
|
|
2526
|
+
def placement: (Component child) -> ::Hash[Symbol, Object]
|
|
2527
|
+
|
|
2528
|
+
# _@param_ `rect`
|
|
2529
|
+
#
|
|
2530
|
+
# _@return_ — the extent along the main axis.
|
|
2531
|
+
def main_extent: (Rect rect) -> Integer
|
|
2532
|
+
|
|
2533
|
+
# _@param_ `rect`
|
|
2534
|
+
#
|
|
2535
|
+
# _@return_ — the extent along the cross axis.
|
|
2536
|
+
def cross_extent: (Rect rect) -> Integer
|
|
2537
|
+
|
|
2538
|
+
# _@param_ `inner` — {#inner_rect}, the origin both offsets are relative to.
|
|
2539
|
+
#
|
|
2540
|
+
# _@param_ `main_offset` — cells along the main axis.
|
|
2541
|
+
#
|
|
2542
|
+
# _@param_ `main_size` — extent along the main axis.
|
|
2543
|
+
#
|
|
2544
|
+
# _@param_ `cross_offset` — cells along the cross axis.
|
|
2545
|
+
#
|
|
2546
|
+
# _@param_ `cross_size` — extent along the cross axis.
|
|
2547
|
+
#
|
|
2548
|
+
# _@return_ — absolute screen rect for one child.
|
|
2549
|
+
def build_rect: (
|
|
2550
|
+
Rect inner,
|
|
2551
|
+
Integer main_offset,
|
|
2552
|
+
Integer main_size,
|
|
2553
|
+
Integer cross_offset,
|
|
2554
|
+
Integer cross_size
|
|
2555
|
+
) -> Rect
|
|
2556
|
+
|
|
2557
|
+
# _@param_ `cells`
|
|
2558
|
+
#
|
|
2559
|
+
# _@return_ — `cells`.
|
|
2560
|
+
def validate_spacing: (Integer cells) -> Integer
|
|
2561
|
+
|
|
2562
|
+
# _@param_ `constraint`
|
|
2563
|
+
def validate_main: (Object constraint) -> void
|
|
2564
|
+
|
|
2565
|
+
# _@param_ `constraint`
|
|
2566
|
+
def validate_cross: (Object constraint) -> void
|
|
2567
|
+
|
|
2568
|
+
# _@param_ `align`
|
|
2569
|
+
def validate_align: (Object align) -> void
|
|
2570
|
+
|
|
2571
|
+
# _@return_ — blank cells between adjacent children.
|
|
2572
|
+
attr_accessor spacing: Integer
|
|
2573
|
+
|
|
2574
|
+
# _@return_ — inset from this layout's own rect.
|
|
2575
|
+
attr_accessor padding: (Insets | Integer)
|
|
2576
|
+
end
|
|
2577
|
+
|
|
2578
|
+
# Stacks children top to bottom. The main axis is vertical, so a child's
|
|
2579
|
+
# positional constraint is its **height** and `cross:` is its **width**;
|
|
2580
|
+
# `align: :start` is the left edge, `:end` the right.
|
|
2581
|
+
#
|
|
2582
|
+
# form = Component::Layout::Vertical.new(spacing: 1)
|
|
2583
|
+
# form.add(caption, Component::Layout::Fixed[1])
|
|
2584
|
+
# form.add(field, Component::Layout::Fixed[1], cross: Component::Layout::Fixed[30])
|
|
2585
|
+
# form.add(log, Component::Layout::Expand[1]) # takes whatever is left below
|
|
2586
|
+
#
|
|
2587
|
+
# Inside a subclass the constraints need no prefix at all — see {Box}.
|
|
2588
|
+
#
|
|
2589
|
+
# See {Box} for the constraint vocabulary and how the space is divided.
|
|
2590
|
+
class Vertical < Tuile::Component::Layout::Box
|
|
2591
|
+
DEFAULT_PLACEMENT: ::Hash[Symbol, Object]
|
|
2592
|
+
ALIGNMENTS: ::Array[Symbol]
|
|
2593
|
+
|
|
2594
|
+
# _@param_ `rect`
|
|
2595
|
+
def main_extent: (Rect rect) -> Integer
|
|
2596
|
+
|
|
2597
|
+
# _@param_ `rect`
|
|
2598
|
+
def cross_extent: (Rect rect) -> Integer
|
|
2599
|
+
|
|
2600
|
+
# _@param_ `inner`
|
|
2601
|
+
#
|
|
2602
|
+
# _@param_ `main_offset` — rows down from `inner`'s top.
|
|
2603
|
+
#
|
|
2604
|
+
# _@param_ `main_size` — height.
|
|
2605
|
+
#
|
|
2606
|
+
# _@param_ `cross_offset` — columns right of `inner`'s left.
|
|
2607
|
+
#
|
|
2608
|
+
# _@param_ `cross_size` — width.
|
|
2609
|
+
def build_rect: (
|
|
2610
|
+
Rect inner,
|
|
2611
|
+
Integer main_offset,
|
|
2612
|
+
Integer main_size,
|
|
2613
|
+
Integer cross_offset,
|
|
2614
|
+
Integer cross_size
|
|
2615
|
+
) -> Rect
|
|
2616
|
+
end
|
|
2617
|
+
|
|
2618
|
+
# Lays children out left to right. The main axis is horizontal, so a
|
|
2619
|
+
# child's positional constraint is its **width** and `cross:` is its
|
|
2620
|
+
# **height**; `align: :start` is the top edge, `:end` the bottom.
|
|
2621
|
+
#
|
|
2622
|
+
# split = Component::Layout::Horizontal.new
|
|
2623
|
+
# split.add(sidebar, Component::Layout::Fixed[30])
|
|
2624
|
+
# split.add(main, Component::Layout::Expand[1]) # takes the rest of the row
|
|
2625
|
+
#
|
|
2626
|
+
# Inside a subclass the constraints need no prefix at all — see {Box}.
|
|
2627
|
+
#
|
|
2628
|
+
# See {Box} for the constraint vocabulary and how the space is divided.
|
|
2629
|
+
class Horizontal < Tuile::Component::Layout::Box
|
|
2630
|
+
DEFAULT_PLACEMENT: ::Hash[Symbol, Object]
|
|
2631
|
+
ALIGNMENTS: ::Array[Symbol]
|
|
2632
|
+
|
|
2633
|
+
# _@param_ `rect`
|
|
2634
|
+
def main_extent: (Rect rect) -> Integer
|
|
2635
|
+
|
|
2636
|
+
# _@param_ `rect`
|
|
2637
|
+
def cross_extent: (Rect rect) -> Integer
|
|
2638
|
+
|
|
2639
|
+
# _@param_ `inner`
|
|
2640
|
+
#
|
|
2641
|
+
# _@param_ `main_offset` — columns right of `inner`'s left.
|
|
2642
|
+
#
|
|
2643
|
+
# _@param_ `main_size` — width.
|
|
2644
|
+
#
|
|
2645
|
+
# _@param_ `cross_offset` — rows down from `inner`'s top.
|
|
2646
|
+
#
|
|
2647
|
+
# _@param_ `cross_size` — height.
|
|
2648
|
+
def build_rect: (
|
|
2649
|
+
Rect inner,
|
|
2650
|
+
Integer main_offset,
|
|
2651
|
+
Integer main_size,
|
|
2652
|
+
Integer cross_offset,
|
|
2653
|
+
Integer cross_size
|
|
2654
|
+
) -> Rect
|
|
2655
|
+
end
|
|
2656
|
+
end
|
|
2657
|
+
|
|
2658
|
+
# A closed-choice field on one row: the selected item's label plus a `▾`
|
|
2659
|
+
# affordance, dropping open a {ListDropdown} of the options. Enter, Space or
|
|
2660
|
+
# Down opens it; the arrows (and PgUp/PgDn) move the highlight; Enter or
|
|
2661
|
+
# Space commits; ESC dismisses without committing.
|
|
2662
|
+
#
|
|
2663
|
+
# warn ▾ <- the face: one row, on a field well
|
|
2664
|
+
# debug <- the dropdown, measured to the widest label
|
|
2665
|
+
# info (the one-column gutters are {List}'s)
|
|
2666
|
+
# warn <- highlighted: the value's row, on open
|
|
2667
|
+
# error
|
|
2668
|
+
#
|
|
2669
|
+
# sel = Component::Select.new(items: LogLevel.all)
|
|
2670
|
+
# sel.item_label = ->(l) { l.name } # item -> shown label; default :to_s
|
|
2671
|
+
# sel.on_value_change = ->(l) { relog(l) } # fires on commit, with the item
|
|
2672
|
+
# sel.value = LogLevel::WARN # selects it; the face shows its label
|
|
2673
|
+
#
|
|
2674
|
+
# Use it for an **enum** — labels the developer authored, a closed set known
|
|
2675
|
+
# when the code is written: log level, sort order, line endings, Yes/No/Ask.
|
|
2676
|
+
# For items the app supplies at runtime with labels you don't control
|
|
2677
|
+
# (countries, users, branches) reach for {ComboBox} instead, where filtering
|
|
2678
|
+
# is the navigation. Item count is a symptom, not the criterion; book ch7 has
|
|
2679
|
+
# the widget-choice table.
|
|
2680
|
+
#
|
|
2681
|
+
# {#value} is the selected *item*, of whatever type {#items} holds, never its
|
|
2682
|
+
# label; `nil` — a blank face — is the initial state and stays legal, so an
|
|
2683
|
+
# optional enum field needs no placeholder. As on {ComboBox}, {#items=} is
|
|
2684
|
+
# chrome: it never touches {#value}, never fires {HasValue#on_value_change},
|
|
2685
|
+
# and a value absent from {#items} survives intact while rendering nothing
|
|
2686
|
+
# selected. Keeping the two in sync is the app's job.
|
|
2687
|
+
#
|
|
2688
|
+
# == It claims no printable key but Space
|
|
2689
|
+
# Enter, Space, ESC, {ListDropdown::MOVE_KEYS} and the mouse. *Every other*
|
|
2690
|
+
# printable key bubbles past it (key-dispatch rung 3), so a form's `s`-to-save
|
|
2691
|
+
# and a layout's `1`/`2`/`3` pane jumps keep working while a Select has focus
|
|
2692
|
+
# — the one capability no {ComboBox} configuration can offer, since a text
|
|
2693
|
+
# field eats printables unconditionally. Space is the single exception, and it
|
|
2694
|
+
# forecloses nothing: every activatable widget in the gem already claims it.
|
|
2695
|
+
# Home/End are declined too, so they stay available app-wide.
|
|
2696
|
+
#
|
|
2697
|
+
# There is no type-ahead: a hidden prefix buffer *is* the ComboBox query with
|
|
2698
|
+
# the feedback removed (`DECISIONS.md` `D-select`). Which is also why labels
|
|
2699
|
+
# need no prefix-disambiguation.
|
|
2700
|
+
#
|
|
2701
|
+
# == Implementation details
|
|
2702
|
+
# A leaf widget: it paints its own row (the face is *derived* from {#value}
|
|
2703
|
+
# each paint, never a synced copy) and owns the dropdown as an overlay, which
|
|
2704
|
+
# is not a child — like {ComboBox}'s. The well is read from
|
|
2705
|
+
# {Screen#theme} at paint time, so it tracks a theme flip with no hook.
|
|
2706
|
+
#
|
|
2707
|
+
# The dropdown is at least as wide as the face and grows to fit the widest
|
|
2708
|
+
# label, so the labels are never the thing that ellipsizes. It is not opened
|
|
2709
|
+
# at all when {#items} is empty: an item-less Select is a programming bug, and
|
|
2710
|
+
# an empty tinted panel reads as a broken list rather than as "nothing to
|
|
2711
|
+
# pick". Enter/Space/Down are claimed either way.
|
|
2712
|
+
#
|
|
2713
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
2714
|
+
class Select < Component
|
|
2715
|
+
include Tuile::Component::HasValue
|
|
2716
|
+
|
|
2717
|
+
# _@param_ `items` — the options (any type); also settable via {#items=}.
|
|
2718
|
+
#
|
|
2719
|
+
# _@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.
|
|
2720
|
+
def initialize: (?items: ::Array[untyped], ?value: Object?) -> void
|
|
2721
|
+
|
|
2722
|
+
def tab_stop?: () -> bool
|
|
2723
|
+
|
|
2724
|
+
def keyboard_hint: () -> String
|
|
2725
|
+
|
|
2726
|
+
# Re-anchors the (open) dropdown after a move or resize.
|
|
2727
|
+
#
|
|
2728
|
+
# _@param_ `new_rect`
|
|
2729
|
+
def rect=: (Rect new_rect) -> void
|
|
2730
|
+
|
|
2731
|
+
# Closes the dropdown when the Select leaves the focus chain, so tabbing
|
|
2732
|
+
# away doesn't strand an open menu. Safe against re-entrancy: focus never
|
|
2733
|
+
# sits inside the (non-focusable) {ListDropdown}, so closing it repairs no
|
|
2734
|
+
# focus.
|
|
2735
|
+
#
|
|
2736
|
+
# _@param_ `flag`
|
|
2737
|
+
def active=: (bool flag) -> void
|
|
2738
|
+
|
|
2739
|
+
# Opens the dropdown on Enter, Space or Down; while it is open, forwards
|
|
2740
|
+
# {ListDropdown::MOVE_KEYS} to it, commits the highlight on Enter or Space,
|
|
2741
|
+
# and dismisses on ESC. Everything else — every other printable included —
|
|
2742
|
+
# is left unhandled so it bubbles to an ancestor.
|
|
2743
|
+
#
|
|
2744
|
+
# _@param_ `key`
|
|
2745
|
+
def handle_key: (String key) -> bool
|
|
2746
|
+
|
|
2747
|
+
# Toggles the dropdown on a left click anywhere in {#rect} — a field's
|
|
2748
|
+
# affordance is its whole row, as the well advertises; `super` runs first,
|
|
2749
|
+
# so the click also focuses.
|
|
2750
|
+
#
|
|
2751
|
+
# _@param_ `event`
|
|
2752
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
2753
|
+
|
|
2754
|
+
def repaint: () -> void
|
|
2755
|
+
|
|
2756
|
+
# The painted row: the value's label padded across all but the last column,
|
|
2757
|
+
# then the `▾`, all on the field well — {Theme#active_bg_color} while on the
|
|
2758
|
+
# focus chain, {Theme#input_bg_color} otherwise.
|
|
2759
|
+
def face_row: () -> StyledString
|
|
2760
|
+
|
|
2761
|
+
# Rebuilds the dropdown's rows, highlight and geometry, opening it if
|
|
2762
|
+
# needed; closes it instead when there is nothing to show.
|
|
2763
|
+
def refill: () -> void
|
|
2764
|
+
|
|
2765
|
+
def open_menu: () -> void
|
|
2766
|
+
|
|
2767
|
+
def close_menu: () -> void
|
|
2768
|
+
|
|
2769
|
+
# Adopts the item on row `index` as {#value} and closes the dropdown.
|
|
2770
|
+
#
|
|
2771
|
+
# _@param_ `index`
|
|
2772
|
+
def commit: (Integer index) -> void
|
|
2773
|
+
|
|
2774
|
+
def anchor: () -> void
|
|
2775
|
+
|
|
2776
|
+
# The dropdown's width: the widest label plus {List}'s two row gutters, plus
|
|
2777
|
+
# the scrollbar column when the rows can't all be shown at once — but never
|
|
2778
|
+
# narrower than the Select itself, so both edges line up with the face and
|
|
2779
|
+
# the panel reads as belonging to it. Only a label that needs more pushes it
|
|
2780
|
+
# wider.
|
|
2781
|
+
#
|
|
2782
|
+
# A dropdown the screen clamps shorter than
|
|
2783
|
+
# {ListDropdown::MAX_VISIBLE_ROWS} scrolls without having bought that
|
|
2784
|
+
# column, ellipsizing its labels one early — the {ComboBox} trade, in the
|
|
2785
|
+
# one case measuring can't predict the height.
|
|
2786
|
+
def menu_width: () -> Integer
|
|
2787
|
+
|
|
2788
|
+
# _@param_ `item`
|
|
2789
|
+
#
|
|
2790
|
+
# _@return_ — `item`'s label, or empty for `nil` — so {#value}
|
|
2791
|
+
# being unset never reaches an {#item_label} that assumes an item.
|
|
2792
|
+
def label_for: (Object item) -> StyledString
|
|
2793
|
+
|
|
2794
|
+
# _@return_ — the current value; `nil` until first set.
|
|
2795
|
+
def value: () -> Object
|
|
2796
|
+
|
|
2797
|
+
# No-op (no repaint, no listener) when equal to the current value.
|
|
2798
|
+
#
|
|
2799
|
+
# _@param_ `new_value`
|
|
2800
|
+
def value=: (Object new_value) -> void
|
|
2801
|
+
|
|
2802
|
+
# _@return_ — true iff {#value} equals {#empty_value}.
|
|
2803
|
+
def empty?: () -> bool
|
|
2804
|
+
|
|
2805
|
+
# Resets {#value} to {#empty_value}.
|
|
2806
|
+
def clear: () -> void
|
|
2807
|
+
|
|
2808
|
+
# _@return_ — the value {#empty?}/{#clear} treat as empty; `nil`
|
|
2809
|
+
# unless an includer overrides it.
|
|
2810
|
+
def empty_value: () -> Object
|
|
2811
|
+
|
|
2812
|
+
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
2813
|
+
# a read-only display field could override back to `false`. Only
|
|
2814
|
+
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
2815
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
2816
|
+
# `D-integer-field`).
|
|
2817
|
+
def focusable?: () -> bool
|
|
2818
|
+
|
|
2819
|
+
# _@return_ — the options.
|
|
2820
|
+
attr_accessor items: ::Array[untyped]
|
|
2821
|
+
|
|
2822
|
+
# _@return_ — item -> shown label (a `String` or
|
|
2823
|
+
# {StyledString}); `:to_s` by default. Never called with `nil` — an
|
|
2824
|
+
# unselected Select renders a blank face.
|
|
2825
|
+
attr_accessor item_label: (Proc | Method)
|
|
2106
2826
|
end
|
|
2107
2827
|
|
|
2108
2828
|
# A window with a frame, a {#caption} and a content {Component}. Doesn't
|
|
@@ -2116,14 +2836,13 @@ module Tuile
|
|
|
2116
2836
|
# by {Component#invalidate}; subclasses don't need to re-check.)
|
|
2117
2837
|
class Window < Component
|
|
2118
2838
|
include Tuile::Component::HasContent
|
|
2839
|
+
include Tuile::Component::HasCaption
|
|
2119
2840
|
|
|
2120
|
-
# _@param_ `caption`
|
|
2121
|
-
def initialize: (?String caption) -> void
|
|
2841
|
+
# _@param_ `caption` — the border title, coerced the same way {HasCaption#caption=} coerces it.
|
|
2842
|
+
def initialize: (?(String | StyledString)? caption) -> void
|
|
2122
2843
|
|
|
2123
2844
|
def focusable?: () -> bool
|
|
2124
2845
|
|
|
2125
|
-
def children: () -> ::Array[Component]
|
|
2126
|
-
|
|
2127
2846
|
# _@param_ `event`
|
|
2128
2847
|
def handle_mouse: (MouseEvent event) -> void
|
|
2129
2848
|
|
|
@@ -2145,20 +2864,29 @@ module Tuile
|
|
|
2145
2864
|
# cycle.
|
|
2146
2865
|
def repaint: () -> void
|
|
2147
2866
|
|
|
2148
|
-
# _@param_ `key`
|
|
2149
|
-
def key_shortcut=: (String? key) -> void
|
|
2150
|
-
|
|
2151
2867
|
# _@param_ `content`
|
|
2152
2868
|
def layout: (Component content) -> void
|
|
2153
2869
|
|
|
2154
|
-
# Paints the window border
|
|
2155
|
-
#
|
|
2156
|
-
#
|
|
2870
|
+
# Paints the window border via {Component#draw_line}/{Component#draw_char},
|
|
2871
|
+
# so the border cells inherit {Component#effective_bg_color} — a
|
|
2872
|
+
# {Component#bg_color} on the window tints border and content alike. Both
|
|
2873
|
+
# border lines are clipped by *display* width, so no caption overflows the
|
|
2874
|
+
# box; when the window is active the whole border — the caption's own
|
|
2875
|
+
# colors included — is drawn in {Theme#active_border_color}.
|
|
2157
2876
|
def repaint_border: () -> void
|
|
2158
2877
|
|
|
2159
|
-
# Builds the
|
|
2160
|
-
#
|
|
2161
|
-
#
|
|
2878
|
+
# Builds the top border line: corners, {#caption} embedded at its own
|
|
2879
|
+
# width, dashes filling the remainder. The caption keeps its own styling
|
|
2880
|
+
# unless `fg` is set — an active window's border claims it.
|
|
2881
|
+
#
|
|
2882
|
+
# _@param_ `inner_w` — the border's interior width.
|
|
2883
|
+
#
|
|
2884
|
+
# _@param_ `fg` — the active-border color, or nil when inactive.
|
|
2885
|
+
def top_border: (Integer inner_w, Color? fg) -> StyledString
|
|
2886
|
+
|
|
2887
|
+
# Builds the bottom border line. The corners take the border color; the
|
|
2888
|
+
# interior is plain dashes when a {#footer} component occupies the row
|
|
2889
|
+
# (it overpaints them) or when there's no chrome, otherwise it carries
|
|
2162
2890
|
# {#footer_text} embedded at its own width — keeping the text's own
|
|
2163
2891
|
# styling — with dashes filling the remainder up to the inner width.
|
|
2164
2892
|
#
|
|
@@ -2167,15 +2895,24 @@ module Tuile
|
|
|
2167
2895
|
# _@param_ `fg` — the active-border color, or nil when inactive.
|
|
2168
2896
|
def bottom_border: (Integer inner_w, Color? fg) -> StyledString
|
|
2169
2897
|
|
|
2170
|
-
# The caption text as it appears in the rendered border, including the
|
|
2171
|
-
# shortcut prefix when {#key_shortcut} is set.
|
|
2172
|
-
def frame_caption: () -> String
|
|
2173
|
-
|
|
2174
2898
|
# Positions the footer over the bottom border row, spanning the full
|
|
2175
2899
|
# inner width (the only dimension a bottom-row widget needs — the window
|
|
2176
2900
|
# already knows it).
|
|
2177
2901
|
def layout_footer: () -> void
|
|
2178
2902
|
|
|
2903
|
+
# Read through *this* method, never `@caption` — the ivar stays nil until
|
|
2904
|
+
# the first non-empty set ({#caption=} short-circuits when unchanged).
|
|
2905
|
+
#
|
|
2906
|
+
# _@return_ — the caption; empty when never set.
|
|
2907
|
+
def caption: () -> StyledString
|
|
2908
|
+
|
|
2909
|
+
# Sets the caption and invalidates the component. No-op when unchanged. A
|
|
2910
|
+
# `String` is parsed via {StyledString.parse} (embedded ANSI is honored);
|
|
2911
|
+
# a {StyledString} is used as-is; `nil` clears it.
|
|
2912
|
+
#
|
|
2913
|
+
# _@param_ `new_caption`
|
|
2914
|
+
def caption=: ((String | StyledString)? new_caption) -> void
|
|
2915
|
+
|
|
2179
2916
|
def on_focus: () -> void
|
|
2180
2917
|
|
|
2181
2918
|
# _@return_ — optional focusable component occupying the
|
|
@@ -2186,9 +2923,346 @@ module Tuile
|
|
|
2186
2923
|
# line, mirroring {#caption} on the top line. Empty by default; hidden
|
|
2187
2924
|
# whenever a {#footer} component is present.
|
|
2188
2925
|
attr_accessor footer_text: (StyledString | String)?
|
|
2926
|
+
end
|
|
2927
|
+
|
|
2928
|
+
# A boolean input on one row. Space, Enter or a left click toggles it:
|
|
2929
|
+
#
|
|
2930
|
+
# [x] Enable syslog forwarding
|
|
2931
|
+
# [ ] Enable syslog forwarding
|
|
2932
|
+
#
|
|
2933
|
+
# cb = Component::Checkbox.new("Enable syslog forwarding", value: true)
|
|
2934
|
+
# cb.on_value_change = ->(on) { config.syslog = on }
|
|
2935
|
+
# cb.toggle # unchecks it, firing the listener with false
|
|
2936
|
+
# cb.checked? # => false
|
|
2937
|
+
#
|
|
2938
|
+
# {#value} is the canonical seam ({HasValue}), always `true`/`false` and
|
|
2939
|
+
# never `nil`; {#checked?} / {#checked=} / {#toggle} are the domain-word face
|
|
2940
|
+
# over it — one piece of state, four names. Unchecked is the
|
|
2941
|
+
# {#empty_value}, so a fresh checkbox is {HasValue#empty? empty} and
|
|
2942
|
+
# {HasValue#clear} unchecks.
|
|
2943
|
+
#
|
|
2944
|
+
# Space and Enter both toggle — same as a checkable row in a
|
|
2945
|
+
# {Component::List} ({CheckboxGroup}, {RadioGroup}), so the gesture reads the
|
|
2946
|
+
# same standalone and grouped. A focused checkbox therefore *consumes* Enter:
|
|
2947
|
+
# a form's Enter-to-submit on an ancestor won't see it, exactly as with a
|
|
2948
|
+
# focused {Button} or {TextArea}. Which widget lets Enter through is per
|
|
2949
|
+
# widget, never a framework guarantee — book ch5's Enter table is the list.
|
|
2950
|
+
#
|
|
2951
|
+
# A tab stop, so Tab lands on it, and the widget highlights while on the focus
|
|
2952
|
+
# chain. Assign a {#rect} (typically from the surrounding {Layout}) at least
|
|
2953
|
+
# `caption.display_width + 4` wide; a narrower one ellipsizes the caption, a
|
|
2954
|
+
# wider one leaves a dead tail — see {#extent}.
|
|
2955
|
+
#
|
|
2956
|
+
# == Implementation details
|
|
2957
|
+
# The glyphs are a house convention rather than constants: three columns plus
|
|
2958
|
+
# a trailing space (`[x] `, `[ ] `), ASCII because `☑`/`☐` are absent from
|
|
2959
|
+
# most monospace fonts and the fallback glyph bleeds over its cell. A widget
|
|
2960
|
+
# painting checkbox-like rows without instantiating a Checkbox — checkable
|
|
2961
|
+
# rows in a {Component::List} — repeats those literals to match.
|
|
2962
|
+
class Checkbox < Component
|
|
2963
|
+
include Tuile::Component::HasValue
|
|
2964
|
+
include Tuile::Component::HasCaption
|
|
2965
|
+
|
|
2966
|
+
# _@param_ `caption` — the label, coerced as {HasCaption#caption=} coerces it.
|
|
2967
|
+
#
|
|
2968
|
+
# _@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.
|
|
2969
|
+
def initialize: (?(String | StyledString)? caption, ?value: bool) -> void
|
|
2970
|
+
|
|
2971
|
+
def tab_stop?: () -> bool
|
|
2972
|
+
|
|
2973
|
+
# _@return_ — `false` — {HasValue#empty?} means unchecked.
|
|
2974
|
+
def empty_value: () -> bool
|
|
2975
|
+
|
|
2976
|
+
# Coerces to `true`/`false` before storing, so the two-state invariant holds
|
|
2977
|
+
# whatever a caller assigns — and `cb.value = nil` on a fresh checkbox is
|
|
2978
|
+
# the no-op it looks like rather than a spurious change event.
|
|
2979
|
+
#
|
|
2980
|
+
# _@param_ `new_value` — anything; truthiness decides.
|
|
2981
|
+
def value=: (Object new_value) -> void
|
|
2982
|
+
|
|
2983
|
+
# _@return_ — {#value} under its domain word — `license.checked?`
|
|
2984
|
+
# reads better than `license.value`. Not a second piece of state.
|
|
2985
|
+
def checked?: () -> bool
|
|
2986
|
+
|
|
2987
|
+
# {#value=} under its domain word. A delegator rather than an `alias`, so it
|
|
2988
|
+
# keeps routing through the one write path even if a subclass overrides
|
|
2989
|
+
# {#value=} (an `alias` would freeze this onto the body defined here).
|
|
2990
|
+
#
|
|
2991
|
+
# _@param_ `new_value` — anything; truthiness decides.
|
|
2992
|
+
def checked=: (Object new_value) -> void
|
|
2993
|
+
|
|
2994
|
+
# Flips {#value}.
|
|
2995
|
+
def toggle: () -> void
|
|
2996
|
+
|
|
2997
|
+
# The cells the widget actually paints: one row, `caption.display_width + 4`
|
|
2998
|
+
# columns, clipped to {#rect}. A form column routinely hands a checkbox a
|
|
2999
|
+
# 40-column rect for a 22-column `[ ] Enable syslog forwarding` — the extent
|
|
3000
|
+
# is those 22 columns.
|
|
3001
|
+
#
|
|
3002
|
+
# Both the focus highlight and the click hit test use it, so a click on the
|
|
3003
|
+
# blank tail — or on a lower row, when the rect is taller than one — does
|
|
3004
|
+
# not toggle. It still *focuses*: {Component#handle_mouse}'s click-to-focus
|
|
3005
|
+
# is ungated by geometry, and the tail is the field's own row.
|
|
3006
|
+
#
|
|
3007
|
+
# The extent ignores {Component#bg_color}: an inherited tint paints the dead
|
|
3008
|
+
# tail, but a hit test that silently widened with a background would be a
|
|
3009
|
+
# mode switch invisible in the code and untestable by inspection.
|
|
3010
|
+
def extent: () -> Rect
|
|
3011
|
+
|
|
3012
|
+
# Toggles on Space or Enter. Every other key is left unhandled so it bubbles
|
|
3013
|
+
# to an ancestor.
|
|
3014
|
+
#
|
|
3015
|
+
# _@param_ `key`
|
|
3016
|
+
def handle_key: (String key) -> bool
|
|
3017
|
+
|
|
3018
|
+
# Toggles on a left click within {#extent}; `super` runs first, so a click
|
|
3019
|
+
# anywhere in {#rect} still focuses.
|
|
3020
|
+
#
|
|
3021
|
+
# _@param_ `event`
|
|
3022
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
3023
|
+
|
|
3024
|
+
def repaint: () -> void
|
|
3025
|
+
|
|
3026
|
+
# Read through *this* method, never `@caption` — the ivar stays nil until
|
|
3027
|
+
# the first non-empty set ({#caption=} short-circuits when unchanged).
|
|
3028
|
+
#
|
|
3029
|
+
# _@return_ — the caption; empty when never set.
|
|
3030
|
+
def caption: () -> StyledString
|
|
3031
|
+
|
|
3032
|
+
# Sets the caption and invalidates the component. No-op when unchanged. A
|
|
3033
|
+
# `String` is parsed via {StyledString.parse} (embedded ANSI is honored);
|
|
3034
|
+
# a {StyledString} is used as-is; `nil` clears it.
|
|
3035
|
+
#
|
|
3036
|
+
# _@param_ `new_caption`
|
|
3037
|
+
def caption=: ((String | StyledString)? new_caption) -> void
|
|
3038
|
+
|
|
3039
|
+
# _@return_ — the current value; `nil` until first set.
|
|
3040
|
+
def value: () -> Object
|
|
3041
|
+
|
|
3042
|
+
# _@return_ — true iff {#value} equals {#empty_value}.
|
|
3043
|
+
def empty?: () -> bool
|
|
3044
|
+
|
|
3045
|
+
# Resets {#value} to {#empty_value}.
|
|
3046
|
+
def clear: () -> void
|
|
3047
|
+
|
|
3048
|
+
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
3049
|
+
# a read-only display field could override back to `false`. Only
|
|
3050
|
+
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
3051
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
3052
|
+
# `D-integer-field`).
|
|
3053
|
+
def focusable?: () -> bool
|
|
3054
|
+
end
|
|
3055
|
+
|
|
3056
|
+
# A text field with a filtering dropdown: type to narrow the candidates,
|
|
3057
|
+
# arrow to move the highlight, Enter (or click) to accept. Its {#value} is
|
|
3058
|
+
# the *selected item* — of whatever type the items are — not the display
|
|
3059
|
+
# string, so a combo over domain objects hands back the object:
|
|
3060
|
+
#
|
|
3061
|
+
# combo = Component::ComboBox.new
|
|
3062
|
+
# combo.items = User.all # Array of any type
|
|
3063
|
+
# combo.item_label = ->(u) { u.full_name } # item -> shown text; default :to_s
|
|
3064
|
+
# combo.on_value_change = ->(u) { open(u) } # fires on commit, with the item
|
|
3065
|
+
# combo.value = some_user # selects it; field shows its label
|
|
3066
|
+
#
|
|
3067
|
+
# It's the assembly you'd otherwise wire by hand — a {TextField} plus a
|
|
3068
|
+
# non-modal {Popup} over a {List} — promoted to one component. Give it a
|
|
3069
|
+
# single-row {#rect}; it paints the field across that row with a `▾` in the
|
|
3070
|
+
# last column and floats the dropdown above or below.
|
|
3071
|
+
#
|
|
3072
|
+
# == The two values
|
|
3073
|
+
# {#value} (the committed selection) and the field's typed text (a transient
|
|
3074
|
+
# *query*) are deliberately distinct. Keystrokes move the query and refilter
|
|
3075
|
+
# the list; only Enter/click commits, and only a commit changes {#value} and
|
|
3076
|
+
# fires {#on_value_change}. An uncommitted query reverts to the current
|
|
3077
|
+
# value's label when the dropdown is dismissed (ESC) or the combo loses
|
|
3078
|
+
# focus. Selecting by list index (not by matching the label back) is what
|
|
3079
|
+
# lets two items share a label and still resolve to the right object.
|
|
3080
|
+
#
|
|
3081
|
+
# The dropdown is a {ListDropdown}, tinted to read as a floating panel; see
|
|
3082
|
+
# it for the theming knob.
|
|
3083
|
+
#
|
|
3084
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
3085
|
+
class ComboBox < Component
|
|
3086
|
+
include Tuile::Component::HasContent
|
|
3087
|
+
include Tuile::Component::HasValue
|
|
3088
|
+
|
|
3089
|
+
# _@param_ `items` — the candidate items (any type); also settable via {#items=}.
|
|
3090
|
+
def initialize: (?items: ::Array[untyped]) -> void
|
|
3091
|
+
|
|
3092
|
+
# Selects `new_value` programmatically: updates the field to its label
|
|
3093
|
+
# *without* opening the dropdown, then fires {#on_value_change}. `nil`
|
|
3094
|
+
# clears the selection (blank field). The value need not be in {#items}.
|
|
3095
|
+
#
|
|
3096
|
+
# _@param_ `new_value`
|
|
3097
|
+
def value=: (Object new_value) -> void
|
|
3098
|
+
|
|
3099
|
+
# _@return_ — the field's caret position (the combo delegates the
|
|
3100
|
+
# hardware cursor to its field).
|
|
3101
|
+
def cursor_position: () -> Point?
|
|
3102
|
+
|
|
3103
|
+
def keyboard_hint: () -> String
|
|
3104
|
+
|
|
3105
|
+
# Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
|
|
3106
|
+
# field via {#layout}.
|
|
3107
|
+
#
|
|
3108
|
+
# _@param_ `new_rect`
|
|
3109
|
+
def rect=: (Rect new_rect) -> void
|
|
3110
|
+
|
|
3111
|
+
# Closes the dropdown and reverts an uncommitted query when the combo
|
|
3112
|
+
# leaves the focus chain — so tabbing away doesn't strand an open menu or
|
|
3113
|
+
# a half-typed filter. Safe against re-entrancy: focus never sits inside
|
|
3114
|
+
# the (non-focusable) {ListDropdown}, so closing the overlay repairs no
|
|
3115
|
+
# focus.
|
|
3116
|
+
#
|
|
3117
|
+
# _@param_ `flag`
|
|
3118
|
+
def active=: (bool flag) -> void
|
|
2189
3119
|
|
|
2190
|
-
# _@
|
|
2191
|
-
|
|
3120
|
+
# _@param_ `event`
|
|
3121
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
3122
|
+
|
|
3123
|
+
def repaint: () -> void
|
|
3124
|
+
|
|
3125
|
+
# Field spans the row bar the last column, which the `▾` occupies
|
|
3126
|
+
# ({HasContent} layout hook). One row, or none at all when the combo itself
|
|
3127
|
+
# was given none — a starved parent must not hand out a rect it doesn't own.
|
|
3128
|
+
#
|
|
3129
|
+
# _@param_ `field`
|
|
3130
|
+
def layout: (Component field) -> void
|
|
3131
|
+
|
|
3132
|
+
# The field's key interceptor: while the dropdown is open forwards movement
|
|
3133
|
+
# to it ({ListDropdown#move}), commits on Enter ({ListDropdown#choose}),
|
|
3134
|
+
# and dismisses on ESC (reverting the query); opens it on Down or Enter
|
|
3135
|
+
# when closed. Everything else (printable keys, editing) falls through to
|
|
3136
|
+
# the field, whose {TextField#on_change} refilters.
|
|
3137
|
+
#
|
|
3138
|
+
# _@param_ `key`
|
|
3139
|
+
#
|
|
3140
|
+
# _@return_ — true if consumed.
|
|
3141
|
+
def field_key: (String key) -> bool
|
|
3142
|
+
|
|
3143
|
+
# Recomputes the matches for the current query, opening the dropdown when
|
|
3144
|
+
# there are any (and preselecting the current value's row) or closing it
|
|
3145
|
+
# when there are none.
|
|
3146
|
+
def refill: () -> void
|
|
3147
|
+
|
|
3148
|
+
# Items whose label contains `query` (case-insensitive). A query still
|
|
3149
|
+
# equal to the current value's label — the resting state, or a fresh
|
|
3150
|
+
# open — is treated as "show everything", so Down opens the full list.
|
|
3151
|
+
#
|
|
3152
|
+
# _@param_ `query`
|
|
3153
|
+
def matching: (String query) -> ::Array[untyped]
|
|
3154
|
+
|
|
3155
|
+
# Commits the item at the menu's `index`: closes the dropdown and adopts
|
|
3156
|
+
# it as {#value} (which repaints the field with its label).
|
|
3157
|
+
#
|
|
3158
|
+
# _@param_ `index`
|
|
3159
|
+
def commit: (Integer index) -> void
|
|
3160
|
+
|
|
3161
|
+
def open_menu: () -> void
|
|
3162
|
+
|
|
3163
|
+
def close_menu: () -> void
|
|
3164
|
+
|
|
3165
|
+
def revert_query: () -> void
|
|
3166
|
+
|
|
3167
|
+
# Sets the field's text without triggering a refilter — for programmatic
|
|
3168
|
+
# value changes and query reverts, which must not spring the dropdown.
|
|
3169
|
+
# Parks the caret at the end: `text=` only *clamps* the caret, so a
|
|
3170
|
+
# shorter query replaced by a longer label would otherwise strand it
|
|
3171
|
+
# mid-word (commit "Go", then pick "Kotlin" → caret after "Ko").
|
|
3172
|
+
#
|
|
3173
|
+
# _@param_ `text`
|
|
3174
|
+
def sync_field: (String text) -> void
|
|
3175
|
+
|
|
3176
|
+
# _@param_ `item`
|
|
3177
|
+
#
|
|
3178
|
+
# _@return_ — the plain-text label for `item`, or "" for nil.
|
|
3179
|
+
def display_for: (Object item) -> String
|
|
3180
|
+
|
|
3181
|
+
# Places the dropdown at the combo's own width, so both its edges line up
|
|
3182
|
+
# with the field — at the cost of the scrollbar taking its column from the
|
|
3183
|
+
# labels, which ellipsize a column earlier once the list scrolls. That is
|
|
3184
|
+
# the trade a measuring driver ({Select}) makes the other way.
|
|
3185
|
+
def anchor: () -> void
|
|
3186
|
+
|
|
3187
|
+
# _@return_ — the current value; `nil` until first set.
|
|
3188
|
+
def value: () -> Object
|
|
3189
|
+
|
|
3190
|
+
# _@return_ — true iff {#value} equals {#empty_value}.
|
|
3191
|
+
def empty?: () -> bool
|
|
3192
|
+
|
|
3193
|
+
# Resets {#value} to {#empty_value}.
|
|
3194
|
+
def clear: () -> void
|
|
3195
|
+
|
|
3196
|
+
# _@return_ — the value {#empty?}/{#clear} treat as empty; `nil`
|
|
3197
|
+
# unless an includer overrides it.
|
|
3198
|
+
def empty_value: () -> Object
|
|
3199
|
+
|
|
3200
|
+
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
3201
|
+
# a read-only display field could override back to `false`. Only
|
|
3202
|
+
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
3203
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
3204
|
+
# `D-integer-field`).
|
|
3205
|
+
def focusable?: () -> bool
|
|
3206
|
+
|
|
3207
|
+
def on_focus: () -> void
|
|
3208
|
+
|
|
3209
|
+
# _@return_ — the candidate items.
|
|
3210
|
+
attr_accessor items: ::Array[untyped]
|
|
3211
|
+
|
|
3212
|
+
# _@return_ — item -> shown label (a `String` or
|
|
3213
|
+
# {StyledString}); the field shows its `#to_s`, the list its styled form.
|
|
3214
|
+
attr_accessor item_label: (Proc | Method)
|
|
3215
|
+
end
|
|
3216
|
+
|
|
3217
|
+
# The value seam every input component shares: a settable/gettable {#value}
|
|
3218
|
+
# of *any* type, an {#on_value_change} listener, {#empty?}, and {#clear}. A
|
|
3219
|
+
# form (a future binder) drives a mix of field types uniformly through it,
|
|
3220
|
+
# not caring that a {TextField}'s value is a `String` while another field's
|
|
3221
|
+
# is a domain object.
|
|
3222
|
+
#
|
|
3223
|
+
# field.on_value_change = ->(v) { puts "now: #{v.inspect}" }
|
|
3224
|
+
# field.value = "hello" # fires the listener
|
|
3225
|
+
# field.clear # value = empty_value, fires again
|
|
3226
|
+
#
|
|
3227
|
+
# The default {#value=}/{#value} keep the value in `@value` and are enough
|
|
3228
|
+
# for a component with nothing more natural — you get a repaint and the
|
|
3229
|
+
# listener for free. An includer whose value lives elsewhere overrides both
|
|
3230
|
+
# ({AbstractStringField} backs them with its text buffer). Override {#empty_value}
|
|
3231
|
+
# when the empty sentinel isn't `nil` (a text field's is `""`).
|
|
3232
|
+
#
|
|
3233
|
+
# == Implementation details
|
|
3234
|
+
# Deliberately smaller than Vaadin's `HasValue`: read-only,
|
|
3235
|
+
# required-indicator, the from-client/old-value event payload, and
|
|
3236
|
+
# converters all belong to the not-yet-built form layer, not here.
|
|
3237
|
+
module HasValue
|
|
3238
|
+
# _@return_ — the current value; `nil` until first set.
|
|
3239
|
+
def value: () -> Object
|
|
3240
|
+
|
|
3241
|
+
# No-op (no repaint, no listener) when equal to the current value.
|
|
3242
|
+
#
|
|
3243
|
+
# _@param_ `new_value`
|
|
3244
|
+
def value=: (Object new_value) -> void
|
|
3245
|
+
|
|
3246
|
+
# _@return_ — true iff {#value} equals {#empty_value}.
|
|
3247
|
+
def empty?: () -> bool
|
|
3248
|
+
|
|
3249
|
+
# Resets {#value} to {#empty_value}.
|
|
3250
|
+
def clear: () -> void
|
|
3251
|
+
|
|
3252
|
+
# _@return_ — the value {#empty?}/{#clear} treat as empty; `nil`
|
|
3253
|
+
# unless an includer overrides it.
|
|
3254
|
+
def empty_value: () -> Object
|
|
3255
|
+
|
|
3256
|
+
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
3257
|
+
# a read-only display field could override back to `false`. Only
|
|
3258
|
+
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
3259
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
3260
|
+
# `D-integer-field`).
|
|
3261
|
+
def focusable?: () -> bool
|
|
3262
|
+
|
|
3263
|
+
# _@return_ — one-arg callable fired with the new value
|
|
3264
|
+
# whenever {#value} actually changes — never on a no-op set.
|
|
3265
|
+
attr_accessor on_value_change: (Proc | Method)?
|
|
2192
3266
|
end
|
|
2193
3267
|
|
|
2194
3268
|
# A multi-line, word-wrapping text input.
|
|
@@ -2199,15 +3273,33 @@ module Tuile
|
|
|
2199
3273
|
# follows the caret so the line being edited stays visible. There is no
|
|
2200
3274
|
# horizontal scrolling.
|
|
2201
3275
|
#
|
|
2202
|
-
# The caret is a logical index in `0..text.length
|
|
3276
|
+
# The caret is a logical index in `0..text.length`, always on a
|
|
3277
|
+
# grapheme-cluster boundary ({AbstractStringField}). When the caret falls
|
|
2203
3278
|
# inside a whitespace run that was absorbed by a soft wrap, it displays
|
|
2204
3279
|
# at the end of the previous row (which is visually identical to the
|
|
2205
3280
|
# start of the next row in nearly all cases).
|
|
2206
3281
|
#
|
|
2207
|
-
#
|
|
2208
|
-
#
|
|
2209
|
-
#
|
|
2210
|
-
|
|
3282
|
+
# Enter inserts a newline, as in a plain `<textarea>` or text editor; only
|
|
3283
|
+
# {#on_change} is wired. A pasted line break arrives as `\n`
|
|
3284
|
+
# ({Keys::CTRL_J}) rather than the `\r` a typed Enter sends, so both are
|
|
3285
|
+
# accepted — otherwise a multi-line paste would silently lose its
|
|
3286
|
+
# newlines.
|
|
3287
|
+
#
|
|
3288
|
+
# == Implementation details
|
|
3289
|
+
#
|
|
3290
|
+
# The same two axes {TextField} names apply, and the wrap straddles both: an
|
|
3291
|
+
# **index** counts characters into {#text} ({#caret}, a row's `start` and
|
|
3292
|
+
# `length`), a **column** counts terminal cells ({#rect}, a row's `columns`,
|
|
3293
|
+
# {#cursor_position}, a {MouseEvent}). A row therefore carries *both* counts,
|
|
3294
|
+
# and the wrap fills each row to a column budget while recording a character
|
|
3295
|
+
# span. Everything crossing between them goes through the inherited
|
|
3296
|
+
# `columns_of` and the private `chars_for_column`.
|
|
3297
|
+
#
|
|
3298
|
+
# The wrap walks **grapheme clusters**, not characters — a combining mark must
|
|
3299
|
+
# add no columns and must not be split from its base across a row break. Note
|
|
3300
|
+
# `"\r\n"` is a *single* cluster, so a hard break tests `end_with?("\n")`
|
|
3301
|
+
# rather than equality.
|
|
3302
|
+
class TextArea < Tuile::Component::AbstractStringField
|
|
2211
3303
|
def initialize: () -> void
|
|
2212
3304
|
|
|
2213
3305
|
def cursor_position: () -> Point?
|
|
@@ -2230,30 +3322,98 @@ module Tuile
|
|
|
2230
3322
|
# current {Rect#width}. Each entry is `{start:, length:}`.
|
|
2231
3323
|
def display_rows: () -> ::Array[::Hash[Symbol, Integer]]
|
|
2232
3324
|
|
|
2233
|
-
#
|
|
2234
|
-
#
|
|
2235
|
-
#
|
|
2236
|
-
|
|
3325
|
+
# _@return_ — one entry per grapheme cluster of
|
|
3326
|
+
# {#text}: `{offset: <text-index>, text: <cluster>, width: <columns>}`.
|
|
3327
|
+
# Rebuilt per wrap and discarded — the wrap is what's cached.
|
|
3328
|
+
def cluster_table: () -> ::Array[::Hash[Symbol, Object]]
|
|
3329
|
+
|
|
3330
|
+
# _@param_ `cluster`
|
|
3331
|
+
#
|
|
3332
|
+
# _@return_ — true for a space or tab (each exactly one column).
|
|
3333
|
+
def blank?: (::Hash[Symbol, Object] cluster) -> bool
|
|
3334
|
+
|
|
3335
|
+
# _@param_ `cluster`
|
|
3336
|
+
#
|
|
3337
|
+
# _@return_ — true for a hard line break. Tests the suffix rather
|
|
3338
|
+
# than equality because `"\r\n"` is one grapheme cluster.
|
|
3339
|
+
def newline?: (::Hash[Symbol, Object] cluster) -> bool
|
|
3340
|
+
|
|
3341
|
+
# Greedy word-wrap, filling each row to a **column** budget while recording
|
|
3342
|
+
# the **character** span that produced it. Whitespace at a soft-wrap break
|
|
3343
|
+
# point is absorbed (not rendered on either row). A token wider than
|
|
3344
|
+
# {Rect#width} hard-wraps inside the token. Newlines force a hard break and
|
|
3345
|
+
# the wrap restarts on the next cluster.
|
|
2237
3346
|
def compute_display_rows: () -> ::Array[::Hash[Symbol, Integer]]
|
|
2238
3347
|
|
|
3348
|
+
# _@param_ `clusters`
|
|
3349
|
+
#
|
|
3350
|
+
# _@param_ `index` — cluster index of the word's first glyph.
|
|
3351
|
+
#
|
|
3352
|
+
# _@return_ — `[chars, columns, next_index]`
|
|
3353
|
+
# for the run of non-whitespace starting at `index`.
|
|
3354
|
+
def measure_word: (::Array[::Hash[Symbol, Object]] clusters, Integer index) -> [Integer, Integer, Integer]
|
|
3355
|
+
|
|
3356
|
+
# Splits a token too wide for a whole row, taking entire glyphs while they
|
|
3357
|
+
# fit. Consumes at least one glyph even when that single glyph is wider than
|
|
3358
|
+
# the row — otherwise the wrap would not terminate (the row would stay empty
|
|
3359
|
+
# and the same token be reconsidered forever). Such a row reports more
|
|
3360
|
+
# columns than the rect holds and {#padded_row} drops the glyph; a
|
|
3361
|
+
# 2-column glyph in a 1-column area is unpaintable either way.
|
|
3362
|
+
#
|
|
3363
|
+
# _@param_ `clusters`
|
|
3364
|
+
#
|
|
3365
|
+
# _@param_ `index`
|
|
3366
|
+
#
|
|
3367
|
+
# _@param_ `width` — column budget.
|
|
3368
|
+
#
|
|
3369
|
+
# _@return_ — `[chars, columns, next_index]`
|
|
3370
|
+
def hard_wrap: (::Array[::Hash[Symbol, Object]] clusters, Integer index, Integer width) -> [Integer, Integer, Integer]
|
|
3371
|
+
|
|
2239
3372
|
# Trims trailing space/tab characters off a row's visible length so the
|
|
2240
3373
|
# whitespace at a soft-wrap point is absorbed (not rendered) rather than
|
|
2241
3374
|
# left at the end of the row. Without this, soft-wrapping `"foo bar"`
|
|
2242
3375
|
# to width 4 would yield row 0 length 4 (`"foo "`) and the natural
|
|
2243
3376
|
# end-of-row caret position would coincide with row 1's start.
|
|
2244
3377
|
#
|
|
3378
|
+
# Both counts drop by one per trimmed character: a space and a tab each
|
|
3379
|
+
# measure exactly one column.
|
|
3380
|
+
#
|
|
2245
3381
|
# _@param_ `row_start`
|
|
2246
3382
|
#
|
|
2247
3383
|
# _@param_ `row_chars`
|
|
2248
3384
|
#
|
|
2249
|
-
# _@
|
|
2250
|
-
|
|
3385
|
+
# _@param_ `row_cols`
|
|
3386
|
+
#
|
|
3387
|
+
# _@return_ — `[row_chars, row_cols]`
|
|
3388
|
+
def trim_trailing_whitespace: (Integer row_start, Integer row_chars, Integer row_cols) -> [Integer, Integer]
|
|
2251
3389
|
|
|
2252
3390
|
# _@param_ `caret`
|
|
2253
3391
|
#
|
|
2254
3392
|
# _@return_ — `[row_index, column]` for `caret`.
|
|
2255
3393
|
def caret_to_display: (Integer caret) -> [Integer, Integer]
|
|
2256
3394
|
|
|
3395
|
+
# _@param_ `row`
|
|
3396
|
+
#
|
|
3397
|
+
# _@param_ `caret`
|
|
3398
|
+
#
|
|
3399
|
+
# _@return_ — `caret`'s column offset within `row`.
|
|
3400
|
+
def caret_column_in: (::Hash[Symbol, Integer] row, Integer caret) -> Integer
|
|
3401
|
+
|
|
3402
|
+
# _@param_ `row`
|
|
3403
|
+
#
|
|
3404
|
+
# _@param_ `column` — a column offset within `row`.
|
|
3405
|
+
#
|
|
3406
|
+
# _@return_ — characters from the row's start. A column landing in a
|
|
3407
|
+
# wide glyph's right half resolves past it, as a click does in
|
|
3408
|
+
# {TextField}.
|
|
3409
|
+
def chars_for_column: (::Hash[Symbol, Integer] row, Integer column) -> Integer
|
|
3410
|
+
|
|
3411
|
+
# _@param_ `row`
|
|
3412
|
+
#
|
|
3413
|
+
# _@return_ — the row's text padded to `rect.width` columns. A glyph
|
|
3414
|
+
# with no room left is dropped rather than half-painted.
|
|
3415
|
+
def padded_row: (::Hash[Symbol, Integer] row) -> String
|
|
3416
|
+
|
|
2257
3417
|
# _@param_ `delta` — `+1` for down, `-1` for up.
|
|
2258
3418
|
def move_caret_vertical: (Integer delta) -> void
|
|
2259
3419
|
|
|
@@ -2274,41 +3434,31 @@ module Tuile
|
|
|
2274
3434
|
end
|
|
2275
3435
|
|
|
2276
3436
|
# A read-only viewer for prose: chunks of formatted text that scroll
|
|
2277
|
-
# vertically. Shape-wise a hybrid between {Label} (string
|
|
2278
|
-
#
|
|
2279
|
-
#
|
|
2280
|
-
# Text is
|
|
2281
|
-
#
|
|
2282
|
-
#
|
|
2283
|
-
# ANSI
|
|
2284
|
-
#
|
|
2285
|
-
#
|
|
2286
|
-
#
|
|
2287
|
-
#
|
|
2288
|
-
#
|
|
2289
|
-
#
|
|
2290
|
-
#
|
|
2291
|
-
#
|
|
2292
|
-
#
|
|
2293
|
-
#
|
|
2294
|
-
#
|
|
2295
|
-
#
|
|
2296
|
-
# Markdown that may need to retract its last paragraph) can replace
|
|
2297
|
-
# the tail without rewriting the whole text. Turn on {#auto_scroll}
|
|
2298
|
-
# to keep the latest content in view.
|
|
2299
|
-
#
|
|
2300
|
-
# TextView is meant to be the content of a {Window} — focus indication and
|
|
2301
|
-
# keyboard-hint surfacing rely on the surrounding window chrome.
|
|
3437
|
+
# vertically. Shape-wise a hybrid between {Label} (string content via
|
|
3438
|
+
# {#text=}) and {List} (scroll keys, optional scrollbar, auto-scroll).
|
|
3439
|
+
#
|
|
3440
|
+
# Text is a {StyledString}: embedded `\n` are hard line breaks, longer lines
|
|
3441
|
+
# are word-wrapped via {StyledString#wrap} with style spans preserved across
|
|
3442
|
+
# wrap boundaries. {#text=} takes a {String} (parsed via {StyledString.parse},
|
|
3443
|
+
# honoring embedded ANSI) or a {StyledString}; {#text} always returns the
|
|
3444
|
+
# {StyledString}.
|
|
3445
|
+
#
|
|
3446
|
+
# Pick the right incremental primitive: {#append} (aliased `<<`) concatenates
|
|
3447
|
+
# a chunk verbatim onto the buffer (stream-friendly, `\n` → hard breaks);
|
|
3448
|
+
# {#add_line} starts the chunk on a fresh line (the "log entry" convenience);
|
|
3449
|
+
# {#remove_last_n_lines} pops hard lines off the tail, so a caller streaming
|
|
3450
|
+
# reformattable content can retract and rewrite it; {#replace} / {#insert}
|
|
3451
|
+
# splice a range in place. Turn on {#auto_scroll} to keep the latest content
|
|
3452
|
+
# in view.
|
|
3453
|
+
#
|
|
3454
|
+
# Meant to be the content of a {Window} — focus indication and keyboard-hint
|
|
3455
|
+
# surfacing rely on the surrounding window chrome.
|
|
2302
3456
|
class TextView < Component
|
|
2303
3457
|
def initialize: () -> void
|
|
2304
3458
|
|
|
2305
|
-
# _@return_ — the current text
|
|
2306
|
-
#
|
|
2307
|
-
#
|
|
2308
|
-
# whole buffer; the joined {StyledString} returned here is
|
|
2309
|
-
# reconstructed on first read after a mutation and cached, so
|
|
2310
|
-
# repeated reads are O(1) but the first read after {#append} pays
|
|
2311
|
-
# O(total spans).
|
|
3459
|
+
# _@return_ — the current text (empty by default). Rebuilt
|
|
3460
|
+
# lazily on the first read after a mutation (O(total spans)), then
|
|
3461
|
+
# cached — repeated reads are O(1).
|
|
2312
3462
|
def text: () -> StyledString
|
|
2313
3463
|
|
|
2314
3464
|
# _@return_ — whether {#auto_scroll} is currently tailing. True
|
|
@@ -2346,19 +3496,12 @@ module Tuile
|
|
|
2346
3496
|
# _@return_ — true iff {#text} is empty (no hard lines).
|
|
2347
3497
|
def empty?: () -> bool
|
|
2348
3498
|
|
|
2349
|
-
# Appends `str` verbatim. Embedded `\n`
|
|
2350
|
-
#
|
|
2351
|
-
#
|
|
2352
|
-
#
|
|
2353
|
-
#
|
|
2354
|
-
#
|
|
2355
|
-
# For the "add an entry on a new line" pattern use {#add_line}.
|
|
2356
|
-
#
|
|
2357
|
-
# Cost is O(appended + width-of-current-last-hard-line) — the
|
|
2358
|
-
# previously last hard line is re-wrapped (because the extension may
|
|
2359
|
-
# cause it to wrap differently), any additional hard lines created by
|
|
2360
|
-
# embedded `\n` are wrapped fresh. The cached {#text} is invalidated
|
|
2361
|
-
# and rebuilt on demand.
|
|
3499
|
+
# Appends `str` verbatim. Embedded `\n` become hard line breaks; otherwise
|
|
3500
|
+
# the text is concatenated onto the current last hard line. Designed for
|
|
3501
|
+
# streaming use (feed each partial chunk straight in). Accepts the same
|
|
3502
|
+
# input forms as {#text=}; empty/`nil` is a no-op. For the "entry on a new
|
|
3503
|
+
# line" pattern use {#add_line}. Cost is O(appended + width of the last
|
|
3504
|
+
# hard line), which is re-wrapped since the extension may wrap differently.
|
|
2362
3505
|
#
|
|
2363
3506
|
# _@param_ `str`
|
|
2364
3507
|
def append: ((String | StyledString)? str) -> void
|
|
@@ -2378,56 +3521,30 @@ module Tuile
|
|
|
2378
3521
|
# _@param_ `str`
|
|
2379
3522
|
def add_line: ((String | StyledString)? str) -> void
|
|
2380
3523
|
|
|
2381
|
-
# Drops the last `n` hard lines from the buffer
|
|
2382
|
-
#
|
|
2383
|
-
#
|
|
2384
|
-
#
|
|
2385
|
-
#
|
|
2386
|
-
# by `append(new_tail)` to replace the damaged region in place.
|
|
2387
|
-
#
|
|
2388
|
-
# `n == 0` and the empty-buffer case are no-ops (no invalidation).
|
|
2389
|
-
# `n >= hard-line count` empties the buffer.
|
|
2390
|
-
#
|
|
2391
|
-
# Operates on **hard lines** (the `\n`-delimited entries the
|
|
2392
|
-
# buffer stores), not on wrapped physical rows — same granularity
|
|
2393
|
-
# as {#add_line}. Cost is O(rendered-rows of the popped lines).
|
|
3524
|
+
# Drops the last `n` hard lines from the buffer — the inverse of building
|
|
3525
|
+
# up a tail with {#append} / {#add_line}, so a caller can `remove` then
|
|
3526
|
+
# `append` to rewrite a damaged tail in place. Operates on **hard lines**
|
|
3527
|
+
# (the `\n`-delimited entries), not wrapped physical rows. `n == 0` and the
|
|
3528
|
+
# empty buffer are no-ops; `n >= hard-line count` empties the buffer.
|
|
2394
3529
|
#
|
|
2395
3530
|
# _@param_ `n` — number of hard lines to drop; must be >= 0.
|
|
2396
3531
|
def remove_last_n_lines: (Integer n) -> void
|
|
2397
3532
|
|
|
2398
|
-
# Replaces a contiguous range of hard lines with the parsed content
|
|
2399
|
-
#
|
|
2400
|
-
#
|
|
2401
|
-
#
|
|
2402
|
-
#
|
|
2403
|
-
#
|
|
2404
|
-
# `
|
|
2405
|
-
#
|
|
2406
|
-
# `
|
|
2407
|
-
#
|
|
2408
|
-
#
|
|
2409
|
-
#
|
|
2410
|
-
#
|
|
2411
|
-
#
|
|
2412
|
-
#
|
|
2413
|
-
# position — no lines are removed. {#insert} is a thin alias for
|
|
2414
|
-
# this case.
|
|
2415
|
-
#
|
|
2416
|
-
# Endpoints must be non-negative integers; `begin` may equal
|
|
2417
|
-
# `hard-line count` (insertion at the end), `end` may not exceed
|
|
2418
|
-
# `hard-line count - 1`. `nil` endpoints (beginless / endless ranges)
|
|
2419
|
-
# are not accepted.
|
|
2420
|
-
#
|
|
2421
|
-
# Cost is roughly `O(from + length + new content)`: the splice
|
|
2422
|
-
# updates only the affected slice of the physical-row buffer, using
|
|
2423
|
-
# the per-hard-line wrap-count cache to locate the starting offset
|
|
2424
|
-
# without re-wrapping preceding lines. Lines outside the splice are
|
|
2425
|
-
# never re-wrapped. {#top_line} is clamped if the new line count
|
|
2426
|
-
# puts it past the end; {#auto_scroll} pins it to the bottom as
|
|
2427
|
-
# usual. The call is a no-op (no invalidation) when the parsed
|
|
2428
|
-
# replacement equals the covered range (vacuously true for an empty
|
|
2429
|
-
# range plus empty replacement, so `replace(n...n, "")` is a cheap
|
|
2430
|
-
# no-op).
|
|
3533
|
+
# Replaces a contiguous range of hard lines with the parsed content of
|
|
3534
|
+
# `str` (parsed like {#text=}: `String` → {StyledString.parse}, `nil` →
|
|
3535
|
+
# empty, so `nil` deletes the range). Embedded `"\n"` yields multiple hard
|
|
3536
|
+
# lines, so one `replace` can grow or shrink the buffer. `range` selects
|
|
3537
|
+
# which hard lines to swap out:
|
|
3538
|
+
#
|
|
3539
|
+
# - an `Integer` `n` is shorthand for `n..n` (replace one existing line);
|
|
3540
|
+
# - a non-empty `Range` replaces those lines;
|
|
3541
|
+
# - an empty `Range` (e.g. `2...2`, or `size...size` at the end) is
|
|
3542
|
+
# *insertion* at that position — nothing removed. {#insert} aliases this.
|
|
3543
|
+
#
|
|
3544
|
+
# Splices in place — only the affected slice of the physical-row buffer is
|
|
3545
|
+
# touched, no preceding lines re-wrapped (cost O(from + length + new
|
|
3546
|
+
# content)). A no-op when the replacement equals the covered range, so
|
|
3547
|
+
# `replace(n...n, "")` is cheap.
|
|
2431
3548
|
#
|
|
2432
3549
|
# _@param_ `range` — hard-line indices to replace.
|
|
2433
3550
|
#
|
|
@@ -2463,6 +3580,8 @@ module Tuile
|
|
|
2463
3580
|
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
2464
3581
|
# painted explicitly (with padded blanks past the last line), so the
|
|
2465
3582
|
# "fully draw over your rect" contract is met without an upfront wipe.
|
|
3583
|
+
# Rows go through {Component#draw_line}, so content and blank rows inherit
|
|
3584
|
+
# {Component#effective_bg_color} (a {#bg_color} set here or on an ancestor).
|
|
2466
3585
|
def repaint: () -> void
|
|
2467
3586
|
|
|
2468
3587
|
# Rewraps the text on width changes. Wrap width depends on
|
|
@@ -2547,19 +3666,12 @@ module Tuile
|
|
|
2547
3666
|
# _@param_ `region`
|
|
2548
3667
|
def remove_region: (Region region) -> void
|
|
2549
3668
|
|
|
2550
|
-
# Adjusts region line counts after a {@hard_lines} splice that
|
|
2551
|
-
#
|
|
2552
|
-
#
|
|
2553
|
-
#
|
|
2554
|
-
#
|
|
2555
|
-
#
|
|
2556
|
-
# region that lost lines — that's the natural home for the
|
|
2557
|
-
# replacement content.
|
|
2558
|
-
# 2. Credit `added_count` to that region. For pure insertions (no
|
|
2559
|
-
# removal), there's no "first overlapping region" to pick from;
|
|
2560
|
-
# walk regions and credit the latest one starting at `from` (the
|
|
2561
|
-
# boundary tiebreaker matches the spatial-tail-routing of
|
|
2562
|
-
# {#append}). Past-the-end inserts fall back to the tail region.
|
|
3669
|
+
# Adjusts region line counts after a {@hard_lines} splice that removed
|
|
3670
|
+
# `removed_count` lines at `from` and inserted `added_count`. Subtracts
|
|
3671
|
+
# each region's overlap with the removed range, then credits the added
|
|
3672
|
+
# lines to the first region that lost lines. Pure insertions have no such
|
|
3673
|
+
# region — they credit the latest region starting at `from`, matching
|
|
3674
|
+
# {#append}'s spatial-tail routing (past-the-end falls back to the tail).
|
|
2563
3675
|
#
|
|
2564
3676
|
# _@param_ `from`
|
|
2565
3677
|
#
|
|
@@ -2739,14 +3851,9 @@ module Tuile
|
|
|
2739
3851
|
def text=: ((String | StyledString)? value) -> void
|
|
2740
3852
|
|
|
2741
3853
|
# Verbatim append into this region's tail. Same semantics as
|
|
2742
|
-
# {TextView#append} but scoped
|
|
2743
|
-
#
|
|
2744
|
-
#
|
|
2745
|
-
# is a no-op (but still raises when detached). When the region is
|
|
2746
|
-
# the spatial tail of the view, this uses the incremental
|
|
2747
|
-
# {TextView#append} path; mid-document regions splice the affected
|
|
2748
|
-
# slice of the physical-row buffer (lines outside the region are
|
|
2749
|
-
# not re-wrapped).
|
|
3854
|
+
# {TextView#append} but scoped: embedded `"\n"` creates new hard lines
|
|
3855
|
+
# within the region, other input extends the region's last hard line.
|
|
3856
|
+
# Empty / `nil` is a no-op (but still raises when detached).
|
|
2750
3857
|
#
|
|
2751
3858
|
# _@param_ `str`
|
|
2752
3859
|
def append: ((String | StyledString)? str) -> void
|
|
@@ -2813,121 +3920,1299 @@ module Tuile
|
|
|
2813
3920
|
# _@param_ `n`
|
|
2814
3921
|
def remove_last_n_lines: (Integer n) -> void
|
|
2815
3922
|
|
|
2816
|
-
def detach!: () -> void
|
|
3923
|
+
def detach!: () -> void
|
|
3924
|
+
|
|
3925
|
+
def check_attached: () -> void
|
|
3926
|
+
|
|
3927
|
+
# _@return_ — number of hard lines this region owns. Safe to
|
|
3928
|
+
# read on a detached region (no error raised).
|
|
3929
|
+
attr_accessor line_count: (Integer | untyped)
|
|
3930
|
+
end
|
|
3931
|
+
end
|
|
3932
|
+
|
|
3933
|
+
# Shows a log. Construct your logger pointed at a {LogWindow::IO} to route
|
|
3934
|
+
# log lines into this window:
|
|
3935
|
+
#
|
|
3936
|
+
# log_window = Tuile::Component::LogWindow.new
|
|
3937
|
+
# logger = Logger.new(Tuile::Component::LogWindow::IO.new(log_window))
|
|
3938
|
+
#
|
|
3939
|
+
# Any logger that writes formatted lines to an IO works the same way —
|
|
3940
|
+
# for example `TTY::Logger` configured with the `:console` handler and
|
|
3941
|
+
# `output: LogWindow::IO.new(window)`.
|
|
3942
|
+
class LogWindow < Tuile::Component::Window
|
|
3943
|
+
# _@param_ `caption`
|
|
3944
|
+
def initialize: (?String caption) -> void
|
|
3945
|
+
|
|
3946
|
+
# Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
|
|
3947
|
+
#
|
|
3948
|
+
# _@param_ `string` — the line (or multiple lines) to log.
|
|
3949
|
+
def log: (String? string) -> void
|
|
3950
|
+
|
|
3951
|
+
# IO-shaped adapter that forwards each log line to the owning {LogWindow}.
|
|
3952
|
+
# Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
|
|
3953
|
+
# call `output.puts`, e.g. `TTY::Logger`).
|
|
3954
|
+
class IO
|
|
3955
|
+
# _@param_ `window`
|
|
3956
|
+
def initialize: (LogWindow window) -> void
|
|
3957
|
+
|
|
3958
|
+
# _@param_ `string`
|
|
3959
|
+
def write: (String string) -> void
|
|
3960
|
+
|
|
3961
|
+
# _@param_ `string`
|
|
3962
|
+
def puts: (String string) -> void
|
|
3963
|
+
|
|
3964
|
+
# Stdlib `Logger` only treats an object as an IO target when it
|
|
3965
|
+
# responds to both {#write} and {#close}; otherwise it tries to
|
|
3966
|
+
# interpret it as a filename. This is a no-op.
|
|
3967
|
+
def close: () -> void
|
|
3968
|
+
end
|
|
3969
|
+
end
|
|
3970
|
+
|
|
3971
|
+
# A single-line text input with a real hardware caret, scrolling
|
|
3972
|
+
# horizontally to keep that caret in view:
|
|
3973
|
+
#
|
|
3974
|
+
# f = TextField.new
|
|
3975
|
+
# f.rect = Rect.new(0, 0, 6, 1) # six columns wide …
|
|
3976
|
+
# f.text = "hello world" # … eleven columns of text, so it scrolls
|
|
3977
|
+
# f.caret = 11 # paints "world " — left_column 6, cursor on the last column
|
|
3978
|
+
# f.caret = 0 # paints "hello " — left_column 0
|
|
3979
|
+
#
|
|
3980
|
+
# The field's width never bounds its contents — {#max_text_length} does, and
|
|
3981
|
+
# only for typing.
|
|
3982
|
+
#
|
|
3983
|
+
# == Implementation details
|
|
3984
|
+
#
|
|
3985
|
+
# Two axes run through this class and are *not* interchangeable:
|
|
3986
|
+
#
|
|
3987
|
+
# - an **index** counts characters into {#text} — {#caret},
|
|
3988
|
+
# {#max_text_length}, `text[i]`, every edit;
|
|
3989
|
+
# - a **column** counts terminal cells — {#rect}, {#left_column},
|
|
3990
|
+
# {#cursor_position}, a {MouseEvent}.
|
|
3991
|
+
#
|
|
3992
|
+
# They coincide only while every glyph is one column wide. A fullwidth CJK
|
|
3993
|
+
# char is two columns and a combining mark zero, so index 3 of `"日本語"` is
|
|
3994
|
+
# column 6. Every crossing goes through the private `column_at` / `index_at`
|
|
3995
|
+
# pair; adding an index to a column anywhere else is the bug those two exist
|
|
3996
|
+
# to prevent.
|
|
3997
|
+
#
|
|
3998
|
+
# Indices count characters while widths measure grapheme clusters, but the
|
|
3999
|
+
# caret never falls between the two: {AbstractStringField} keeps it on a
|
|
4000
|
+
# cluster boundary, so a column derived from it always names a real glyph
|
|
4001
|
+
# edge.
|
|
4002
|
+
#
|
|
4003
|
+
# What gets *painted* is {#display_text}, a third seam that is `text` itself
|
|
4004
|
+
# here and the mask in {PasswordField}. Every column measurement reads it, so
|
|
4005
|
+
# a subclass showing something else overrides that and never {#repaint} —
|
|
4006
|
+
# overriding the paint alone leaves the measurements on the buffer while the
|
|
4007
|
+
# cells show the substitute, and the two drift apart by a growing offset.
|
|
4008
|
+
class TextField < Tuile::Component::AbstractStringField
|
|
4009
|
+
def initialize: () -> void
|
|
4010
|
+
|
|
4011
|
+
def cursor_position: () -> Point?
|
|
4012
|
+
|
|
4013
|
+
# Places the caret at the clicked column. A click on the right half of a
|
|
4014
|
+
# wide glyph lands *after* it, as in any editor.
|
|
4015
|
+
#
|
|
4016
|
+
# _@param_ `event`
|
|
4017
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
4018
|
+
|
|
4019
|
+
def repaint: () -> void
|
|
4020
|
+
|
|
4021
|
+
# _@param_ `key`
|
|
4022
|
+
def handle_text_input_key: (String key) -> bool
|
|
4023
|
+
|
|
4024
|
+
def on_text_mutated: () -> void
|
|
4025
|
+
|
|
4026
|
+
def on_caret_mutated: () -> void
|
|
4027
|
+
|
|
4028
|
+
def on_width_changed: () -> void
|
|
4029
|
+
|
|
4030
|
+
# What the field paints in place of {#text}: one display character per
|
|
4031
|
+
# {#text} character, in order. `column_at` measures `display_text[0, i]` as
|
|
4032
|
+
# the rendering of `text[0, i]`, so an override that changes the character
|
|
4033
|
+
# count — or reorders — desynchronizes the caret from the display. Nothing
|
|
4034
|
+
# enforces it at runtime; a subclass pins it with a spec.
|
|
4035
|
+
#
|
|
4036
|
+
# _@return_ — {#text} itself, unless a subclass substitutes.
|
|
4037
|
+
def display_text: () -> String
|
|
4038
|
+
|
|
4039
|
+
# _@param_ `char`
|
|
4040
|
+
#
|
|
4041
|
+
# _@return_ — always true — a field at {#max_text_length} swallows the
|
|
4042
|
+
# key rather than declining it, so typing can never fall through to a
|
|
4043
|
+
# scope-wide binding.
|
|
4044
|
+
def insert: (String char) -> bool
|
|
4045
|
+
|
|
4046
|
+
# _@param_ `index` — a {#text} index in `0..text.length`.
|
|
4047
|
+
#
|
|
4048
|
+
# _@return_ — the column it sits at. An index landing inside a
|
|
4049
|
+
# grapheme cluster measures the whole cluster, putting the caret just
|
|
4050
|
+
# past it.
|
|
4051
|
+
def column_at: (Integer index) -> Integer
|
|
4052
|
+
|
|
4053
|
+
# _@param_ `column` — a text column (0 is the first glyph).
|
|
4054
|
+
#
|
|
4055
|
+
# _@return_ — the nearest {#text} index — a column falling in a wide
|
|
4056
|
+
# glyph's right half resolves past it.
|
|
4057
|
+
def index_at: (Integer column) -> Integer
|
|
4058
|
+
|
|
4059
|
+
# _@return_ — total display width of {#text}.
|
|
4060
|
+
def text_columns: () -> Integer
|
|
4061
|
+
|
|
4062
|
+
# _@return_ — the windowed text, padded with spaces to `rect.width`.
|
|
4063
|
+
# A wide glyph straddling the right edge is dropped rather than painted
|
|
4064
|
+
# as a half glyph.
|
|
4065
|
+
def visible_text: () -> String
|
|
4066
|
+
|
|
4067
|
+
# Scrolls the minimum needed to keep the caret's column visible.
|
|
4068
|
+
def adjust_left_column: () -> void
|
|
4069
|
+
|
|
4070
|
+
# Snapping *right* is the only safe direction, and not because it shows
|
|
4071
|
+
# more: the caret's own column is always a glyph boundary, so the next
|
|
4072
|
+
# boundary at or after `left_column` can never overshoot it. Snapping left
|
|
4073
|
+
# instead pulls the window's right edge inward, which strands the caret
|
|
4074
|
+
# outside it whenever wide glyphs exactly fill a narrow field.
|
|
4075
|
+
#
|
|
4076
|
+
# _@param_ `column`
|
|
4077
|
+
#
|
|
4078
|
+
# _@return_ — the smallest glyph-boundary column `>= column`, so the
|
|
4079
|
+
# window never opens on a wide glyph's right half.
|
|
4080
|
+
def snap_to_glyph_start: (Integer column) -> Integer
|
|
4081
|
+
|
|
4082
|
+
# Optional cap on {#text}'s length **in characters** — a wide glyph counts
|
|
4083
|
+
# once. Typing into a field already at the cap does nothing.
|
|
4084
|
+
#
|
|
4085
|
+
# Deliberately does not police {#text=}: lowering the cap under an existing
|
|
4086
|
+
# value leaves that value intact rather than silently trimming it.
|
|
4087
|
+
#
|
|
4088
|
+
# _@return_ — maximum characters, or nil for unbounded (default).
|
|
4089
|
+
attr_accessor max_text_length: Integer?
|
|
4090
|
+
|
|
4091
|
+
# _@return_ — text column drawn in the field's leftmost cell — the
|
|
4092
|
+
# horizontal scroll offset. Follows {#caret}, always on a glyph boundary.
|
|
4093
|
+
attr_reader left_column: Integer
|
|
4094
|
+
|
|
4095
|
+
# Optional callback fired when the UP arrow key is pressed. When set, UP
|
|
4096
|
+
# is consumed by the field; when nil, UP falls through to the parent
|
|
4097
|
+
# (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
|
|
4098
|
+
# since `k` is a printable character inserted into {#text}.
|
|
4099
|
+
#
|
|
4100
|
+
# _@return_ — no-arg callable, or nil.
|
|
4101
|
+
attr_accessor on_key_up: (Proc | Method)?
|
|
4102
|
+
|
|
4103
|
+
# Optional callback fired when the DOWN arrow key is pressed. When set,
|
|
4104
|
+
# DOWN is consumed by the field; when nil, DOWN falls through to the
|
|
4105
|
+
# parent (default behavior). Only triggered by {Keys::DOWN_ARROW}, not by
|
|
4106
|
+
# `j`, since `j` is a printable character inserted into {#text}.
|
|
4107
|
+
#
|
|
4108
|
+
# _@return_ — no-arg callable, or nil.
|
|
4109
|
+
attr_accessor on_key_down: (Proc | Method)?
|
|
4110
|
+
|
|
4111
|
+
# Optional callback fired when ENTER is pressed. When set, ENTER is
|
|
4112
|
+
# consumed by the field; when nil, ENTER falls through to the parent
|
|
4113
|
+
# (default behavior).
|
|
4114
|
+
#
|
|
4115
|
+
# _@return_ — no-arg callable, or nil.
|
|
4116
|
+
attr_accessor on_enter: (Proc | Method)?
|
|
4117
|
+
end
|
|
4118
|
+
|
|
4119
|
+
# A single-line field whose {#value} is a `Float` (or `nil` when empty) —
|
|
4120
|
+
# the {IntegerField} twin, one Ruby type over. Give it a single-row {#rect}:
|
|
4121
|
+
#
|
|
4122
|
+
# field = Component::FloatField.new
|
|
4123
|
+
# field.on_value_change = ->(x) { puts x.inspect } # Float or nil, per change
|
|
4124
|
+
# field.value = 19.99 # field shows "19.99"
|
|
4125
|
+
# field.clear # empties it; value => nil
|
|
4126
|
+
#
|
|
4127
|
+
# Only `0`–`9`, one leading `-` and one `.` can be typed; any other
|
|
4128
|
+
# printable key is dropped without moving the caret. Up/Down step by `1.0`
|
|
4129
|
+
# (an empty field counting as `0.0`). A `Float` is a binary double, so this
|
|
4130
|
+
# is the wrong field for money — hold that as `Integer` cents in an
|
|
4131
|
+
# {IntegerField} — and range checks (`min`/`max`) belong to a forms layer,
|
|
4132
|
+
# not here.
|
|
4133
|
+
#
|
|
4134
|
+
# == Implementation details
|
|
4135
|
+
# {#value} is a *derived parse*: the buffer is the single source of truth,
|
|
4136
|
+
# recomputed on read and left exactly as typed (`"007"` keeps its zeros).
|
|
4137
|
+
# It reads `nil` for a buffer that isn't a number (`""`, a lone `"-"`) but
|
|
4138
|
+
# `1.0` / `0.5` for a half-typed `"1."` / `".5"`, so reaching for the
|
|
4139
|
+
# decimal point doesn't blink the value to `nil` and back through
|
|
4140
|
+
# {#on_value_change} — which fires per keystroke, but only on a real *value*
|
|
4141
|
+
# change (`"7"`→`"07"` is silent). The parse also accepts the exponent
|
|
4142
|
+
# `Float#to_s` writes for extreme magnitudes, so `value = 1e-5` round-trips
|
|
4143
|
+
# through the `"1.0e-05"` it displays, though no key types an `e`.
|
|
4144
|
+
#
|
|
4145
|
+
# It *composes* a {TextField} (its single {HasContent} child) rather than
|
|
4146
|
+
# subclassing one, so its face carries only the typed {HasValue} seam, never
|
|
4147
|
+
# the widget's `String`-typed `text`.
|
|
4148
|
+
#
|
|
4149
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
4150
|
+
class FloatField < Component
|
|
4151
|
+
include Tuile::Component::HasContent
|
|
4152
|
+
include Tuile::Component::HasValue
|
|
4153
|
+
NUMERIC: Regexp
|
|
4154
|
+
|
|
4155
|
+
def initialize: () -> void
|
|
4156
|
+
|
|
4157
|
+
# _@return_ — the parsed buffer; `nil` when empty or not a
|
|
4158
|
+
# number (e.g. a lone `"-"`).
|
|
4159
|
+
def value: () -> Float?
|
|
4160
|
+
|
|
4161
|
+
# Writes `new_value` into the buffer and parks the caret at its end; fires
|
|
4162
|
+
# {#on_value_change} only if the value actually changed.
|
|
4163
|
+
#
|
|
4164
|
+
# _@param_ `new_value` — `nil` empties the field; anything else is coerced with `Float()`, so an `Integer` `3` shows as `"3.0"`.
|
|
4165
|
+
def value=: (Numeric? new_value) -> void
|
|
4166
|
+
|
|
4167
|
+
# `nil`, not `""`: a numeric field with no parseable number is empty.
|
|
4168
|
+
def empty_value: () -> void
|
|
4169
|
+
|
|
4170
|
+
# _@return_ — the field's caret (the hardware cursor is delegated
|
|
4171
|
+
# to the inner field).
|
|
4172
|
+
def cursor_position: () -> Point?
|
|
4173
|
+
|
|
4174
|
+
# Fired when ENTER is pressed in the field; see {TextField#on_enter}.
|
|
4175
|
+
#
|
|
4176
|
+
# _@return_ — no-arg callable, or nil.
|
|
4177
|
+
def on_enter: () -> (Proc | Method)?
|
|
4178
|
+
|
|
4179
|
+
# _@param_ `callback`
|
|
4180
|
+
def on_enter=: ((Proc | Method)? callback) -> void
|
|
4181
|
+
|
|
4182
|
+
# Places the wrapped field across the whole rect ({HasContent} hook).
|
|
4183
|
+
#
|
|
4184
|
+
# _@param_ `field`
|
|
4185
|
+
def layout: (Component field) -> void
|
|
4186
|
+
|
|
4187
|
+
# _@param_ `new_value`
|
|
4188
|
+
def coerce: (Numeric new_value) -> Float
|
|
4189
|
+
|
|
4190
|
+
# The field's key interceptor, consulted *before* the field acts on the
|
|
4191
|
+
# key — which is what lets a rejected character be swallowed without the
|
|
4192
|
+
# caret ever moving.
|
|
4193
|
+
#
|
|
4194
|
+
# _@param_ `key`
|
|
4195
|
+
#
|
|
4196
|
+
# _@return_ — true to consume the key.
|
|
4197
|
+
def field_key: (String key) -> bool
|
|
4198
|
+
|
|
4199
|
+
# Nudges {#value} by `delta`, treating an empty/un-parseable field as
|
|
4200
|
+
# `0.0`.
|
|
4201
|
+
#
|
|
4202
|
+
# _@param_ `delta`
|
|
4203
|
+
def step: (Float delta) -> void
|
|
4204
|
+
|
|
4205
|
+
# Whether `char` may be inserted. Deliberately shallow: it keeps the
|
|
4206
|
+
# buffer *typeable* rather than always-valid — a transient `"-"` or
|
|
4207
|
+
# `"1."` has to be reachable — and {#value} decides what parses.
|
|
4208
|
+
#
|
|
4209
|
+
# _@param_ `char` — a single printable character.
|
|
4210
|
+
def accepts?: (String char) -> bool
|
|
4211
|
+
|
|
4212
|
+
# Re-emits {#on_value_change} with the freshly-parsed {#value}, but only
|
|
4213
|
+
# when it differs from the last one fired — so a buffer edit that leaves
|
|
4214
|
+
# the value unchanged (`"7"`→`"07"`) stays silent.
|
|
4215
|
+
def fire_if_changed: () -> void
|
|
4216
|
+
|
|
4217
|
+
# _@return_ — true iff {#value} equals {#empty_value}.
|
|
4218
|
+
def empty?: () -> bool
|
|
4219
|
+
|
|
4220
|
+
# Resets {#value} to {#empty_value}.
|
|
4221
|
+
def clear: () -> void
|
|
4222
|
+
|
|
4223
|
+
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
4224
|
+
# a read-only display field could override back to `false`. Only
|
|
4225
|
+
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
4226
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
4227
|
+
# `D-integer-field`).
|
|
4228
|
+
def focusable?: () -> bool
|
|
4229
|
+
|
|
4230
|
+
# _@param_ `event`
|
|
4231
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
4232
|
+
|
|
4233
|
+
# _@param_ `rect`
|
|
4234
|
+
def rect=: (Rect rect) -> void
|
|
4235
|
+
|
|
4236
|
+
def on_focus: () -> void
|
|
4237
|
+
end
|
|
4238
|
+
|
|
4239
|
+
# The chrome text a component *wears* — a {Window}'s border title, a
|
|
4240
|
+
# {Button}'s label — as opposed to the value it *holds*.
|
|
4241
|
+
#
|
|
4242
|
+
# button.caption = "Submit"
|
|
4243
|
+
# window.caption = StyledString.styled("Settings", fg: Color::RED)
|
|
4244
|
+
#
|
|
4245
|
+
# Tuile's naming split, which decides what a new component gets:
|
|
4246
|
+
# **caption** is chrome, authored by the app; **text** is the value the
|
|
4247
|
+
# user edits (aliased to {HasValue#value} on {AbstractStringField}). A
|
|
4248
|
+
# component may carry both, hence two mixins.
|
|
4249
|
+
#
|
|
4250
|
+
# Includers own the *rendering* — clipping, width arithmetic, decoration
|
|
4251
|
+
# such as {Window}'s `[key]-` shortcut prefix; this holds only the text.
|
|
4252
|
+
#
|
|
4253
|
+
# == Implementation details
|
|
4254
|
+
# Being a mixin is what lets tree-walking code find "the {Button} captioned
|
|
4255
|
+
# Submit" via `is_a?(HasCaption)` plus a caption compare, rather than a
|
|
4256
|
+
# hardcoded list of classes that happen to respond to `caption`. Don't
|
|
4257
|
+
# collapse it back into per-class accessors.
|
|
4258
|
+
module HasCaption
|
|
4259
|
+
# Read through *this* method, never `@caption` — the ivar stays nil until
|
|
4260
|
+
# the first non-empty set ({#caption=} short-circuits when unchanged).
|
|
4261
|
+
#
|
|
4262
|
+
# _@return_ — the caption; empty when never set.
|
|
4263
|
+
def caption: () -> StyledString
|
|
4264
|
+
|
|
4265
|
+
# Sets the caption and invalidates the component. No-op when unchanged. A
|
|
4266
|
+
# `String` is parsed via {StyledString.parse} (embedded ANSI is honored);
|
|
4267
|
+
# a {StyledString} is used as-is; `nil` clears it.
|
|
4268
|
+
#
|
|
4269
|
+
# _@param_ `new_caption`
|
|
4270
|
+
def caption=: ((String | StyledString)? new_caption) -> void
|
|
4271
|
+
end
|
|
4272
|
+
|
|
4273
|
+
# A mixin interface for a component with one child tops. The host must
|
|
4274
|
+
# provide a protected `layout(content)` method which repositions the
|
|
4275
|
+
# content component; the mixin manages `@content` itself.
|
|
4276
|
+
module HasContent
|
|
4277
|
+
# _@param_ `event`
|
|
4278
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
4279
|
+
|
|
4280
|
+
# _@param_ `rect`
|
|
4281
|
+
def rect=: (Rect rect) -> void
|
|
4282
|
+
|
|
4283
|
+
def on_focus: () -> void
|
|
4284
|
+
|
|
4285
|
+
# _@return_ — the current content component.
|
|
4286
|
+
attr_accessor content: Component?
|
|
4287
|
+
end
|
|
4288
|
+
|
|
4289
|
+
# A {Window} preconfigured with a {List} of static lines. Useful for
|
|
4290
|
+
# showing read-only information.
|
|
4291
|
+
#
|
|
4292
|
+
# Usable tiled (just add to a {Layout}) or as a popup via {.open}, which
|
|
4293
|
+
# wraps it in a {Popup}.
|
|
4294
|
+
class InfoWindow < Tuile::Component::Window
|
|
4295
|
+
# _@param_ `caption`
|
|
4296
|
+
#
|
|
4297
|
+
# _@param_ `lines` — initial content; each entry may contain Rainbow formatting.
|
|
4298
|
+
def initialize: (?String caption, ?::Array[String] lines) -> void
|
|
4299
|
+
|
|
4300
|
+
# Opens the info window as a popup.
|
|
4301
|
+
#
|
|
4302
|
+
# _@param_ `caption`
|
|
4303
|
+
#
|
|
4304
|
+
# _@param_ `lines` — the content, may contain formatting.
|
|
4305
|
+
#
|
|
4306
|
+
# _@param_ `size` — the popup's size, applied top-down; the list wraps and scrolls within it. Defaults to {Fraction::HALF}.
|
|
4307
|
+
#
|
|
4308
|
+
# _@return_ — the opened popup.
|
|
4309
|
+
def self.open: (String caption, ::Array[String] lines, ?size: (Size | Fraction)) -> Popup
|
|
4310
|
+
end
|
|
4311
|
+
|
|
4312
|
+
# Single-select from a set of typed items, one row each. Arrows move a
|
|
4313
|
+
# cursor; Space, Enter or a left click selects the row under it:
|
|
4314
|
+
#
|
|
4315
|
+
# (*) Ascending
|
|
4316
|
+
# ( ) Descending <- cursor row, highlighted across the full width
|
|
4317
|
+
# ( ) Unsorted
|
|
4318
|
+
# ^ the composed {List}'s one-column gutter
|
|
4319
|
+
#
|
|
4320
|
+
# rg = Component::RadioGroup.new(items: %w[Ascending Descending Unsorted])
|
|
4321
|
+
# rg.value = "Descending" # or seed it via the ctor
|
|
4322
|
+
# rg.on_value_change = ->(order) { resort(order) }
|
|
4323
|
+
# rg.value # => "Descending"
|
|
4324
|
+
# rg.item_label = ->(o) { o.title } # default :to_s
|
|
4325
|
+
#
|
|
4326
|
+
# {#value} is **the selected item itself** — of whatever type {#items}
|
|
4327
|
+
# holds, never its label. `nil` means nothing is selected: that is the
|
|
4328
|
+
# initial state, and assigning it is the only way back, since Space on the
|
|
4329
|
+
# already-selected row is a no-op rather than a deselect.
|
|
4330
|
+
#
|
|
4331
|
+
# Composes rather than subclasses, like {ComboBox}: a {List} is its single
|
|
4332
|
+
# {HasContent} child, which is where the cursor, scrolling, the scrollbar
|
|
4333
|
+
# and per-row mouse hit-testing come from. `content` is that list, so an app
|
|
4334
|
+
# can tune it (`scrollbar_visibility`, `show_cursor_when_inactive`, …). Rows
|
|
4335
|
+
# beyond {#rect}'s height scroll; the inner list is the tab stop, not the
|
|
4336
|
+
# group.
|
|
4337
|
+
#
|
|
4338
|
+
# == The cursor is chrome
|
|
4339
|
+
# The cursor and the selection are two independent things, as in
|
|
4340
|
+
# {CheckboxGroup} — arrows roam without changing {#value}, so a listener
|
|
4341
|
+
# that resorts a pane fires once on intent instead of once per row crossed.
|
|
4342
|
+
# {#value=} therefore does *not* move the cursor. An app that wants it
|
|
4343
|
+
# parked on the selection parks it:
|
|
4344
|
+
#
|
|
4345
|
+
# rg.content.cursor = List::Cursor.new(position: rg.items.index(rg.value))
|
|
4346
|
+
#
|
|
4347
|
+
# {#items=} is the one thing that moves it, clamping it back into range.
|
|
4348
|
+
#
|
|
4349
|
+
# == +items+ is chrome; +value+ is authoritative
|
|
4350
|
+
# {#items=} changes only what is *presented*. It never touches {#value} and
|
|
4351
|
+
# never fires {HasValue#on_value_change}, and a selected item absent from
|
|
4352
|
+
# {#items} renders no marked row while surviving intact — so a form saved
|
|
4353
|
+
# without the user editing anything changes nothing silently. Keeping the
|
|
4354
|
+
# two in sync is the app's job. Same contract as {ComboBox#value} and
|
|
4355
|
+
# {CheckboxGroup#value}.
|
|
4356
|
+
#
|
|
4357
|
+
# == Implementation details
|
|
4358
|
+
# Two `==`-equal items share one selection, so selecting either marks both
|
|
4359
|
+
# rows; two *distinct* items that merely render the same label stay
|
|
4360
|
+
# independent, because a row resolves to an item by index.
|
|
4361
|
+
#
|
|
4362
|
+
# Rows are `(*) `/`( ) ` literals, mirroring {Checkbox}'s convention rather
|
|
4363
|
+
# than importing constants from it. ASCII deliberately: `(•)` would measure
|
|
4364
|
+
# two columns in a terminal configured for East-Asian-Ambiguous glyphs and
|
|
4365
|
+
# shift every row's text, which no test would catch.
|
|
4366
|
+
#
|
|
4367
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
4368
|
+
class RadioGroup < Component
|
|
4369
|
+
include Tuile::Component::HasContent
|
|
4370
|
+
include Tuile::Component::HasValue
|
|
4371
|
+
|
|
4372
|
+
# _@param_ `items` — the items to present, one row each; also settable via {#items=}.
|
|
4373
|
+
#
|
|
4374
|
+
# _@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.
|
|
4375
|
+
def initialize: (?items: ::Array[untyped], ?value: Object?) -> void
|
|
4376
|
+
|
|
4377
|
+
# Selects `new_value`, firing {HasValue#on_value_change} when it really
|
|
4378
|
+
# changed. The cursor stays where it is.
|
|
4379
|
+
#
|
|
4380
|
+
# _@param_ `new_value` — `nil` selects nothing; an item outside {#items} is kept but renders no marked row.
|
|
4381
|
+
def value=: (Object? new_value) -> void
|
|
4382
|
+
|
|
4383
|
+
# Selects the cursor row on Space. Nothing else is claimed: the composed
|
|
4384
|
+
# {List} — being the focused component — has already had its chance at the
|
|
4385
|
+
# key (its arrows, Home/End, PgUp/PgDn, ^U/^D and Enter), and whatever
|
|
4386
|
+
# neither of us wants bubbles on to an ancestor.
|
|
4387
|
+
#
|
|
4388
|
+
# _@param_ `key`
|
|
4389
|
+
def handle_key: (String key) -> bool
|
|
4390
|
+
|
|
4391
|
+
# Places the composed list across the whole rect ({HasContent} hook).
|
|
4392
|
+
#
|
|
4393
|
+
# _@param_ `list`
|
|
4394
|
+
def layout: (Component list) -> void
|
|
4395
|
+
|
|
4396
|
+
# Selects the item on row `index`; an index outside {#items} is ignored.
|
|
4397
|
+
#
|
|
4398
|
+
# _@param_ `index`
|
|
4399
|
+
def select_at: (Integer index) -> void
|
|
4400
|
+
|
|
4401
|
+
# Re-renders every row from the current items, labels and selection.
|
|
4402
|
+
def rebuild_rows: () -> void
|
|
4403
|
+
|
|
4404
|
+
# Pulls an over-range cursor back onto the last row (row 0 when there are
|
|
4405
|
+
# none). {List#lines=} leaves a stale cursor alone, which would strand it
|
|
4406
|
+
# off-content: no highlight, a dead Enter, and a Space that resolves to
|
|
4407
|
+
# `nil` and silently clears the selection.
|
|
4408
|
+
def clamp_cursor: () -> void
|
|
4409
|
+
|
|
4410
|
+
# _@param_ `item`
|
|
4411
|
+
#
|
|
4412
|
+
# _@return_ — whichever {StyledString#+} accepts on the
|
|
4413
|
+
# right — so a styled label keeps its spans and a plain one is parsed.
|
|
4414
|
+
def label_for: (Object item) -> (StyledString | String)
|
|
4415
|
+
|
|
4416
|
+
# _@return_ — the current value; `nil` until first set.
|
|
4417
|
+
def value: () -> Object
|
|
4418
|
+
|
|
4419
|
+
# _@return_ — true iff {#value} equals {#empty_value}.
|
|
4420
|
+
def empty?: () -> bool
|
|
4421
|
+
|
|
4422
|
+
# Resets {#value} to {#empty_value}.
|
|
4423
|
+
def clear: () -> void
|
|
4424
|
+
|
|
4425
|
+
# _@return_ — the value {#empty?}/{#clear} treat as empty; `nil`
|
|
4426
|
+
# unless an includer overrides it.
|
|
4427
|
+
def empty_value: () -> Object
|
|
4428
|
+
|
|
4429
|
+
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
4430
|
+
# a read-only display field could override back to `false`. Only
|
|
4431
|
+
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
4432
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
4433
|
+
# `D-integer-field`).
|
|
4434
|
+
def focusable?: () -> bool
|
|
4435
|
+
|
|
4436
|
+
# _@param_ `event`
|
|
4437
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
4438
|
+
|
|
4439
|
+
# _@param_ `rect`
|
|
4440
|
+
def rect=: (Rect rect) -> void
|
|
4441
|
+
|
|
4442
|
+
def on_focus: () -> void
|
|
4443
|
+
|
|
4444
|
+
# _@return_ — the presented items.
|
|
4445
|
+
attr_accessor items: ::Array[untyped]
|
|
4446
|
+
|
|
4447
|
+
# _@return_ — item -> row label (a `String`, {StyledString}, or
|
|
4448
|
+
# anything with `#to_s`); `:to_s` by default.
|
|
4449
|
+
attr_accessor item_label: (Proc | Method)
|
|
4450
|
+
end
|
|
4451
|
+
|
|
4452
|
+
# A one-row progress bar: a run of `█` growing left to right across {#rect},
|
|
4453
|
+
# over a `░` track.
|
|
4454
|
+
#
|
|
4455
|
+
# ████████░░░░░░░░░░░░
|
|
4456
|
+
#
|
|
4457
|
+
# bar = Component::ProgressBar.new(range: 0..files.size)
|
|
4458
|
+
# label = Component::Label.new
|
|
4459
|
+
# add(bar)
|
|
4460
|
+
# add(label)
|
|
4461
|
+
#
|
|
4462
|
+
# def rect=(new_rect) # the enclosing Layout positions both
|
|
4463
|
+
# super
|
|
4464
|
+
# bar.rect = Rect.new(rect.left, rect.top, rect.width, 1)
|
|
4465
|
+
# label.rect = Rect.new(rect.left, rect.top + 1, rect.width, 1)
|
|
4466
|
+
# end
|
|
4467
|
+
#
|
|
4468
|
+
# bar.value = done
|
|
4469
|
+
# label.text = "#{bar.percent}% — #{done}/#{files.size}"
|
|
4470
|
+
#
|
|
4471
|
+
# The bar paints no text of its own: put a {Label} beside it and feed it
|
|
4472
|
+
# {#percent} or {#fraction}, so the app words it ("42% — 3/7 files") and
|
|
4473
|
+
# places it freely. Display-only — not focusable, no keys, no mouse.
|
|
4474
|
+
#
|
|
4475
|
+
# While the total is still unknown, {#indeterminate=} swaps the fill for a
|
|
4476
|
+
# block sliding across the bar:
|
|
4477
|
+
#
|
|
4478
|
+
# ░░░░░░░████░░░░░░░░░
|
|
4479
|
+
#
|
|
4480
|
+
# Both endpoints are exact: the bar is full only at {#max} and empty only at
|
|
4481
|
+
# {#min}, so a full bar always means done. Assign a one-row {#rect}; a taller
|
|
4482
|
+
# one paints the bar on its first row and leaves the rest to the background.
|
|
4483
|
+
#
|
|
4484
|
+
# == Implementation details
|
|
4485
|
+
# The `█`/`░` pair is the same one {VerticalScrollBar} uses — East-Asian
|
|
4486
|
+
# Ambiguous and Neutral respectively, so under an ambiguous-as-wide terminal
|
|
4487
|
+
# the rendered length would vary with the fill level. Shipped anyway, per
|
|
4488
|
+
# `DECISIONS.md` `D-ambiguous-width`: a bar that rhymes with the scrollbar
|
|
4489
|
+
# beats a third convention, and if that bet is ever reversed both swap
|
|
4490
|
+
# together.
|
|
4491
|
+
class ProgressBar < Component
|
|
4492
|
+
DEFAULT_RANGE: ::Range[untyped]
|
|
4493
|
+
INDETERMINATE_FPS: Integer
|
|
4494
|
+
BLOCK_DIVISOR: Integer
|
|
4495
|
+
|
|
4496
|
+
# _@param_ `range` — initial {#range=}.
|
|
4497
|
+
#
|
|
4498
|
+
# _@param_ `value` — initial {#value=}; `nil` starts at the range's lower bound.
|
|
4499
|
+
#
|
|
4500
|
+
# _@param_ `indeterminate` — initial {#indeterminate=}.
|
|
4501
|
+
def initialize: (?range: ::Range[untyped], ?value: Numeric?, ?indeterminate: bool) -> void
|
|
4502
|
+
|
|
4503
|
+
# _@return_ — the scale {#value} is measured against.
|
|
4504
|
+
def range: () -> ::Range[untyped]
|
|
4505
|
+
|
|
4506
|
+
# Replaces the scale, re-clamping {#value} into it. `min == max` is legal
|
|
4507
|
+
# and reads as complete — a zero-length job has nothing outstanding — so
|
|
4508
|
+
# `bar.range = 0..files.size` needs no special case for an empty list.
|
|
4509
|
+
#
|
|
4510
|
+
# _@param_ `new_range` — inclusive; endpoints Numeric and finite.
|
|
4511
|
+
def range=: (::Range[untyped] new_range) -> void
|
|
4512
|
+
|
|
4513
|
+
# _@return_ — {#value} as `0.0..1.0`. `1.0` when the range is empty.
|
|
4514
|
+
def fraction: () -> Float
|
|
4515
|
+
|
|
4516
|
+
# _@return_ — {#fraction} as `0..100`, floored — `100` means done and
|
|
4517
|
+
# nothing else does, matching the painted bar exactly.
|
|
4518
|
+
def percent: () -> Integer
|
|
4519
|
+
|
|
4520
|
+
# _@return_ — whether the sliding-block animation is showing.
|
|
4521
|
+
def indeterminate?: () -> bool
|
|
4522
|
+
|
|
4523
|
+
# Switches between the fill and the sliding block. {#value} keeps working
|
|
4524
|
+
# while indeterminate — it is simply not painted — so switching back shows
|
|
4525
|
+
# the progress that accumulated meanwhile.
|
|
4526
|
+
#
|
|
4527
|
+
# The animation only runs while the bar is {Component#attached? attached},
|
|
4528
|
+
# and stops on detach. It also keeps the event loop awake at
|
|
4529
|
+
# {INDETERMINATE_FPS}, so turn it off (or remove the bar) when the job ends.
|
|
4530
|
+
#
|
|
4531
|
+
# _@param_ `flag` — coerced; truthiness decides.
|
|
4532
|
+
def indeterminate=: (bool flag) -> void
|
|
4533
|
+
|
|
4534
|
+
def on_attached: () -> void
|
|
4535
|
+
|
|
4536
|
+
def on_detached: () -> void
|
|
4537
|
+
|
|
4538
|
+
# Paints the bar on the first row of {#rect} and blanks the rest.
|
|
4539
|
+
#
|
|
4540
|
+
# Deliberately not `super`: {Component#repaint}'s default blanks the
|
|
4541
|
+
# *whole* rect, which dirties every cell of the bar's own row before it is
|
|
4542
|
+
# painted over — so {Buffer#flush} re-emits the entire row every frame
|
|
4543
|
+
# instead of the one or two cells that actually moved.
|
|
4544
|
+
def repaint: () -> void
|
|
4545
|
+
|
|
4546
|
+
# Filled cells out of `steps` — the rect width when painting, 100 for
|
|
4547
|
+
# {#percent}, so the bar and a {Label} showing the percentage can never
|
|
4548
|
+
# disagree about being done.
|
|
4549
|
+
#
|
|
4550
|
+
# _@param_ `steps`
|
|
4551
|
+
def scale: (Integer steps) -> Integer
|
|
4552
|
+
|
|
4553
|
+
# _@param_ `width` — columns available.
|
|
4554
|
+
#
|
|
4555
|
+
# _@return_ — the row, `width` glyphs wide.
|
|
4556
|
+
def glyphs: (Integer width) -> String
|
|
4557
|
+
|
|
4558
|
+
# Where the sliding block sits this frame: it enters at the left edge and
|
|
4559
|
+
# leaves at the right, one cell per frame, then loops. The period is one
|
|
4560
|
+
# short of `width + block` so at least one cell is always lit — a full
|
|
4561
|
+
# `width + block` blanks the bar for exactly one frame per cycle.
|
|
4562
|
+
#
|
|
4563
|
+
# _@param_ `width` — columns available.
|
|
4564
|
+
#
|
|
4565
|
+
# _@return_ — start column and length, clipped.
|
|
4566
|
+
def block_at: (Integer width) -> [Integer, Integer]
|
|
4567
|
+
|
|
4568
|
+
def resolved_bar_color: () -> Color?
|
|
4569
|
+
|
|
4570
|
+
# Brings the ticker in line with "animating and on screen". The sole writer
|
|
4571
|
+
# of `@ticker`, and idempotent, so the attach/detach hooks and
|
|
4572
|
+
# {#indeterminate=} are all the same call and a repeated `indeterminate =
|
|
4573
|
+
# true` cannot start a second one.
|
|
4574
|
+
def sync_ticker: () -> void
|
|
4575
|
+
|
|
4576
|
+
# _@return_ — lower bound of {#range}.
|
|
4577
|
+
attr_reader min: Float
|
|
4578
|
+
|
|
4579
|
+
# _@return_ — upper bound of {#range}.
|
|
4580
|
+
attr_reader max: Float
|
|
4581
|
+
|
|
4582
|
+
# _@return_ — the value as set, so a {Theme::Ref} comes back
|
|
4583
|
+
# unresolved. Both glyphs paint in it; `nil` (the default) is the
|
|
4584
|
+
# terminal's default foreground.
|
|
4585
|
+
attr_accessor bar_color: (Color | Theme::Ref | Symbol | Integer | ::Array[Integer])?
|
|
4586
|
+
|
|
4587
|
+
# _@return_ — the progress, clamped into {#range} when assigned — so
|
|
4588
|
+
# `bar.value = 999` on a `0..250` bar reads back as `250.0`.
|
|
4589
|
+
attr_accessor value: (Float | Numeric)
|
|
4590
|
+
end
|
|
4591
|
+
|
|
4592
|
+
# A single-line field whose {#value} is an `Integer` (or `nil` when empty).
|
|
4593
|
+
# The user may type only `0`–`9` and a single leading `-`; anything else is
|
|
4594
|
+
# silently rejected without moving the caret. Up/Down step the value by one
|
|
4595
|
+
# (an empty field counting as `0`). An empty or otherwise un-parseable
|
|
4596
|
+
# buffer reads back as `nil`:
|
|
4597
|
+
#
|
|
4598
|
+
# field = Component::IntegerField.new
|
|
4599
|
+
# field.on_value_change = ->(n) { puts n.inspect } # Integer or nil, per change
|
|
4600
|
+
# field.value = 42 # field shows "42"
|
|
4601
|
+
# field.value # => 42
|
|
4602
|
+
# field.clear # empties it; value => nil
|
|
4603
|
+
#
|
|
4604
|
+
# Like {ComboBox}, it *composes* a {TextField} (its single {HasContent}
|
|
4605
|
+
# child) rather than subclassing one — its face carries only the typed
|
|
4606
|
+
# {HasValue} value seam, never the widget's `String`-typed `text`. It's the
|
|
4607
|
+
# same wrapper shape as {ComboBox} minus the dropdown: a digit-filtered text
|
|
4608
|
+
# field re-exposed as a typed input. Give it a single-row {#rect}.
|
|
4609
|
+
#
|
|
4610
|
+
# == The value is a *derived parse* of the buffer
|
|
4611
|
+
# {#value} is `Integer(buffer, 10)` (or `nil`), recomputed on read — the
|
|
4612
|
+
# buffer is the single source of truth, {#value=} just writes it. So `"-"`
|
|
4613
|
+
# alone and `""` both read as `nil`, and `on_value_change` fires eagerly
|
|
4614
|
+
# once per real *value* change: typing `0`→`7` in `"07"` shifts the buffer
|
|
4615
|
+
# but not the value (`7`), so it does not fire. No normalization — a typed
|
|
4616
|
+
# `"007"` stays `"007"` on screen though its value is `7`.
|
|
4617
|
+
#
|
|
4618
|
+
# `min`/`max`, a `+` sign, and thousands separators are deliberately out of
|
|
4619
|
+
# scope (range and formatting are a forms concern).
|
|
4620
|
+
#
|
|
4621
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
4622
|
+
class IntegerField < Component
|
|
4623
|
+
include Tuile::Component::HasContent
|
|
4624
|
+
include Tuile::Component::HasValue
|
|
4625
|
+
|
|
4626
|
+
def initialize: () -> void
|
|
4627
|
+
|
|
4628
|
+
# _@return_ — the parsed buffer; `nil` when empty or not a
|
|
4629
|
+
# valid integer (e.g. a lone `"-"`).
|
|
4630
|
+
def value: () -> Integer?
|
|
4631
|
+
|
|
4632
|
+
# Writes `new_value` into the buffer and parks the caret at its end; fires
|
|
4633
|
+
# {#on_value_change} only if the value actually changed.
|
|
4634
|
+
#
|
|
4635
|
+
# _@param_ `new_value` — `nil` empties the field.
|
|
4636
|
+
def value=: (Integer? new_value) -> void
|
|
4637
|
+
|
|
4638
|
+
# `nil`, not `""`: an integer field with no parseable number is empty.
|
|
4639
|
+
def empty_value: () -> void
|
|
4640
|
+
|
|
4641
|
+
# _@return_ — the field's caret (the hardware cursor is delegated
|
|
4642
|
+
# to the inner field).
|
|
4643
|
+
def cursor_position: () -> Point?
|
|
4644
|
+
|
|
4645
|
+
# Fired when ENTER is pressed in the field; see {TextField#on_enter}.
|
|
4646
|
+
#
|
|
4647
|
+
# _@return_ — no-arg callable, or nil.
|
|
4648
|
+
def on_enter: () -> (Proc | Method)?
|
|
4649
|
+
|
|
4650
|
+
# _@param_ `callback`
|
|
4651
|
+
def on_enter=: ((Proc | Method)? callback) -> void
|
|
4652
|
+
|
|
4653
|
+
# Places the wrapped field across the whole rect ({HasContent} hook).
|
|
4654
|
+
#
|
|
4655
|
+
# _@param_ `field`
|
|
4656
|
+
def layout: (Component field) -> void
|
|
4657
|
+
|
|
4658
|
+
# The field's key interceptor, consulted *before* the field acts on the
|
|
4659
|
+
# key: Up/Down step the value; a printable key the field mustn't accept is
|
|
4660
|
+
# swallowed (so a rejected key never moves the caret); everything else —
|
|
4661
|
+
# digits, the leading sign, and all editing/navigation keys — falls
|
|
4662
|
+
# through.
|
|
4663
|
+
#
|
|
4664
|
+
# _@param_ `key`
|
|
4665
|
+
#
|
|
4666
|
+
# _@return_ — true to consume the key.
|
|
4667
|
+
def field_key: (String key) -> bool
|
|
4668
|
+
|
|
4669
|
+
# Nudges {#value} by `delta`, treating an empty/un-parseable field as `0`.
|
|
4670
|
+
#
|
|
4671
|
+
# _@param_ `delta`
|
|
4672
|
+
def step: (Integer delta) -> void
|
|
4673
|
+
|
|
4674
|
+
# A digit anywhere, or a `-` only as the very first character.
|
|
4675
|
+
#
|
|
4676
|
+
# _@param_ `char` — a single printable character.
|
|
4677
|
+
def accepts?: (String char) -> bool
|
|
4678
|
+
|
|
4679
|
+
# Re-emits {#on_value_change} with the freshly-parsed {#value}, but only
|
|
4680
|
+
# when it differs from the last one fired — so a buffer edit that leaves
|
|
4681
|
+
# the value unchanged (`"7"`→`"07"`) stays silent.
|
|
4682
|
+
def fire_if_changed: () -> void
|
|
4683
|
+
|
|
4684
|
+
# _@return_ — true iff {#value} equals {#empty_value}.
|
|
4685
|
+
def empty?: () -> bool
|
|
4686
|
+
|
|
4687
|
+
# Resets {#value} to {#empty_value}.
|
|
4688
|
+
def clear: () -> void
|
|
4689
|
+
|
|
4690
|
+
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
4691
|
+
# a read-only display field could override back to `false`. Only
|
|
4692
|
+
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
4693
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
4694
|
+
# `D-integer-field`).
|
|
4695
|
+
def focusable?: () -> bool
|
|
4696
|
+
|
|
4697
|
+
# _@param_ `event`
|
|
4698
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
4699
|
+
|
|
4700
|
+
# _@param_ `rect`
|
|
4701
|
+
def rect=: (Rect rect) -> void
|
|
4702
|
+
|
|
4703
|
+
def on_focus: () -> void
|
|
4704
|
+
end
|
|
4705
|
+
|
|
4706
|
+
# A borderless, tinted, non-focusable floating selection list — the dropdown
|
|
4707
|
+
# a *driver* drops open, drives by forwarding movement keys, and commits a
|
|
4708
|
+
# pick from: a non-modal {Popup} wrapping a {List} that never takes focus, so
|
|
4709
|
+
# focus stays on the driver while the caller refills the rows, moves the
|
|
4710
|
+
# highlight, and reads the pick.
|
|
4711
|
+
#
|
|
4712
|
+
# drop = Component::ListDropdown.new
|
|
4713
|
+
# drop.on_item_chosen = ->(index, _line) { commit(index) } # caller commits
|
|
4714
|
+
# # …then, from the driver's key handler:
|
|
4715
|
+
# drop.lines = matches.map { |m| render(m) } # caller filters + renders
|
|
4716
|
+
# drop.anchor_to(rect, rows: matches.size) # below the driver, or flipped
|
|
4717
|
+
# drop.open
|
|
4718
|
+
# return true if drop.move(key) # Up/Down/PgUp/PgDn/^U/^D → list scroll
|
|
4719
|
+
# drop.choose if key == Keys::ENTER # commit the highlight
|
|
4720
|
+
#
|
|
4721
|
+
# It owns only what every such dropdown shares — *placement* included, via
|
|
4722
|
+
# {#anchor_to}. What stays with the driver: the width **policy** ({#anchor_to}
|
|
4723
|
+
# measures nothing itself), filtering, row rendering, the commit action, and
|
|
4724
|
+
# ESC/Enter handling. ESC and Enter carry driver-specific tails (ESC may
|
|
4725
|
+
# revert a query; Enter may commit via {#choose} *or* via a separate submit
|
|
4726
|
+
# path), so {#move} claims neither — the driver calls {#choose} and {#close}
|
|
4727
|
+
# from its own branches.
|
|
4728
|
+
#
|
|
4729
|
+
# == Theming
|
|
4730
|
+
# Borderless, told apart from the content beneath by a background tint —
|
|
4731
|
+
# {Theme#input_bg_color} by default, assigned as a live {Theme::Ref} so it
|
|
4732
|
+
# tracks light/dark flips with no hook. Reassign {Component#bg_color=} for a
|
|
4733
|
+
# different tint (a `Theme.ref(:token)` keeps the flip-tracking).
|
|
4734
|
+
#
|
|
4735
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
4736
|
+
class ListDropdown < Tuile::Component::Popup
|
|
4737
|
+
MOVE_KEYS: ::Array[String]
|
|
4738
|
+
MAX_VISIBLE_ROWS: Integer
|
|
4739
|
+
|
|
4740
|
+
def initialize: () -> void
|
|
4741
|
+
|
|
4742
|
+
# _@param_ `lines` — the rows to show; see {List#lines=}.
|
|
4743
|
+
def lines=: (::Array[untyped] lines) -> void
|
|
4744
|
+
|
|
4745
|
+
# _@return_ — the current rows.
|
|
4746
|
+
def lines: () -> ::Array[StyledString]
|
|
4747
|
+
|
|
4748
|
+
# _@param_ `proc` — commit callback; see {List#on_item_chosen}.
|
|
4749
|
+
def on_item_chosen=: ((Proc | Method)? proc) -> void
|
|
4750
|
+
|
|
4751
|
+
# _@param_ `cursor` — the highlight; see {List#cursor=}.
|
|
4752
|
+
def cursor=: (List::Cursor cursor) -> void
|
|
4753
|
+
|
|
4754
|
+
# _@return_ — the list's cursor (the current highlight).
|
|
4755
|
+
def cursor: () -> List::Cursor
|
|
4756
|
+
|
|
4757
|
+
# Sizes and places the dropdown against `anchor`: directly beneath it,
|
|
4758
|
+
# flipped above when `rows` won't fit below, clamped — with the list
|
|
4759
|
+
# scrolling — when neither side has room. Horizontally the left edges line
|
|
4760
|
+
# up, sliding left only far enough to keep the panel on screen.
|
|
4761
|
+
#
|
|
4762
|
+
# drop.anchor_to(field.rect, rows: matches.size) # field width
|
|
4763
|
+
# drop.anchor_to(rect, rows: items.size, width: measured) # own width
|
|
4764
|
+
#
|
|
4765
|
+
# Vertical flips but horizontal slides because covering the driver would
|
|
4766
|
+
# hide what is being chosen, while sharing its columns is the point.
|
|
4767
|
+
#
|
|
4768
|
+
# _@param_ `anchor` — the driver's rect; the dropdown never covers it.
|
|
4769
|
+
#
|
|
4770
|
+
# _@param_ `rows` — how many rows there are to show — the content count, not the height: more than fits turns the scrollbar on. `0` collapses the dropdown to an empty rect (drivers close instead).
|
|
4771
|
+
#
|
|
4772
|
+
# _@param_ `width` — the panel's width in columns, clamped to the screen. Defaults to the anchor's, which lines both edges up with a field; a driver that measured its labels passes its own. A label wider than the screen clips — {List} has no horizontal scrolling.
|
|
4773
|
+
#
|
|
4774
|
+
# _@param_ `max_rows` — rows shown before the list scrolls.
|
|
4775
|
+
def anchor_to: (
|
|
4776
|
+
Rect anchor,
|
|
4777
|
+
rows: Integer,
|
|
4778
|
+
?width: Integer,
|
|
4779
|
+
?max_rows: Integer
|
|
4780
|
+
) -> void
|
|
4781
|
+
|
|
4782
|
+
# Forwards a cursor-movement key to the list. The driver calls this from
|
|
4783
|
+
# its own key handler; a truthy return means "consumed — stop here", falsy
|
|
4784
|
+
# means "not mine — proceed with normal editing/dispatch". Only {MOVE_KEYS}
|
|
4785
|
+
# are claimed, and only while open.
|
|
4786
|
+
#
|
|
4787
|
+
# _@param_ `key`
|
|
4788
|
+
#
|
|
4789
|
+
# _@return_ — true iff the key was consumed.
|
|
4790
|
+
def move: (String key) -> bool
|
|
4791
|
+
|
|
4792
|
+
# Commits the highlighted row by firing {List#on_item_chosen}, exactly as
|
|
4793
|
+
# pressing Enter on the focused list would — the driver calls this from its
|
|
4794
|
+
# own Enter branch.
|
|
4795
|
+
#
|
|
4796
|
+
# _@return_ — true iff a row was chosen (false when the cursor is
|
|
4797
|
+
# off-content).
|
|
4798
|
+
def choose: () -> bool
|
|
4799
|
+
|
|
4800
|
+
# The dropdown's {List}. Non-focusable on purpose: the driver forwards keys
|
|
4801
|
+
# while focus stays on it, and a mouse click selects an item without
|
|
4802
|
+
# stealing focus — so a driving text input never loses its caret
|
|
4803
|
+
# mid-interaction.
|
|
4804
|
+
class Menu < Tuile::Component::List
|
|
4805
|
+
def focusable?: () -> bool
|
|
4806
|
+
|
|
4807
|
+
def tab_stop?: () -> bool
|
|
4808
|
+
end
|
|
4809
|
+
end
|
|
4810
|
+
|
|
4811
|
+
# A {Window} that lists options identified by single keyboard keys, asks
|
|
4812
|
+
# the user to pick one, and fires a callback with the picked key.
|
|
4813
|
+
#
|
|
4814
|
+
# Usable tiled (just add to a {Layout} and read picks via the block) or
|
|
4815
|
+
# as a popup via {.open}, which wraps it in a {Popup} that closes itself
|
|
4816
|
+
# after a pick. ESC / `q` close without firing the callback.
|
|
4817
|
+
class PickerWindow < Tuile::Component::Window
|
|
4818
|
+
MAX_ITEMS: Integer
|
|
4819
|
+
|
|
4820
|
+
# _@param_ `caption` — the window caption.
|
|
4821
|
+
#
|
|
4822
|
+
# _@param_ `options` — pairs of keyboard key and option caption. No Rainbow formatting must be used.
|
|
4823
|
+
def initialize: (String caption, ::Array[[String, String]] options) ?{ (String key) -> void } -> void
|
|
4824
|
+
|
|
4825
|
+
# Handles an option-key press. Reached by bubbling: the inner {List}
|
|
4826
|
+
# (the focused component) sees the key first and handles cursor/Enter
|
|
4827
|
+
# picks; anything it declines bubbles up here, where a key matching an
|
|
4828
|
+
# option's `key` picks that option.
|
|
4829
|
+
#
|
|
4830
|
+
# _@param_ `key`
|
|
4831
|
+
def handle_key: (String key) -> bool
|
|
4832
|
+
|
|
4833
|
+
def keyboard_hint: () -> String
|
|
4834
|
+
|
|
4835
|
+
# Opens a picker as a popup. Picking an option fires `block`, then
|
|
4836
|
+
# closes the popup; ESC / `q` close without firing `block`.
|
|
4837
|
+
#
|
|
4838
|
+
# _@param_ `caption`
|
|
4839
|
+
#
|
|
4840
|
+
# _@param_ `options`
|
|
4841
|
+
#
|
|
4842
|
+
# _@return_ — the wrapping popup.
|
|
4843
|
+
def self.open: (String caption, ::Array[[String, String]] options) ?{ (String key) -> void } -> Popup
|
|
4844
|
+
|
|
4845
|
+
# _@param_ `key`
|
|
4846
|
+
def select_option: (String key) -> void
|
|
4847
|
+
|
|
4848
|
+
# Callback invoked after the user picks an option (after the block
|
|
4849
|
+
# fires). The {Popup} returned by {.open} sets this to its own `close`.
|
|
4850
|
+
attr_accessor on_pick: Proc?
|
|
4851
|
+
|
|
4852
|
+
# One picker option.
|
|
4853
|
+
#
|
|
4854
|
+
# @!attribute [r] key
|
|
4855
|
+
# @return [String] the keyboard key that picks this option.
|
|
4856
|
+
# @!attribute [r] caption
|
|
4857
|
+
# @return [String] the option caption.
|
|
4858
|
+
class Option
|
|
4859
|
+
# _@return_ — the keyboard key that picks this option.
|
|
4860
|
+
attr_reader key: String
|
|
4861
|
+
|
|
4862
|
+
# _@return_ — the option caption.
|
|
4863
|
+
attr_reader caption: String
|
|
4864
|
+
end
|
|
4865
|
+
end
|
|
4866
|
+
|
|
4867
|
+
# Multi-select from a set of typed items, one checkable row each. Arrows move
|
|
4868
|
+
# a cursor; Space, Enter or a left click toggles the row under it:
|
|
4869
|
+
#
|
|
4870
|
+
# [x] Errors
|
|
4871
|
+
# [ ] Warnings <- cursor row, highlighted across the full width
|
|
4872
|
+
# [x] Info
|
|
4873
|
+
# ^ the composed {List}'s one-column gutter
|
|
4874
|
+
#
|
|
4875
|
+
# cg = Component::CheckboxGroup.new(items: %w[Errors Warnings Info])
|
|
4876
|
+
# cg.value = %w[Errors Info] # any Enumerable, stored as a Set
|
|
4877
|
+
# cg.on_value_change = ->(set) { filter(set) } # once per toggle
|
|
4878
|
+
# cg.value # => #<Set: {"Errors", "Info"}>
|
|
4879
|
+
# cg.item_label = ->(level) { level.name } # default :to_s
|
|
4880
|
+
#
|
|
4881
|
+
# {#value} is a **frozen `Set` of the selected items themselves** — of
|
|
4882
|
+
# whatever type {#items} holds, never their labels. Frozen so `cg.value <<
|
|
4883
|
+
# item` fails loudly rather than mutating the selection behind
|
|
4884
|
+
# {HasValue#on_value_change}'s back; assign a new set or an `Array` instead.
|
|
4885
|
+
# Treat it as *unordered*: it iterates in toggle order, so use
|
|
4886
|
+
# `cg.items & cg.value.to_a` when you need {#items} order.
|
|
4887
|
+
#
|
|
4888
|
+
# Composes rather than subclasses, like {ComboBox}: a {List} is its single
|
|
4889
|
+
# {HasContent} child, which is where the cursor, scrolling, the scrollbar and
|
|
4890
|
+
# per-row mouse hit-testing come from. `content` is that list, so an app can
|
|
4891
|
+
# tune it (`scrollbar_visibility`, `show_cursor_when_inactive`, …). Rows
|
|
4892
|
+
# beyond {#rect}'s height scroll; the inner list is the tab stop, not the
|
|
4893
|
+
# group.
|
|
4894
|
+
#
|
|
4895
|
+
# == +items+ is chrome; +value+ is authoritative
|
|
4896
|
+
# {#items=} changes only what is *presented*. It never touches {#value} and
|
|
4897
|
+
# never fires {HasValue#on_value_change}, and a selected item absent from
|
|
4898
|
+
# {#items} renders no checked row while surviving intact — so a form saved
|
|
4899
|
+
# without the user editing anything changes nothing silently. Keeping the two
|
|
4900
|
+
# in sync is the app's job: `cg.value &= cg.items.to_set` reconciles them.
|
|
4901
|
+
# Same contract as {ComboBox#value}, one item at a time.
|
|
4902
|
+
#
|
|
4903
|
+
# There is no select-all — neither a key nor a header row. An app that wants
|
|
4904
|
+
# one writes `cg.value = cg.items` behind its own affordance.
|
|
4905
|
+
#
|
|
4906
|
+
# == Implementation details
|
|
4907
|
+
# Items need stable `#hash`/`#eql?`, since the selection is a `Set`: an item
|
|
4908
|
+
# mutated after being selected becomes unfindable. Two `==`-equal items also
|
|
4909
|
+
# share one selection — their rows check and uncheck together — whereas two
|
|
4910
|
+
# *distinct* items that merely render the same label toggle independently,
|
|
4911
|
+
# because a row resolves to an item by index.
|
|
4912
|
+
#
|
|
4913
|
+
# Rows repeat {Checkbox}'s `[x] `/`[ ] ` glyph convention rather than
|
|
4914
|
+
# importing a constant from it.
|
|
4915
|
+
#
|
|
4916
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
4917
|
+
class CheckboxGroup < Component
|
|
4918
|
+
include Tuile::Component::HasContent
|
|
4919
|
+
include Tuile::Component::HasValue
|
|
4920
|
+
EMPTY_SELECTION: ::Set[untyped]
|
|
4921
|
+
|
|
4922
|
+
# _@param_ `items` — the items to present, one row each; also settable via {#items=}.
|
|
4923
|
+
#
|
|
4924
|
+
# _@param_ `value` — the initial selection. Seeds the backing ivar directly, so no listener fires and assignment order doesn't matter to a form helper.
|
|
4925
|
+
def initialize: (?items: ::Array[untyped], ?value: ::Enumerable[untyped]?) -> void
|
|
4926
|
+
|
|
4927
|
+
# _@return_ — the frozen empty set — {HasValue#empty?} means nothing is
|
|
4928
|
+
# selected.
|
|
4929
|
+
def empty_value: () -> ::Set[untyped]
|
|
4930
|
+
|
|
4931
|
+
# Replaces the selection, firing {HasValue#on_value_change} when it really
|
|
4932
|
+
# changed. Stores a frozen `Set` *copy*, so a set the caller goes on
|
|
4933
|
+
# mutating can't reach in.
|
|
4934
|
+
#
|
|
4935
|
+
# _@param_ `new_value` — `nil` selects nothing.
|
|
4936
|
+
def value=: (::Enumerable[untyped]? new_value) -> void
|
|
4937
|
+
|
|
4938
|
+
# Toggles the cursor row on Space. Nothing else is claimed: the composed
|
|
4939
|
+
# {List} — being the focused component — has already had its chance at the
|
|
4940
|
+
# key (its arrows, Home/End, PgUp/PgDn, ^U/^D and Enter), and whatever
|
|
4941
|
+
# neither of us wants bubbles on to an ancestor.
|
|
4942
|
+
#
|
|
4943
|
+
# _@param_ `key`
|
|
4944
|
+
def handle_key: (String key) -> bool
|
|
4945
|
+
|
|
4946
|
+
# Places the composed list across the whole rect ({HasContent} hook).
|
|
4947
|
+
#
|
|
4948
|
+
# _@param_ `list`
|
|
4949
|
+
def layout: (Component list) -> void
|
|
4950
|
+
|
|
4951
|
+
# Flips membership of the item on row `index`; an index outside {#items} is
|
|
4952
|
+
# ignored.
|
|
4953
|
+
#
|
|
4954
|
+
# _@param_ `index`
|
|
4955
|
+
def toggle_at: (Integer index) -> void
|
|
4956
|
+
|
|
4957
|
+
# Re-renders every row from the current items, labels and selection.
|
|
4958
|
+
def rebuild_rows: () -> void
|
|
4959
|
+
|
|
4960
|
+
# _@param_ `new_value`
|
|
4961
|
+
#
|
|
4962
|
+
# _@return_ — a frozen copy; `nil` becomes {#empty_value}.
|
|
4963
|
+
def coerce: (::Enumerable[untyped]? new_value) -> ::Set[untyped]
|
|
4964
|
+
|
|
4965
|
+
# _@param_ `item`
|
|
4966
|
+
#
|
|
4967
|
+
# _@return_ — whichever {StyledString#+} accepts on the
|
|
4968
|
+
# right — so a styled label keeps its spans and a plain one is parsed.
|
|
4969
|
+
def label_for: (Object item) -> (StyledString | String)
|
|
4970
|
+
|
|
4971
|
+
# _@return_ — the current value; `nil` until first set.
|
|
4972
|
+
def value: () -> Object
|
|
4973
|
+
|
|
4974
|
+
# _@return_ — true iff {#value} equals {#empty_value}.
|
|
4975
|
+
def empty?: () -> bool
|
|
4976
|
+
|
|
4977
|
+
# Resets {#value} to {#empty_value}.
|
|
4978
|
+
def clear: () -> void
|
|
4979
|
+
|
|
4980
|
+
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
4981
|
+
# a read-only display field could override back to `false`. Only
|
|
4982
|
+
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
4983
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
4984
|
+
# `D-integer-field`).
|
|
4985
|
+
def focusable?: () -> bool
|
|
4986
|
+
|
|
4987
|
+
# _@param_ `event`
|
|
4988
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
2817
4989
|
|
|
2818
|
-
|
|
4990
|
+
# _@param_ `rect`
|
|
4991
|
+
def rect=: (Rect rect) -> void
|
|
2819
4992
|
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
4993
|
+
def on_focus: () -> void
|
|
4994
|
+
|
|
4995
|
+
# _@return_ — the presented items.
|
|
4996
|
+
attr_accessor items: ::Array[untyped]
|
|
4997
|
+
|
|
4998
|
+
# _@return_ — item -> row label (a `String`, {StyledString}, or
|
|
4999
|
+
# anything with `#to_s`); `:to_s` by default.
|
|
5000
|
+
attr_accessor item_label: (Proc | Method)
|
|
2824
5001
|
end
|
|
2825
5002
|
|
|
2826
|
-
#
|
|
2827
|
-
#
|
|
5003
|
+
# A {TextField} that paints one mask glyph per character instead of the
|
|
5004
|
+
# text. Editing, caret, clicks and horizontal scrolling are the field's,
|
|
5005
|
+
# unchanged:
|
|
2828
5006
|
#
|
|
2829
|
-
#
|
|
2830
|
-
#
|
|
5007
|
+
# pf = Component::PasswordField.new
|
|
5008
|
+
# pf.rect = Rect.new(0, 0, 20, 1)
|
|
5009
|
+
# pf.value # => the plaintext String
|
|
5010
|
+
# pf.mask_char = "•" # default "*"
|
|
5011
|
+
# pf.revealed = true # show the plaintext, e.g. behind a Checkbox
|
|
2831
5012
|
#
|
|
2832
|
-
#
|
|
2833
|
-
#
|
|
2834
|
-
#
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
5013
|
+
# A password's value *is* its text, so this subclasses {TextField} rather
|
|
5014
|
+
# than composing one the way {IntegerField} does — the delta is presentation
|
|
5015
|
+
# only, and it lands entirely on {TextField#display_text}.
|
|
5016
|
+
#
|
|
5017
|
+
# == What it hides, and what it doesn't
|
|
5018
|
+
# The plaintext is an ordinary Ruby `String`: not pinned, not wiped, not
|
|
5019
|
+
# kept out of GC. Anything stronger needs a frozen-buffer type and the
|
|
5020
|
+
# cooperation of every consumer, which is out of scope for a widget.
|
|
5021
|
+
#
|
|
5022
|
+
# The mask shows the text's *length* — accepted, since a caret has to sit
|
|
5023
|
+
# somewhere. Its *word structure* is hidden: CTRL+LEFT / CTRL+RIGHT jump to
|
|
5024
|
+
# the ends while masked instead of hopping the spaces a watcher could then
|
|
5025
|
+
# read off the caret. They resume word-jumping when {#revealed}.
|
|
5026
|
+
#
|
|
5027
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
5028
|
+
class PasswordField < Tuile::Component::TextField
|
|
5029
|
+
def initialize: () -> void
|
|
2838
5030
|
|
|
2839
|
-
#
|
|
2840
|
-
|
|
2841
|
-
# _@param_ `string` — the line (or multiple lines) to log.
|
|
2842
|
-
def log: (String? string) -> void
|
|
5031
|
+
# _@return_ — {#revealed} in predicate form.
|
|
5032
|
+
def revealed?: () -> bool
|
|
2843
5033
|
|
|
2844
|
-
#
|
|
2845
|
-
|
|
2846
|
-
# call `output.puts`, e.g. `TTY::Logger`).
|
|
2847
|
-
class IO
|
|
2848
|
-
# _@param_ `window`
|
|
2849
|
-
def initialize: (LogWindow window) -> void
|
|
5034
|
+
# _@return_ — the mask, one glyph per character, unless {#revealed}.
|
|
5035
|
+
def display_text: () -> String
|
|
2850
5036
|
|
|
2851
|
-
|
|
2852
|
-
|
|
5037
|
+
# _@param_ `char`
|
|
5038
|
+
def single_cluster?: (String char) -> bool
|
|
2853
5039
|
|
|
2854
|
-
|
|
2855
|
-
|
|
5040
|
+
# _@return_ — caret target for CTRL+LEFT: the start, while masked.
|
|
5041
|
+
def word_left: () -> Integer
|
|
2856
5042
|
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
5043
|
+
# _@return_ — caret target for CTRL+RIGHT: the end, while masked.
|
|
5044
|
+
def word_right: () -> Integer
|
|
5045
|
+
|
|
5046
|
+
# _@return_ — the glyph painted per character; `"*"` by default.
|
|
5047
|
+
attr_accessor mask_char: String
|
|
5048
|
+
|
|
5049
|
+
# _@return_ — whether the plaintext is shown; `false` by default.
|
|
5050
|
+
attr_accessor revealed: (bool | Object)
|
|
2862
5051
|
end
|
|
2863
5052
|
|
|
2864
|
-
# A single-line
|
|
2865
|
-
#
|
|
2866
|
-
#
|
|
2867
|
-
#
|
|
2868
|
-
#
|
|
2869
|
-
#
|
|
2870
|
-
#
|
|
2871
|
-
#
|
|
2872
|
-
#
|
|
2873
|
-
|
|
5053
|
+
# A single-line field whose {#value} is a `BigDecimal` (or `nil` when
|
|
5054
|
+
# empty) — the numeric field for money, where {FloatField}'s binary double
|
|
5055
|
+
# would round. Give it a single-row {#rect}:
|
|
5056
|
+
#
|
|
5057
|
+
# price = Component::BigDecimalField.new
|
|
5058
|
+
# price.on_value_change = ->(d) { total.value = d } # BigDecimal or nil
|
|
5059
|
+
# price.value = BigDecimal("19.99") # field shows "19.99"
|
|
5060
|
+
# price.value = 19.99 # ArgumentError: a Float can't be exact
|
|
5061
|
+
#
|
|
5062
|
+
# Only `0`–`9`, one leading `-` and one `.` can be typed; any other
|
|
5063
|
+
# printable key is dropped without moving the caret. Up/Down step by one.
|
|
5064
|
+
# Range checks (`min`/`max`) and a display scale (`19.9` → `19.90`) belong
|
|
5065
|
+
# to a forms layer, not here — nothing rounds or pads what you typed.
|
|
5066
|
+
#
|
|
5067
|
+
# Requires the `bigdecimal` gem, which Tuile does *not* depend on: it is a
|
|
5068
|
+
# bundled gem from Ruby 3.4 on, so a `Gemfile` naming it is what puts it on
|
|
5069
|
+
# the load path. Referencing this class without it raises `LoadError`.
|
|
5070
|
+
#
|
|
5071
|
+
# == Implementation details
|
|
5072
|
+
# {#value} is a *derived parse*: the buffer is the single source of truth,
|
|
5073
|
+
# recomputed on read and left exactly as typed (`"19.90"` keeps its zero,
|
|
5074
|
+
# which `BigDecimal#to_s` would not). It reads `nil` for a buffer that
|
|
5075
|
+
# isn't a number (`""`, a lone `"-"`) but `1` / `0.5` for a half-typed
|
|
5076
|
+
# `"1."` / `".5"`, so reaching for the decimal point doesn't blink the
|
|
5077
|
+
# value to `nil` and back through {#on_value_change} — which fires per
|
|
5078
|
+
# keystroke, but only on a real *value* change (`"1.0"`→`"1.00"` is silent,
|
|
5079
|
+
# since the two compare equal).
|
|
5080
|
+
#
|
|
5081
|
+
# Both ends of that round-trip are written here rather than left to the
|
|
5082
|
+
# library, because `bigdecimal` 3.1 (Ruby 3.3's default gem) and 4.x
|
|
5083
|
+
# disagree about them: 3.1 rejects `BigDecimal("1.")` and `BigDecimal(0.1)`
|
|
5084
|
+
# where 4.x accepts both. So the buffer is normalized before parsing, a
|
|
5085
|
+
# `Float` is refused on both, and display goes through `to_s("F")` — plain
|
|
5086
|
+
# notation, never `BigDecimal#to_s`'s `"0.1999e2"`.
|
|
5087
|
+
#
|
|
5088
|
+
# It *composes* a {TextField} (its single {HasContent} child) rather than
|
|
5089
|
+
# subclassing one, so its face carries only the typed {HasValue} seam,
|
|
5090
|
+
# never the widget's `String`-typed `text`.
|
|
5091
|
+
#
|
|
5092
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
5093
|
+
class BigDecimalField < Component
|
|
5094
|
+
include Tuile::Component::HasContent
|
|
5095
|
+
include Tuile::Component::HasValue
|
|
5096
|
+
NUMERIC: Regexp
|
|
5097
|
+
|
|
2874
5098
|
def initialize: () -> void
|
|
2875
5099
|
|
|
2876
|
-
|
|
5100
|
+
# _@return_ — the parsed buffer; `nil` when empty or not a
|
|
5101
|
+
# number (e.g. a lone `"-"`).
|
|
5102
|
+
def value: () -> ::BigDecimal?
|
|
2877
5103
|
|
|
2878
|
-
#
|
|
2879
|
-
|
|
5104
|
+
# Writes `new_value` into the buffer in plain notation and parks the
|
|
5105
|
+
# caret at its end; fires {#on_value_change} only if the value actually
|
|
5106
|
+
# changed.
|
|
5107
|
+
#
|
|
5108
|
+
# _@param_ `new_value` — `nil` empties the field. A `Float` is refused, not converted — see the raise.
|
|
5109
|
+
def value=: ((::BigDecimal | Integer | String)? new_value) -> void
|
|
2880
5110
|
|
|
2881
|
-
|
|
5111
|
+
# `nil`, not `""`: a numeric field with no parseable number is empty.
|
|
5112
|
+
def empty_value: () -> void
|
|
2882
5113
|
|
|
2883
|
-
#
|
|
2884
|
-
#
|
|
5114
|
+
# _@return_ — the field's caret (the hardware cursor is delegated
|
|
5115
|
+
# to the inner field).
|
|
5116
|
+
def cursor_position: () -> Point?
|
|
5117
|
+
|
|
5118
|
+
# Fired when ENTER is pressed in the field; see {TextField#on_enter}.
|
|
2885
5119
|
#
|
|
2886
|
-
# _@
|
|
2887
|
-
def
|
|
5120
|
+
# _@return_ — no-arg callable, or nil.
|
|
5121
|
+
def on_enter: () -> (Proc | Method)?
|
|
2888
5122
|
|
|
2889
|
-
# _@param_ `
|
|
2890
|
-
def
|
|
5123
|
+
# _@param_ `callback`
|
|
5124
|
+
def on_enter=: ((Proc | Method)? callback) -> void
|
|
2891
5125
|
|
|
2892
|
-
|
|
5126
|
+
# Places the wrapped field across the whole rect ({HasContent} hook).
|
|
5127
|
+
#
|
|
5128
|
+
# _@param_ `field`
|
|
5129
|
+
def layout: (Component field) -> void
|
|
2893
5130
|
|
|
2894
|
-
#
|
|
2895
|
-
|
|
5131
|
+
# Rewrites the half-typed shapes {NUMERIC} admits into ones every
|
|
5132
|
+
# `bigdecimal` version parses: `".5"` → `"0.5"`, `"1."` → `"1"`.
|
|
5133
|
+
#
|
|
5134
|
+
# _@param_ `text` — a buffer matching {NUMERIC}.
|
|
5135
|
+
def normalize: (String text) -> String
|
|
2896
5136
|
|
|
2897
|
-
# _@param_ `
|
|
2898
|
-
def
|
|
5137
|
+
# _@param_ `new_value`
|
|
5138
|
+
def coerce: ((::BigDecimal | Integer | String) new_value) -> ::BigDecimal
|
|
2899
5139
|
|
|
2900
|
-
#
|
|
2901
|
-
#
|
|
2902
|
-
#
|
|
2903
|
-
# since `k` is a printable character inserted into {#text}.
|
|
5140
|
+
# The field's key interceptor, consulted *before* the field acts on the
|
|
5141
|
+
# key — which is what lets a rejected character be swallowed without the
|
|
5142
|
+
# caret ever moving.
|
|
2904
5143
|
#
|
|
2905
|
-
# _@
|
|
2906
|
-
|
|
5144
|
+
# _@param_ `key`
|
|
5145
|
+
#
|
|
5146
|
+
# _@return_ — true to consume the key.
|
|
5147
|
+
def field_key: (String key) -> bool
|
|
2907
5148
|
|
|
2908
|
-
#
|
|
2909
|
-
#
|
|
2910
|
-
# parent (default behavior). Only triggered by {Keys::DOWN_ARROW}, not by
|
|
2911
|
-
# `j`, since `j` is a printable character inserted into {#text}.
|
|
5149
|
+
# Nudges {#value} by `delta`, treating an empty/un-parseable field as
|
|
5150
|
+
# zero.
|
|
2912
5151
|
#
|
|
2913
|
-
# _@
|
|
2914
|
-
|
|
5152
|
+
# _@param_ `delta`
|
|
5153
|
+
def step: (Integer delta) -> void
|
|
2915
5154
|
|
|
2916
|
-
#
|
|
2917
|
-
#
|
|
2918
|
-
#
|
|
5155
|
+
# Whether `char` may be inserted. Deliberately shallow: it keeps the
|
|
5156
|
+
# buffer *typeable* rather than always-valid — a transient `"-"` or
|
|
5157
|
+
# `"1."` has to be reachable — and {#value} decides what parses.
|
|
2919
5158
|
#
|
|
2920
|
-
# _@
|
|
2921
|
-
|
|
5159
|
+
# _@param_ `char` — a single printable character.
|
|
5160
|
+
def accepts?: (String char) -> bool
|
|
5161
|
+
|
|
5162
|
+
# Re-emits {#on_value_change} with the freshly-parsed {#value}, but only
|
|
5163
|
+
# when it differs from the last one fired — so a buffer edit that leaves
|
|
5164
|
+
# the value unchanged (`"1.0"`→`"1.00"`) stays silent.
|
|
5165
|
+
def fire_if_changed: () -> void
|
|
5166
|
+
|
|
5167
|
+
# _@return_ — true iff {#value} equals {#empty_value}.
|
|
5168
|
+
def empty?: () -> bool
|
|
5169
|
+
|
|
5170
|
+
# Resets {#value} to {#empty_value}.
|
|
5171
|
+
def clear: () -> void
|
|
5172
|
+
|
|
5173
|
+
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
5174
|
+
# a read-only display field could override back to `false`. Only
|
|
5175
|
+
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
5176
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
5177
|
+
# `D-integer-field`).
|
|
5178
|
+
def focusable?: () -> bool
|
|
5179
|
+
|
|
5180
|
+
# _@param_ `event`
|
|
5181
|
+
def handle_mouse: (MouseEvent event) -> void
|
|
5182
|
+
|
|
5183
|
+
# _@param_ `rect`
|
|
5184
|
+
def rect=: (Rect rect) -> void
|
|
5185
|
+
|
|
5186
|
+
def on_focus: () -> void
|
|
2922
5187
|
end
|
|
2923
5188
|
|
|
2924
|
-
# Abstract base for editable text components
|
|
5189
|
+
# Abstract base for the **String-valued** editable text components
|
|
5190
|
+
# ({TextField}, {TextArea}): a field whose {HasValue#value} *is* its text.
|
|
5191
|
+
# A field whose value is a different type (an `Integer`, a domain object)
|
|
5192
|
+
# *composes* one of these rather than subclassing it — subclassing would
|
|
5193
|
+
# drag this String-typed `text`/`value` seam onto its face alongside the
|
|
5194
|
+
# real typed one.
|
|
2925
5195
|
#
|
|
2926
5196
|
# Holds the shared state — a mutable {#text} buffer, a {#caret} index,
|
|
2927
5197
|
# {#on_change} and {#on_escape} callbacks — and the keyboard machinery
|
|
2928
5198
|
# that single-line and multi-line inputs both need: ESC handling,
|
|
2929
5199
|
# LEFT/RIGHT caret movement, CTRL+LEFT/CTRL+RIGHT word jumps, and the
|
|
2930
|
-
# `
|
|
5200
|
+
# `tab_stop?` flag (`focusable?` comes from {HasValue}).
|
|
5201
|
+
#
|
|
5202
|
+
# {#caret} counts *characters* into {#text} but may only sit *between*
|
|
5203
|
+
# grapheme clusters — the glyphs a terminal draws. Both write sites snap it
|
|
5204
|
+
# forward onto the enclosing cluster's end, and every edit steps by a whole
|
|
5205
|
+
# cluster:
|
|
5206
|
+
#
|
|
5207
|
+
# f.text = "e\u{0301}x" # a decomposed e-acute then "x": 3 chars, 2 glyphs
|
|
5208
|
+
# f.caret = 1 # into the middle of the e-acute …
|
|
5209
|
+
# f.caret # => 2, its end — where the caret already drew
|
|
5210
|
+
# f.handle_key(Keys::BACKSPACE)
|
|
5211
|
+
# f.text # => "x": the whole glyph went, not its accent
|
|
5212
|
+
#
|
|
5213
|
+
# Insertion stays character-native, so `String#insert` merges a typed
|
|
5214
|
+
# combining mark into its base; {#text=}'s snap covers the case where that
|
|
5215
|
+
# re-segments the text around the caret.
|
|
2931
5216
|
#
|
|
2932
5217
|
# Subclasses implement the layout-specific pieces ({#cursor_position},
|
|
2933
5218
|
# {#repaint}) and add their own keys (HOME/END, ENTER, UP/DOWN,
|
|
@@ -2944,13 +5229,22 @@ module Tuile
|
|
|
2944
5229
|
# - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
|
|
2945
5230
|
# effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
|
|
2946
5231
|
# keep the caret visible).
|
|
2947
|
-
class
|
|
5232
|
+
class AbstractStringField < Component
|
|
5233
|
+
include Tuile::Component::HasValue
|
|
5234
|
+
|
|
2948
5235
|
def initialize: () -> void
|
|
2949
5236
|
|
|
2950
|
-
#
|
|
2951
|
-
|
|
5237
|
+
# A text component's value *is* its text: {#value}/{#value=} are the
|
|
5238
|
+
# {HasValue} seam over the same buffer as {#text}/{#text=}, so a form can
|
|
5239
|
+
# drive it alongside typed fields. `text` stays the text-native name.
|
|
5240
|
+
def value: () -> String
|
|
2952
5241
|
|
|
2953
|
-
|
|
5242
|
+
# sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS
|
|
5243
|
+
# _@param_ `new_value`
|
|
5244
|
+
def value=: ((String | _ToS) new_value) -> void
|
|
5245
|
+
|
|
5246
|
+
# `""` (not `nil`): a text field is empty when its buffer is blank.
|
|
5247
|
+
def empty_value: () -> String
|
|
2954
5248
|
|
|
2955
5249
|
def tab_stop?: () -> bool
|
|
2956
5250
|
|
|
@@ -2981,6 +5275,17 @@ module Tuile
|
|
|
2981
5275
|
# _@return_ — possibly transformed text.
|
|
2982
5276
|
def preprocess_text: (String new_text) -> String
|
|
2983
5277
|
|
|
5278
|
+
# The one measurement primitive both inputs share: a caret index counts
|
|
5279
|
+
# characters, but every rect, cursor and click counts columns, and only
|
|
5280
|
+
# this converts between them.
|
|
5281
|
+
#
|
|
5282
|
+
# _@param_ `str`
|
|
5283
|
+
#
|
|
5284
|
+
# _@return_ — `str`'s width in terminal columns, measured per
|
|
5285
|
+
# grapheme cluster — so a combining mark adds nothing and a fullwidth
|
|
5286
|
+
# glyph adds two.
|
|
5287
|
+
def columns_of: (String str) -> Integer
|
|
5288
|
+
|
|
2984
5289
|
# Hook called after {#text} has been mutated, before invalidation /
|
|
2985
5290
|
# {#on_change}. Default no-op. Subclasses use this to invalidate caches
|
|
2986
5291
|
# ({TextArea}'s wrap cache) and update derived state.
|
|
@@ -2993,7 +5298,8 @@ module Tuile
|
|
|
2993
5298
|
|
|
2994
5299
|
# Dispatch hook for {#handle_key}. Handles ESC and the navigation keys
|
|
2995
5300
|
# that have identical semantics in single-line and multi-line inputs:
|
|
2996
|
-
# LEFT/RIGHT arrows,
|
|
5301
|
+
# LEFT/RIGHT arrows (one grapheme cluster per press, so a press always
|
|
5302
|
+
# moves), CTRL+LEFT/CTRL+RIGHT for word jumps. Subclasses
|
|
2997
5303
|
# override to add their own keys (HOME/END, UP/DOWN, ENTER, BACKSPACE/
|
|
2998
5304
|
# DELETE, printable insertion) and call `super` to fall back to the
|
|
2999
5305
|
# common navigation handling.
|
|
@@ -3003,10 +5309,31 @@ module Tuile
|
|
|
3003
5309
|
# _@return_ — true if the key was handled.
|
|
3004
5310
|
def handle_text_input_key: (String key) -> bool
|
|
3005
5311
|
|
|
5312
|
+
# Removes the whole grapheme cluster before the caret — one press, one
|
|
5313
|
+
# glyph, whatever it is built from (a ZWJ emoji family and a three-jamo
|
|
5314
|
+
# Hangul syllable each go whole).
|
|
3006
5315
|
def delete_before_caret: () -> void
|
|
3007
5316
|
|
|
5317
|
+
# Removes the whole grapheme cluster at the caret.
|
|
3008
5318
|
def delete_at_caret: () -> void
|
|
3009
5319
|
|
|
5320
|
+
# _@param_ `index` — a {#text} index in `0..text.length`.
|
|
5321
|
+
#
|
|
5322
|
+
# _@return_ — the smallest grapheme-cluster boundary `>= index`.
|
|
5323
|
+
def snap_to_cluster: (Integer index) -> Integer
|
|
5324
|
+
|
|
5325
|
+
# _@param_ `index`
|
|
5326
|
+
#
|
|
5327
|
+
# _@return_ — the greatest grapheme-cluster boundary `< index`, or
|
|
5328
|
+
# 0 at the start of the text.
|
|
5329
|
+
def cluster_boundary_before: (Integer index) -> Integer
|
|
5330
|
+
|
|
5331
|
+
# _@param_ `index`
|
|
5332
|
+
#
|
|
5333
|
+
# _@return_ — the smallest grapheme-cluster boundary `> index`, or
|
|
5334
|
+
# `text.length` at the end of the text.
|
|
5335
|
+
def cluster_boundary_after: (Integer index) -> Integer
|
|
5336
|
+
|
|
3010
5337
|
# Default {#on_escape} action: clear focus. Component deactivates; user
|
|
3011
5338
|
# can re-focus by clicking or tabbing back in.
|
|
3012
5339
|
def default_on_escape: () -> void
|
|
@@ -3021,10 +5348,24 @@ module Tuile
|
|
|
3021
5348
|
# end of the text if no further word exists.
|
|
3022
5349
|
def word_right: () -> Integer
|
|
3023
5350
|
|
|
5351
|
+
# _@return_ — true iff {#value} equals {#empty_value}.
|
|
5352
|
+
def empty?: () -> bool
|
|
5353
|
+
|
|
5354
|
+
# Resets {#value} to {#empty_value}.
|
|
5355
|
+
def clear: () -> void
|
|
5356
|
+
|
|
5357
|
+
# Input fields are focusable by default (overrides {Component#focusable?});
|
|
5358
|
+
# a read-only display field could override back to `false`. Only
|
|
5359
|
+
# `focusable?` lives here — `tab_stop?` diverges between leaf fields and
|
|
5360
|
+
# composing wrappers, so it stays per-class (`DECISIONS.md`
|
|
5361
|
+
# `D-integer-field`).
|
|
5362
|
+
def focusable?: () -> bool
|
|
5363
|
+
|
|
3024
5364
|
# _@return_ — current text contents.
|
|
3025
5365
|
attr_accessor text: String
|
|
3026
5366
|
|
|
3027
|
-
# _@return_ — caret index in `0..text.length
|
|
5367
|
+
# _@return_ — caret index in `0..text.length`, counting characters
|
|
5368
|
+
# and always on a grapheme-cluster boundary (see the class doc).
|
|
3028
5369
|
attr_accessor caret: Integer
|
|
3029
5370
|
|
|
3030
5371
|
# Optional callback fired whenever {#text} changes. Receives the new text
|
|
@@ -3058,103 +5399,6 @@ module Tuile
|
|
|
3058
5399
|
# _@return_ — no-arg callable, or nil.
|
|
3059
5400
|
attr_accessor on_escape: (Proc | Method)?
|
|
3060
5401
|
end
|
|
3061
|
-
|
|
3062
|
-
# A mixin interface for a component with one child tops. The host must
|
|
3063
|
-
# provide a protected `layout(content)` method which repositions the
|
|
3064
|
-
# content component; the mixin manages `@content` itself.
|
|
3065
|
-
module HasContent
|
|
3066
|
-
# _@param_ `event`
|
|
3067
|
-
def handle_mouse: (MouseEvent event) -> void
|
|
3068
|
-
|
|
3069
|
-
def children: () -> ::Array[Component]
|
|
3070
|
-
|
|
3071
|
-
# _@param_ `rect`
|
|
3072
|
-
def rect=: (Rect rect) -> void
|
|
3073
|
-
|
|
3074
|
-
def on_focus: () -> void
|
|
3075
|
-
|
|
3076
|
-
# _@return_ — the current content component.
|
|
3077
|
-
attr_accessor content: Component?
|
|
3078
|
-
end
|
|
3079
|
-
|
|
3080
|
-
# A {Window} preconfigured with a {List} of static lines. Useful for
|
|
3081
|
-
# showing read-only information.
|
|
3082
|
-
#
|
|
3083
|
-
# Usable tiled (just add to a {Layout}) or as a popup via {.open}, which
|
|
3084
|
-
# wraps it in a {Popup}.
|
|
3085
|
-
class InfoWindow < Tuile::Component::Window
|
|
3086
|
-
# _@param_ `caption`
|
|
3087
|
-
#
|
|
3088
|
-
# _@param_ `lines` — initial content; each entry may contain Rainbow formatting.
|
|
3089
|
-
def initialize: (?String caption, ?::Array[String] lines) -> void
|
|
3090
|
-
|
|
3091
|
-
# Opens the info window as a popup.
|
|
3092
|
-
#
|
|
3093
|
-
# _@param_ `caption`
|
|
3094
|
-
#
|
|
3095
|
-
# _@param_ `lines` — the content, may contain formatting.
|
|
3096
|
-
#
|
|
3097
|
-
# _@param_ `size` — the popup's size, applied top-down; the list wraps and scrolls within it. Defaults to {Fraction::HALF}.
|
|
3098
|
-
#
|
|
3099
|
-
# _@return_ — the opened popup.
|
|
3100
|
-
def self.open: (String caption, ::Array[String] lines, ?size: (Size | Fraction)) -> Popup
|
|
3101
|
-
end
|
|
3102
|
-
|
|
3103
|
-
# A {Window} that lists options identified by single keyboard keys, asks
|
|
3104
|
-
# the user to pick one, and fires a callback with the picked key.
|
|
3105
|
-
#
|
|
3106
|
-
# Usable tiled (just add to a {Layout} and read picks via the block) or
|
|
3107
|
-
# as a popup via {.open}, which wraps it in a {Popup} that closes itself
|
|
3108
|
-
# after a pick. ESC / `q` close without firing the callback.
|
|
3109
|
-
class PickerWindow < Tuile::Component::Window
|
|
3110
|
-
MAX_ITEMS: Integer
|
|
3111
|
-
|
|
3112
|
-
# _@param_ `caption` — the window caption.
|
|
3113
|
-
#
|
|
3114
|
-
# _@param_ `options` — pairs of keyboard key and option caption. No Rainbow formatting must be used.
|
|
3115
|
-
def initialize: (String caption, ::Array[[String, String]] options) ?{ (String key) -> void } -> void
|
|
3116
|
-
|
|
3117
|
-
# Handles an option-key press. Reached by bubbling: the inner {List}
|
|
3118
|
-
# (the focused component) sees the key first and handles cursor/Enter
|
|
3119
|
-
# picks; anything it declines bubbles up here, where a key matching an
|
|
3120
|
-
# option's `key` picks that option.
|
|
3121
|
-
#
|
|
3122
|
-
# _@param_ `key`
|
|
3123
|
-
def handle_key: (String key) -> bool
|
|
3124
|
-
|
|
3125
|
-
def keyboard_hint: () -> String
|
|
3126
|
-
|
|
3127
|
-
# Opens a picker as a popup. Picking an option fires `block`, then
|
|
3128
|
-
# closes the popup; ESC / `q` close without firing `block`.
|
|
3129
|
-
#
|
|
3130
|
-
# _@param_ `caption`
|
|
3131
|
-
#
|
|
3132
|
-
# _@param_ `options`
|
|
3133
|
-
#
|
|
3134
|
-
# _@return_ — the wrapping popup.
|
|
3135
|
-
def self.open: (String caption, ::Array[[String, String]] options) ?{ (String key) -> void } -> Popup
|
|
3136
|
-
|
|
3137
|
-
# _@param_ `key`
|
|
3138
|
-
def select_option: (String key) -> void
|
|
3139
|
-
|
|
3140
|
-
# Callback invoked after the user picks an option (after the block
|
|
3141
|
-
# fires). The {Popup} returned by {.open} sets this to its own `close`.
|
|
3142
|
-
attr_accessor on_pick: Proc?
|
|
3143
|
-
|
|
3144
|
-
# One picker option.
|
|
3145
|
-
#
|
|
3146
|
-
# @!attribute [r] key
|
|
3147
|
-
# @return [String] the keyboard key that picks this option.
|
|
3148
|
-
# @!attribute [r] caption
|
|
3149
|
-
# @return [String] the option caption.
|
|
3150
|
-
class Option
|
|
3151
|
-
# _@return_ — the keyboard key that picks this option.
|
|
3152
|
-
attr_reader key: String
|
|
3153
|
-
|
|
3154
|
-
# _@return_ — the option caption.
|
|
3155
|
-
attr_reader caption: String
|
|
3156
|
-
end
|
|
3157
|
-
end
|
|
3158
5402
|
end
|
|
3159
5403
|
|
|
3160
5404
|
# An app's theme definition: the {Theme} pair covering both terminal
|
|
@@ -3251,24 +5495,18 @@ module Tuile
|
|
|
3251
5495
|
def await_empty: () -> void
|
|
3252
5496
|
|
|
3253
5497
|
# Schedules `block` to fire on the event-loop thread every `seconds`,
|
|
3254
|
-
# passing a 0-based monotonically increasing tick counter
|
|
3255
|
-
#
|
|
3256
|
-
#
|
|
3257
|
-
#
|
|
3258
|
-
# (poll a status, redraw a clock). For animation, where frames-per-second
|
|
3259
|
-
# is the natural unit, {#tick_fps} reads better.
|
|
3260
|
-
#
|
|
3261
|
-
# The returned {Ticker} controls the schedule — call {Ticker#cancel} to
|
|
3262
|
-
# stop it.
|
|
5498
|
+
# passing a 0-based monotonically increasing tick counter — `tick(0.2)`
|
|
5499
|
+
# fires five times a second. Use it for periodic UI refresh (poll a status,
|
|
5500
|
+
# redraw a clock); for animation, {#tick_fps} reads more naturally. The
|
|
5501
|
+
# returned {Ticker} controls the schedule — {Ticker#cancel} stops it.
|
|
3263
5502
|
#
|
|
3264
5503
|
# **Errors:** if `block` raises, the {Ticker} cancels itself and the
|
|
3265
|
-
# exception flows through the normal event-loop error path
|
|
3266
|
-
# {Screen#on_error}
|
|
3267
|
-
#
|
|
5504
|
+
# exception flows through the normal event-loop error path
|
|
5505
|
+
# ({Screen#on_error} by default) — auto-cancel keeps a broken block from
|
|
5506
|
+
# spamming `on_error` at the tick rate.
|
|
3268
5507
|
#
|
|
3269
|
-
# Tickers reuse `concurrent-ruby`'s shared timer thread
|
|
3270
|
-
#
|
|
3271
|
-
# threads, just more work on the shared scheduler.
|
|
5508
|
+
# Tickers reuse `concurrent-ruby`'s shared timer thread, so adding more
|
|
5509
|
+
# tickers doesn't add threads.
|
|
3272
5510
|
#
|
|
3273
5511
|
# _@param_ `seconds` — interval between firings, must be positive. Fractional values are fine (`tick(0.05)` ⇒ ~20 firings a second).
|
|
3274
5512
|
def tick: (Numeric seconds) ?{ (Integer tick) -> void } -> Ticker
|
|
@@ -3295,8 +5533,11 @@ module Tuile
|
|
|
3295
5533
|
# event-handler error, instead of bypassing it.
|
|
3296
5534
|
def run_loop: () ?{ (Object event) -> void } -> void
|
|
3297
5535
|
|
|
3298
|
-
# _@return_ — true if
|
|
3299
|
-
def
|
|
5536
|
+
# _@return_ — true if a {#run_loop} is in progress on *any* thread.
|
|
5537
|
+
def running?: () -> bool
|
|
5538
|
+
|
|
5539
|
+
# _@return_ — true if this thread is the one running {#run_loop}.
|
|
5540
|
+
def on_loop_thread?: () -> bool
|
|
3300
5541
|
|
|
3301
5542
|
# Stops ongoing {#run_loop}. The stop may not be immediate: {#run_loop} may
|
|
3302
5543
|
# process a bunch of events before terminating.
|
|
@@ -3418,8 +5659,10 @@ module Tuile
|
|
|
3418
5659
|
end
|
|
3419
5660
|
end
|
|
3420
5661
|
|
|
3421
|
-
# Testing only — a screen which doesn't paint anything
|
|
3422
|
-
#
|
|
5662
|
+
# Testing only — a screen which doesn't paint anything, so the TTY running
|
|
5663
|
+
# the tests is not painted over. It runs no event loop, so
|
|
5664
|
+
# {Screen#check_locked} admits the thread that called {Screen.fake}: a spec
|
|
5665
|
+
# mutating the UI from a *spawned* thread raises, exactly as an app would.
|
|
3423
5666
|
#
|
|
3424
5667
|
# Intended for unit-testing individual components: instantiate a component,
|
|
3425
5668
|
# mutate it, and assert against {#prints} or {#invalidated?}. It does not
|
|
@@ -3438,9 +5681,9 @@ module Tuile
|
|
|
3438
5681
|
# assert_includes Screen.instance.prints.join, "hi"
|
|
3439
5682
|
# end
|
|
3440
5683
|
class FakeScreen < Tuile::Screen
|
|
3441
|
-
|
|
5684
|
+
EDITING_KEYS: ::Array[String]
|
|
3442
5685
|
|
|
3443
|
-
def
|
|
5686
|
+
def initialize: () -> void
|
|
3444
5687
|
|
|
3445
5688
|
def clear: () -> void
|
|
3446
5689
|
|
|
@@ -3543,16 +5786,18 @@ module Tuile
|
|
|
3543
5786
|
|
|
3544
5787
|
def focusable?: () -> bool
|
|
3545
5788
|
|
|
3546
|
-
# Children for tree traversal: content first, popups in stacking order,
|
|
3547
|
-
# status bar last.
|
|
3548
|
-
def children: () -> ::Array[Component]
|
|
3549
|
-
|
|
3550
5789
|
# Adds a popup and invalidates it for repaint. A modal popup is centered
|
|
3551
5790
|
# and grabs focus; a non-modal overlay ({Component::Popup#modal?} false) is
|
|
3552
5791
|
# left wherever the caller positions it and does *not* take focus, so the
|
|
3553
5792
|
# component that was focused keeps the cursor and keeps receiving keys —
|
|
3554
5793
|
# the overlay floats above the content, driven from app code.
|
|
3555
5794
|
#
|
|
5795
|
+
# The *whole subtree* is invalidated, not just the popup wrapper (which
|
|
5796
|
+
# paints nothing on its own): a reopened popup may land on cells that the
|
|
5797
|
+
# tiled content has since overpainted, and if its rect is unchanged from
|
|
5798
|
+
# last time its content components won't re-invalidate themselves — so
|
|
5799
|
+
# without this the popup's contents would stay blank on reopen.
|
|
5800
|
+
#
|
|
3556
5801
|
# _@param_ `window`
|
|
3557
5802
|
def add_popup: (Component::Popup window) -> void
|
|
3558
5803
|
|
|
@@ -3563,6 +5808,17 @@ module Tuile
|
|
|
3563
5808
|
# _@param_ `window`
|
|
3564
5809
|
def remove_popup: (Component window) -> void
|
|
3565
5810
|
|
|
5811
|
+
# Unmounts everything: each child is detached — firing {Component#on_detached}
|
|
5812
|
+
# down its subtree — and every slot is emptied. Terminal; the pane isn't
|
|
5813
|
+
# reusable afterwards, and {Screen#close} is its only caller.
|
|
5814
|
+
#
|
|
5815
|
+
# Deliberately not named `close` ({Component::Popup#close} already means
|
|
5816
|
+
# "remove *me* from the pane"), and deliberately not a generic
|
|
5817
|
+
# `Component#remove_all_children`: a slot container calling that would empty
|
|
5818
|
+
# `@children` while `#content` / `#footer` still pointed at detached
|
|
5819
|
+
# components, which is the desync the tree API exists to prevent.
|
|
5820
|
+
def detach_all: () -> void
|
|
5821
|
+
|
|
3566
5822
|
# _@param_ `window`
|
|
3567
5823
|
#
|
|
3568
5824
|
# _@return_ — true if this pane currently hosts the popup.
|
|
@@ -3590,20 +5846,21 @@ module Tuile
|
|
|
3590
5846
|
# Pane paints nothing itself; its children paint over the entire rect.
|
|
3591
5847
|
def repaint: () -> void
|
|
3592
5848
|
|
|
3593
|
-
#
|
|
3594
|
-
#
|
|
3595
|
-
#
|
|
3596
|
-
#
|
|
3597
|
-
#
|
|
3598
|
-
#
|
|
3599
|
-
#
|
|
3600
|
-
#
|
|
3601
|
-
#
|
|
3602
|
-
#
|
|
3603
|
-
#
|
|
3604
|
-
#
|
|
3605
|
-
#
|
|
3606
|
-
#
|
|
5849
|
+
# Delivers a key to {Screen#focused}, then bubbles it up the focus chain —
|
|
5850
|
+
# the first component whose `handle_key` returns true wins.
|
|
5851
|
+
#
|
|
5852
|
+
# Bubbling stops at the *scope* root: the topmost *modal* popup when one is
|
|
5853
|
+
# open, else the tiled {#content}. Focus that is nil or sits outside the
|
|
5854
|
+
# scope receives nothing, which is what keeps an open modal popup modal.
|
|
5855
|
+
# Non-modal overlays are never the scope: focus stays in the content
|
|
5856
|
+
# beneath them, and the overlay is driven by app code (which forwards keys
|
|
5857
|
+
# to it explicitly), so it doesn't appear in this path at all.
|
|
5858
|
+
#
|
|
5859
|
+
# Because an ancestor sees a key only after every descendant on the chain
|
|
5860
|
+
# declined it, the scope root is the natural home for scope-wide fallbacks
|
|
5861
|
+
# — a form's default button, or a layout's one-key jumps to its panes (a
|
|
5862
|
+
# focused {Component::TextField} consumes the key first, so typing is never
|
|
5863
|
+
# hijacked).
|
|
3607
5864
|
#
|
|
3608
5865
|
# _@param_ `key`
|
|
3609
5866
|
#
|
|
@@ -3669,14 +5926,11 @@ module Tuile
|
|
|
3669
5926
|
|
|
3670
5927
|
# An immutable string-with-styling, modeled as a sequence of {Span}s where
|
|
3671
5928
|
# each span carries a complete {Style} (`fg`, `bg`, `bold`, `italic`,
|
|
3672
|
-
# `underline`, `strikethrough`). Spans are non-overlapping and fully tile
|
|
3673
|
-
# character has exactly one resolved style, no overlay
|
|
3674
|
-
#
|
|
3675
|
-
#
|
|
3676
|
-
#
|
|
3677
|
-
# they never have to "figure out what SGR state is active at column N" —
|
|
3678
|
-
# the answer is just the containing span's `style`. The flip side is one
|
|
3679
|
-
# extra type to construct (or parse) before doing styled-text math.
|
|
5929
|
+
# `underline`, `strikethrough`). Spans are non-overlapping and fully tile
|
|
5930
|
+
# the string — every character has exactly one resolved style, no overlay
|
|
5931
|
+
# layers to merge, so the style at any column is just its span's `style`
|
|
5932
|
+
# rather than a replay of the SGR state machine. The book's chapter 9 is
|
|
5933
|
+
# the long-form *why* (spans vs. a `String` full of escape codes).
|
|
3680
5934
|
#
|
|
3681
5935
|
# ## Constructors
|
|
3682
5936
|
#
|
|
@@ -3700,31 +5954,17 @@ module Tuile
|
|
|
3700
5954
|
# ss.each_char_with_style { |ch, style| ... }
|
|
3701
5955
|
# ```
|
|
3702
5956
|
#
|
|
3703
|
-
# ## Rendering
|
|
3704
|
-
#
|
|
3705
|
-
# - `#to_s` — plain text, no SGR.
|
|
3706
|
-
# - `#to_ansi` — minimal-diff SGR rendering, ending with `\e[0m` only when
|
|
3707
|
-
# the last span carried a non-default style. Transitions to the default
|
|
3708
|
-
# style emit `\e[0m` (shorter than re-emitting every off-code).
|
|
3709
|
-
#
|
|
3710
5957
|
# ## Parser
|
|
3711
5958
|
#
|
|
3712
|
-
# {.parse} is strict by default
|
|
3713
|
-
#
|
|
3714
|
-
#
|
|
3715
|
-
#
|
|
3716
|
-
#
|
|
3717
|
-
#
|
|
3718
|
-
#
|
|
3719
|
-
# Pass `lenient: true` to instead **discard** everything the parser can't
|
|
3720
|
-
# model and keep going — recognized fg/bg/bold/italic/underline/strikethrough codes still
|
|
3721
|
-
# apply, and any unmodeled SGR code, malformed extended color, non-SGR CSI
|
|
3722
|
-
# (cursor moves, `\e[K`), OSC/DCS/string sequence, or stray escape is
|
|
3723
|
-
# silently dropped. This is the mode for piping in colored output you don't
|
|
3724
|
-
# control (e.g. `git --color` through a pager): "give me the colors, throw
|
|
3725
|
-
# the rest away." It is lossy by design — `parse(x, lenient: true)` does not
|
|
3726
|
-
# round-trip back to `x`.
|
|
5959
|
+
# {.parse} is strict by default — it recognizes only the SGR codes for
|
|
5960
|
+
# {Style}'s attributes (fg/bg/bold/italic/underline/strikethrough) and
|
|
5961
|
+
# raises {ParseError} on anything else, keeping the `parse(to_ansi(x)) == x`
|
|
5962
|
+
# round-trip honest. Pass `lenient: true` to instead discard everything it
|
|
5963
|
+
# can't model (unmodeled SGR, cursor moves, OSC/DCS, stray escapes) and keep
|
|
5964
|
+
# only the recognized colors — lossy by design, for piping in colored output
|
|
5965
|
+
# you don't control. See the book for the full rationale.
|
|
3727
5966
|
class StyledString
|
|
5967
|
+
EMOJI_WIDTH: Symbol
|
|
3728
5968
|
EMPTY: StyledString
|
|
3729
5969
|
|
|
3730
5970
|
# sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS
|
|
@@ -3752,7 +5992,6 @@ module Tuile
|
|
|
3752
5992
|
|
|
3753
5993
|
# Total display width in terminal columns, accounting for Unicode wide
|
|
3754
5994
|
# characters (fullwidth CJK = 2 columns, combining marks = 0, etc.).
|
|
3755
|
-
# Memoized — safe because spans are frozen and immutable.
|
|
3756
5995
|
def display_width: () -> Integer
|
|
3757
5996
|
|
|
3758
5997
|
def empty?: () -> bool
|
|
@@ -3765,7 +6004,7 @@ module Tuile
|
|
|
3765
6004
|
# emits `\e[0m` (one code) instead of the longer "turn each attribute
|
|
3766
6005
|
# off" form. Always closes with `\e[0m` when the last span carried a
|
|
3767
6006
|
# non-default style, so the styled run doesn't bleed into subsequent
|
|
3768
|
-
# output.
|
|
6007
|
+
# output.
|
|
3769
6008
|
def to_ansi: () -> String
|
|
3770
6009
|
|
|
3771
6010
|
# _@param_ `other`
|
|
@@ -3814,11 +6053,20 @@ module Tuile
|
|
|
3814
6053
|
# wrapped continuations, hard `"\n"` breaks preserved as separate output
|
|
3815
6054
|
# lines.
|
|
3816
6055
|
#
|
|
6056
|
+
# An indent is content, so it survives onto the first row — but there is no
|
|
6057
|
+
# hanging indent:
|
|
6058
|
+
#
|
|
6059
|
+
# StyledString.plain(" read config").wrap(20).map(&:to_s)
|
|
6060
|
+
# # => [" read config"] indent kept; the line never wrapped
|
|
6061
|
+
# StyledString.plain(" read config").wrap(6).map(&:to_s)
|
|
6062
|
+
# # => [" read", "config"] ...but a continuation starts at column 0
|
|
6063
|
+
#
|
|
3817
6064
|
# Whitespace runs are space or tab; other characters are treated as word
|
|
3818
6065
|
# content. When a single character is wider than `width` (e.g. a 2-column
|
|
3819
6066
|
# CJK character with `width = 1`), it is still emitted on its own line at
|
|
3820
6067
|
# its natural width. The "no line exceeds `width`" guarantee therefore
|
|
3821
|
-
# holds whenever every character is at most `width` columns wide.
|
|
6068
|
+
# holds whenever every character is at most `width` columns wide. An indent
|
|
6069
|
+
# that alone exceeds `width` is dropped rather than given a row of its own.
|
|
3822
6070
|
#
|
|
3823
6071
|
# _@param_ `width` — target column width. `nil` or `<= 0` skips wrapping and returns each hard-line as-is, so callers can pass a stale viewport width without crashing.
|
|
3824
6072
|
#
|
|
@@ -3838,6 +6086,15 @@ module Tuile
|
|
|
3838
6086
|
# _@param_ `bg` — background color, coerced via {Color.coerce}. `nil` clears bg back to the terminal default.
|
|
3839
6087
|
def with_bg: ((Color | Symbol | Integer | ::Array[Integer])? bg) -> StyledString
|
|
3840
6088
|
|
|
6089
|
+
# Returns a copy with `bg` set **only on spans that have none**; a span with
|
|
6090
|
+
# an explicit bg is left untouched. The fill-unset counterpart of {#with_bg}
|
|
6091
|
+
# (which overrides every span) — it slides a background *under* the content,
|
|
6092
|
+
# so a log line keeps its red error-level bg while its plain text picks up an
|
|
6093
|
+
# inherited panel tint.
|
|
6094
|
+
#
|
|
6095
|
+
# _@param_ `bg` — background color, coerced via {Color.coerce}. `nil` returns `self` unchanged.
|
|
6096
|
+
def under_bg: ((Color | Symbol | Integer | ::Array[Integer])? bg) -> StyledString
|
|
6097
|
+
|
|
3841
6098
|
# Returns a new {StyledString} with `fg` applied to every span, preserving
|
|
3842
6099
|
# each span's text and other style attributes (`bg`, `bold`, `italic`,
|
|
3843
6100
|
# `underline`, `strikethrough`). The new fg overlays without dropping background colors or
|
|
@@ -3872,23 +6129,40 @@ module Tuile
|
|
|
3872
6129
|
# _@param_ `width`
|
|
3873
6130
|
def wrap_one: (StyledString hard_line, Integer width) -> ::Array[StyledString]
|
|
3874
6131
|
|
|
6132
|
+
# Splits into whitespace/word tokens by **grapheme cluster**, not character:
|
|
6133
|
+
# a cluster is the unit a terminal draws, so measuring its parts separately
|
|
6134
|
+
# would both mis-total an emoji sequence and let a wrap break a letter away
|
|
6135
|
+
# from its combining mark.
|
|
6136
|
+
#
|
|
3875
6137
|
# _@param_ `hard_line`
|
|
3876
6138
|
#
|
|
3877
|
-
# _@return_ — tokens shaped `[type,
|
|
3878
|
-
# `:space` or `:word`, `
|
|
3879
|
-
# (
|
|
6139
|
+
# _@return_ — tokens shaped `[type, glyphs, w]` where `type` is
|
|
6140
|
+
# `:space` or `:word`, `glyphs` is an `Array<[String, Style, Integer]>`
|
|
6141
|
+
# (grapheme cluster, style, display width), and `w` is the token's total
|
|
6142
|
+
# width.
|
|
3880
6143
|
def tokenize_for_wrap: (StyledString hard_line) -> ::Array[::Array[untyped]]
|
|
3881
6144
|
|
|
3882
|
-
#
|
|
6145
|
+
# Like {#each_char_with_style} but per grapheme cluster. A cluster spanning a
|
|
6146
|
+
# style boundary takes the style of its first span — pathological input, and
|
|
6147
|
+
# splitting the cluster to honor both styles would paint a headless mark.
|
|
6148
|
+
#
|
|
6149
|
+
# _@param_ `styled`
|
|
6150
|
+
def each_glyph_with_style: (StyledString styled) ?{ (String glyph, Style style) -> void } -> void
|
|
6151
|
+
|
|
6152
|
+
# _@param_ `glyphs` — `[grapheme cluster, style, width]` triples.
|
|
3883
6153
|
#
|
|
3884
6154
|
# _@param_ `width`
|
|
3885
6155
|
#
|
|
3886
|
-
# _@return_ — each inner Array is a `
|
|
3887
|
-
def
|
|
6156
|
+
# _@return_ — each inner Array is a `glyphs`-shaped chunk.
|
|
6157
|
+
def hard_break_glyphs: (::Array[::Array[untyped]] glyphs, Integer width) -> ::Array[::Array[::Array[untyped]]]
|
|
3888
6158
|
|
|
3889
|
-
# _@param_ `
|
|
3890
|
-
def
|
|
6159
|
+
# _@param_ `glyphs` — `[grapheme cluster, style, width]` triples.
|
|
6160
|
+
def glyphs_to_styled: (::Array[::Array[untyped]] glyphs) -> StyledString
|
|
3891
6161
|
|
|
6162
|
+
# Walks **grapheme clusters**, so a slice boundary can never fall inside one:
|
|
6163
|
+
# cutting a cluster would strand a combining mark with no base, which the
|
|
6164
|
+
# painter drops outright, silently losing the accent off a letter.
|
|
6165
|
+
#
|
|
3892
6166
|
# _@param_ `text`
|
|
3893
6167
|
#
|
|
3894
6168
|
# _@param_ `start_col`
|
|
@@ -3958,10 +6232,6 @@ module Tuile
|
|
|
3958
6232
|
# (`\e[0m`, one code) when `other` is the default style — shorter than
|
|
3959
6233
|
# turning each attribute off individually.
|
|
3960
6234
|
#
|
|
3961
|
-
# Shared by {StyledString#to_ansi} (diffing span-to-span from the default
|
|
3962
|
-
# style) and {Buffer}'s flush (diffing cell-to-cell against the style the
|
|
3963
|
-
# terminal currently holds), so both emit identical minimal sequences.
|
|
3964
|
-
#
|
|
3965
6235
|
# _@param_ `other` — the style to transition to.
|
|
3966
6236
|
def sgr_to: (Style other) -> String
|
|
3967
6237
|
|
|
@@ -4074,7 +6344,11 @@ module Tuile
|
|
|
4074
6344
|
class FakeEventQueue
|
|
4075
6345
|
def initialize: () -> void
|
|
4076
6346
|
|
|
4077
|
-
|
|
6347
|
+
# _@return_ — always false — {#run_loop} raises, so no loop ever runs.
|
|
6348
|
+
def running?: () -> bool
|
|
6349
|
+
|
|
6350
|
+
# _@return_ — always true.
|
|
6351
|
+
def on_loop_thread?: () -> bool
|
|
4078
6352
|
|
|
4079
6353
|
def stop: () -> void
|
|
4080
6354
|
|
|
@@ -4107,6 +6381,14 @@ module Tuile
|
|
|
4107
6381
|
# tests pump N frames by calling this N times.
|
|
4108
6382
|
def tick_once: () -> void
|
|
4109
6383
|
|
|
6384
|
+
# Lets a spec assert that a component started a ticker, and — via
|
|
6385
|
+
# {FakeTicker#cancelled?} — that it cancelled one rather than merely
|
|
6386
|
+
# dropping it. Cancelled tickers stay here until the next {#tick_once}
|
|
6387
|
+
# prunes them.
|
|
6388
|
+
#
|
|
6389
|
+
# _@return_ — the registered tickers, in creation order.
|
|
6390
|
+
attr_reader tickers: ::Array[FakeTicker]
|
|
6391
|
+
|
|
4110
6392
|
# Handle returned by {FakeEventQueue#tick}. Mirrors the public surface of
|
|
4111
6393
|
# {EventQueue::Ticker} (`cancel`, `cancelled?`) but does not auto-fire —
|
|
4112
6394
|
# the host {FakeEventQueue} drives firing via {FakeEventQueue#tick_once}.
|