tuile 0.2.0 → 0.4.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.
data/sig/tuile.rbs CHANGED
@@ -16,6 +16,14 @@ module Tuile
16
16
  class Error < StandardError
17
17
  end
18
18
 
19
+ # ANSI escape sequence constants. Tuile emits colors and text attributes
20
+ # via Rainbow, which produces **SGR** sequences ("Select Graphic
21
+ # Rendition", `ESC [ <params> m` — e.g. `\e[31m` red, `\e[1m` bold,
22
+ # `\e[0m` reset).
23
+ module Ansi
24
+ RESET: String
25
+ end
26
+
19
27
  # Constants for keys returned by {.getkey} and helpers for reading them from
20
28
  # stdin. The constants are the raw escape sequences emitted by the terminal;
21
29
  # see https://en.wikipedia.org/wiki/ANSI_escape_code for the encoding.
@@ -37,14 +45,51 @@ module Tuile
37
45
  PAGE_DOWN: String
38
46
  BACKSPACE: String
39
47
  DELETE: String
48
+ CTRL_A: String
49
+ CTRL_B: String
50
+ CTRL_C: String
51
+ CTRL_D: String
52
+ CTRL_E: String
53
+ CTRL_F: String
54
+ CTRL_G: String
40
55
  CTRL_H: String
41
- BACKSPACES: ::Array[String]
56
+ CTRL_I: String
57
+ CTRL_J: String
58
+ CTRL_K: String
59
+ CTRL_L: String
60
+ CTRL_M: String
61
+ CTRL_N: String
62
+ CTRL_O: String
63
+ CTRL_P: String
64
+ CTRL_Q: String
65
+ CTRL_R: String
66
+ CTRL_S: String
67
+ CTRL_T: String
42
68
  CTRL_U: String
43
- CTRL_D: String
69
+ CTRL_V: String
70
+ CTRL_W: String
71
+ CTRL_X: String
72
+ CTRL_Y: String
73
+ CTRL_Z: String
74
+ BACKSPACES: ::Array[String]
44
75
  ENTER: String
45
76
  TAB: String
46
77
  SHIFT_TAB: String
47
78
 
79
+ # True iff `key` is a single printable character — a one-character string
80
+ # whose codepoint is not in Unicode's C (Other) category. Rejects multi-
81
+ # character escape sequences ({UP_ARROW}, mouse events, …), control bytes
82
+ # ({TAB}, {ENTER}, {ESC}, {CTRL_A}..{CTRL_Z}, {BACKSPACE}), and the empty
83
+ # string; accepts ASCII letters/digits/punctuation/space *and* non-ASCII
84
+ # printables like "é".
85
+ #
86
+ # Used by {Screen#register_global_shortcut} to reject keys that would
87
+ # collide with typing, and by {Tuile::Component::TextField} to decide
88
+ # whether to insert a key at the caret.
89
+ #
90
+ # _@param_ `key`
91
+ def self.printable?: (String key) -> bool
92
+
48
93
  # Grabs a key from stdin and returns it. Blocks until the key is obtained.
49
94
  # Reads a full ESC key sequence; see constants above for some values returned
50
95
  # by this function.
@@ -208,6 +253,22 @@ module Tuile
208
253
  # _@param_ `component`
209
254
  def invalidate: (Component component) -> void
210
255
 
256
+ # Rebuild the status-bar text from the current focus and global-shortcut
257
+ # registry. Called from {#focused=} and whenever the global registry
258
+ # changes. Popups own their own "q Close" prefix in `#keyboard_hint`;
259
+ # for the tiled case Screen tacks on the global "q quit" instead.
260
+ # Global-shortcut hints get spliced in too — see {#global_shortcut_hints}
261
+ # for the over_popups filter rule.
262
+ def refresh_status_bar: () -> void
263
+
264
+ # Status-bar hints from currently-registered global shortcuts.
265
+ # When a popup is open, only `over_popups: true` shortcuts contribute —
266
+ # the rest don't fire in that context, so showing them would be a lie.
267
+ # Insertion order is preserved (Hash iteration order).
268
+ #
269
+ # _@param_ `popup_open`
270
+ def global_shortcut_hints: (popup_open: bool) -> ::Array[String]
271
+
211
272
  # Internal — use {Component::Popup#open} instead. Adds the popup to
212
273
  # {#pane}, centers and focuses it.
213
274
  #
@@ -216,7 +277,9 @@ module Tuile
216
277
 
217
278
  # Runs event loop – waits for keys and sends them to active window. The
218
279
  # function exits when the 'ESC' or 'q' key is pressed.
219
- def run_event_loop: () -> void
280
+ #
281
+ # _@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).
282
+ def run_event_loop: (?capture_mouse: bool) -> void
220
283
 
221
284
  # Advances focus to the next {Component#tab_stop?} in tree order, wrapping
222
285
  # around. Scope is the topmost popup if one is open, otherwise {#content}
@@ -231,6 +294,51 @@ module Tuile
231
294
  # _@return_ — true if focus moved.
232
295
  def focus_previous: () -> bool
233
296
 
297
+ # Registers an app-level keyboard shortcut. When `key` arrives, the block
298
+ # is invoked on the event-loop thread (so it may freely mutate UI) before
299
+ # the key reaches any component. Re-registering the same key replaces the
300
+ # previous binding; use {#unregister_global_shortcut} to remove one.
301
+ #
302
+ # Only unprintable keys are accepted — control characters (Ctrl+letter,
303
+ # ESC, BACKSPACE, ENTER, …) and multi-character escape sequences (arrows,
304
+ # F-keys, …). Printable keys raise {ArgumentError}: they'd hijack typing
305
+ # into a {Component::TextField} and should be expressed as
306
+ # {Component#key_shortcut} instead, which the dispatcher suppresses while
307
+ # a text widget owns the hardware cursor. TAB and SHIFT_TAB are also
308
+ # rejected because {#handle_key} intercepts them for focus navigation
309
+ # before the global registry is consulted, so a binding on them would
310
+ # silently never fire.
311
+ #
312
+ # Pass `hint:` to surface the shortcut in the status bar. It's a
313
+ # preformatted string the caller fully owns (so colors and the key label
314
+ # style stay consistent with whatever the host app uses elsewhere). The
315
+ # framework splices it in like any other status hint: in the tiled case,
316
+ # right after `q quit` and before the active window's own hint; while a
317
+ # popup is open, only hints from `over_popups: true` shortcuts are
318
+ # shown, and they're prepended before the popup's `q Close`.
319
+ #
320
+ # Example — open a log popup with Ctrl+L from anywhere, even while a
321
+ # popup is already on screen:
322
+ #
323
+ # screen.register_global_shortcut(Keys::CTRL_L,
324
+ # over_popups: true,
325
+ # hint: "^L #{Rainbow("log").cadetblue}") do
326
+ # log_popup.open
327
+ # end
328
+ #
329
+ # _@param_ `key` — unprintable key (e.g. {Keys::CTRL_L}, {Keys::ESC}, {Keys::PAGE_UP}).
330
+ #
331
+ # _@param_ `over_popups` — when true, fires even while a modal popup is open (pre-empting the popup's own key handling). When false (default), the shortcut is suppressed while any popup is open and the popup gets the key instead.
332
+ #
333
+ # _@param_ `hint` — preformatted status-bar hint (e.g. `"^L #{Rainbow("log").cadetblue}"`). When nil (default) the shortcut is silent in the status bar.
334
+ def register_global_shortcut: (String key, ?over_popups: bool, ?hint: String?) -> void
335
+
336
+ # Removes a shortcut previously installed by {#register_global_shortcut}.
337
+ # No-op if `key` was not registered.
338
+ #
339
+ # _@param_ `key`
340
+ def unregister_global_shortcut: (String key) -> void
341
+
234
342
  # _@return_ — current active tiled component.
235
343
  def active_window: () -> Component?
236
344
 
@@ -322,10 +430,17 @@ module Tuile
322
430
  # A key has been pressed on the keyboard. Handle it, or forward to active
323
431
  # window.
324
432
  #
325
- # Tab / Shift+Tab are reserved navigation keys: intercepted here before
326
- # the pane sees them, so a focused {Component::TextField} (which would
327
- # otherwise swallow printable keys via the standard cursor-owner
328
- # suppression) doesn't trap them.
433
+ # Dispatch order:
434
+ # 1. Tab / Shift+Tab reserved focus navigation, intercepted before
435
+ # anything else so a focused {Component::TextField} (which would
436
+ # otherwise swallow printable keys via cursor-owner suppression)
437
+ # doesn't trap them.
438
+ # 2. App-level shortcuts from {#register_global_shortcut}. An entry
439
+ # registered with `over_popups: true` always fires; one with the
440
+ # default `over_popups: false` fires only when no popup is open
441
+ # (otherwise the popup receives the key normally).
442
+ # 3. {ScreenPane#handle_key}, which routes to the topmost popup or
443
+ # tiled content.
329
444
  #
330
445
  # _@param_ `key`
331
446
  #
@@ -371,46 +486,29 @@ module Tuile
371
486
 
372
487
  # _@return_ — currently focused component.
373
488
  attr_accessor focused: Component?
374
- end
375
489
 
376
- # Truncates a string to a given column width, preserving ANSI escape
377
- # sequences and accounting for Unicode display width. Truncated output is
378
- # suffixed with an ellipsis (`…`).
379
- #
380
- # Extracted from `strings-truncation` 0.1.0 (MIT, Piotr Murach) — only the
381
- # default end-position, default-omission, no-separator path Tuile uses.
382
- module Truncate
383
- ANSI_REGEXP: Regexp
384
- RESET: String
385
- RESET_REGEXP: Regexp
386
- END_REGEXP: Regexp
387
- OMISSION: String
388
- OMISSION_WIDTH: Integer
490
+ # Entry in the global shortcut registry: the block to run, whether it
491
+ # pre-empts open popups, and an optional preformatted status-bar hint.
492
+ # @api private
493
+ class Shortcut < Data
494
+ # Returns the value of attribute block
495
+ attr_reader block: Object
389
496
 
390
- # Truncate `text` to at most `length` display columns. ANSI escape
391
- # sequences pass through without consuming budget; when characters are
392
- # dropped, an ellipsis (`…`) is appended (and counts toward `length`).
393
- #
394
- # _@param_ `text`
395
- #
396
- # _@param_ `length` — target column width. A `nil` returns `text` unchanged.
397
- def truncate: (String text, length: Integer?) -> String
497
+ # Returns the value of attribute over_popups
498
+ attr_reader over_popups: Object
398
499
 
399
- # Truncate `text` to at most `length` display columns. ANSI escape
400
- # sequences pass through without consuming budget; when characters are
401
- # dropped, an ellipsis (`…`) is appended (and counts toward `length`).
402
- #
403
- # _@param_ `text`
404
- #
405
- # _@param_ `length` — target column width. A `nil` returns `text` unchanged.
406
- def self.truncate: (String text, length: Integer?) -> String
500
+ # Returns the value of attribute hint
501
+ attr_reader hint: Object
502
+ end
407
503
  end
408
504
 
409
505
  # A UI component which is positioned on the screen and draws characters into
410
506
  # its bounding rectangle (in {#repaint}).
411
507
  #
412
- # Component is considered invisible if {#rect} is empty or one of left/top is
413
- # negative. The component won't draw when invisible.
508
+ # Painting is gated by attachment: a detached component (one whose {#root}
509
+ # isn't {Screen#pane}) is never enqueued for repaint via {#invalidate}, and
510
+ # any stale invalidation entries are filtered out at drain time. Subclasses
511
+ # can paint freely in {#repaint} without re-asserting attachment.
414
512
  class Component
415
513
  def initialize: () -> void
416
514
 
@@ -442,6 +540,8 @@ module Tuile
442
540
  # responsibility for {#rect}. Everything else should call super.
443
541
  #
444
542
  # A component must not draw outside of {#rect}.
543
+ #
544
+ # Only called when the component is attached.
445
545
  def repaint: () -> void
446
546
 
447
547
  # Called when a character is pressed on the keyboard.
@@ -562,6 +662,12 @@ module Tuile
562
662
 
563
663
  # Invalidates the component: {Screen} records this component as
564
664
  # needs-repaint and once all events are processed, will call {#repaint}.
665
+ #
666
+ # No-op when the component is not {#attached?} — a detached component has
667
+ # no place on the screen to paint to, so {Screen} must never end up
668
+ # repainting it. Callers don't need to guard their own `invalidate` calls;
669
+ # mutating a detached component (e.g. setting `lines=` on a {List} sitting
670
+ # inside a closed {Component::Popup}) is silent.
565
671
  def invalidate: () -> void
566
672
 
567
673
  # Whether direct children fully tile {#rect}. Used by the default
@@ -589,24 +695,34 @@ module Tuile
589
695
  # no parent.
590
696
  attr_accessor parent: Component?
591
697
 
592
- # A scrollable list of String items with cursor support.
698
+ # A scrollable list of items with cursor support.
593
699
  #
594
- # Items are lines painted directly into the component's {#rect}. Lines are
595
- # automatically clipped horizontally. Vertical scrolling is supported via
596
- # {#top_line}; the list can also automatically scroll to the bottom if
597
- # {#auto_scroll} is enabled.
700
+ # Items are modeled as {StyledString}s and painted directly into the
701
+ # component's {#rect}. Lines wider than the viewport are ellipsized via
702
+ # {StyledString#ellipsize} (span styles are preserved across the cut
703
+ # unlike the older ANSI-as-bytes truncation, color does *not* get
704
+ # dropped on the surviving characters). Vertical scrolling is supported
705
+ # via {#top_line}; the list can also automatically scroll to the bottom
706
+ # if {#auto_scroll} is enabled.
598
707
  #
599
708
  # Cursor is supported; call {#cursor=} to change cursor behavior. The
600
- # cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the list
601
- # automatically.
709
+ # cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the
710
+ # list automatically. The cursor highlight overlays a dark background
711
+ # while preserving each span's foreground color.
602
712
  class List < Component
713
+ CURSOR_BG: Integer
714
+
603
715
  def initialize: () -> void
604
716
 
605
- # Sets new lines. Each entry is coerced via `#to_s`, split on `\n` into
606
- # separate lines, and trailing whitespace stripped symmetric with
607
- # {#add_lines}, so the stored `@lines` is always `Array<String>`.
717
+ # Sets new lines. Each entry is coerced into a {StyledString} (a
718
+ # `String` is parsed via {StyledString.parse}, so embedded ANSI is
719
+ # honored; a {StyledString} is used as-is; anything else is stringified
720
+ # via `#to_s` first), then split on `\n` into separate lines via
721
+ # {StyledString#lines}, with trailing empty pieces dropped and trailing
722
+ # ASCII whitespace stripped — symmetric with {#add_lines}, so the
723
+ # stored `@lines` is always `Array<StyledString>`.
608
724
  #
609
- # _@param_ `lines` — new lines. Entries need only respond to `#to_s`.
725
+ # _@param_ `lines` — entries are `String`, `StyledString`, or anything that responds to `#to_s`.
610
726
  def lines=: (::Array[untyped] lines) -> void
611
727
 
612
728
  # Without a block, returns the current lines. With a block, fully
@@ -617,19 +733,21 @@ module Tuile
617
733
  # end
618
734
  # ```
619
735
  #
620
- # _@return_ — current lines (when called without a block).
621
- def lines: () ?{ (::Array[String] buffer) -> void } -> ::Array[String]
736
+ # _@return_ — current lines (when called without a
737
+ # block).
738
+ def lines: () ?{ (::Array[untyped] buffer) -> void } -> ::Array[StyledString]
622
739
 
740
+ # sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS
623
741
  # Adds a line.
624
742
  #
625
743
  # _@param_ `line`
626
- def add_line: (String line) -> void
744
+ def add_line: ((String | StyledString | _ToS) line) -> void
627
745
 
628
- # Appends given lines. Each entry is coerced via `#to_s`, split on `\n`
629
- # into separate lines, and trailing whitespace stripped symmetric with
630
- # {#lines=}.
746
+ # Appends given lines. Each entry is parsed the same way as in
747
+ # {#lines=}: coerced to a {StyledString}, split on `\n`, with trailing
748
+ # empty pieces dropped and trailing ASCII whitespace stripped.
631
749
  #
632
- # _@param_ `lines` — entries need only respond to `#to_s`.
750
+ # _@param_ `lines` — entries are `String`, `StyledString`, or anything that responds to `#to_s`.
633
751
  def add_lines: (::Array[untyped] lines) -> void
634
752
 
635
753
  def content_size: () -> Size
@@ -646,6 +764,8 @@ module Tuile
646
764
  # Moves the cursor to the next line whose text contains `query`
647
765
  # (case-insensitive substring match). Search wraps around the end of the
648
766
  # list. Only lines reachable by the current {#cursor} are considered.
767
+ # Matching uses the line's plain text — span styles do not affect the
768
+ # match.
649
769
  #
650
770
  # _@param_ `query` — substring to match. Empty query never matches.
651
771
  #
@@ -669,11 +789,42 @@ module Tuile
669
789
  # Paints the list items into {#rect}.
670
790
  #
671
791
  # Skips the {Component#repaint} default's auto-clear: every row of
672
- # {#rect} is painted below (with padded content past the last item),
792
+ # {#rect} is painted below (with blank padding past the last item),
673
793
  # so the parent contract — "fully draw over your rect" — is met
674
794
  # without an upfront wipe.
675
795
  def repaint: () -> void
676
796
 
797
+ # Rebuilds pre-padded lines when the wrap width changes. The wrap width
798
+ # depends on {#rect}`.width` and the scrollbar gutter, both of which
799
+ # trigger this hook. Also re-evaluates {#auto_scroll}: if items were
800
+ # appended while the rect was empty (e.g. a {Popup}-wrapped list got
801
+ # `add_line` calls before the popup was opened), the auto-scroll update
802
+ # was skipped because there was no viewport — re-run it now that there
803
+ # is one, so the list snaps to the bottom on first paint.
804
+ def on_width_changed: () -> void
805
+
806
+ # Coerces and flattens a list of input entries into trimmed
807
+ # {StyledString} lines. Each entry becomes a {StyledString} (String
808
+ # via {StyledString.parse}, StyledString passed through, anything else
809
+ # via `#to_s`), then split on `\n` via {StyledString#lines} — with
810
+ # trailing empty pieces dropped (matching `String#split("\n")`'s
811
+ # default behavior, so `add_line ""` is a no-op) — and trailing ASCII
812
+ # whitespace stripped on each resulting line.
813
+ #
814
+ # _@param_ `entries`
815
+ def parse_input_lines: (::Array[untyped] entries) -> ::Array[StyledString]
816
+
817
+ # _@param_ `entry`
818
+ def split_to_lines: (Object entry) -> ::Array[StyledString]
819
+
820
+ # Returns `line` with trailing ASCII whitespace (space/tab) dropped,
821
+ # preserving span styles on the surviving prefix. Whitespace chars are
822
+ # all single-column ASCII, so byte-count delta equals column-count
823
+ # delta and {StyledString#slice} can do the cut.
824
+ #
825
+ # _@param_ `line`
826
+ def rstrip_styled: (StyledString line) -> StyledString
827
+
677
828
  # _@return_ — true if the cursor sits on a real content line.
678
829
  def cursor_on_item?: () -> bool
679
830
 
@@ -681,9 +832,9 @@ module Tuile
681
832
  # Caller must ensure {#cursor_on_item?}.
682
833
  def fire_item_chosen: () -> void
683
834
 
684
- # _@return_ — `[position, line_at_position]`,
685
- # with `line` nil when the cursor is off-content.
686
- def cursor_state: () -> [[Integer, String, NilClass]]
835
+ # _@return_ — `[position, line_at_position]`, with `line` nil when the cursor is
836
+ # off-content.
837
+ def cursor_state: () -> [[Integer, StyledString, NilClass]]
687
838
 
688
839
  # Fires {#on_cursor_changed} if {#cursor_state} differs from the last
689
840
  # fired state. Idempotent — safe to call after any mutation.
@@ -728,51 +879,66 @@ module Tuile
728
879
  # _@param_ `delta` — negative scrolls up, positive scrolls down.
729
880
  def move_top_line_by: (Integer delta) -> void
730
881
 
731
- # If auto-scrolling, recalculate the top line.
882
+ # If auto-scrolling, recalculate the top line and snap the cursor to the
883
+ # last reachable position. Without the cursor snap the viewport gets
884
+ # yanked back to wherever the cursor sat on the next arrow press,
885
+ # negating the auto-scroll. Skipped when {#rect} is empty: without a
886
+ # viewport the "lines minus viewport" formula yields `@lines.size`,
887
+ # which would leave `top_line` past the last item once a real rect
888
+ # arrives. {#on_width_changed} re-runs this hook when the rect grows so
889
+ # the snap-to-bottom intent is preserved.
732
890
  def update_top_line_if_auto_scroll: () -> void
733
891
 
734
892
  # _@return_ — whether the scrollbar should be drawn right now.
735
893
  def scrollbar_visible?: () -> bool
736
894
 
737
- # Trims string exactly to `width` columns.
895
+ # _@return_ column width available for line content (rect width
896
+ # minus the scrollbar gutter, when visible). `0` when {#rect}'s width
897
+ # is non-positive.
898
+ def content_width: () -> Integer
899
+
900
+ # Recomputes {@padded_lines} for the current rect width and scrollbar
901
+ # visibility. Each line is ellipsized to fit and pre-padded with
902
+ # single-space gutters on each side, so {#paintable_line} only has to
903
+ # apply the cursor highlight (if any) and append the scrollbar glyph.
904
+ def rebuild_padded_lines: () -> void
905
+
906
+ # Pads `line` to one full row of the viewport (scrollbar gutter
907
+ # excluded). Lines wider than the content area are ellipsized via
908
+ # {StyledString#ellipsize} (span styles survive the cut); shorter
909
+ # lines are padded with default-styled spaces.
738
910
  #
739
- # _@param_ `str`
911
+ # _@param_ `line`
740
912
  #
741
- # _@param_ `width`
742
- def trim_to: (String str, Integer width) -> String
913
+ # _@return_ — exactly {#content_width} display columns wide
914
+ # (or {StyledString::EMPTY} when content_width is non-positive).
915
+ def pad_to_row: (StyledString line) -> StyledString
743
916
 
744
917
  # _@param_ `index` — 0-based index into {#lines}.
745
918
  #
746
919
  # _@param_ `row_in_viewport` — 0-based row within the viewport.
747
920
  #
748
- # _@param_ `width` — number of columns the line should occupy.
749
- #
750
921
  # _@param_ `scrollbar` — scrollbar instance, or nil if not shown.
751
922
  #
752
- # _@return_ — paintable line exactly `width` columns wide;
753
- # highlighted if cursor is here.
754
- def paintable_line: (
755
- Integer index,
756
- Integer row_in_viewport,
757
- Integer width,
758
- VerticalScrollBar? scrollbar
759
- ) -> String
923
+ # _@return_ — paintable ANSI-encoded line exactly `rect.width`
924
+ # columns wide; highlighted if cursor is here.
925
+ def paintable_line: (Integer index, Integer row_in_viewport, VerticalScrollBar? scrollbar) -> String
760
926
 
761
927
  # _@return_ — callback fired when an item is chosen — by pressing
762
928
  # Enter on the cursor's item, or by left-clicking an item. Called as
763
- # `proc.call(index, line)` with the chosen 0-based index and its line.
764
- # Never fires when the cursor's position is outside the content (e.g.
765
- # {Cursor::None}, or empty content).
929
+ # `proc.call(index, line)` with the chosen 0-based index and its
930
+ # {StyledString} line. Never fires when the cursor's position is
931
+ # outside the content (e.g. {Cursor::None}, or empty content).
766
932
  attr_accessor on_item_chosen: Proc?
767
933
 
768
934
  # _@return_ — callback fired when the `(index, line)` tuple under
769
935
  # the cursor changes. Called as `proc.call(index, line)` where `line`
770
- # is `nil` when the cursor is off-content ({Cursor::None}, empty list,
771
- # or `index` past the last line). Fires on cursor moves (key, mouse,
772
- # search), on {#cursor=}, and on {#lines=}/{#add_lines} when the line
773
- # at the cursor's index changes (or its in-range/out-of-range status
774
- # flips). Useful for keeping a details pane in sync with the
775
- # highlighted row.
936
+ # is the {StyledString} at the cursor, or `nil` when the cursor is
937
+ # off-content ({Cursor::None}, empty list, or `index` past the last
938
+ # line). Fires on cursor moves (key, mouse, search), on {#cursor=},
939
+ # and on {#lines=}/{#add_lines} when the line at the cursor's index
940
+ # changes (or its in-range/out-of-range status flips). Useful for
941
+ # keeping a details pane in sync with the highlighted row.
776
942
  attr_accessor on_cursor_changed: Proc?
777
943
 
778
944
  # _@return_ — if true and a line is added or new content is set,
@@ -829,6 +995,15 @@ module Tuile
829
995
  # _@return_ — true if the position changed.
830
996
  def go: (Integer new_position) -> bool
831
997
 
998
+ # Moves the cursor to the last reachable position. For base {Cursor},
999
+ # the last line; {Limited} clamps to the last allowed position; {None}
1000
+ # is a no-op.
1001
+ #
1002
+ # _@param_ `line_count` — number of lines in the list.
1003
+ #
1004
+ # _@return_ — true if the position changed.
1005
+ def go_to_last: (Integer line_count) -> bool
1006
+
832
1007
  # _@param_ `lines`
833
1008
  #
834
1009
  # _@param_ `line_count`
@@ -839,9 +1014,6 @@ module Tuile
839
1014
 
840
1015
  def go_to_first: () -> bool
841
1016
 
842
- # _@param_ `line_count`
843
- def go_to_last: (Integer line_count) -> bool
844
-
845
1017
  # _@return_ — 0-based line index of the current cursor position.
846
1018
  attr_reader position: Integer
847
1019
 
@@ -865,6 +1037,16 @@ module Tuile
865
1037
 
866
1038
  # _@param_ `_line_count`
867
1039
  def candidate_positions: (Integer _line_count) -> ::Array[Integer]
1040
+
1041
+ # Overridden so all movement funnels — base {Cursor#go_to_last},
1042
+ # {Cursor#go_to_first}, etc., which all call {#go} — become safe
1043
+ # no-ops on a disabled cursor. The instance is frozen, so a default
1044
+ # mutating {#go} would raise.
1045
+ #
1046
+ # _@param_ `_new_position`
1047
+ #
1048
+ # _@return_ — always false.
1049
+ def go: (Integer _new_position) -> bool
868
1050
  end
869
1051
 
870
1052
  # Cursor which can only land on specific allowed lines.
@@ -884,6 +1066,9 @@ module Tuile
884
1066
  # _@param_ `line_count`
885
1067
  def candidate_positions: (Integer line_count) -> ::Array[Integer]
886
1068
 
1069
+ # _@param_ `_line_count`
1070
+ def go_to_last: (Integer _line_count) -> bool
1071
+
887
1072
  # _@param_ `lines`
888
1073
  #
889
1074
  # _@param_ `line_count`
@@ -893,27 +1078,48 @@ module Tuile
893
1078
  def go_up_by: (Integer lines) -> bool
894
1079
 
895
1080
  def go_to_first: () -> bool
896
-
897
- # _@param_ `_line_count`
898
- def go_to_last: (Integer _line_count) -> bool
899
1081
  end
900
1082
  end
901
1083
  end
902
1084
 
903
- # A label which shows static text. No word-wrapping; clips long lines.
1085
+ # A label which shows static text. No word-wrapping; long lines are
1086
+ # truncated with an ellipsis. Text is modeled as a {StyledString};
1087
+ # {#text=} accepts a {String} (parsed via {StyledString.parse}, so
1088
+ # embedded ANSI is honored) or a {StyledString} directly. {#text}
1089
+ # always returns the {StyledString}.
904
1090
  class Label < Component
905
1091
  def initialize: () -> void
906
1092
 
907
- # _@param_ `text` draws this text. May contain ANSI formatting. Clipped automatically.
908
- def text=: (String? text) -> void
909
-
1093
+ # _@return_longest hard-line's display width × number of hard
1094
+ # lines. Reported on the *unclipped* text sizing is intrinsic to
1095
+ # the content, not the viewport. Empty text returns `Size.new(0, 0)`.
910
1096
  def content_size: () -> Size
911
1097
 
1098
+ # Paints the text into {#rect}.
1099
+ #
1100
+ # Skips the {Component#repaint} default's auto-clear: every row is
1101
+ # painted explicitly (with pre-padded blanks past the last line), so
1102
+ # the "fully draw over your rect" contract is met without an upfront
1103
+ # wipe.
912
1104
  def repaint: () -> void
913
1105
 
914
1106
  def on_width_changed: () -> void
915
1107
 
916
- def update_clipped_text: () -> void
1108
+ # Recomputes {@clipped_lines} for the current text and rect width.
1109
+ # Each line is ellipsized to fit, padded with trailing spaces out to
1110
+ # the full width, and pre-rendered to ANSI so {#repaint} is just a
1111
+ # lookup + screen.print per row. {@blank_line} covers rows past the
1112
+ # last text line.
1113
+ def update_clipped_lines: () -> void
1114
+
1115
+ # _@param_ `line`
1116
+ #
1117
+ # _@param_ `width`
1118
+ def pad_to: (StyledString line, Integer width) -> StyledString
1119
+
1120
+ # _@return_ — the current text. Defaults to an empty
1121
+ # {StyledString}.
1122
+ attr_accessor text: (StyledString | String)?
917
1123
  end
918
1124
 
919
1125
  # A modal overlay that wraps any {Component} as its content. Popup itself
@@ -944,7 +1150,9 @@ module Tuile
944
1150
 
945
1151
  def focusable?: () -> bool
946
1152
 
947
- # Mounts this popup on the {Screen}.
1153
+ # Mounts this popup on the {Screen}. Recomputes the popup's size from
1154
+ # the current content first, so reopening a popup whose content has
1155
+ # grown or shrunk while closed picks up the new size.
948
1156
  def open: () -> void
949
1157
 
950
1158
  # Constructs and opens a popup in one call.
@@ -1102,8 +1310,9 @@ module Tuile
1102
1310
  #
1103
1311
  # The window's `content` is unset by default; assign one via {#content=}.
1104
1312
  #
1105
- # Window is considered invisible if {#rect} is empty or one of left/top is
1106
- # negative. The window won't draw when invisible.
1313
+ # Window is considered invisible if {#rect} is empty. The window won't
1314
+ # draw when invisible. (Repaint of detached windows is short-circuited
1315
+ # by {Component#invalidate}; subclasses don't need to re-check.)
1107
1316
  class Window < Component
1108
1317
  include Tuile::Component::HasContent
1109
1318
 
@@ -1132,10 +1341,6 @@ module Tuile
1132
1341
  # window has no content, footer, or caption.
1133
1342
  def content_size: () -> Size
1134
1343
 
1135
- # _@return_ — true if {#rect} is off screen and the window won't
1136
- # paint.
1137
- def visible?: () -> bool
1138
-
1139
1344
  # Fully repaints the window: both frame and contents.
1140
1345
  #
1141
1346
  # Window deliberately paints over its entire rect (border around the
@@ -1196,27 +1401,26 @@ module Tuile
1196
1401
  # Currently only {#on_change} is wired; Enter inserts a newline as in any
1197
1402
  # plain `<textarea>` or text editor. A future `on_enter`/`on_submit`
1198
1403
  # callback may opt out of that by consuming Enter instead.
1199
- class TextArea < Component
1404
+ class TextArea < Tuile::Component::TextInput
1200
1405
  ACTIVE_BG_SGR: String
1201
1406
  INACTIVE_BG_SGR: String
1202
- SGR_RESET: String
1203
1407
 
1204
1408
  def initialize: () -> void
1205
1409
 
1206
- def focusable?: () -> bool
1207
-
1208
- def tab_stop?: () -> bool
1209
-
1210
1410
  def cursor_position: () -> Point?
1211
1411
 
1212
- # _@param_ `key`
1213
- def handle_key: (String key) -> bool
1214
-
1215
1412
  # _@param_ `event`
1216
1413
  def handle_mouse: (MouseEvent event) -> void
1217
1414
 
1218
1415
  def repaint: () -> void
1219
1416
 
1417
+ def on_text_mutated: () -> void
1418
+
1419
+ def on_caret_mutated: () -> void
1420
+
1421
+ # _@param_ `key`
1422
+ def handle_text_input_key: (String key) -> bool
1423
+
1220
1424
  def on_width_changed: () -> void
1221
1425
 
1222
1426
  # _@return_ — cached wrap of {#text} for the
@@ -1259,38 +1463,234 @@ module Tuile
1259
1463
  # _@return_ — always true.
1260
1464
  def insert_char: (String char) -> bool
1261
1465
 
1262
- def delete_before_caret: () -> void
1263
-
1264
- def delete_at_caret: () -> void
1265
-
1266
1466
  # Keeps the caret visible by scrolling vertically.
1267
1467
  def adjust_top_display_row: () -> void
1268
1468
 
1469
+ # _@return_ — index of the topmost display row currently visible.
1470
+ attr_reader top_display_row: Integer
1471
+ end
1472
+
1473
+ # A read-only viewer for prose: chunks of formatted text that scroll
1474
+ # vertically. Shape-wise a hybrid between {Label} (string-shaped content
1475
+ # via {#text=}) and {List} (scroll keys, optional scrollbar, auto-scroll).
1476
+ #
1477
+ # Text is modeled as a {StyledString}: embedded `\n` are hard line breaks,
1478
+ # lines wider than the viewport are word-wrapped via {StyledString#wrap}
1479
+ # (style spans are preserved across wrap boundaries — unlike the older
1480
+ # ANSI-as-bytes wrapping, color does *not* get dropped on continuation
1481
+ # rows). {#text=} accepts a {String} (parsed via {StyledString.parse},
1482
+ # so embedded ANSI is honored) or a {StyledString} directly; {#text}
1483
+ # always returns the {StyledString}.
1484
+ #
1485
+ # For incremental updates pick the right primitive: {#append} (aliased
1486
+ # as `<<`) is verbatim and stream-friendly — chunks are concatenated
1487
+ # straight onto the buffer, with embedded `\n` becoming hard breaks.
1488
+ # {#add_line} is the "log entry" convenience — it starts the content on
1489
+ # a fresh line by inserting a leading `\n` when the buffer is non-empty.
1490
+ # {#remove_last_n_lines} pops hard lines back off the tail — the
1491
+ # inverse of building up a region with {#append} / {#add_line}, so a
1492
+ # caller streaming reformattable content (e.g. partially-rendered
1493
+ # Markdown that may need to retract its last paragraph) can replace
1494
+ # the tail without rewriting the whole text. Turn on {#auto_scroll}
1495
+ # to keep the latest content in view.
1496
+ #
1497
+ # TextView is meant to be the content of a {Window} — focus indication and
1498
+ # keyboard-hint surfacing rely on the surrounding window chrome.
1499
+ class TextView < Component
1500
+ def initialize: () -> void
1501
+
1502
+ # _@return_ — the current text. Defaults to an empty
1503
+ # {StyledString}. Internally the text is stored as an array of hard
1504
+ # lines so {#append} can stay O(appended) instead of re-scanning the
1505
+ # whole buffer; the joined {StyledString} returned here is
1506
+ # reconstructed on first read after a mutation and cached, so
1507
+ # repeated reads are O(1) but the first read after {#append} pays
1508
+ # O(total spans).
1509
+ def text: () -> StyledString
1510
+
1511
+ # Replaces the text. Embedded `\n` characters become hard line breaks.
1512
+ # A `String` is parsed via {StyledString.parse} (so embedded ANSI is
1513
+ # honored); a `StyledString` is used as-is; `nil` is coerced to an
1514
+ # empty {StyledString}.
1515
+ #
1516
+ # _@param_ `value`
1517
+ def text=: ((String | StyledString)? value) -> void
1518
+
1519
+ # _@return_ — true iff {#text} is empty (no hard lines).
1520
+ def empty?: () -> bool
1521
+
1522
+ # Appends `str` verbatim. Embedded `\n` characters become hard line
1523
+ # breaks; otherwise the text is concatenated onto the current last
1524
+ # hard line. Designed for streaming use (e.g. an LLM chat window
1525
+ # receiving partial messages — feed each chunk straight in). Accepts
1526
+ # the same input forms as {#text=}; empty/`nil` input is a no-op.
1527
+ #
1528
+ # For the "add an entry on a new line" pattern use {#add_line}.
1529
+ #
1530
+ # Cost is O(appended + width-of-current-last-hard-line) — the
1531
+ # previously last hard line is re-wrapped (because the extension may
1532
+ # cause it to wrap differently), any additional hard lines created by
1533
+ # embedded `\n` are wrapped fresh. The cached {#text} is invalidated
1534
+ # and rebuilt on demand.
1535
+ #
1536
+ # _@param_ `str`
1537
+ def append: ((String | StyledString)? str) -> void
1538
+
1539
+ # Verbatim append, returning `self` for chainability (`view << a << b`).
1540
+ #
1541
+ # _@param_ `str`
1542
+ def <<: ((String | StyledString)? str) -> self
1543
+
1544
+ # Appends `str` as a new entry: starts a fresh hard line first (when
1545
+ # the buffer is non-empty) and then appends `str`. Equivalent to
1546
+ # `append("\n" + str)` on a non-empty buffer, or `append(str)` on an
1547
+ # empty one. `nil` and `""` produce a blank entry on a non-empty
1548
+ # buffer and a no-op on an empty buffer (matches the old `append`
1549
+ # semantics for "log line" callers).
1550
+ #
1551
+ # _@param_ `str`
1552
+ def add_line: ((String | StyledString)? str) -> void
1553
+
1554
+ # Drops the last `n` hard lines from the buffer. The inverse of
1555
+ # building up a tail region with {#append} / {#add_line}: a caller
1556
+ # streaming partially-rendered content whose tail must occasionally
1557
+ # be retracted (e.g. Markdown-to-ANSI where a new token reformats
1558
+ # the table being built) can call `remove_last_n_lines(k)` followed
1559
+ # by `append(new_tail)` to replace the damaged region in place.
1560
+ #
1561
+ # `n == 0` and the empty-buffer case are no-ops (no invalidation).
1562
+ # `n >= hard-line count` empties the buffer.
1563
+ #
1564
+ # Operates on **hard lines** (the `\n`-delimited entries the
1565
+ # buffer stores), not on wrapped physical rows — same granularity
1566
+ # as {#add_line}. Cost is O(rendered-rows of the popped lines).
1567
+ #
1568
+ # _@param_ `n` — number of hard lines to drop; must be >= 0.
1569
+ def remove_last_n_lines: (Integer n) -> void
1570
+
1571
+ # Clears the text. Equivalent to `text = ""`.
1572
+ def clear: () -> void
1573
+
1574
+ def focusable?: () -> bool
1575
+
1576
+ def tab_stop?: () -> bool
1577
+
1269
1578
  # _@param_ `key`
1270
- def printable?: (String key) -> bool
1579
+ def handle_key: (String key) -> bool
1271
1580
 
1272
- # Same semantics as {TextField}'s ctrl+left.
1273
- def word_left: () -> Integer
1581
+ # _@param_ `event`
1582
+ def handle_mouse: (MouseEvent event) -> void
1274
1583
 
1275
- # Same semantics as {TextField}'s ctrl+right.
1276
- def word_right: () -> Integer
1584
+ # Paints the text into {#rect}.
1585
+ #
1586
+ # Skips the {Component#repaint} default's auto-clear: every row is
1587
+ # painted explicitly (with padded blanks past the last line), so the
1588
+ # "fully draw over your rect" contract is met without an upfront wipe.
1589
+ def repaint: () -> void
1277
1590
 
1278
- # _@return_ current text contents (may contain embedded `\n`).
1279
- attr_accessor text: String
1591
+ # Rewraps the text on width changes. Wrap width depends on
1592
+ # {#rect}`.width` and the scrollbar gutter, both of which trigger
1593
+ # this hook.
1594
+ def on_width_changed: () -> void
1280
1595
 
1281
- # _@return_ — caret index in `0..text.length`.
1282
- attr_accessor caret: Integer
1596
+ # _@return_ — number of visible lines.
1597
+ def viewport_lines: () -> Integer
1283
1598
 
1284
- # _@return_ — index of the topmost display row currently visible.
1285
- attr_reader top_display_row: Integer
1599
+ # _@return_ — the max value of {#top_line} for scroll-key clamping.
1600
+ def top_line_max: () -> Integer
1286
1601
 
1287
- # Optional callback fired whenever {#text} changes. Receives the new text
1288
- # as a single argument. Not fired by {#caret=} (text unchanged), not
1289
- # fired by a no-op setter, and not fired by a re-wrap caused by a width
1290
- # change ({#text} itself is unchanged).
1602
+ # Recomputes {@physical_lines} for the current text and wrap width,
1603
+ # pre-padding every line to `wrap_width` so {#paintable_line} is just
1604
+ # a lookup + optional scrollbar-char append at paint time (and the
1605
+ # rendered ANSI is cached on each line via {StyledString#to_ansi}'s
1606
+ # memoization, so re-painting on scroll is near-free). Clamps
1607
+ # {@top_line} if the new line count puts it out of range.
1608
+ def rewrap: () -> void
1609
+
1610
+ # Wraps `hard_line` at `width` and appends the padded physical lines
1611
+ # to {@physical_lines}. Empty hard lines (e.g. from a `"\n\n"` run)
1612
+ # and degenerate `width <= 0` both emit a single {@blank_line} row,
1613
+ # matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
1614
+ # would have produced for those cases.
1291
1615
  #
1292
- # _@return_ — one-arg callable, or nil.
1293
- attr_accessor on_change: (Proc | Method)?
1616
+ # _@param_ `hard_line` — one hard-broken line (no embedded `"\n"`).
1617
+ #
1618
+ # _@param_ `width`
1619
+ def append_physical_lines: (StyledString hard_line, Integer width) -> void
1620
+
1621
+ # Pops from {@physical_lines} the rows that `hard_line` previously
1622
+ # contributed (the inverse of {#append_physical_lines} for the same
1623
+ # input). Used by {#append} when extending the last hard line: its
1624
+ # old wrapped rows are dropped, then the extended hard line is
1625
+ # re-wrapped and appended.
1626
+ #
1627
+ # _@param_ `hard_line`
1628
+ #
1629
+ # _@param_ `width`
1630
+ def drop_physical_rows_for: (StyledString hard_line, Integer width) -> void
1631
+
1632
+ # Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
1633
+ # default-styled `"\n"` between hard lines. Called from the {#text}
1634
+ # reader when the cache is cold. Cost is O(total spans).
1635
+ def build_text: () -> StyledString
1636
+
1637
+ # _@return_ — {#content_size} computed from {@hard_lines}.
1638
+ def compute_content_size: () -> Size
1639
+
1640
+ # _@return_ — column width available for wrapped text — viewport
1641
+ # width minus the scrollbar gutter (when visible). `0` when {#rect}'s
1642
+ # width is non-positive, which yields a degenerate "no wrap" result.
1643
+ def wrap_width: () -> Integer
1644
+
1645
+ # _@param_ `delta` — negative scrolls up, positive scrolls down.
1646
+ def move_top_line_by: (Integer delta) -> void
1647
+
1648
+ # _@param_ `target` — desired top line; clamped to `[0, top_line_max]`.
1649
+ def move_top_line_to: (Integer target) -> void
1650
+
1651
+ def update_top_line_if_auto_scroll: () -> void
1652
+
1653
+ def scrollbar_visible?: () -> bool
1654
+
1655
+ # Pads `line` with trailing default-styled spaces out to `width` display
1656
+ # columns. Callers rely on {StyledString#wrap} having already
1657
+ # constrained the line to `<= width`, so no truncation is performed.
1658
+ # `width <= 0` returns {StyledString::EMPTY} to handle the degenerate
1659
+ # `wrap_width == 0` case (rect.width == 1 with scrollbar).
1660
+ #
1661
+ # _@param_ `line`
1662
+ #
1663
+ # _@param_ `width`
1664
+ def pad_to: (StyledString line, Integer width) -> StyledString
1665
+
1666
+ # _@param_ `index` — 0-based index into `@physical_lines`.
1667
+ #
1668
+ # _@param_ `row_in_viewport` — 0-based row within the viewport.
1669
+ #
1670
+ # _@param_ `scrollbar`
1671
+ #
1672
+ # _@return_ — paintable ANSI-encoded line exactly `rect.width`
1673
+ # columns wide. Body lines come pre-padded from {#rewrap}, so this
1674
+ # reduces to a memoized {StyledString#to_ansi} read plus an
1675
+ # ASCII-string concat of the scrollbar glyph when one is present.
1676
+ def paintable_line: (Integer index, Integer row_in_viewport, VerticalScrollBar? scrollbar) -> String
1677
+
1678
+ # _@return_ — index of the first visible physical line.
1679
+ attr_accessor top_line: Integer
1680
+
1681
+ # _@return_ — `:gone` or `:visible`.
1682
+ attr_accessor scrollbar_visibility: Symbol
1683
+
1684
+ # _@return_ — if true, mutating the text scrolls the viewport so
1685
+ # the last line stays in view. Default `false`.
1686
+ attr_accessor auto_scroll: bool
1687
+
1688
+ # _@return_ — longest hard-line's display width × number of hard
1689
+ # lines. Reported on the *unwrapped* text — wrap-aware sizing would
1690
+ # be circular (width depends on width). Empty text returns
1691
+ # `Size.new(0, 0)`. Maintained incrementally by {#text=} and
1692
+ # {#append}, so reads are O(1).
1693
+ attr_reader content_size: Size
1294
1694
  end
1295
1695
 
1296
1696
  # Shows a log. Construct your logger pointed at a {LogWindow::IO} to route
@@ -1306,6 +1706,11 @@ module Tuile
1306
1706
  # _@param_ `caption`
1307
1707
  def initialize: (?String caption) -> void
1308
1708
 
1709
+ # Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
1710
+ #
1711
+ # _@param_ `string` — the line (or multiple lines) to log.
1712
+ def log: (String? string) -> void
1713
+
1309
1714
  # IO-shaped adapter that forwards each log line to the owning {LogWindow}.
1310
1715
  # Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
1311
1716
  # call `output.puts`, e.g. `TTY::Logger`).
@@ -1335,27 +1740,28 @@ module Tuile
1335
1740
  # The caret is a logical index in `0..text.length`. The hardware cursor is
1336
1741
  # positioned by {Screen} after each repaint cycle when this component is
1337
1742
  # focused; see {Component#cursor_position}.
1338
- class TextField < Component
1743
+ class TextField < Tuile::Component::TextInput
1339
1744
  ACTIVE_BG_SGR: String
1340
1745
  INACTIVE_BG_SGR: String
1341
- SGR_RESET: String
1342
1746
 
1343
1747
  def initialize: () -> void
1344
1748
 
1345
- def focusable?: () -> bool
1346
-
1347
- def tab_stop?: () -> bool
1348
-
1349
1749
  def cursor_position: () -> Point?
1350
1750
 
1351
- # _@param_ `key`
1352
- def handle_key: (String key) -> bool
1353
-
1354
1751
  # _@param_ `event`
1355
1752
  def handle_mouse: (MouseEvent event) -> void
1356
1753
 
1357
1754
  def repaint: () -> void
1358
1755
 
1756
+ # Truncate to fit `rect.width - 1` — single-line fields can't grow past
1757
+ # their width.
1758
+ #
1759
+ # _@param_ `new_text`
1760
+ def preprocess_text: (String new_text) -> String
1761
+
1762
+ # _@param_ `key`
1763
+ def handle_text_input_key: (String key) -> bool
1764
+
1359
1765
  def on_width_changed: () -> void
1360
1766
 
1361
1767
  # Maximum number of characters {#text} can hold given current width.
@@ -1364,12 +1770,110 @@ module Tuile
1364
1770
  # _@param_ `char`
1365
1771
  def insert: (String char) -> bool
1366
1772
 
1773
+ # Optional callback fired when the UP arrow key is pressed. When set, UP
1774
+ # is consumed by the field; when nil, UP falls through to the parent
1775
+ # (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
1776
+ # since `k` is a printable character inserted into {#text}.
1777
+ #
1778
+ # _@return_ — no-arg callable, or nil.
1779
+ attr_accessor on_key_up: (Proc | Method)?
1780
+
1781
+ # Optional callback fired when the DOWN arrow key is pressed. When set,
1782
+ # DOWN is consumed by the field; when nil, DOWN falls through to the
1783
+ # parent (default behavior). Only triggered by {Keys::DOWN_ARROW}, not by
1784
+ # `j`, since `j` is a printable character inserted into {#text}.
1785
+ #
1786
+ # _@return_ — no-arg callable, or nil.
1787
+ attr_accessor on_key_down: (Proc | Method)?
1788
+
1789
+ # Optional callback fired when ENTER is pressed. When set, ENTER is
1790
+ # consumed by the field; when nil, ENTER falls through to the parent
1791
+ # (default behavior).
1792
+ #
1793
+ # _@return_ — no-arg callable, or nil.
1794
+ attr_accessor on_enter: (Proc | Method)?
1795
+ end
1796
+
1797
+ # Abstract base for editable text components ({TextField}, {TextArea}).
1798
+ #
1799
+ # Holds the shared state — a mutable {#text} buffer, a {#caret} index,
1800
+ # {#on_change} and {#on_escape} callbacks — and the keyboard machinery
1801
+ # that single-line and multi-line inputs both need: ESC handling,
1802
+ # LEFT/RIGHT caret movement, CTRL+LEFT/CTRL+RIGHT word jumps, and the
1803
+ # `focusable?`/`tab_stop?` flags.
1804
+ #
1805
+ # Subclasses implement the layout-specific pieces ({#cursor_position},
1806
+ # {#repaint}) and add their own keys (HOME/END, ENTER, UP/DOWN,
1807
+ # printable insertion) by overriding the protected
1808
+ # {#handle_text_input_key} hook — `super` falls through to the common
1809
+ # navigation handling.
1810
+ #
1811
+ # The mutation pipeline is a template method: {#text=} and {#caret=}
1812
+ # detect no-ops, mutate state, fire {#on_change}, and invalidate.
1813
+ # Subclasses inject their own behavior via two protected hooks:
1814
+ #
1815
+ # - {#preprocess_text} — input filter (e.g. {TextField} truncates to
1816
+ # fit `rect.width - 1`).
1817
+ # - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
1818
+ # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
1819
+ # keep the caret visible).
1820
+ class TextInput < Component
1821
+ ACTIVE_BG_SGR: String
1822
+ INACTIVE_BG_SGR: String
1823
+
1824
+ def initialize: () -> void
1825
+
1826
+ # _@return_ — true iff {#text} is the empty string.
1827
+ def empty?: () -> bool
1828
+
1829
+ def focusable?: () -> bool
1830
+
1831
+ def tab_stop?: () -> bool
1832
+
1833
+ # Handles a key. Returns false when the component is inactive. Otherwise
1834
+ # first runs the {Component#handle_key} shortcut search via `super`, then
1835
+ # delegates to {#handle_text_input_key}.
1836
+ #
1837
+ # _@param_ `key`
1838
+ def handle_key: (String key) -> bool
1839
+
1840
+ # Input filter for {#text=}. Subclasses override to truncate or reject
1841
+ # invalid input. Default coerces to String.
1842
+ #
1843
+ # _@param_ `new_text`
1844
+ #
1845
+ # _@return_ — possibly transformed text.
1846
+ def preprocess_text: (String new_text) -> String
1847
+
1848
+ # Hook called after {#text} has been mutated, before invalidation /
1849
+ # {#on_change}. Default no-op. Subclasses use this to invalidate caches
1850
+ # ({TextArea}'s wrap cache) and update derived state.
1851
+ def on_text_mutated: () -> void
1852
+
1853
+ # Hook called after {#caret} has been mutated, before invalidation.
1854
+ # Default no-op. Subclasses use this to keep the caret visible
1855
+ # ({TextArea}'s vertical scroll).
1856
+ def on_caret_mutated: () -> void
1857
+
1858
+ # Dispatch hook for {#handle_key}. Handles ESC and the navigation keys
1859
+ # that have identical semantics in single-line and multi-line inputs:
1860
+ # LEFT/RIGHT arrows, CTRL+LEFT/CTRL+RIGHT for word jumps. Subclasses
1861
+ # override to add their own keys (HOME/END, UP/DOWN, ENTER, BACKSPACE/
1862
+ # DELETE, printable insertion) and call `super` to fall back to the
1863
+ # common navigation handling.
1864
+ #
1865
+ # _@param_ `key`
1866
+ #
1867
+ # _@return_ — true if the key was handled.
1868
+ def handle_text_input_key: (String key) -> bool
1869
+
1367
1870
  def delete_before_caret: () -> void
1368
1871
 
1369
1872
  def delete_at_caret: () -> void
1370
1873
 
1371
- # _@param_ `key`
1372
- def printable?: (String key) -> bool
1874
+ # Default {#on_escape} action: clear focus. Component deactivates; user
1875
+ # can re-focus by clicking or tabbing back in.
1876
+ def default_on_escape: () -> void
1373
1877
 
1374
1878
  # Caret target for ctrl+left: skip whitespace going left, then a run of
1375
1879
  # non-whitespace. Lands at the beginning of the current word, or the
@@ -1387,13 +1891,6 @@ module Tuile
1387
1891
  # _@return_ — caret index in `0..text.length`.
1388
1892
  attr_accessor caret: Integer
1389
1893
 
1390
- # Optional callback fired when ESC is pressed. When set, ESC is consumed
1391
- # by the field; when nil, ESC falls through to the parent (default
1392
- # behavior).
1393
- #
1394
- # _@return_ — no-arg callable, or nil.
1395
- attr_accessor on_escape: (Proc | Method)?
1396
-
1397
1894
  # Optional callback fired whenever {#text} changes. Receives the new text
1398
1895
  # as a single argument. Not fired by {#caret=} (text unchanged) and not
1399
1896
  # fired when a setter is a no-op.
@@ -1401,28 +1898,14 @@ module Tuile
1401
1898
  # _@return_ — one-arg callable, or nil.
1402
1899
  attr_accessor on_change: (Proc | Method)?
1403
1900
 
1404
- # Optional callback fired when the UP arrow key is pressed. When set, UP
1405
- # is consumed by the field; when nil, UP falls through to the parent
1406
- # (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
1407
- # since `k` is a printable character inserted into {#text}.
1901
+ # Callback fired when ESC is pressed. Defaults to a closure that clears
1902
+ # focus (`screen.focused = nil`) so ESC visibly cancels text entry instead
1903
+ # of bubbling to the parent — and, in particular, instead of reaching the
1904
+ # screen's default ESC-to-quit handler. Set to nil to let ESC fall through
1905
+ # to the parent again; set to any other callable to replace the default.
1408
1906
  #
1409
1907
  # _@return_ — no-arg callable, or nil.
1410
- attr_accessor on_key_up: (Proc | Method)?
1411
-
1412
- # Optional callback fired when the DOWN arrow key is pressed. When set,
1413
- # DOWN is consumed by the field; when nil, DOWN falls through to the
1414
- # parent (default behavior). Only triggered by {Keys::DOWN_ARROW}, not by
1415
- # `j`, since `j` is a printable character inserted into {#text}.
1416
- #
1417
- # _@return_ — no-arg callable, or nil.
1418
- attr_accessor on_key_down: (Proc | Method)?
1419
-
1420
- # Optional callback fired when ENTER is pressed. When set, ENTER is
1421
- # consumed by the field; when nil, ENTER falls through to the parent
1422
- # (default behavior).
1423
- #
1424
- # _@return_ — no-arg callable, or nil.
1425
- attr_accessor on_enter: (Proc | Method)?
1908
+ attr_accessor on_escape: (Proc | Method)?
1426
1909
  end
1427
1910
 
1428
1911
  # A mixin interface for a component with one child tops. The host must
@@ -1672,7 +2155,7 @@ module Tuile
1672
2155
  #
1673
2156
  # @!attribute [r] button
1674
2157
  # @return [Symbol, nil] one of `:left`, `:middle`, `:right`, `:scroll_up`,
1675
- # `:scroll_down`; `nil` if not known.
2158
+ # `:scroll_down`, `:scroll_left`, `:scroll_right`; `nil` if not known.
1676
2159
  # @!attribute [r] x
1677
2160
  # @return [Integer] x coordinate, 0-based.
1678
2161
  # @!attribute [r] y
@@ -1681,14 +2164,29 @@ module Tuile
1681
2164
  # _@return_ — the event's position.
1682
2165
  def point: () -> Point
1683
2166
 
1684
- # Checks whether given key is a mouse event key
2167
+ # Checks whether given key is a mouse event key. Returns true on the X10
2168
+ # `\e[M` prefix regardless of length — {.parse} is the place that
2169
+ # validates the full 6-byte shape and raises on malformed input.
1685
2170
  #
1686
2171
  # _@param_ `key` — key read via {Keys.getkey}
1687
2172
  #
1688
2173
  # _@return_ — true if it is a mouse event
1689
2174
  def self.mouse_event?: (String key) -> bool
1690
2175
 
2176
+ # Parses an X10 mouse report (`\e[M` + 3 bytes: button, x, y).
2177
+ #
2178
+ # Raises {Tuile::Error} when `key` starts with the mouse prefix but is
2179
+ # not exactly 6 bytes long. Both shorter and longer inputs are bugs in
2180
+ # the upstream key-reader: a shorter prefix means the tail was lost on
2181
+ # the way in, and a longer one means we over-consumed into the next
2182
+ # escape sequence. We refuse to silently truncate either case because
2183
+ # the trailing `\e` of an over-read corrupts the *next* getkey, and the
2184
+ # corruption then surfaces as garbled keystrokes in focused inputs
2185
+ # rather than as a parser failure pointing at the actual cause.
2186
+ #
1691
2187
  # _@param_ `key` — key read via {Keys.getkey}
2188
+ #
2189
+ # _@return_ — `nil` if `key` is not a mouse event
1692
2190
  def self.parse: (String key) -> MouseEvent?
1693
2191
 
1694
2192
  def self.start_tracking: () -> String
@@ -1696,7 +2194,7 @@ module Tuile
1696
2194
  def self.stop_tracking: () -> String
1697
2195
 
1698
2196
  # _@return_ — one of `:left`, `:middle`, `:right`, `:scroll_up`,
1699
- # `:scroll_down`; `nil` if not known.
2197
+ # `:scroll_down`, `:scroll_left`, `:scroll_right`; `nil` if not known.
1700
2198
  attr_reader button: Symbol?
1701
2199
 
1702
2200
  # _@return_ — x coordinate, 0-based.
@@ -1801,6 +2299,360 @@ module Tuile
1801
2299
  attr_reader status_bar: Component::Label
1802
2300
  end
1803
2301
 
2302
+ # An immutable string-with-styling, modeled as a sequence of {Span}s where
2303
+ # each span carries a complete {Style} (`fg`, `bg`, `bold`, `italic`,
2304
+ # `underline`). Spans are non-overlapping and fully tile the string — every
2305
+ # character has exactly one resolved style, no overlay layers to merge.
2306
+ #
2307
+ # Where this differs from threading SGR escapes through a plain `String`:
2308
+ # slicing, wrapping, and concatenation operate on the structured spans, so
2309
+ # they never have to "figure out what SGR state is active at column N" —
2310
+ # the answer is just the containing span's `style`. The flip side is one
2311
+ # extra type to construct (or parse) before doing styled-text math.
2312
+ #
2313
+ # ## Constructors
2314
+ #
2315
+ # ```ruby
2316
+ # StyledString.new # empty
2317
+ # StyledString.plain("hello") # default style
2318
+ # StyledString.styled("hello", fg: :red, bold: true)
2319
+ # StyledString.parse("\e[31mhello\e[0m world") # ANSI → spans
2320
+ # ```
2321
+ #
2322
+ # ## Algebra
2323
+ #
2324
+ # All operations return a fresh {StyledString} — the underlying spans are
2325
+ # frozen and shared. `+` coerces a `String` operand via {.parse}.
2326
+ #
2327
+ # ```ruby
2328
+ # a + b # concatenate
2329
+ # ss.slice(2, 5) # 5 display columns starting at column 2
2330
+ # ss.slice(2..5) # range (inclusive end)
2331
+ # ss.lines # split on "\n" → Array<StyledString>
2332
+ # ss.each_char_with_style { |ch, style| ... }
2333
+ # ```
2334
+ #
2335
+ # ## Rendering
2336
+ #
2337
+ # - `#to_s` — plain text, no SGR.
2338
+ # - `#to_ansi` — minimal-diff SGR rendering, ending with `\e[0m` only when
2339
+ # the last span carried a non-default style. Transitions to the default
2340
+ # style emit `\e[0m` (shorter than re-emitting every off-code).
2341
+ #
2342
+ # ## Parser
2343
+ #
2344
+ # {.parse} is strict by design: it recognizes only the SGR codes
2345
+ # corresponding to {Style}'s supported attributes (fg/bg/bold/italic/
2346
+ # underline). Anything else — unmodeled attributes (dim, blink, reverse,
2347
+ # strike, conceal, double-underline, overline, ...), unknown SGR codes, or
2348
+ # non-SGR escapes (cursor moves, OSC) — raises {ParseError}. This keeps the
2349
+ # round-trip parse(to_ansi(x)) == x contract honest.
2350
+ class StyledString
2351
+ EMPTY: StyledString
2352
+
2353
+ # sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS
2354
+ # _@param_ `text`
2355
+ def self.plain: (_ToS text) -> StyledString
2356
+
2357
+ # sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS
2358
+ # _@param_ `text`
2359
+ #
2360
+ # _@param_ `style_kwargs` — forwarded to {Style.new}.
2361
+ def self.styled: (_ToS text, **::Hash[Symbol, Object] style_kwargs) -> StyledString
2362
+
2363
+ # Parses an ANSI/SGR-coded string into a {StyledString}. A {StyledString}
2364
+ # input is returned as-is. `nil` and the empty string both fast-path to
2365
+ # {EMPTY}. Strings without any `\e` byte fast-path to a single
2366
+ # default-styled span.
2367
+ #
2368
+ # _@param_ `input`
2369
+ def self.parse: ((String | StyledString)? input) -> StyledString
2370
+
2371
+ # _@param_ `spans`
2372
+ def initialize: (?::Array[Span] spans) -> void
2373
+
2374
+ # Total display width in terminal columns, accounting for Unicode wide
2375
+ # characters (fullwidth CJK = 2 columns, combining marks = 0, etc.).
2376
+ # Memoized — safe because spans are frozen and immutable.
2377
+ def display_width: () -> Integer
2378
+
2379
+ def empty?: () -> bool
2380
+
2381
+ # Plain text concatenation across all spans — no SGR codes.
2382
+ def to_s: () -> String
2383
+
2384
+ # Rendered ANSI string. Minimal-diff between adjacent spans: only the
2385
+ # attributes that changed are emitted. A transition to the default style
2386
+ # emits `\e[0m` (one code) instead of the longer "turn each attribute
2387
+ # off" form. Always closes with `\e[0m` when the last span carried a
2388
+ # non-default style, so the styled run doesn't bleed into subsequent
2389
+ # output. Memoized — safe because spans are frozen and immutable.
2390
+ def to_ansi: () -> String
2391
+
2392
+ # _@param_ `other`
2393
+ def ==: (Object other) -> bool
2394
+
2395
+ def hash: () -> Integer
2396
+
2397
+ # Concatenation. A `String` operand is parsed via {.parse} before joining
2398
+ # (so embedded ANSI escapes round-trip through spans).
2399
+ #
2400
+ # _@param_ `other`
2401
+ def +: ((StyledString | String) other) -> StyledString
2402
+
2403
+ # Substring by display columns, preserving spans. Characters whose column
2404
+ # range only partially overlaps the slice (e.g. a 2-column CJK character
2405
+ # straddling the start or end boundary) are dropped — never split.
2406
+ #
2407
+ # Accepts either `slice(start_col, len_col)` or `slice(range)`. Both
2408
+ # forms support negative indices counting from the end of the string.
2409
+ #
2410
+ # _@param_ `start_col`
2411
+ #
2412
+ # _@param_ `len_col`
2413
+ def slice: (Integer start_col, Integer len_col) -> StyledString
2414
+
2415
+ # Truncates to a target column width, appending an ellipsis when
2416
+ # characters were dropped. The ellipsis counts toward the target — the
2417
+ # returned {StyledString}'s `display_width` never exceeds
2418
+ # `display_width`. When `self` already fits, `self` is returned. When
2419
+ # `display_width` is smaller than the ellipsis's own width, the ellipsis
2420
+ # is sliced down to fit and no original content is included.
2421
+ #
2422
+ # _@param_ `display_width` — target column width.
2423
+ #
2424
+ # _@param_ `ellipsis` — appended when truncation occurs. Defaults to the Unicode horizontal-ellipsis `…` (one column). A `String` is parsed via {.parse}, so ANSI in it is preserved.
2425
+ def ellipsize: (Integer display_width, ?(String | StyledString) ellipsis) -> StyledString
2426
+
2427
+ # Splits on `"\n"`, preserving spans on each side. A trailing newline
2428
+ # produces a trailing empty {StyledString} (matches `split("\n", -1)`).
2429
+ # An empty {StyledString} returns a single empty entry, like `"".split`.
2430
+ def lines: () -> ::Array[StyledString]
2431
+
2432
+ # Word-wraps to physical lines that each fit within `width` display
2433
+ # columns, preserving spans and styles across breaks. Greedy word-wrap,
2434
+ # hard-break for words wider than `width`, leading whitespace dropped on
2435
+ # wrapped continuations, hard `"\n"` breaks preserved as separate output
2436
+ # lines.
2437
+ #
2438
+ # Whitespace runs are space or tab; other characters are treated as word
2439
+ # content. When a single character is wider than `width` (e.g. a 2-column
2440
+ # CJK character with `width = 1`), it is still emitted on its own line at
2441
+ # its natural width. The "no line exceeds `width`" guarantee therefore
2442
+ # holds whenever every character is at most `width` columns wide.
2443
+ #
2444
+ # _@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.
2445
+ #
2446
+ # _@return_ — one entry per physical (output) line.
2447
+ # An empty receiver returns `[]`.
2448
+ def wrap: (Integer? width) -> ::Array[StyledString]
2449
+
2450
+ # Yields each character (per `String#each_char`) along with the {Style}
2451
+ # it carries. Returns an `Enumerator` without a block.
2452
+ def each_char_with_style: () -> (::Enumerator[untyped] | self)
2453
+
2454
+ # Returns a new {StyledString} with `bg` applied to every span, preserving
2455
+ # each span's text and other style attributes (`fg`, `bold`, `italic`,
2456
+ # `underline`). Useful for row-level highlights — the new bg overlays
2457
+ # without dropping foreground colors the original styling carried.
2458
+ #
2459
+ # _@param_ `bg` — background color, in any of the forms accepted by {Style.new}. `nil` clears bg back to the terminal default.
2460
+ def with_bg: ((Symbol | Integer | ::Array[Integer])? bg) -> StyledString
2461
+
2462
+ # Returns a new {StyledString} with `fg` applied to every span, preserving
2463
+ # each span's text and other style attributes (`bg`, `bold`, `italic`,
2464
+ # `underline`). The new fg overlays without dropping background colors or
2465
+ # text attributes the original styling carried.
2466
+ #
2467
+ # _@param_ `fg` — foreground color, in any of the forms accepted by {Style.new}. `nil` clears fg back to the terminal default.
2468
+ def with_fg: ((Symbol | Integer | ::Array[Integer])? fg) -> StyledString
2469
+
2470
+ def inspect: () -> String
2471
+
2472
+ def build_ansi: () -> String
2473
+
2474
+ # _@param_ `spans`
2475
+ def normalize: (::Array[Span] spans) -> ::Array[Span]
2476
+
2477
+ # _@param_ `from`
2478
+ #
2479
+ # _@param_ `to`
2480
+ def sgr_diff: (Style from, Style to) -> String
2481
+
2482
+ # _@param_ `color`
2483
+ #
2484
+ # _@param_ `base` — base SGR code — 30 for fg, 40 for bg.
2485
+ #
2486
+ # _@param_ `ext` — extended-color SGR code — 38 for fg, 48 for bg.
2487
+ def color_codes: ((Symbol | Integer | ::Array[Integer])? color, base: Integer, ext: Integer) -> ::Array[Integer]
2488
+
2489
+ # _@param_ `start_or_range`
2490
+ #
2491
+ # _@param_ `len`
2492
+ #
2493
+ # _@param_ `total` — receiver's full display width.
2494
+ #
2495
+ # _@return_ — normalized `[start_col, len_col]`.
2496
+ def resolve_slice_bounds: ((Integer | ::Range[untyped]) start_or_range, Integer? len, Integer total) -> [Integer, Integer]
2497
+
2498
+ # _@param_ `start`
2499
+ #
2500
+ # _@param_ `len`
2501
+ def slice_spans: (Integer start, Integer len) -> StyledString
2502
+
2503
+ # _@param_ `hard_line` — one hard-broken line — no embedded `"\n"`.
2504
+ #
2505
+ # _@param_ `width`
2506
+ def wrap_one: (StyledString hard_line, Integer width) -> ::Array[StyledString]
2507
+
2508
+ # _@param_ `hard_line`
2509
+ #
2510
+ # _@return_ — tokens shaped `[type, chars, w]` where `type` is
2511
+ # `:space` or `:word`, `chars` is an `Array<[String, Style, Integer]>`
2512
+ # (char, style, display width), and `w` is the token's total width.
2513
+ def tokenize_for_wrap: (StyledString hard_line) -> ::Array[::Array[untyped]]
2514
+
2515
+ # _@param_ `chars` — `[char, style, width]` triples.
2516
+ #
2517
+ # _@param_ `width`
2518
+ #
2519
+ # _@return_ — each inner Array is a `chars`-shaped chunk.
2520
+ def hard_break_chars: (::Array[::Array[untyped]] chars, Integer width) -> ::Array[::Array[::Array[untyped]]]
2521
+
2522
+ # _@param_ `chars` — `[char, style, width]` triples.
2523
+ def chars_to_styled: (::Array[::Array[untyped]] chars) -> StyledString
2524
+
2525
+ # _@param_ `text`
2526
+ #
2527
+ # _@param_ `start_col`
2528
+ #
2529
+ # _@param_ `len_col`
2530
+ def slice_text_by_columns: (String text, Integer start_col, Integer len_col) -> String
2531
+
2532
+ # _@return_ — the frozen, normalized span list — no empty-text
2533
+ # entries, no two adjacent entries sharing a style.
2534
+ attr_reader spans: ::Array[Span]
2535
+
2536
+ # Raised by {.parse} on malformed or unsupported escape sequences.
2537
+ class ParseError < Tuile::Error
2538
+ end
2539
+
2540
+ # A frozen value type describing the visual style of a {Span}.
2541
+ #
2542
+ # `fg` and `bg` accept:
2543
+ # - `nil` — the terminal default (SGR 39 / 49)
2544
+ # - a symbol from {COLOR_SYMBOLS} — 8 standard + 8 bright ANSI colors
2545
+ # - an Integer 0..255 — 256-color palette index (SGR 38;5;N / 48;5;N)
2546
+ # - an `[r, g, b]` Array of three 0..255 Integers — 24-bit RGB
2547
+ #
2548
+ # @!attribute [r] fg
2549
+ # @return [Symbol, Integer, Array<Integer>, nil]
2550
+ # @!attribute [r] bg
2551
+ # @return [Symbol, Integer, Array<Integer>, nil]
2552
+ # @!attribute [r] bold
2553
+ # @return [Boolean]
2554
+ # @!attribute [r] italic
2555
+ # @return [Boolean]
2556
+ # @!attribute [r] underline
2557
+ # @return [Boolean]
2558
+ class Style
2559
+ COLOR_SYMBOLS: ::Array[Symbol]
2560
+ DEFAULT: Style
2561
+
2562
+ # _@param_ `fg`
2563
+ #
2564
+ # _@param_ `bg`
2565
+ #
2566
+ # _@param_ `bold`
2567
+ #
2568
+ # _@param_ `italic`
2569
+ #
2570
+ # _@param_ `underline`
2571
+ def self.new: (
2572
+ ?fg: (Symbol | Integer | ::Array[Integer])?,
2573
+ ?bg: (Symbol | Integer | ::Array[Integer])?,
2574
+ ?bold: bool,
2575
+ ?italic: bool,
2576
+ ?underline: bool
2577
+ ) -> Style
2578
+
2579
+ # _@param_ `color`
2580
+ #
2581
+ # _@param_ `which`
2582
+ def self.validate_color!: (Object color, Symbol which) -> void
2583
+
2584
+ def default?: () -> bool
2585
+
2586
+ # Returns a new {Style} with the given attributes overridden.
2587
+ #
2588
+ # _@param_ `overrides`
2589
+ def merge: (**::Hash[Symbol, Object] overrides) -> Style
2590
+
2591
+ attr_reader fg: (Symbol | Integer | ::Array[Integer])?
2592
+
2593
+ attr_reader bg: (Symbol | Integer | ::Array[Integer])?
2594
+
2595
+ attr_reader bold: bool
2596
+
2597
+ attr_reader italic: bool
2598
+
2599
+ attr_reader underline: bool
2600
+ end
2601
+
2602
+ # A maximal run of text sharing a single {Style}. `text` is plain — it
2603
+ # never contains ANSI escape sequences. Spans inside a {StyledString} are
2604
+ # normalized: no empty text, no two adjacent spans share a style.
2605
+ #
2606
+ # @!attribute [r] text
2607
+ # @return [String] frozen plain text.
2608
+ # @!attribute [r] style
2609
+ # @return [Style]
2610
+ class Span
2611
+ # _@param_ `text`
2612
+ #
2613
+ # _@param_ `style`
2614
+ def initialize: (text: String, style: Style) -> void
2615
+
2616
+ # _@return_ — frozen plain text.
2617
+ attr_reader text: String
2618
+
2619
+ attr_reader style: Style
2620
+ end
2621
+
2622
+ # @api private
2623
+ # Hand-rolled SGR parser. State machine over a {StringScanner}: plain
2624
+ # text accumulates into the current span; each `\e[...m` flushes the
2625
+ # current span and updates the running {Style}. Anything outside the
2626
+ # supported SGR alphabet raises {ParseError}.
2627
+ class Parser
2628
+ STANDARD_COLORS: ::Array[Symbol]
2629
+ BRIGHT_COLORS: ::Array[Symbol]
2630
+
2631
+ # _@param_ `input`
2632
+ def initialize: (String input) -> void
2633
+
2634
+ def parse: () -> StyledString
2635
+
2636
+ def consume_text: () -> void
2637
+
2638
+ def consume_escape: () -> void
2639
+
2640
+ # _@param_ `params_str`
2641
+ def apply_sgr: (String params_str) -> void
2642
+
2643
+ # _@param_ `codes`
2644
+ #
2645
+ # _@param_ `index`
2646
+ #
2647
+ # _@param_ `target` — either `:fg` or `:bg`.
2648
+ #
2649
+ # _@return_ — how many SGR codes were consumed (3 for 256-color, 5 for RGB).
2650
+ def consume_extended_color: (::Array[Integer] codes, Integer index, Symbol target) -> Integer
2651
+
2652
+ def flush: () -> void
2653
+ end
2654
+ end
2655
+
1804
2656
  # A "synchronous" event queue – no loop is run, submitted blocks are run right
1805
2657
  # away and submitted events are thrown away. Intended for testing only.
1806
2658
  class FakeEventQueue