tuile 0.2.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.
@@ -373,39 +381,6 @@ module Tuile
373
381
  attr_accessor focused: Component?
374
382
  end
375
383
 
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
389
-
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
398
-
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
407
- end
408
-
409
384
  # A UI component which is positioned on the screen and draws characters into
410
385
  # its bounding rectangle (in {#repaint}).
411
386
  #
@@ -589,24 +564,34 @@ module Tuile
589
564
  # no parent.
590
565
  attr_accessor parent: Component?
591
566
 
592
- # A scrollable list of String items with cursor support.
567
+ # A scrollable list of items with cursor support.
593
568
  #
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.
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.
598
576
  #
599
577
  # 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.
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.
602
581
  class List < Component
582
+ CURSOR_BG: Integer
583
+
603
584
  def initialize: () -> void
604
585
 
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>`.
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>`.
608
593
  #
609
- # _@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`.
610
595
  def lines=: (::Array[untyped] lines) -> void
611
596
 
612
597
  # Without a block, returns the current lines. With a block, fully
@@ -617,19 +602,21 @@ module Tuile
617
602
  # end
618
603
  # ```
619
604
  #
620
- # _@return_ — current lines (when called without a block).
621
- 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]
622
608
 
609
+ # sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS
623
610
  # Adds a line.
624
611
  #
625
612
  # _@param_ `line`
626
- def add_line: (String line) -> void
613
+ def add_line: ((String | StyledString | _ToS) line) -> void
627
614
 
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=}.
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.
631
618
  #
632
- # _@param_ `lines` — entries need only respond to `#to_s`.
619
+ # _@param_ `lines` — entries are `String`, `StyledString`, or anything that responds to `#to_s`.
633
620
  def add_lines: (::Array[untyped] lines) -> void
634
621
 
635
622
  def content_size: () -> Size
@@ -646,6 +633,8 @@ module Tuile
646
633
  # Moves the cursor to the next line whose text contains `query`
647
634
  # (case-insensitive substring match). Search wraps around the end of the
648
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.
649
638
  #
650
639
  # _@param_ `query` — substring to match. Empty query never matches.
651
640
  #
@@ -669,11 +658,38 @@ module Tuile
669
658
  # Paints the list items into {#rect}.
670
659
  #
671
660
  # Skips the {Component#repaint} default's auto-clear: every row of
672
- # {#rect} is painted below (with padded content past the last item),
661
+ # {#rect} is painted below (with blank padding past the last item),
673
662
  # so the parent contract — "fully draw over your rect" — is met
674
663
  # without an upfront wipe.
675
664
  def repaint: () -> void
676
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
+
677
693
  # _@return_ — true if the cursor sits on a real content line.
678
694
  def cursor_on_item?: () -> bool
679
695
 
@@ -681,9 +697,9 @@ module Tuile
681
697
  # Caller must ensure {#cursor_on_item?}.
682
698
  def fire_item_chosen: () -> void
683
699
 
684
- # _@return_ — `[position, line_at_position]`,
685
- # with `line` nil when the cursor is off-content.
686
- def cursor_state: () -> [[Integer, String, NilClass]]
700
+ # _@return_ — `[position, line_at_position]`, with `line` nil when the cursor is
701
+ # off-content.
702
+ def cursor_state: () -> [[Integer, StyledString, NilClass]]
687
703
 
688
704
  # Fires {#on_cursor_changed} if {#cursor_state} differs from the last
689
705
  # fired state. Idempotent — safe to call after any mutation.
@@ -734,45 +750,53 @@ module Tuile
734
750
  # _@return_ — whether the scrollbar should be drawn right now.
735
751
  def scrollbar_visible?: () -> bool
736
752
 
737
- # 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.
738
768
  #
739
- # _@param_ `str`
769
+ # _@param_ `line`
740
770
  #
741
- # _@param_ `width`
742
- 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
743
774
 
744
775
  # _@param_ `index` — 0-based index into {#lines}.
745
776
  #
746
777
  # _@param_ `row_in_viewport` — 0-based row within the viewport.
747
778
  #
748
- # _@param_ `width` — number of columns the line should occupy.
749
- #
750
779
  # _@param_ `scrollbar` — scrollbar instance, or nil if not shown.
751
780
  #
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
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
760
784
 
761
785
  # _@return_ — callback fired when an item is chosen — by pressing
762
786
  # 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).
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).
766
790
  attr_accessor on_item_chosen: Proc?
767
791
 
768
792
  # _@return_ — callback fired when the `(index, line)` tuple under
769
793
  # 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.
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.
776
800
  attr_accessor on_cursor_changed: Proc?
777
801
 
778
802
  # _@return_ — if true and a line is added or new content is set,
@@ -900,20 +924,44 @@ module Tuile
900
924
  end
901
925
  end
902
926
 
903
- # 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}.
904
932
  class Label < Component
905
933
  def initialize: () -> void
906
934
 
907
- # _@param_ `text` draws this text. May contain ANSI formatting. Clipped automatically.
908
- def text=: (String? text) -> void
909
-
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)`.
910
938
  def content_size: () -> Size
911
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.
912
946
  def repaint: () -> void
913
947
 
914
948
  def on_width_changed: () -> void
915
949
 
916
- 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)?
917
965
  end
918
966
 
919
967
  # A modal overlay that wraps any {Component} as its content. Popup itself
@@ -1199,7 +1247,6 @@ module Tuile
1199
1247
  class TextArea < Component
1200
1248
  ACTIVE_BG_SGR: String
1201
1249
  INACTIVE_BG_SGR: String
1202
- SGR_RESET: String
1203
1250
 
1204
1251
  def initialize: () -> void
1205
1252
 
@@ -1293,6 +1340,169 @@ module Tuile
1293
1340
  attr_accessor on_change: (Proc | Method)?
1294
1341
  end
1295
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
+
1296
1506
  # Shows a log. Construct your logger pointed at a {LogWindow::IO} to route
1297
1507
  # log lines into this window:
1298
1508
  #
@@ -1338,7 +1548,6 @@ module Tuile
1338
1548
  class TextField < Component
1339
1549
  ACTIVE_BG_SGR: String
1340
1550
  INACTIVE_BG_SGR: String
1341
- SGR_RESET: String
1342
1551
 
1343
1552
  def initialize: () -> void
1344
1553
 
@@ -1801,6 +2010,352 @@ module Tuile
1801
2010
  attr_reader status_bar: Component::Label
1802
2011
  end
1803
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
+
1804
2359
  # A "synchronous" event queue – no loop is run, submitted blocks are run right
1805
2360
  # away and submitted events are thrown away. Intended for testing only.
1806
2361
  class FakeEventQueue