tuile 0.1.0 → 0.3.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.
@@ -26,9 +34,13 @@ module Tuile
26
34
  UP_ARROWS: ::Array[String]
27
35
  LEFT_ARROW: String
28
36
  RIGHT_ARROW: String
37
+ CTRL_LEFT_ARROW: String
38
+ CTRL_RIGHT_ARROW: String
29
39
  ESC: String
30
40
  HOME: String
31
41
  END_: String
42
+ HOMES: ::Array[String]
43
+ ENDS_: ::Array[String]
32
44
  PAGE_UP: String
33
45
  PAGE_DOWN: String
34
46
  BACKSPACE: String
@@ -38,6 +50,8 @@ module Tuile
38
50
  CTRL_U: String
39
51
  CTRL_D: String
40
52
  ENTER: String
53
+ TAB: String
54
+ SHIFT_TAB: String
41
55
 
42
56
  # Grabs a key from stdin and returns it. Blocks until the key is obtained.
43
57
  # Reads a full ESC key sequence; see constants above for some values returned
@@ -212,6 +226,19 @@ module Tuile
212
226
  # function exits when the 'ESC' or 'q' key is pressed.
213
227
  def run_event_loop: () -> void
214
228
 
229
+ # Advances focus to the next {Component#tab_stop?} in tree order, wrapping
230
+ # around. Scope is the topmost popup if one is open, otherwise {#content}
231
+ # — this keeps Tab confined inside a modal popup. No-op (returns false) if
232
+ # the modal scope has no tab stops or no content at all.
233
+ #
234
+ # _@return_ — true if focus moved.
235
+ def focus_next: () -> bool
236
+
237
+ # Mirror of {#focus_next} that walks backwards through the tab order.
238
+ #
239
+ # _@return_ — true if focus moved.
240
+ def focus_previous: () -> bool
241
+
215
242
  # _@return_ — current active tiled component.
216
243
  def active_window: () -> Component?
217
244
 
@@ -240,7 +267,23 @@ module Tuile
240
267
 
241
268
  def self.close: () -> void
242
269
 
243
- # Prints given strings.
270
+ # Prints given strings. While {#repaint} is running, writes are
271
+ # accumulated into a frame buffer and flushed to the terminal as a
272
+ # single `$stdout.write` at the end of the cycle. This stops the
273
+ # emulator from rendering half-finished frames (e.g. a layout's
274
+ # clear-background pass before its children have re-painted), which
275
+ # was visible as a brief flicker when the auto-clear path triggers.
276
+ #
277
+ # Outside repaint, writes go straight to stdout. We deliberately
278
+ # don't raise on a "print outside repaint" — that would be a useful
279
+ # guardrail against components painting outside the repaint loop,
280
+ # but it'd force terminal-housekeeping writes (`Screen#clear`,
281
+ # mouse-tracking start/stop, cursor-show on teardown) to bypass
282
+ # this method entirely and write directly to `$stdout`. {FakeScreen}
283
+ # overrides `print` to capture every byte into its `@prints` array,
284
+ # and tests that exercise `run_event_loop` against a real {Screen}
285
+ # would otherwise leak escape sequences to the test runner's stdout.
286
+ # Keeping `print` as the single sink preserves that override seam.
244
287
  #
245
288
  # _@param_ `args` — stuff to print.
246
289
  def print: (*String args) -> void
@@ -255,6 +298,17 @@ module Tuile
255
298
  # but only one focused.
256
299
  def cursor_position: () -> Point?
257
300
 
301
+ # Walks the current modal scope in pre-order, collects tab stops, and
302
+ # advances focus by one (wrapping). When the focused component isn't in
303
+ # the tab order (e.g. focus is parked on a popup/window chrome with no
304
+ # interactable widgets), Tab goes to the first stop and Shift+Tab to the
305
+ # last.
306
+ #
307
+ # _@param_ `forward`
308
+ #
309
+ # _@return_ — true if focus moved.
310
+ def cycle_focus: (forward: bool) -> bool
311
+
258
312
  # Collects a component and all its descendants in tree order
259
313
  # (parent before children).
260
314
  #
@@ -276,6 +330,11 @@ module Tuile
276
330
  # A key has been pressed on the keyboard. Handle it, or forward to active
277
331
  # window.
278
332
  #
333
+ # Tab / Shift+Tab are reserved navigation keys: intercepted here before
334
+ # the pane sees them, so a focused {Component::TextField} (which would
335
+ # otherwise swallow printable keys via the standard cursor-owner
336
+ # suppression) doesn't trap them.
337
+ #
279
338
  # _@param_ `key`
280
339
  #
281
340
  # _@return_ — true if the key was handled by some window.
@@ -336,12 +395,28 @@ module Tuile
336
395
  # Focuses this component. Equivalent to `screen.focused = self`.
337
396
  def focus: () -> void
338
397
 
339
- # Repaints the component. Default implementation does nothing.
398
+ # Repaints the component.
340
399
  #
341
- # The component must fully draw over {#rect}, and must not draw outside of
342
- # {#rect}.
400
+ # The default does the bookkeeping that almost every component would
401
+ # otherwise have to remember: it clears the background and re-invalidates
402
+ # any direct children whose rects leave gaps in {#rect}. Concretely:
343
403
  #
344
- # Tip: use {#clear_background} to clear component background before painting.
404
+ # - Leaf (no children): always clears, so subclasses can paint their
405
+ # content directly without an explicit `clear_background` call.
406
+ # - Container with children that fully tile {#rect}: skipped — the
407
+ # children themselves will repaint and cover everything.
408
+ # - Container with gappy children (e.g. a form layout where widgets
409
+ # don't tile): clears, then invalidates the children so they re-paint
410
+ # on top of the cleared background. This is what makes mixed
411
+ # field/button forms safe without each container learning a custom
412
+ # damage-tracking pass.
413
+ #
414
+ # Subclasses that paint their entire rect themselves (e.g. {Window}'s
415
+ # border draws over the area the default would clear; {Component::List}
416
+ # explicitly paints every row) may skip super and take full
417
+ # responsibility for {#rect}. Everything else should call super.
418
+ #
419
+ # A component must not draw outside of {#rect}.
345
420
  def repaint: () -> void
346
421
 
347
422
  # Called when a character is pressed on the keyboard.
@@ -386,9 +461,23 @@ module Tuile
386
461
  # only focusable ones can become a focus target that puts themselves and
387
462
  # their ancestors on the active chain.
388
463
  #
464
+ # See also {#tab_stop?}: focusable controls _can_ receive focus (via click
465
+ # or programmatic assignment), but only tab stops participate in Tab /
466
+ # Shift+Tab cycling. Containers like {Window} and {Popup} are focusable
467
+ # (so a click on chrome lands focus) but are not tab stops.
468
+ #
389
469
  # _@return_ — true if this component can be focused.
390
470
  def focusable?: () -> bool
391
471
 
472
+ # Whether this component participates in Tab / Shift+Tab focus cycling.
473
+ # `false` by default. Only true on components that accept direct user
474
+ # input (e.g. {TextField}, {List}, {Component::Button}). Implies
475
+ # {#focusable?} — Screen will skip non-focusable tab stops, but in
476
+ # practice every override should keep the two consistent.
477
+ #
478
+ # _@return_ — true if Tab / Shift+Tab should land on this component.
479
+ def tab_stop?: () -> bool
480
+
392
481
  # _@return_ — the distance from the root component; 0 if {#parent}
393
482
  # is nil.
394
483
  def depth: () -> Integer
@@ -450,6 +539,15 @@ module Tuile
450
539
  # needs-repaint and once all events are processed, will call {#repaint}.
451
540
  def invalidate: () -> void
452
541
 
542
+ # Whether direct children fully tile {#rect}. Used by the default
543
+ # {#repaint} to decide whether the framework needs to wipe gaps.
544
+ #
545
+ # Approximated by area: sum of (non-empty) child areas vs the parent's
546
+ # area. Cheap, and correct as long as siblings don't overlap each other
547
+ # — which Tuile already requires (no clipping in the tiled tree).
548
+ # Children with empty rects contribute zero, since they paint nothing.
549
+ def children_tile_rect?: () -> bool
550
+
453
551
  # Clears the background: prints spaces into all characters occupied by the
454
552
  # component's rect.
455
553
  def clear_background: () -> void
@@ -466,24 +564,34 @@ module Tuile
466
564
  # no parent.
467
565
  attr_accessor parent: Component?
468
566
 
469
- # A scrollable list of String items with cursor support.
567
+ # A scrollable list of items with cursor support.
470
568
  #
471
- # Items are lines painted directly into the component's {#rect}. Lines are
472
- # automatically clipped horizontally. Vertical scrolling is supported via
473
- # {#top_line}; the list can also automatically scroll to the bottom if
474
- # {#auto_scroll} is enabled.
569
+ # Items are modeled as {StyledString}s and painted directly into the
570
+ # component's {#rect}. Lines wider than the viewport are ellipsized via
571
+ # {StyledString#ellipsize} (span styles are preserved across the cut
572
+ # unlike the older ANSI-as-bytes truncation, color does *not* get
573
+ # dropped on the surviving characters). Vertical scrolling is supported
574
+ # via {#top_line}; the list can also automatically scroll to the bottom
575
+ # if {#auto_scroll} is enabled.
475
576
  #
476
577
  # Cursor is supported; call {#cursor=} to change cursor behavior. The
477
- # cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the list
478
- # automatically.
578
+ # cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the
579
+ # list automatically. The cursor highlight overlays a dark background
580
+ # while preserving each span's foreground color.
479
581
  class List < Component
582
+ CURSOR_BG: Integer
583
+
480
584
  def initialize: () -> void
481
585
 
482
- # Sets new lines. Each entry is coerced via `#to_s`, split on `\n` into
483
- # separate lines, and trailing whitespace stripped symmetric with
484
- # {#add_lines}, so the stored `@lines` is always `Array<String>`.
586
+ # Sets new lines. Each entry is coerced into a {StyledString} (a
587
+ # `String` is parsed via {StyledString.parse}, so embedded ANSI is
588
+ # honored; a {StyledString} is used as-is; anything else is stringified
589
+ # via `#to_s` first), then split on `\n` into separate lines via
590
+ # {StyledString#lines}, with trailing empty pieces dropped and trailing
591
+ # ASCII whitespace stripped — symmetric with {#add_lines}, so the
592
+ # stored `@lines` is always `Array<StyledString>`.
485
593
  #
486
- # _@param_ `lines` — new lines. Entries need only respond to `#to_s`.
594
+ # _@param_ `lines` — entries are `String`, `StyledString`, or anything that responds to `#to_s`.
487
595
  def lines=: (::Array[untyped] lines) -> void
488
596
 
489
597
  # Without a block, returns the current lines. With a block, fully
@@ -494,25 +602,29 @@ module Tuile
494
602
  # end
495
603
  # ```
496
604
  #
497
- # _@return_ — current lines (when called without a block).
498
- def lines: () ?{ (::Array[String] buffer) -> void } -> ::Array[String]
605
+ # _@return_ — current lines (when called without a
606
+ # block).
607
+ def lines: () ?{ (::Array[untyped] buffer) -> void } -> ::Array[StyledString]
499
608
 
609
+ # sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS
500
610
  # Adds a line.
501
611
  #
502
612
  # _@param_ `line`
503
- def add_line: (String line) -> void
613
+ def add_line: ((String | StyledString | _ToS) line) -> void
504
614
 
505
- # Appends given lines. Each entry is coerced via `#to_s`, split on `\n`
506
- # into separate lines, and trailing whitespace stripped symmetric with
507
- # {#lines=}.
615
+ # Appends given lines. Each entry is parsed the same way as in
616
+ # {#lines=}: coerced to a {StyledString}, split on `\n`, with trailing
617
+ # empty pieces dropped and trailing ASCII whitespace stripped.
508
618
  #
509
- # _@param_ `lines` — entries need only respond to `#to_s`.
619
+ # _@param_ `lines` — entries are `String`, `StyledString`, or anything that responds to `#to_s`.
510
620
  def add_lines: (::Array[untyped] lines) -> void
511
621
 
512
622
  def content_size: () -> Size
513
623
 
514
624
  def focusable?: () -> bool
515
625
 
626
+ def tab_stop?: () -> bool
627
+
516
628
  # _@param_ `key` — a key.
517
629
  #
518
630
  # _@return_ — true if the key was handled.
@@ -521,6 +633,8 @@ module Tuile
521
633
  # Moves the cursor to the next line whose text contains `query`
522
634
  # (case-insensitive substring match). Search wraps around the end of the
523
635
  # list. Only lines reachable by the current {#cursor} are considered.
636
+ # Matching uses the line's plain text — span styles do not affect the
637
+ # match.
524
638
  #
525
639
  # _@param_ `query` — substring to match. Empty query never matches.
526
640
  #
@@ -542,8 +656,40 @@ module Tuile
542
656
  def handle_mouse: (MouseEvent event) -> void
543
657
 
544
658
  # Paints the list items into {#rect}.
659
+ #
660
+ # Skips the {Component#repaint} default's auto-clear: every row of
661
+ # {#rect} is painted below (with blank padding past the last item),
662
+ # so the parent contract — "fully draw over your rect" — is met
663
+ # without an upfront wipe.
545
664
  def repaint: () -> void
546
665
 
666
+ # Rebuilds pre-padded lines when the wrap width changes. The wrap width
667
+ # depends on {#rect}`.width` and the scrollbar gutter, both of which
668
+ # trigger this hook.
669
+ def on_width_changed: () -> void
670
+
671
+ # Coerces and flattens a list of input entries into trimmed
672
+ # {StyledString} lines. Each entry becomes a {StyledString} (String
673
+ # via {StyledString.parse}, StyledString passed through, anything else
674
+ # via `#to_s`), then split on `\n` via {StyledString#lines} — with
675
+ # trailing empty pieces dropped (matching `String#split("\n")`'s
676
+ # default behavior, so `add_line ""` is a no-op) — and trailing ASCII
677
+ # whitespace stripped on each resulting line.
678
+ #
679
+ # _@param_ `entries`
680
+ def parse_input_lines: (::Array[untyped] entries) -> ::Array[StyledString]
681
+
682
+ # _@param_ `entry`
683
+ def split_to_lines: (Object entry) -> ::Array[StyledString]
684
+
685
+ # Returns `line` with trailing ASCII whitespace (space/tab) dropped,
686
+ # preserving span styles on the surviving prefix. Whitespace chars are
687
+ # all single-column ASCII, so byte-count delta equals column-count
688
+ # delta and {StyledString#slice} can do the cut.
689
+ #
690
+ # _@param_ `line`
691
+ def rstrip_styled: (StyledString line) -> StyledString
692
+
547
693
  # _@return_ — true if the cursor sits on a real content line.
548
694
  def cursor_on_item?: () -> bool
549
695
 
@@ -551,6 +697,14 @@ module Tuile
551
697
  # Caller must ensure {#cursor_on_item?}.
552
698
  def fire_item_chosen: () -> void
553
699
 
700
+ # _@return_ — `[position, line_at_position]`, with `line` nil when the cursor is
701
+ # off-content.
702
+ def cursor_state: () -> [[Integer, StyledString, NilClass]]
703
+
704
+ # Fires {#on_cursor_changed} if {#cursor_state} differs from the last
705
+ # fired state. Idempotent — safe to call after any mutation.
706
+ def notify_cursor_changed: () -> void
707
+
554
708
  # _@param_ `query`
555
709
  #
556
710
  # _@param_ `include_current`
@@ -596,37 +750,55 @@ module Tuile
596
750
  # _@return_ — whether the scrollbar should be drawn right now.
597
751
  def scrollbar_visible?: () -> bool
598
752
 
599
- # Trims string exactly to `width` columns.
753
+ # _@return_ column width available for line content (rect width
754
+ # minus the scrollbar gutter, when visible). `0` when {#rect}'s width
755
+ # is non-positive.
756
+ def content_width: () -> Integer
757
+
758
+ # Recomputes {@padded_lines} for the current rect width and scrollbar
759
+ # visibility. Each line is ellipsized to fit and pre-padded with
760
+ # single-space gutters on each side, so {#paintable_line} only has to
761
+ # apply the cursor highlight (if any) and append the scrollbar glyph.
762
+ def rebuild_padded_lines: () -> void
763
+
764
+ # Pads `line` to one full row of the viewport (scrollbar gutter
765
+ # excluded). Lines wider than the content area are ellipsized via
766
+ # {StyledString#ellipsize} (span styles survive the cut); shorter
767
+ # lines are padded with default-styled spaces.
600
768
  #
601
- # _@param_ `str`
769
+ # _@param_ `line`
602
770
  #
603
- # _@param_ `width`
604
- def trim_to: (String str, Integer width) -> String
771
+ # _@return_ — exactly {#content_width} display columns wide
772
+ # (or {StyledString::EMPTY} when content_width is non-positive).
773
+ def pad_to_row: (StyledString line) -> StyledString
605
774
 
606
775
  # _@param_ `index` — 0-based index into {#lines}.
607
776
  #
608
777
  # _@param_ `row_in_viewport` — 0-based row within the viewport.
609
778
  #
610
- # _@param_ `width` — number of columns the line should occupy.
611
- #
612
779
  # _@param_ `scrollbar` — scrollbar instance, or nil if not shown.
613
780
  #
614
- # _@return_ — paintable line exactly `width` columns wide;
615
- # highlighted if cursor is here.
616
- def paintable_line: (
617
- Integer index,
618
- Integer row_in_viewport,
619
- Integer width,
620
- VerticalScrollBar? scrollbar
621
- ) -> String
781
+ # _@return_ — paintable ANSI-encoded line exactly `rect.width`
782
+ # columns wide; highlighted if cursor is here.
783
+ def paintable_line: (Integer index, Integer row_in_viewport, VerticalScrollBar? scrollbar) -> String
622
784
 
623
785
  # _@return_ — callback fired when an item is chosen — by pressing
624
786
  # Enter on the cursor's item, or by left-clicking an item. Called as
625
- # `proc.call(index, line)` with the chosen 0-based index and its line.
626
- # Never fires when the cursor's position is outside the content (e.g.
627
- # {Cursor::None}, or empty content).
787
+ # `proc.call(index, line)` with the chosen 0-based index and its
788
+ # {StyledString} line. Never fires when the cursor's position is
789
+ # outside the content (e.g. {Cursor::None}, or empty content).
628
790
  attr_accessor on_item_chosen: Proc?
629
791
 
792
+ # _@return_ — callback fired when the `(index, line)` tuple under
793
+ # the cursor changes. Called as `proc.call(index, line)` where `line`
794
+ # is the {StyledString} at the cursor, or `nil` when the cursor is
795
+ # off-content ({Cursor::None}, empty list, or `index` past the last
796
+ # line). Fires on cursor moves (key, mouse, search), on {#cursor=},
797
+ # and on {#lines=}/{#add_lines} when the line at the cursor's index
798
+ # changes (or its in-range/out-of-range status flips). Useful for
799
+ # keeping a details pane in sync with the highlighted row.
800
+ attr_accessor on_cursor_changed: Proc?
801
+
630
802
  # _@return_ — if true and a line is added or new content is set,
631
803
  # auto-scrolls to the bottom.
632
804
  attr_accessor auto_scroll: bool
@@ -752,20 +924,44 @@ module Tuile
752
924
  end
753
925
  end
754
926
 
755
- # A label which shows static text. No word-wrapping; clips long lines.
927
+ # A label which shows static text. No word-wrapping; long lines are
928
+ # truncated with an ellipsis. Text is modeled as a {StyledString};
929
+ # {#text=} accepts a {String} (parsed via {StyledString.parse}, so
930
+ # embedded ANSI is honored) or a {StyledString} directly. {#text}
931
+ # always returns the {StyledString}.
756
932
  class Label < Component
757
933
  def initialize: () -> void
758
934
 
759
- # _@param_ `text` draws this text. May contain ANSI formatting. Clipped automatically.
760
- def text=: (String? text) -> void
761
-
935
+ # _@return_longest hard-line's display width × number of hard
936
+ # lines. Reported on the *unclipped* text sizing is intrinsic to
937
+ # the content, not the viewport. Empty text returns `Size.new(0, 0)`.
762
938
  def content_size: () -> Size
763
939
 
940
+ # Paints the text into {#rect}.
941
+ #
942
+ # Skips the {Component#repaint} default's auto-clear: every row is
943
+ # painted explicitly (with pre-padded blanks past the last line), so
944
+ # the "fully draw over your rect" contract is met without an upfront
945
+ # wipe.
764
946
  def repaint: () -> void
765
947
 
766
948
  def on_width_changed: () -> void
767
949
 
768
- def update_clipped_text: () -> void
950
+ # Recomputes {@clipped_lines} for the current text and rect width.
951
+ # Each line is ellipsized to fit, padded with trailing spaces out to
952
+ # the full width, and pre-rendered to ANSI so {#repaint} is just a
953
+ # lookup + screen.print per row. {@blank_line} covers rows past the
954
+ # last text line.
955
+ def update_clipped_lines: () -> void
956
+
957
+ # _@param_ `line`
958
+ #
959
+ # _@param_ `width`
960
+ def pad_to: (StyledString line, Integer width) -> StyledString
961
+
962
+ # _@return_ — the current text. Defaults to an empty
963
+ # {StyledString}.
964
+ attr_accessor text: (StyledString | String)?
769
965
  end
770
966
 
771
967
  # A modal overlay that wraps any {Component} as its content. Popup itself
@@ -854,17 +1050,70 @@ module Tuile
854
1050
  def on_focus: () -> void
855
1051
  end
856
1052
 
1053
+ # A clickable button. Activated by Enter, Space, or a left mouse click;
1054
+ # fires the {#on_click} callback. Renders as `[ caption ]` on a single
1055
+ # row; the background is highlighted when the button is focused so the
1056
+ # user can see which button is active.
1057
+ #
1058
+ # Buttons are tab stops — Tab and Shift+Tab will land on them as part of
1059
+ # the standard focus cycle. Click-to-focus also works via the inherited
1060
+ # {Component#handle_mouse}.
1061
+ #
1062
+ # Assign a {#rect} (typically by the surrounding {Layout}) wide enough to
1063
+ # show `[ caption ]`; {#content_size} reports that natural width.
1064
+ class Button < Component
1065
+ # _@param_ `caption` — the button's label.
1066
+ def initialize: (?String caption) -> void
1067
+
1068
+ def focusable?: () -> bool
1069
+
1070
+ def tab_stop?: () -> bool
1071
+
1072
+ # _@return_ — natural width is `caption.length + 4` to fit
1073
+ # `[ caption ]`; height is 1.
1074
+ def content_size: () -> Size
1075
+
1076
+ # _@param_ `key`
1077
+ def handle_key: (String key) -> bool
1078
+
1079
+ # _@param_ `event`
1080
+ def handle_mouse: (MouseEvent event) -> void
1081
+
1082
+ def repaint: () -> void
1083
+
1084
+ # _@return_ — the button's label.
1085
+ attr_accessor caption: String
1086
+
1087
+ # Callback fired when the button is activated (Enter, Space, or
1088
+ # left-click). The callable receives no arguments.
1089
+ #
1090
+ # _@return_ — no-arg callable, or nil.
1091
+ attr_accessor on_click: (Proc | Method)?
1092
+ end
1093
+
857
1094
  # A layout doesn't paint anything by itself: its job is to position child
858
1095
  # components.
859
1096
  #
860
- # All children must completely cover the contents of a layout: that way,
861
- # the layout itself doesn't have to draw and no clipping algorithm is
862
- # necessary.
1097
+ # Children that fully tile the layout's rect repaint themselves and
1098
+ # cover everything; children that leave gaps (e.g. a form with widgets
1099
+ # of varying widths) trigger {Component#repaint}'s default behavior —
1100
+ # the background is cleared and children are re-invalidated so they
1101
+ # paint over a clean surface.
863
1102
  class Layout < Component
864
1103
  def initialize: () -> void
865
1104
 
866
1105
  def children: () -> ::Array[Component]
867
1106
 
1107
+ # Layouts are focusable containers — like {Window} and {Popup}, they
1108
+ # don't accept input themselves but they need to participate in the
1109
+ # {HasContent} focus cascade so a Popup wrapping a Layout wrapping a
1110
+ # {TextField} ends up focusing the field rather than parking focus on
1111
+ # the popup. Layouts don't paint any visible chrome of their own
1112
+ # (the auto-cleared background is just blank space), so this has no
1113
+ # mouse-routing consequences — clicks on a gap area land back on the
1114
+ # Layout itself and the on_focus cascade forwards to a tab stop.
1115
+ def focusable?: () -> bool
1116
+
868
1117
  # Adds a child component to this layout.
869
1118
  #
870
1119
  # _@param_ `child`
@@ -875,8 +1124,6 @@ module Tuile
875
1124
 
876
1125
  def content_size: () -> Size
877
1126
 
878
- def repaint: () -> void
879
-
880
1127
  # Dispatches the event to the child under the mouse cursor.
881
1128
  #
882
1129
  # _@param_ `event`
@@ -938,6 +1185,15 @@ module Tuile
938
1185
  def visible?: () -> bool
939
1186
 
940
1187
  # Fully repaints the window: both frame and contents.
1188
+ #
1189
+ # Window deliberately paints over its entire rect (border around the
1190
+ # edge, content/footer over the interior), so we don't need the
1191
+ # {Component#repaint} default's auto-clear — but we do still want its
1192
+ # "re-invalidate children" effect, since the border overpaints
1193
+ # whatever the content/footer drew on the perimeter. Calling super
1194
+ # handles both: the auto-clear is harmless (we re-paint over it), and
1195
+ # the invalidation queues content + footer for repaint in the same
1196
+ # cycle.
941
1197
  def repaint: () -> void
942
1198
 
943
1199
  # _@param_ `key`
@@ -972,6 +1228,281 @@ module Tuile
972
1228
  attr_accessor caption: String
973
1229
  end
974
1230
 
1231
+ # A multi-line, word-wrapping text input.
1232
+ #
1233
+ # Sized by the caller — {#rect} is fixed; the area does not grow with
1234
+ # content. Text is wrapped to {Rect#width} columns and any text that
1235
+ # doesn't fit vertically is reached by scrolling: {#top_display_row}
1236
+ # follows the caret so the line being edited stays visible. There is no
1237
+ # horizontal scrolling.
1238
+ #
1239
+ # The caret is a logical index in `0..text.length`. When the caret falls
1240
+ # inside a whitespace run that was absorbed by a soft wrap, it displays
1241
+ # at the end of the previous row (which is visually identical to the
1242
+ # start of the next row in nearly all cases).
1243
+ #
1244
+ # Currently only {#on_change} is wired; Enter inserts a newline as in any
1245
+ # plain `<textarea>` or text editor. A future `on_enter`/`on_submit`
1246
+ # callback may opt out of that by consuming Enter instead.
1247
+ class TextArea < Component
1248
+ ACTIVE_BG_SGR: String
1249
+ INACTIVE_BG_SGR: String
1250
+
1251
+ def initialize: () -> void
1252
+
1253
+ def focusable?: () -> bool
1254
+
1255
+ def tab_stop?: () -> bool
1256
+
1257
+ def cursor_position: () -> Point?
1258
+
1259
+ # _@param_ `key`
1260
+ def handle_key: (String key) -> bool
1261
+
1262
+ # _@param_ `event`
1263
+ def handle_mouse: (MouseEvent event) -> void
1264
+
1265
+ def repaint: () -> void
1266
+
1267
+ def on_width_changed: () -> void
1268
+
1269
+ # _@return_ — cached wrap of {#text} for the
1270
+ # current {Rect#width}. Each entry is `{start:, length:}`.
1271
+ def display_rows: () -> ::Array[::Hash[Symbol, Integer]]
1272
+
1273
+ # Greedy word-wrap. Whitespace at a soft-wrap break point is absorbed
1274
+ # (not rendered on either row). A token longer than {Rect#width} hard-
1275
+ # wraps inside the token. Newlines force a hard break and the wrap
1276
+ # restarts on the next character.
1277
+ def compute_display_rows: () -> ::Array[::Hash[Symbol, Integer]]
1278
+
1279
+ # Trims trailing space/tab characters off a row's visible length so the
1280
+ # whitespace at a soft-wrap point is absorbed (not rendered) rather than
1281
+ # left at the end of the row. Without this, soft-wrapping `"foo bar"`
1282
+ # to width 4 would yield row 0 length 4 (`"foo "`) and the natural
1283
+ # end-of-row caret position would coincide with row 1's start.
1284
+ #
1285
+ # _@param_ `row_start`
1286
+ #
1287
+ # _@param_ `row_chars`
1288
+ #
1289
+ # _@return_ — new row_chars.
1290
+ def trim_trailing_whitespace: (Integer row_start, Integer row_chars) -> Integer
1291
+
1292
+ # _@param_ `caret`
1293
+ #
1294
+ # _@return_ — `[row_index, column]` for `caret`.
1295
+ def caret_to_display: (Integer caret) -> [Integer, Integer]
1296
+
1297
+ # _@param_ `delta` — `+1` for down, `-1` for up.
1298
+ def move_caret_vertical: (Integer delta) -> void
1299
+
1300
+ def move_caret_to_row_start: () -> void
1301
+
1302
+ def move_caret_to_row_end: () -> void
1303
+
1304
+ # _@param_ `char`
1305
+ #
1306
+ # _@return_ — always true.
1307
+ def insert_char: (String char) -> bool
1308
+
1309
+ def delete_before_caret: () -> void
1310
+
1311
+ def delete_at_caret: () -> void
1312
+
1313
+ # Keeps the caret visible by scrolling vertically.
1314
+ def adjust_top_display_row: () -> void
1315
+
1316
+ # _@param_ `key`
1317
+ def printable?: (String key) -> bool
1318
+
1319
+ # Same semantics as {TextField}'s ctrl+left.
1320
+ def word_left: () -> Integer
1321
+
1322
+ # Same semantics as {TextField}'s ctrl+right.
1323
+ def word_right: () -> Integer
1324
+
1325
+ # _@return_ — current text contents (may contain embedded `\n`).
1326
+ attr_accessor text: String
1327
+
1328
+ # _@return_ — caret index in `0..text.length`.
1329
+ attr_accessor caret: Integer
1330
+
1331
+ # _@return_ — index of the topmost display row currently visible.
1332
+ attr_reader top_display_row: Integer
1333
+
1334
+ # Optional callback fired whenever {#text} changes. Receives the new text
1335
+ # as a single argument. Not fired by {#caret=} (text unchanged), not
1336
+ # fired by a no-op setter, and not fired by a re-wrap caused by a width
1337
+ # change ({#text} itself is unchanged).
1338
+ #
1339
+ # _@return_ — one-arg callable, or nil.
1340
+ attr_accessor on_change: (Proc | Method)?
1341
+ end
1342
+
1343
+ # A read-only viewer for prose: chunks of formatted text that scroll
1344
+ # vertically. Shape-wise a hybrid between {Label} (string-shaped content
1345
+ # via {#text=}) and {List} (scroll keys, optional scrollbar, auto-scroll).
1346
+ #
1347
+ # Text is modeled as a {StyledString}: embedded `\n` are hard line breaks,
1348
+ # lines wider than the viewport are word-wrapped via {StyledString#wrap}
1349
+ # (style spans are preserved across wrap boundaries — unlike the older
1350
+ # ANSI-as-bytes wrapping, color does *not* get dropped on continuation
1351
+ # rows). {#text=} accepts a {String} (parsed via {StyledString.parse},
1352
+ # so embedded ANSI is honored) or a {StyledString} directly; {#text}
1353
+ # always returns the {StyledString}. Use {#append} for incremental "log
1354
+ # line" style updates; turn on {#auto_scroll} to keep the latest content
1355
+ # in view.
1356
+ #
1357
+ # TextView is meant to be the content of a {Window} — focus indication and
1358
+ # keyboard-hint surfacing rely on the surrounding window chrome.
1359
+ class TextView < Component
1360
+ def initialize: () -> void
1361
+
1362
+ # _@return_ — the current text. Defaults to an empty
1363
+ # {StyledString}. Internally the text is stored as an array of hard
1364
+ # lines so {#append} can stay O(appended) instead of re-scanning the
1365
+ # whole buffer; the joined {StyledString} returned here is
1366
+ # reconstructed on first read after a mutation and cached, so
1367
+ # repeated reads are O(1) but the first read after {#append} pays
1368
+ # O(total spans).
1369
+ def text: () -> StyledString
1370
+
1371
+ # Replaces the text. Embedded `\n` characters become hard line breaks.
1372
+ # A `String` is parsed via {StyledString.parse} (so embedded ANSI is
1373
+ # honored); a `StyledString` is used as-is; `nil` is coerced to an
1374
+ # empty {StyledString}.
1375
+ #
1376
+ # _@param_ `value`
1377
+ def text=: ((String | StyledString)? value) -> void
1378
+
1379
+ # Appends `str` as a new physical line. If the current text is empty,
1380
+ # behaves like `text = str`; otherwise prepends a newline so the new
1381
+ # content lands on a fresh line. Accepts the same input forms as
1382
+ # {#text=}.
1383
+ #
1384
+ # Cost is O(appended) rather than O(total) — the existing wrapped
1385
+ # buffer is reused, only the new hard line(s) are wrapped and padded,
1386
+ # and `@content_size` is updated incrementally. The cached
1387
+ # {#text} is invalidated and rebuilt on demand.
1388
+ #
1389
+ # _@param_ `str`
1390
+ def append: ((String | StyledString)? str) -> void
1391
+
1392
+ # Clears the text. Equivalent to `text = ""`.
1393
+ def clear: () -> void
1394
+
1395
+ def focusable?: () -> bool
1396
+
1397
+ def tab_stop?: () -> bool
1398
+
1399
+ # _@param_ `key`
1400
+ def handle_key: (String key) -> bool
1401
+
1402
+ # _@param_ `event`
1403
+ def handle_mouse: (MouseEvent event) -> void
1404
+
1405
+ # Paints the text into {#rect}.
1406
+ #
1407
+ # Skips the {Component#repaint} default's auto-clear: every row is
1408
+ # painted explicitly (with padded blanks past the last line), so the
1409
+ # "fully draw over your rect" contract is met without an upfront wipe.
1410
+ def repaint: () -> void
1411
+
1412
+ # Rewraps the text on width changes. Wrap width depends on
1413
+ # {#rect}`.width` and the scrollbar gutter, both of which trigger
1414
+ # this hook.
1415
+ def on_width_changed: () -> void
1416
+
1417
+ # _@return_ — number of visible lines.
1418
+ def viewport_lines: () -> Integer
1419
+
1420
+ # _@return_ — the max value of {#top_line} for scroll-key clamping.
1421
+ def top_line_max: () -> Integer
1422
+
1423
+ # Recomputes {@physical_lines} for the current text and wrap width,
1424
+ # pre-padding every line to `wrap_width` so {#paintable_line} is just
1425
+ # a lookup + optional scrollbar-char append at paint time (and the
1426
+ # rendered ANSI is cached on each line via {StyledString#to_ansi}'s
1427
+ # memoization, so re-painting on scroll is near-free). Clamps
1428
+ # {@top_line} if the new line count puts it out of range.
1429
+ def rewrap: () -> void
1430
+
1431
+ # Wraps `hard_line` at `width` and appends the padded physical lines
1432
+ # to {@physical_lines}. Empty hard lines (e.g. from a `"\n\n"` run)
1433
+ # and degenerate `width <= 0` both emit a single {@blank_line} row,
1434
+ # matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
1435
+ # would have produced for those cases.
1436
+ #
1437
+ # _@param_ `hard_line` — one hard-broken line (no embedded `"\n"`).
1438
+ #
1439
+ # _@param_ `width`
1440
+ def append_physical_lines: (StyledString hard_line, Integer width) -> void
1441
+
1442
+ # Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
1443
+ # default-styled `"\n"` between hard lines. Called from the {#text}
1444
+ # reader when the cache is cold. Cost is O(total spans).
1445
+ def build_text: () -> StyledString
1446
+
1447
+ # _@return_ — {#content_size} computed from {@hard_lines}.
1448
+ def compute_content_size: () -> Size
1449
+
1450
+ # _@return_ — column width available for wrapped text — viewport
1451
+ # width minus the scrollbar gutter (when visible). `0` when {#rect}'s
1452
+ # width is non-positive, which yields a degenerate "no wrap" result.
1453
+ def wrap_width: () -> Integer
1454
+
1455
+ # _@param_ `delta` — negative scrolls up, positive scrolls down.
1456
+ def move_top_line_by: (Integer delta) -> void
1457
+
1458
+ # _@param_ `target` — desired top line; clamped to `[0, top_line_max]`.
1459
+ def move_top_line_to: (Integer target) -> void
1460
+
1461
+ def update_top_line_if_auto_scroll: () -> void
1462
+
1463
+ def scrollbar_visible?: () -> bool
1464
+
1465
+ # Pads `line` with trailing default-styled spaces out to `width` display
1466
+ # columns. Callers rely on {StyledString#wrap} having already
1467
+ # constrained the line to `<= width`, so no truncation is performed.
1468
+ # `width <= 0` returns {StyledString::EMPTY} to handle the degenerate
1469
+ # `wrap_width == 0` case (rect.width == 1 with scrollbar).
1470
+ #
1471
+ # _@param_ `line`
1472
+ #
1473
+ # _@param_ `width`
1474
+ def pad_to: (StyledString line, Integer width) -> StyledString
1475
+
1476
+ # _@param_ `index` — 0-based index into `@physical_lines`.
1477
+ #
1478
+ # _@param_ `row_in_viewport` — 0-based row within the viewport.
1479
+ #
1480
+ # _@param_ `scrollbar`
1481
+ #
1482
+ # _@return_ — paintable ANSI-encoded line exactly `rect.width`
1483
+ # columns wide. Body lines come pre-padded from {#rewrap}, so this
1484
+ # reduces to a memoized {StyledString#to_ansi} read plus an
1485
+ # ASCII-string concat of the scrollbar glyph when one is present.
1486
+ def paintable_line: (Integer index, Integer row_in_viewport, VerticalScrollBar? scrollbar) -> String
1487
+
1488
+ # _@return_ — index of the first visible physical line.
1489
+ attr_accessor top_line: Integer
1490
+
1491
+ # _@return_ — `:gone` or `:visible`.
1492
+ attr_accessor scrollbar_visibility: Symbol
1493
+
1494
+ # _@return_ — if true, mutating the text scrolls the viewport so
1495
+ # the last line stays in view. Default `false`.
1496
+ attr_accessor auto_scroll: bool
1497
+
1498
+ # _@return_ — longest hard-line's display width × number of hard
1499
+ # lines. Reported on the *unwrapped* text — wrap-aware sizing would
1500
+ # be circular (width depends on width). Empty text returns
1501
+ # `Size.new(0, 0)`. Maintained incrementally by {#text=} and
1502
+ # {#append}, so reads are O(1).
1503
+ attr_reader content_size: Size
1504
+ end
1505
+
975
1506
  # Shows a log. Construct your logger pointed at a {LogWindow::IO} to route
976
1507
  # log lines into this window:
977
1508
  #
@@ -1015,10 +1546,15 @@ module Tuile
1015
1546
  # positioned by {Screen} after each repaint cycle when this component is
1016
1547
  # focused; see {Component#cursor_position}.
1017
1548
  class TextField < Component
1549
+ ACTIVE_BG_SGR: String
1550
+ INACTIVE_BG_SGR: String
1551
+
1018
1552
  def initialize: () -> void
1019
1553
 
1020
1554
  def focusable?: () -> bool
1021
1555
 
1556
+ def tab_stop?: () -> bool
1557
+
1022
1558
  def cursor_position: () -> Point?
1023
1559
 
1024
1560
  # _@param_ `key`
@@ -1044,6 +1580,16 @@ module Tuile
1044
1580
  # _@param_ `key`
1045
1581
  def printable?: (String key) -> bool
1046
1582
 
1583
+ # Caret target for ctrl+left: skip whitespace going left, then a run of
1584
+ # non-whitespace. Lands at the beginning of the current word, or the
1585
+ # beginning of the previous word if already there.
1586
+ def word_left: () -> Integer
1587
+
1588
+ # Caret target for ctrl+right: skip non-whitespace going right, then a
1589
+ # run of whitespace. Lands at the beginning of the next word, or at the
1590
+ # end of the text if no further word exists.
1591
+ def word_right: () -> Integer
1592
+
1047
1593
  # _@return_ — current text contents.
1048
1594
  attr_accessor text: String
1049
1595
 
@@ -1433,13 +1979,26 @@ module Tuile
1433
1979
 
1434
1980
  # Focus repair when a child detaches. Default {Component#on_child_removed}
1435
1981
  # would refocus to `self` (the pane), which isn't a useful focus target.
1436
- # Instead, route focus to the now-topmost popup, then to the prior focus
1437
- # snapshotted when this popup was opened (if still attached), then to
1438
- # content, then nil.
1982
+ # Instead, route focus to the first interactable widget in the now-topmost
1983
+ # popup; falling back to the focus snapshotted when this popup was opened
1984
+ # (if still attached and still focusable); then to the first interactable
1985
+ # widget in {#content}; then to {#content} itself; then nil.
1986
+ #
1987
+ # "First interactable widget" = first {Component#tab_stop?} in pre-order;
1988
+ # if a scope has no tab stops at all (a borderless ESC-to-close popup, or
1989
+ # tiled content made entirely of {Label}s), we focus the scope's root so
1990
+ # `q`/ESC still has somewhere to dispatch from.
1439
1991
  #
1440
1992
  # _@param_ `child`
1441
1993
  def on_child_removed: (Component child) -> void
1442
1994
 
1995
+ # First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
1996
+ # back to `root` itself when the subtree has no tab stops. Returns `nil`
1997
+ # if `root` is `nil`.
1998
+ #
1999
+ # _@param_ `root`
2000
+ def first_tab_stop_or_root: (Component? root) -> Component?
2001
+
1443
2002
  # _@return_ — the tiled content component.
1444
2003
  attr_accessor content: Component?
1445
2004
 
@@ -1451,6 +2010,352 @@ module Tuile
1451
2010
  attr_reader status_bar: Component::Label
1452
2011
  end
1453
2012
 
2013
+ # An immutable string-with-styling, modeled as a sequence of {Span}s where
2014
+ # each span carries a complete {Style} (`fg`, `bg`, `bold`, `italic`,
2015
+ # `underline`). Spans are non-overlapping and fully tile the string — every
2016
+ # character has exactly one resolved style, no overlay layers to merge.
2017
+ #
2018
+ # Where this differs from threading SGR escapes through a plain `String`:
2019
+ # slicing, wrapping, and concatenation operate on the structured spans, so
2020
+ # they never have to "figure out what SGR state is active at column N" —
2021
+ # the answer is just the containing span's `style`. The flip side is one
2022
+ # extra type to construct (or parse) before doing styled-text math.
2023
+ #
2024
+ # ## Constructors
2025
+ #
2026
+ # ```ruby
2027
+ # StyledString.new # empty
2028
+ # StyledString.plain("hello") # default style
2029
+ # StyledString.styled("hello", fg: :red, bold: true)
2030
+ # StyledString.parse("\e[31mhello\e[0m world") # ANSI → spans
2031
+ # ```
2032
+ #
2033
+ # ## Algebra
2034
+ #
2035
+ # All operations return a fresh {StyledString} — the underlying spans are
2036
+ # frozen and shared. `+` coerces a `String` operand via {.parse}.
2037
+ #
2038
+ # ```ruby
2039
+ # a + b # concatenate
2040
+ # ss.slice(2, 5) # 5 display columns starting at column 2
2041
+ # ss.slice(2..5) # range (inclusive end)
2042
+ # ss.lines # split on "\n" → Array<StyledString>
2043
+ # ss.each_char_with_style { |ch, style| ... }
2044
+ # ```
2045
+ #
2046
+ # ## Rendering
2047
+ #
2048
+ # - `#to_s` — plain text, no SGR.
2049
+ # - `#to_ansi` — minimal-diff SGR rendering, ending with `\e[0m` only when
2050
+ # the last span carried a non-default style. Transitions to the default
2051
+ # style emit `\e[0m` (shorter than re-emitting every off-code).
2052
+ #
2053
+ # ## Parser
2054
+ #
2055
+ # {.parse} is strict by design: it recognizes only the SGR codes
2056
+ # corresponding to {Style}'s supported attributes (fg/bg/bold/italic/
2057
+ # underline). Anything else — unmodeled attributes (dim, blink, reverse,
2058
+ # strike, conceal, double-underline, overline, ...), unknown SGR codes, or
2059
+ # non-SGR escapes (cursor moves, OSC) — raises {ParseError}. This keeps the
2060
+ # round-trip parse(to_ansi(x)) == x contract honest.
2061
+ class StyledString
2062
+ EMPTY: StyledString
2063
+
2064
+ # sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS
2065
+ # _@param_ `text`
2066
+ def self.plain: (_ToS text) -> StyledString
2067
+
2068
+ # sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS
2069
+ # _@param_ `text`
2070
+ #
2071
+ # _@param_ `style_kwargs` — forwarded to {Style.new}.
2072
+ def self.styled: (_ToS text, **::Hash[Symbol, Object] style_kwargs) -> StyledString
2073
+
2074
+ # Parses an ANSI/SGR-coded string into a {StyledString}. A {StyledString}
2075
+ # input is returned as-is. `nil` and the empty string both fast-path to
2076
+ # {EMPTY}. Strings without any `\e` byte fast-path to a single
2077
+ # default-styled span.
2078
+ #
2079
+ # _@param_ `input`
2080
+ def self.parse: ((String | StyledString)? input) -> StyledString
2081
+
2082
+ # _@param_ `spans`
2083
+ def initialize: (?::Array[Span] spans) -> void
2084
+
2085
+ # Total display width in terminal columns, accounting for Unicode wide
2086
+ # characters (fullwidth CJK = 2 columns, combining marks = 0, etc.).
2087
+ # Memoized — safe because spans are frozen and immutable.
2088
+ def display_width: () -> Integer
2089
+
2090
+ def empty?: () -> bool
2091
+
2092
+ # Plain text concatenation across all spans — no SGR codes.
2093
+ def to_s: () -> String
2094
+
2095
+ # Rendered ANSI string. Minimal-diff between adjacent spans: only the
2096
+ # attributes that changed are emitted. A transition to the default style
2097
+ # emits `\e[0m` (one code) instead of the longer "turn each attribute
2098
+ # off" form. Always closes with `\e[0m` when the last span carried a
2099
+ # non-default style, so the styled run doesn't bleed into subsequent
2100
+ # output. Memoized — safe because spans are frozen and immutable.
2101
+ def to_ansi: () -> String
2102
+
2103
+ # _@param_ `other`
2104
+ def ==: (Object other) -> bool
2105
+
2106
+ def hash: () -> Integer
2107
+
2108
+ # Concatenation. A `String` operand is parsed via {.parse} before joining
2109
+ # (so embedded ANSI escapes round-trip through spans).
2110
+ #
2111
+ # _@param_ `other`
2112
+ def +: ((StyledString | String) other) -> StyledString
2113
+
2114
+ # Substring by display columns, preserving spans. Characters whose column
2115
+ # range only partially overlaps the slice (e.g. a 2-column CJK character
2116
+ # straddling the start or end boundary) are dropped — never split.
2117
+ #
2118
+ # Accepts either `slice(start_col, len_col)` or `slice(range)`. Both
2119
+ # forms support negative indices counting from the end of the string.
2120
+ #
2121
+ # _@param_ `start_col`
2122
+ #
2123
+ # _@param_ `len_col`
2124
+ def slice: (Integer start_col, Integer len_col) -> StyledString
2125
+
2126
+ # Truncates to a target column width, appending an ellipsis when
2127
+ # characters were dropped. The ellipsis counts toward the target — the
2128
+ # returned {StyledString}'s `display_width` never exceeds
2129
+ # `display_width`. When `self` already fits, `self` is returned. When
2130
+ # `display_width` is smaller than the ellipsis's own width, the ellipsis
2131
+ # is sliced down to fit and no original content is included.
2132
+ #
2133
+ # _@param_ `display_width` — target column width.
2134
+ #
2135
+ # _@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.
2136
+ def ellipsize: (Integer display_width, ?(String | StyledString) ellipsis) -> StyledString
2137
+
2138
+ # Splits on `"\n"`, preserving spans on each side. A trailing newline
2139
+ # produces a trailing empty {StyledString} (matches `split("\n", -1)`).
2140
+ # An empty {StyledString} returns a single empty entry, like `"".split`.
2141
+ def lines: () -> ::Array[StyledString]
2142
+
2143
+ # Word-wraps to physical lines that each fit within `width` display
2144
+ # columns, preserving spans and styles across breaks. Greedy word-wrap,
2145
+ # hard-break for words wider than `width`, leading whitespace dropped on
2146
+ # wrapped continuations, hard `"\n"` breaks preserved as separate output
2147
+ # lines.
2148
+ #
2149
+ # Whitespace runs are space or tab; other characters are treated as word
2150
+ # content. When a single character is wider than `width` (e.g. a 2-column
2151
+ # CJK character with `width = 1`), it is still emitted on its own line at
2152
+ # its natural width. The "no line exceeds `width`" guarantee therefore
2153
+ # holds whenever every character is at most `width` columns wide.
2154
+ #
2155
+ # _@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.
2156
+ #
2157
+ # _@return_ — one entry per physical (output) line.
2158
+ # An empty receiver returns `[]`.
2159
+ def wrap: (Integer? width) -> ::Array[StyledString]
2160
+
2161
+ # Yields each character (per `String#each_char`) along with the {Style}
2162
+ # it carries. Returns an `Enumerator` without a block.
2163
+ def each_char_with_style: () -> (::Enumerator[untyped] | self)
2164
+
2165
+ # Returns a new {StyledString} with `bg` applied to every span, preserving
2166
+ # each span's text and other style attributes (`fg`, `bold`, `italic`,
2167
+ # `underline`). Useful for row-level highlights — the new bg overlays
2168
+ # without dropping foreground colors the original styling carried.
2169
+ #
2170
+ # _@param_ `bg` — background color, in any of the forms accepted by {Style.new}. `nil` clears bg back to the terminal default.
2171
+ def with_bg: ((Symbol | Integer | ::Array[Integer])? bg) -> StyledString
2172
+
2173
+ def inspect: () -> String
2174
+
2175
+ def build_ansi: () -> String
2176
+
2177
+ # _@param_ `spans`
2178
+ def normalize: (::Array[Span] spans) -> ::Array[Span]
2179
+
2180
+ # _@param_ `from`
2181
+ #
2182
+ # _@param_ `to`
2183
+ def sgr_diff: (Style from, Style to) -> String
2184
+
2185
+ # _@param_ `color`
2186
+ #
2187
+ # _@param_ `base` — base SGR code — 30 for fg, 40 for bg.
2188
+ #
2189
+ # _@param_ `ext` — extended-color SGR code — 38 for fg, 48 for bg.
2190
+ def color_codes: ((Symbol | Integer | ::Array[Integer])? color, base: Integer, ext: Integer) -> ::Array[Integer]
2191
+
2192
+ # _@param_ `start_or_range`
2193
+ #
2194
+ # _@param_ `len`
2195
+ #
2196
+ # _@param_ `total` — receiver's full display width.
2197
+ #
2198
+ # _@return_ — normalized `[start_col, len_col]`.
2199
+ def resolve_slice_bounds: ((Integer | ::Range[untyped]) start_or_range, Integer? len, Integer total) -> [Integer, Integer]
2200
+
2201
+ # _@param_ `start`
2202
+ #
2203
+ # _@param_ `len`
2204
+ def slice_spans: (Integer start, Integer len) -> StyledString
2205
+
2206
+ # _@param_ `hard_line` — one hard-broken line — no embedded `"\n"`.
2207
+ #
2208
+ # _@param_ `width`
2209
+ def wrap_one: (StyledString hard_line, Integer width) -> ::Array[StyledString]
2210
+
2211
+ # _@param_ `hard_line`
2212
+ #
2213
+ # _@return_ — tokens shaped `[type, chars, w]` where `type` is
2214
+ # `:space` or `:word`, `chars` is an `Array<[String, Style, Integer]>`
2215
+ # (char, style, display width), and `w` is the token's total width.
2216
+ def tokenize_for_wrap: (StyledString hard_line) -> ::Array[::Array[untyped]]
2217
+
2218
+ # _@param_ `chars` — `[char, style, width]` triples.
2219
+ #
2220
+ # _@param_ `width`
2221
+ #
2222
+ # _@return_ — each inner Array is a `chars`-shaped chunk.
2223
+ def hard_break_chars: (::Array[::Array[untyped]] chars, Integer width) -> ::Array[::Array[::Array[untyped]]]
2224
+
2225
+ # _@param_ `chars` — `[char, style, width]` triples.
2226
+ def chars_to_styled: (::Array[::Array[untyped]] chars) -> StyledString
2227
+
2228
+ # _@param_ `text`
2229
+ #
2230
+ # _@param_ `start_col`
2231
+ #
2232
+ # _@param_ `len_col`
2233
+ def slice_text_by_columns: (String text, Integer start_col, Integer len_col) -> String
2234
+
2235
+ # _@return_ — the frozen, normalized span list — no empty-text
2236
+ # entries, no two adjacent entries sharing a style.
2237
+ attr_reader spans: ::Array[Span]
2238
+
2239
+ # Raised by {.parse} on malformed or unsupported escape sequences.
2240
+ class ParseError < Tuile::Error
2241
+ end
2242
+
2243
+ # A frozen value type describing the visual style of a {Span}.
2244
+ #
2245
+ # `fg` and `bg` accept:
2246
+ # - `nil` — the terminal default (SGR 39 / 49)
2247
+ # - a symbol from {COLOR_SYMBOLS} — 8 standard + 8 bright ANSI colors
2248
+ # - an Integer 0..255 — 256-color palette index (SGR 38;5;N / 48;5;N)
2249
+ # - an `[r, g, b]` Array of three 0..255 Integers — 24-bit RGB
2250
+ #
2251
+ # @!attribute [r] fg
2252
+ # @return [Symbol, Integer, Array<Integer>, nil]
2253
+ # @!attribute [r] bg
2254
+ # @return [Symbol, Integer, Array<Integer>, nil]
2255
+ # @!attribute [r] bold
2256
+ # @return [Boolean]
2257
+ # @!attribute [r] italic
2258
+ # @return [Boolean]
2259
+ # @!attribute [r] underline
2260
+ # @return [Boolean]
2261
+ class Style
2262
+ COLOR_SYMBOLS: ::Array[Symbol]
2263
+ DEFAULT: Style
2264
+
2265
+ # _@param_ `fg`
2266
+ #
2267
+ # _@param_ `bg`
2268
+ #
2269
+ # _@param_ `bold`
2270
+ #
2271
+ # _@param_ `italic`
2272
+ #
2273
+ # _@param_ `underline`
2274
+ def self.new: (
2275
+ ?fg: (Symbol | Integer | ::Array[Integer])?,
2276
+ ?bg: (Symbol | Integer | ::Array[Integer])?,
2277
+ ?bold: bool,
2278
+ ?italic: bool,
2279
+ ?underline: bool
2280
+ ) -> Style
2281
+
2282
+ # _@param_ `color`
2283
+ #
2284
+ # _@param_ `which`
2285
+ def self.validate_color!: (Object color, Symbol which) -> void
2286
+
2287
+ def default?: () -> bool
2288
+
2289
+ # Returns a new {Style} with the given attributes overridden.
2290
+ #
2291
+ # _@param_ `overrides`
2292
+ def merge: (**::Hash[Symbol, Object] overrides) -> Style
2293
+
2294
+ attr_reader fg: (Symbol | Integer | ::Array[Integer])?
2295
+
2296
+ attr_reader bg: (Symbol | Integer | ::Array[Integer])?
2297
+
2298
+ attr_reader bold: bool
2299
+
2300
+ attr_reader italic: bool
2301
+
2302
+ attr_reader underline: bool
2303
+ end
2304
+
2305
+ # A maximal run of text sharing a single {Style}. `text` is plain — it
2306
+ # never contains ANSI escape sequences. Spans inside a {StyledString} are
2307
+ # normalized: no empty text, no two adjacent spans share a style.
2308
+ #
2309
+ # @!attribute [r] text
2310
+ # @return [String] frozen plain text.
2311
+ # @!attribute [r] style
2312
+ # @return [Style]
2313
+ class Span
2314
+ # _@param_ `text`
2315
+ #
2316
+ # _@param_ `style`
2317
+ def initialize: (text: String, style: Style) -> void
2318
+
2319
+ # _@return_ — frozen plain text.
2320
+ attr_reader text: String
2321
+
2322
+ attr_reader style: Style
2323
+ end
2324
+
2325
+ # @api private
2326
+ # Hand-rolled SGR parser. State machine over a {StringScanner}: plain
2327
+ # text accumulates into the current span; each `\e[...m` flushes the
2328
+ # current span and updates the running {Style}. Anything outside the
2329
+ # supported SGR alphabet raises {ParseError}.
2330
+ class Parser
2331
+ STANDARD_COLORS: ::Array[Symbol]
2332
+ BRIGHT_COLORS: ::Array[Symbol]
2333
+
2334
+ # _@param_ `input`
2335
+ def initialize: (String input) -> void
2336
+
2337
+ def parse: () -> StyledString
2338
+
2339
+ def consume_text: () -> void
2340
+
2341
+ def consume_escape: () -> void
2342
+
2343
+ # _@param_ `params_str`
2344
+ def apply_sgr: (String params_str) -> void
2345
+
2346
+ # _@param_ `codes`
2347
+ #
2348
+ # _@param_ `index`
2349
+ #
2350
+ # _@param_ `target` — either `:fg` or `:bg`.
2351
+ #
2352
+ # _@return_ — how many SGR codes were consumed (3 for 256-color, 5 for RGB).
2353
+ def consume_extended_color: (::Array[Integer] codes, Integer index, Symbol target) -> Integer
2354
+
2355
+ def flush: () -> void
2356
+ end
2357
+ end
2358
+
1454
2359
  # A "synchronous" event queue – no loop is run, submitted blocks are run right
1455
2360
  # away and submitted events are thrown away. Intended for testing only.
1456
2361
  class FakeEventQueue