tuile 0.9.0 → 0.11.0

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