tuile 0.8.0 → 0.10.0

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