tuile 0.3.0 → 0.5.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
@@ -45,14 +45,51 @@ module Tuile
45
45
  PAGE_DOWN: String
46
46
  BACKSPACE: String
47
47
  DELETE: String
48
+ CTRL_A: String
49
+ CTRL_B: String
50
+ CTRL_C: String
51
+ CTRL_D: String
52
+ CTRL_E: String
53
+ CTRL_F: String
54
+ CTRL_G: String
48
55
  CTRL_H: String
49
- BACKSPACES: ::Array[String]
56
+ CTRL_I: String
57
+ CTRL_J: String
58
+ CTRL_K: String
59
+ CTRL_L: String
60
+ CTRL_M: String
61
+ CTRL_N: String
62
+ CTRL_O: String
63
+ CTRL_P: String
64
+ CTRL_Q: String
65
+ CTRL_R: String
66
+ CTRL_S: String
67
+ CTRL_T: String
50
68
  CTRL_U: String
51
- CTRL_D: String
69
+ CTRL_V: String
70
+ CTRL_W: String
71
+ CTRL_X: String
72
+ CTRL_Y: String
73
+ CTRL_Z: String
74
+ BACKSPACES: ::Array[String]
52
75
  ENTER: String
53
76
  TAB: String
54
77
  SHIFT_TAB: String
55
78
 
79
+ # True iff `key` is a single printable character — a one-character string
80
+ # whose codepoint is not in Unicode's C (Other) category. Rejects multi-
81
+ # character escape sequences ({UP_ARROW}, mouse events, …), control bytes
82
+ # ({TAB}, {ENTER}, {ESC}, {CTRL_A}..{CTRL_Z}, {BACKSPACE}), and the empty
83
+ # string; accepts ASCII letters/digits/punctuation/space *and* non-ASCII
84
+ # printables like "é".
85
+ #
86
+ # Used by {Screen#register_global_shortcut} to reject keys that would
87
+ # collide with typing, and by {Tuile::Component::TextField} to decide
88
+ # whether to insert a key at the caret.
89
+ #
90
+ # _@param_ `key`
91
+ def self.printable?: (String key) -> bool
92
+
56
93
  # Grabs a key from stdin and returns it. Blocks until the key is obtained.
57
94
  # Reads a full ESC key sequence; see constants above for some values returned
58
95
  # by this function.
@@ -151,6 +188,70 @@ module Tuile
151
188
  attr_reader height: Integer
152
189
  end
153
190
 
191
+ # An immutable terminal color. Accepts the three forms ANSI/SGR understands:
192
+ #
193
+ # - a Symbol from {COLOR_SYMBOLS} — 8 standard + 8 bright named colors
194
+ # (SGR 30..37 / 90..97 for fg, 40..47 / 100..107 for bg)
195
+ # - an Integer 0..255 — the 256-color palette (SGR 38;5;N / 48;5;N)
196
+ # - an Array of three Integers 0..255 — 24-bit RGB (SGR 38;2;R;G;B / 48;2;R;G;B)
197
+ #
198
+ # A constant per named color is pre-defined (`Color::RED`, `Color::BRIGHT_BLUE`,
199
+ # …) so callers can reach for `Color::RED` instead of building one each time.
200
+ # {.coerce} accepts anything {.new} accepts plus `nil` (terminal default) and
201
+ # an existing {Color} (returned as-is), so APIs that accept colors typically
202
+ # take `[Color, nil]` and pass through {.coerce}.
203
+ #
204
+ # ```ruby
205
+ # Color.new(:red) # named
206
+ # Color.new(42) # 256-color palette
207
+ # Color.new([255, 100, 0]) # RGB
208
+ # Color::RED # constant
209
+ # Color.coerce(:red) # accepts raw forms, returns Color
210
+ # Color.coerce(nil) # nil → nil
211
+ # ```
212
+ #
213
+ # {#to_ansi} renders a full SGR escape (`"\e[31m"`); {#sgr_codes} returns the
214
+ # raw numeric codes so callers (notably {StyledString}) can combine them with
215
+ # other SGR attributes in a single sequence.
216
+ class Color
217
+ COLOR_SYMBOLS: ::Array[Symbol]
218
+
219
+ # Coerces the input to a {Color}. `nil` passes through unchanged (callers
220
+ # use `nil` for the terminal default); an existing {Color} is returned
221
+ # as-is; otherwise the value is fed to {.new}.
222
+ #
223
+ # _@param_ `value`
224
+ def self.coerce: ((Color | Symbol | Integer | ::Array[Integer])? value) -> Color?
225
+
226
+ # _@param_ `value` — see class-level docs for the three accepted forms.
227
+ def initialize: ((Symbol | Integer | ::Array[Integer]) value) -> void
228
+
229
+ # SGR parameter codes for emitting this color as either a foreground
230
+ # (`target: :fg`) or background (`target: :bg`). Returned as an array so
231
+ # callers can splice them into a multi-attribute SGR (e.g. bold + color).
232
+ #
233
+ # _@param_ `target` — `:fg` or `:bg`.
234
+ def sgr_codes: (?Symbol target) -> ::Array[Integer]
235
+
236
+ # Full SGR escape sequence for this color (e.g. `"\e[31m"`). Useful for
237
+ # `print`-style direct emission; for composing with other attributes use
238
+ # {#sgr_codes} instead.
239
+ #
240
+ # _@param_ `target` — `:fg` or `:bg`.
241
+ def to_ansi: (?Symbol target) -> String
242
+
243
+ # _@param_ `other`
244
+ def ==: (Object other) -> bool
245
+
246
+ def hash: () -> Integer
247
+
248
+ def inspect: () -> String
249
+
250
+ # The underlying raw representation — a Symbol, Integer, or frozen
251
+ # Array<Integer>.
252
+ attr_reader value: (Symbol | Integer | ::Array[Integer])
253
+ end
254
+
154
255
  # A point with `x` and `y` integer coordinates, both 0-based.
155
256
  #
156
257
  # @!attribute [r] x
@@ -216,6 +317,22 @@ module Tuile
216
317
  # _@param_ `component`
217
318
  def invalidate: (Component component) -> void
218
319
 
320
+ # Rebuild the status-bar text from the current focus and global-shortcut
321
+ # registry. Called from {#focused=} and whenever the global registry
322
+ # changes. Popups own their own "q Close" prefix in `#keyboard_hint`;
323
+ # for the tiled case Screen tacks on the global "q quit" instead.
324
+ # Global-shortcut hints get spliced in too — see {#global_shortcut_hints}
325
+ # for the over_popups filter rule.
326
+ def refresh_status_bar: () -> void
327
+
328
+ # Status-bar hints from currently-registered global shortcuts.
329
+ # When a popup is open, only `over_popups: true` shortcuts contribute —
330
+ # the rest don't fire in that context, so showing them would be a lie.
331
+ # Insertion order is preserved (Hash iteration order).
332
+ #
333
+ # _@param_ `popup_open`
334
+ def global_shortcut_hints: (popup_open: bool) -> ::Array[String]
335
+
219
336
  # Internal — use {Component::Popup#open} instead. Adds the popup to
220
337
  # {#pane}, centers and focuses it.
221
338
  #
@@ -224,7 +341,9 @@ module Tuile
224
341
 
225
342
  # Runs event loop – waits for keys and sends them to active window. The
226
343
  # function exits when the 'ESC' or 'q' key is pressed.
227
- def run_event_loop: () -> void
344
+ #
345
+ # _@param_ `capture_mouse` — when true (default), enables xterm mouse tracking so clicks and scroll wheel arrive as {MouseEvent}s and feed {Component#handle_mouse}. When false, no tracking escape sequence is written: the terminal keeps its native click handling, which is what you want if the app benefits more from select-to-copy than from click-to-focus. Components' `handle_mouse` is simply never invoked from the loop in that mode (the terminal stops sending the bytes).
346
+ def run_event_loop: (?capture_mouse: bool) -> void
228
347
 
229
348
  # Advances focus to the next {Component#tab_stop?} in tree order, wrapping
230
349
  # around. Scope is the topmost popup if one is open, otherwise {#content}
@@ -239,6 +358,51 @@ module Tuile
239
358
  # _@return_ — true if focus moved.
240
359
  def focus_previous: () -> bool
241
360
 
361
+ # Registers an app-level keyboard shortcut. When `key` arrives, the block
362
+ # is invoked on the event-loop thread (so it may freely mutate UI) before
363
+ # the key reaches any component. Re-registering the same key replaces the
364
+ # previous binding; use {#unregister_global_shortcut} to remove one.
365
+ #
366
+ # Only unprintable keys are accepted — control characters (Ctrl+letter,
367
+ # ESC, BACKSPACE, ENTER, …) and multi-character escape sequences (arrows,
368
+ # F-keys, …). Printable keys raise {ArgumentError}: they'd hijack typing
369
+ # into a {Component::TextField} and should be expressed as
370
+ # {Component#key_shortcut} instead, which the dispatcher suppresses while
371
+ # a text widget owns the hardware cursor. TAB and SHIFT_TAB are also
372
+ # rejected because {#handle_key} intercepts them for focus navigation
373
+ # before the global registry is consulted, so a binding on them would
374
+ # silently never fire.
375
+ #
376
+ # Pass `hint:` to surface the shortcut in the status bar. It's a
377
+ # preformatted string the caller fully owns (so colors and the key label
378
+ # style stay consistent with whatever the host app uses elsewhere). The
379
+ # framework splices it in like any other status hint: in the tiled case,
380
+ # right after `q quit` and before the active window's own hint; while a
381
+ # popup is open, only hints from `over_popups: true` shortcuts are
382
+ # shown, and they're prepended before the popup's `q Close`.
383
+ #
384
+ # Example — open a log popup with Ctrl+L from anywhere, even while a
385
+ # popup is already on screen:
386
+ #
387
+ # screen.register_global_shortcut(Keys::CTRL_L,
388
+ # over_popups: true,
389
+ # hint: "^L #{Rainbow("log").cadetblue}") do
390
+ # log_popup.open
391
+ # end
392
+ #
393
+ # _@param_ `key` — unprintable key (e.g. {Keys::CTRL_L}, {Keys::ESC}, {Keys::PAGE_UP}).
394
+ #
395
+ # _@param_ `over_popups` — when true, fires even while a modal popup is open (pre-empting the popup's own key handling). When false (default), the shortcut is suppressed while any popup is open and the popup gets the key instead.
396
+ #
397
+ # _@param_ `hint` — preformatted status-bar hint (e.g. `"^L #{Rainbow("log").cadetblue}"`). When nil (default) the shortcut is silent in the status bar.
398
+ def register_global_shortcut: (String key, ?over_popups: bool, ?hint: String?) -> void
399
+
400
+ # Removes a shortcut previously installed by {#register_global_shortcut}.
401
+ # No-op if `key` was not registered.
402
+ #
403
+ # _@param_ `key`
404
+ def unregister_global_shortcut: (String key) -> void
405
+
242
406
  # _@return_ — current active tiled component.
243
407
  def active_window: () -> Component?
244
408
 
@@ -330,10 +494,17 @@ module Tuile
330
494
  # A key has been pressed on the keyboard. Handle it, or forward to active
331
495
  # window.
332
496
  #
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.
497
+ # Dispatch order:
498
+ # 1. Tab / Shift+Tab reserved focus navigation, intercepted before
499
+ # anything else so a focused {Component::TextField} (which would
500
+ # otherwise swallow printable keys via cursor-owner suppression)
501
+ # doesn't trap them.
502
+ # 2. App-level shortcuts from {#register_global_shortcut}. An entry
503
+ # registered with `over_popups: true` always fires; one with the
504
+ # default `over_popups: false` fires only when no popup is open
505
+ # (otherwise the popup receives the key normally).
506
+ # 3. {ScreenPane#handle_key}, which routes to the topmost popup or
507
+ # tiled content.
337
508
  #
338
509
  # _@param_ `key`
339
510
  #
@@ -379,13 +550,29 @@ module Tuile
379
550
 
380
551
  # _@return_ — currently focused component.
381
552
  attr_accessor focused: Component?
553
+
554
+ # Entry in the global shortcut registry: the block to run, whether it
555
+ # pre-empts open popups, and an optional preformatted status-bar hint.
556
+ # @api private
557
+ class Shortcut < Data
558
+ # Returns the value of attribute block
559
+ attr_reader block: Object
560
+
561
+ # Returns the value of attribute over_popups
562
+ attr_reader over_popups: Object
563
+
564
+ # Returns the value of attribute hint
565
+ attr_reader hint: Object
566
+ end
382
567
  end
383
568
 
384
569
  # A UI component which is positioned on the screen and draws characters into
385
570
  # its bounding rectangle (in {#repaint}).
386
571
  #
387
- # Component is considered invisible if {#rect} is empty or one of left/top is
388
- # negative. The component won't draw when invisible.
572
+ # Painting is gated by attachment: a detached component (one whose {#root}
573
+ # isn't {Screen#pane}) is never enqueued for repaint via {#invalidate}, and
574
+ # any stale invalidation entries are filtered out at drain time. Subclasses
575
+ # can paint freely in {#repaint} without re-asserting attachment.
389
576
  class Component
390
577
  def initialize: () -> void
391
578
 
@@ -417,6 +604,8 @@ module Tuile
417
604
  # responsibility for {#rect}. Everything else should call super.
418
605
  #
419
606
  # A component must not draw outside of {#rect}.
607
+ #
608
+ # Only called when the component is attached.
420
609
  def repaint: () -> void
421
610
 
422
611
  # Called when a character is pressed on the keyboard.
@@ -537,6 +726,12 @@ module Tuile
537
726
 
538
727
  # Invalidates the component: {Screen} records this component as
539
728
  # needs-repaint and once all events are processed, will call {#repaint}.
729
+ #
730
+ # No-op when the component is not {#attached?} — a detached component has
731
+ # no place on the screen to paint to, so {Screen} must never end up
732
+ # repainting it. Callers don't need to guard their own `invalidate` calls;
733
+ # mutating a detached component (e.g. setting `lines=` on a {List} sitting
734
+ # inside a closed {Component::Popup}) is silent.
540
735
  def invalidate: () -> void
541
736
 
542
737
  # Whether direct children fully tile {#rect}. Used by the default
@@ -665,7 +860,11 @@ module Tuile
665
860
 
666
861
  # Rebuilds pre-padded lines when the wrap width changes. The wrap width
667
862
  # depends on {#rect}`.width` and the scrollbar gutter, both of which
668
- # trigger this hook.
863
+ # trigger this hook. Also re-evaluates {#auto_scroll}: if items were
864
+ # appended while the rect was empty (e.g. a {Popup}-wrapped list got
865
+ # `add_line` calls before the popup was opened), the auto-scroll update
866
+ # was skipped because there was no viewport — re-run it now that there
867
+ # is one, so the list snaps to the bottom on first paint.
669
868
  def on_width_changed: () -> void
670
869
 
671
870
  # Coerces and flattens a list of input entries into trimmed
@@ -744,7 +943,14 @@ module Tuile
744
943
  # _@param_ `delta` — negative scrolls up, positive scrolls down.
745
944
  def move_top_line_by: (Integer delta) -> void
746
945
 
747
- # If auto-scrolling, recalculate the top line.
946
+ # If auto-scrolling, recalculate the top line and snap the cursor to the
947
+ # last reachable position. Without the cursor snap the viewport gets
948
+ # yanked back to wherever the cursor sat on the next arrow press,
949
+ # negating the auto-scroll. Skipped when {#rect} is empty: without a
950
+ # viewport the "lines minus viewport" formula yields `@lines.size`,
951
+ # which would leave `top_line` past the last item once a real rect
952
+ # arrives. {#on_width_changed} re-runs this hook when the rect grows so
953
+ # the snap-to-bottom intent is preserved.
748
954
  def update_top_line_if_auto_scroll: () -> void
749
955
 
750
956
  # _@return_ — whether the scrollbar should be drawn right now.
@@ -853,6 +1059,15 @@ module Tuile
853
1059
  # _@return_ — true if the position changed.
854
1060
  def go: (Integer new_position) -> bool
855
1061
 
1062
+ # Moves the cursor to the last reachable position. For base {Cursor},
1063
+ # the last line; {Limited} clamps to the last allowed position; {None}
1064
+ # is a no-op.
1065
+ #
1066
+ # _@param_ `line_count` — number of lines in the list.
1067
+ #
1068
+ # _@return_ — true if the position changed.
1069
+ def go_to_last: (Integer line_count) -> bool
1070
+
856
1071
  # _@param_ `lines`
857
1072
  #
858
1073
  # _@param_ `line_count`
@@ -863,9 +1078,6 @@ module Tuile
863
1078
 
864
1079
  def go_to_first: () -> bool
865
1080
 
866
- # _@param_ `line_count`
867
- def go_to_last: (Integer line_count) -> bool
868
-
869
1081
  # _@return_ — 0-based line index of the current cursor position.
870
1082
  attr_reader position: Integer
871
1083
 
@@ -889,6 +1101,16 @@ module Tuile
889
1101
 
890
1102
  # _@param_ `_line_count`
891
1103
  def candidate_positions: (Integer _line_count) -> ::Array[Integer]
1104
+
1105
+ # Overridden so all movement funnels — base {Cursor#go_to_last},
1106
+ # {Cursor#go_to_first}, etc., which all call {#go} — become safe
1107
+ # no-ops on a disabled cursor. The instance is frozen, so a default
1108
+ # mutating {#go} would raise.
1109
+ #
1110
+ # _@param_ `_new_position`
1111
+ #
1112
+ # _@return_ — always false.
1113
+ def go: (Integer _new_position) -> bool
892
1114
  end
893
1115
 
894
1116
  # Cursor which can only land on specific allowed lines.
@@ -908,6 +1130,9 @@ module Tuile
908
1130
  # _@param_ `line_count`
909
1131
  def candidate_positions: (Integer line_count) -> ::Array[Integer]
910
1132
 
1133
+ # _@param_ `_line_count`
1134
+ def go_to_last: (Integer _line_count) -> bool
1135
+
911
1136
  # _@param_ `lines`
912
1137
  #
913
1138
  # _@param_ `line_count`
@@ -917,9 +1142,6 @@ module Tuile
917
1142
  def go_up_by: (Integer lines) -> bool
918
1143
 
919
1144
  def go_to_first: () -> bool
920
-
921
- # _@param_ `_line_count`
922
- def go_to_last: (Integer _line_count) -> bool
923
1145
  end
924
1146
  end
925
1147
  end
@@ -951,9 +1173,13 @@ module Tuile
951
1173
  # Each line is ellipsized to fit, padded with trailing spaces out to
952
1174
  # the full width, and pre-rendered to ANSI so {#repaint} is just a
953
1175
  # lookup + screen.print per row. {@blank_line} covers rows past the
954
- # last text line.
1176
+ # last text line. When {#bg} is set, every produced line (and the
1177
+ # blank row) has the bg applied uniformly.
955
1178
  def update_clipped_lines: () -> void
956
1179
 
1180
+ # _@param_ `line`
1181
+ def apply_bg: (StyledString line) -> StyledString
1182
+
957
1183
  # _@param_ `line`
958
1184
  #
959
1185
  # _@param_ `width`
@@ -962,6 +1188,11 @@ module Tuile
962
1188
  # _@return_ — the current text. Defaults to an empty
963
1189
  # {StyledString}.
964
1190
  attr_accessor text: (StyledString | String)?
1191
+
1192
+ # _@return_ — background color applied uniformly across every
1193
+ # painted row (including padding past the text). `nil` (default)
1194
+ # leaves whatever bg the text's own styling carries.
1195
+ attr_accessor bg: (Color | Symbol | Integer | ::Array[Integer])?
965
1196
  end
966
1197
 
967
1198
  # A modal overlay that wraps any {Component} as its content. Popup itself
@@ -992,7 +1223,9 @@ module Tuile
992
1223
 
993
1224
  def focusable?: () -> bool
994
1225
 
995
- # Mounts this popup on the {Screen}.
1226
+ # Mounts this popup on the {Screen}. Recomputes the popup's size from
1227
+ # the current content first, so reopening a popup whose content has
1228
+ # grown or shrunk while closed picks up the new size.
996
1229
  def open: () -> void
997
1230
 
998
1231
  # Constructs and opens a popup in one call.
@@ -1150,8 +1383,9 @@ module Tuile
1150
1383
  #
1151
1384
  # The window's `content` is unset by default; assign one via {#content=}.
1152
1385
  #
1153
- # Window is considered invisible if {#rect} is empty or one of left/top is
1154
- # negative. The window won't draw when invisible.
1386
+ # Window is considered invisible if {#rect} is empty. The window won't
1387
+ # draw when invisible. (Repaint of detached windows is short-circuited
1388
+ # by {Component#invalidate}; subclasses don't need to re-check.)
1155
1389
  class Window < Component
1156
1390
  include Tuile::Component::HasContent
1157
1391
 
@@ -1180,10 +1414,6 @@ module Tuile
1180
1414
  # window has no content, footer, or caption.
1181
1415
  def content_size: () -> Size
1182
1416
 
1183
- # _@return_ — true if {#rect} is off screen and the window won't
1184
- # paint.
1185
- def visible?: () -> bool
1186
-
1187
1417
  # Fully repaints the window: both frame and contents.
1188
1418
  #
1189
1419
  # Window deliberately paints over its entire rect (border around the
@@ -1244,26 +1474,26 @@ module Tuile
1244
1474
  # Currently only {#on_change} is wired; Enter inserts a newline as in any
1245
1475
  # plain `<textarea>` or text editor. A future `on_enter`/`on_submit`
1246
1476
  # callback may opt out of that by consuming Enter instead.
1247
- class TextArea < Component
1477
+ class TextArea < Tuile::Component::TextInput
1248
1478
  ACTIVE_BG_SGR: String
1249
1479
  INACTIVE_BG_SGR: String
1250
1480
 
1251
1481
  def initialize: () -> void
1252
1482
 
1253
- def focusable?: () -> bool
1254
-
1255
- def tab_stop?: () -> bool
1256
-
1257
1483
  def cursor_position: () -> Point?
1258
1484
 
1259
- # _@param_ `key`
1260
- def handle_key: (String key) -> bool
1261
-
1262
1485
  # _@param_ `event`
1263
1486
  def handle_mouse: (MouseEvent event) -> void
1264
1487
 
1265
1488
  def repaint: () -> void
1266
1489
 
1490
+ def on_text_mutated: () -> void
1491
+
1492
+ def on_caret_mutated: () -> void
1493
+
1494
+ # _@param_ `key`
1495
+ def handle_text_input_key: (String key) -> bool
1496
+
1267
1497
  def on_width_changed: () -> void
1268
1498
 
1269
1499
  # _@return_ — cached wrap of {#text} for the
@@ -1306,38 +1536,11 @@ module Tuile
1306
1536
  # _@return_ — always true.
1307
1537
  def insert_char: (String char) -> bool
1308
1538
 
1309
- def delete_before_caret: () -> void
1310
-
1311
- def delete_at_caret: () -> void
1312
-
1313
1539
  # Keeps the caret visible by scrolling vertically.
1314
1540
  def adjust_top_display_row: () -> void
1315
1541
 
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
1542
  # _@return_ — index of the topmost display row currently visible.
1332
1543
  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
1544
  end
1342
1545
 
1343
1546
  # A read-only viewer for prose: chunks of formatted text that scroll
@@ -1350,9 +1553,19 @@ module Tuile
1350
1553
  # ANSI-as-bytes wrapping, color does *not* get dropped on continuation
1351
1554
  # rows). {#text=} accepts a {String} (parsed via {StyledString.parse},
1352
1555
  # 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.
1556
+ # always returns the {StyledString}.
1557
+ #
1558
+ # For incremental updates pick the right primitive: {#append} (aliased
1559
+ # as `<<`) is verbatim and stream-friendly — chunks are concatenated
1560
+ # straight onto the buffer, with embedded `\n` becoming hard breaks.
1561
+ # {#add_line} is the "log entry" convenience — it starts the content on
1562
+ # a fresh line by inserting a leading `\n` when the buffer is non-empty.
1563
+ # {#remove_last_n_lines} pops hard lines back off the tail — the
1564
+ # inverse of building up a region with {#append} / {#add_line}, so a
1565
+ # caller streaming reformattable content (e.g. partially-rendered
1566
+ # Markdown that may need to retract its last paragraph) can replace
1567
+ # the tail without rewriting the whole text. Turn on {#auto_scroll}
1568
+ # to keep the latest content in view.
1356
1569
  #
1357
1570
  # TextView is meant to be the content of a {Window} — focus indication and
1358
1571
  # keyboard-hint surfacing rely on the surrounding window chrome.
@@ -1373,22 +1586,129 @@ module Tuile
1373
1586
  # honored); a `StyledString` is used as-is; `nil` is coerced to an
1374
1587
  # empty {StyledString}.
1375
1588
  #
1589
+ # Detaches every existing {Region} (including the original default)
1590
+ # and installs a fresh internal default region that owns all the new
1591
+ # hard lines. Any handle the caller was holding becomes detached and
1592
+ # raises on use — see {Region#attached?}. The no-op short-circuit
1593
+ # (matching value, same {StyledString}) preserves existing regions.
1594
+ #
1376
1595
  # _@param_ `value`
1377
1596
  def text=: ((String | StyledString)? value) -> void
1378
1597
 
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=}.
1598
+ # Creates a new empty {Region} at the spatial tail of the document
1599
+ # and returns its handle. Subsequent {#append} / {#<<} / {#add_line}
1600
+ # calls route through this new region (since it is now the spatial
1601
+ # tail). Earlier regions keep their content and their handles stay
1602
+ # valid; their {Region#range} shifts as later regions grow.
1603
+ #
1604
+ # Apps streaming logically-distinct sections (e.g. an LLM's "thinking"
1605
+ # vs. "assistant" output) create one region per section, hold the
1606
+ # handles, and call `region.append` / `region.text=` directly when
1607
+ # they need to grow or rewrite an earlier section.
1608
+ def create_region: () -> Region
1609
+
1610
+ # _@return_ — true iff {#text} is empty (no hard lines).
1611
+ def empty?: () -> bool
1612
+
1613
+ # Appends `str` verbatim. Embedded `\n` characters become hard line
1614
+ # breaks; otherwise the text is concatenated onto the current last
1615
+ # hard line. Designed for streaming use (e.g. an LLM chat window
1616
+ # receiving partial messages — feed each chunk straight in). Accepts
1617
+ # the same input forms as {#text=}; empty/`nil` input is a no-op.
1618
+ #
1619
+ # For the "add an entry on a new line" pattern use {#add_line}.
1383
1620
  #
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.
1621
+ # Cost is O(appended + width-of-current-last-hard-line) — the
1622
+ # previously last hard line is re-wrapped (because the extension may
1623
+ # cause it to wrap differently), any additional hard lines created by
1624
+ # embedded `\n` are wrapped fresh. The cached {#text} is invalidated
1625
+ # and rebuilt on demand.
1388
1626
  #
1389
1627
  # _@param_ `str`
1390
1628
  def append: ((String | StyledString)? str) -> void
1391
1629
 
1630
+ # Verbatim append, returning `self` for chainability (`view << a << b`).
1631
+ #
1632
+ # _@param_ `str`
1633
+ def <<: ((String | StyledString)? str) -> self
1634
+
1635
+ # Appends `str` as a new entry: starts a fresh hard line first (when
1636
+ # the buffer is non-empty) and then appends `str`. Equivalent to
1637
+ # `append("\n" + str)` on a non-empty buffer, or `append(str)` on an
1638
+ # empty one. `nil` and `""` produce a blank entry on a non-empty
1639
+ # buffer and a no-op on an empty buffer (matches the old `append`
1640
+ # semantics for "log line" callers).
1641
+ #
1642
+ # _@param_ `str`
1643
+ def add_line: ((String | StyledString)? str) -> void
1644
+
1645
+ # Drops the last `n` hard lines from the buffer. The inverse of
1646
+ # building up a tail region with {#append} / {#add_line}: a caller
1647
+ # streaming partially-rendered content whose tail must occasionally
1648
+ # be retracted (e.g. Markdown-to-ANSI where a new token reformats
1649
+ # the table being built) can call `remove_last_n_lines(k)` followed
1650
+ # by `append(new_tail)` to replace the damaged region in place.
1651
+ #
1652
+ # `n == 0` and the empty-buffer case are no-ops (no invalidation).
1653
+ # `n >= hard-line count` empties the buffer.
1654
+ #
1655
+ # Operates on **hard lines** (the `\n`-delimited entries the
1656
+ # buffer stores), not on wrapped physical rows — same granularity
1657
+ # as {#add_line}. Cost is O(rendered-rows of the popped lines).
1658
+ #
1659
+ # _@param_ `n` — number of hard lines to drop; must be >= 0.
1660
+ def remove_last_n_lines: (Integer n) -> void
1661
+
1662
+ # Replaces a contiguous range of hard lines with the parsed content
1663
+ # of `str`. The replacement is parsed exactly like {#text=} and
1664
+ # {#append}: a {String} is run through {StyledString.parse} (so
1665
+ # embedded ANSI is honored), a {StyledString} is used as-is, `nil`
1666
+ # behaves like an empty replacement (the range is deleted). Embedded
1667
+ # `"\n"` in the replacement produces multiple hard lines, so a single
1668
+ # `replace` can grow or shrink the buffer.
1669
+ #
1670
+ # `range` selects which hard lines to swap out:
1671
+ #
1672
+ # - an `Integer` `n` is shorthand for `n..n` (replace one existing
1673
+ # line — `n` must be in `[0, hard-line count)`);
1674
+ # - a non-empty `Range` of hard-line indices replaces those lines;
1675
+ # - an empty `Range` (e.g. `2...2`, or the canonical end-insertion
1676
+ # `hard_lines.size...hard_lines.size`) is *insertion* at that
1677
+ # position — no lines are removed. {#insert} is a thin alias for
1678
+ # this case.
1679
+ #
1680
+ # Endpoints must be non-negative integers; `begin` may equal
1681
+ # `hard-line count` (insertion at the end), `end` may not exceed
1682
+ # `hard-line count - 1`. `nil` endpoints (beginless / endless ranges)
1683
+ # are not accepted.
1684
+ #
1685
+ # Cost is roughly `O(from + length + new content)`: the splice
1686
+ # updates only the affected slice of the physical-row buffer, using
1687
+ # the per-hard-line wrap-count cache to locate the starting offset
1688
+ # without re-wrapping preceding lines. Lines outside the splice are
1689
+ # never re-wrapped. {#top_line} is clamped if the new line count
1690
+ # puts it past the end; {#auto_scroll} pins it to the bottom as
1691
+ # usual. The call is a no-op (no invalidation) when the parsed
1692
+ # replacement equals the covered range (vacuously true for an empty
1693
+ # range plus empty replacement, so `replace(n...n, "")` is a cheap
1694
+ # no-op).
1695
+ #
1696
+ # _@param_ `range` — hard-line indices to replace.
1697
+ #
1698
+ # _@param_ `str` — replacement content.
1699
+ def replace: ((::Range[untyped] | Integer) range, (String | StyledString)? str) -> void
1700
+
1701
+ # Inserts `str` at hard-line index `at`. Equivalent to
1702
+ # `replace(at...at, str)` — a no-removal splice that grows the buffer
1703
+ # by the parsed line count. `at == hard-line count` is allowed and
1704
+ # appends at the end; for that case {#append} / {#add_line} are
1705
+ # usually more idiomatic.
1706
+ #
1707
+ # _@param_ `at` — 0-based hard-line index in `[0, hard-line count]`.
1708
+ #
1709
+ # _@param_ `str` — content to insert.
1710
+ def insert: (Integer at, (String | StyledString)? str) -> void
1711
+
1392
1712
  # Clears the text. Equivalent to `text = ""`.
1393
1713
  def clear: () -> void
1394
1714
 
@@ -1414,30 +1734,164 @@ module Tuile
1414
1734
  # this hook.
1415
1735
  def on_width_changed: () -> void
1416
1736
 
1737
+ # Validates and unpacks a {#replace}-style range argument into
1738
+ # inclusive `[from, to]` line indices. An `Integer` `n` becomes
1739
+ # `[n, n]` (which must point at an existing line — `Integer` is
1740
+ # never insertion sugar). A `Range` is normalized for
1741
+ # `exclude_end?`; `to == from - 1` is a valid empty range
1742
+ # (insertion at `from`), and `from` may equal `size` for
1743
+ # end-insertion. Shared by {#replace} and {Region#replace};
1744
+ # `size` is the buffer or region line count, and `what` is the
1745
+ # entity name woven into error messages.
1746
+ #
1747
+ # _@param_ `range`
1748
+ #
1749
+ # _@param_ `size`
1750
+ #
1751
+ # _@param_ `what`
1752
+ def normalize_replace_range: ((::Range[untyped] | Integer) range, ?Integer size, ?String what) -> [Integer, Integer]
1753
+
1754
+ # Hard-line index where `region` begins in {@hard_lines} — derived
1755
+ # by summing the line counts of all regions that precede it.
1756
+ #
1757
+ # _@param_ `region`
1758
+ def region_start_index: (Region region) -> Integer
1759
+
1760
+ # Joined {StyledString} of the hard lines that `region` owns. Mirrors
1761
+ # {#text} but scoped to one region.
1762
+ #
1763
+ # _@param_ `region`
1764
+ def text_for_region: (Region region) -> StyledString
1765
+
1766
+ # Replaces all of `region`'s hard lines with the parsed content of
1767
+ # `value`. Symmetric with {#text=}, scoped to one region. Empty/nil
1768
+ # content empties the region (no visible blank line). Works on
1769
+ # already-empty regions (insertion at the region's position).
1770
+ #
1771
+ # _@param_ `region`
1772
+ #
1773
+ # _@param_ `value`
1774
+ def set_region_text: (Region region, (String | StyledString)? value) -> void
1775
+
1776
+ # Region-scoped {#replace}. Validates `range` against
1777
+ # `region.line_count`, translates region-relative indices to
1778
+ # absolute buffer indices, splices, and updates the region's count.
1779
+ #
1780
+ # _@param_ `region`
1781
+ #
1782
+ # _@param_ `range`
1783
+ #
1784
+ # _@param_ `str`
1785
+ def replace_in_region: (Region region, (::Range[untyped] | Integer) range, (String | StyledString)? str) -> void
1786
+
1787
+ # Verbatim append into `region`.
1788
+ #
1789
+ # _@param_ `region`
1790
+ #
1791
+ # _@param_ `str`
1792
+ def append_to_region: (Region region, (String | StyledString)? str) -> void
1793
+
1794
+ # Drops the last `n` hard lines from `region`'s tail via
1795
+ # {#splice_hard_lines}. `n` is clamped to the region's current
1796
+ # line count; callers guarantee `n > 0` and the region is
1797
+ # non-empty (the {Region#remove_last_n_lines} guard handles the
1798
+ # no-op cases).
1799
+ #
1800
+ # _@param_ `region`
1801
+ #
1802
+ # _@param_ `n`
1803
+ def remove_last_n_from_region: (Region region, Integer n) -> void
1804
+
1805
+ # Drops `region` from {@regions}: its hard lines are removed via
1806
+ # {#splice_hard_lines}, the handle is detached, and the always-one
1807
+ # default is restored if the removal would have left zero regions.
1808
+ # Skips the rewrap / invalidate work when the region was empty
1809
+ # (the buffer didn't change), but always detaches.
1810
+ #
1811
+ # _@param_ `region`
1812
+ def remove_region: (Region region) -> void
1813
+
1814
+ # Adjusts region line counts after a {@hard_lines} splice that
1815
+ # removed `removed_count` lines at index `from` and inserted
1816
+ # `added_count` in their place. Two passes:
1817
+ #
1818
+ # 1. Subtract each region's overlap with the removed range (uses
1819
+ # the original counts to compute positions). Remember the first
1820
+ # region that lost lines — that's the natural home for the
1821
+ # replacement content.
1822
+ # 2. Credit `added_count` to that region. For pure insertions (no
1823
+ # removal), there's no "first overlapping region" to pick from;
1824
+ # walk regions and credit the latest one starting at `from` (the
1825
+ # boundary tiebreaker matches the spatial-tail-routing of
1826
+ # {#append}). Past-the-end inserts fall back to the tail region.
1827
+ #
1828
+ # _@param_ `from`
1829
+ #
1830
+ # _@param_ `removed_count`
1831
+ #
1832
+ # _@param_ `added_count`
1833
+ def update_region_counts: (Integer from, Integer removed_count, Integer added_count) -> void
1834
+
1417
1835
  # _@return_ — number of visible lines.
1418
1836
  def viewport_lines: () -> Integer
1419
1837
 
1420
1838
  # _@return_ — the max value of {#top_line} for scroll-key clamping.
1421
1839
  def top_line_max: () -> Integer
1422
1840
 
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.
1841
+ # Full rebuild of {@physical_lines} and {@hard_line_wrap_counts}
1842
+ # from {@hard_lines}. Called when wrap width changes (which
1843
+ # invalidates every cached row count) and from {#text=} (which
1844
+ # replaces the whole logical model). Mid-buffer mutators splice
1845
+ # incrementally via {#splice_hard_lines} and do *not* go through
1846
+ # here. Clamps {@top_line} if the new line count puts it out of
1847
+ # range.
1429
1848
  def rewrap: () -> void
1430
1849
 
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.
1850
+ # Wraps `hard_line` at `width` and returns the padded physical rows
1851
+ # alongside the row count. Empty hard lines (e.g. from a `"\n\n"`
1852
+ # run) and degenerate `width <= 0` both emit a single {@blank_line}
1853
+ # row, matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
1854
+ # would have produced.
1436
1855
  #
1437
- # _@param_ `hard_line` — one hard-broken line (no embedded `"\n"`).
1856
+ # _@param_ `hard_line`
1438
1857
  #
1439
1858
  # _@param_ `width`
1440
- def append_physical_lines: (StyledString hard_line, Integer width) -> void
1859
+ def wrap_hard_line: (StyledString hard_line, Integer width) -> [::Array[StyledString], Integer]
1860
+
1861
+ # Appends `hard_line` to the tail of {@hard_lines}, updating the
1862
+ # wrap-count cache and {@physical_lines} in lockstep.
1863
+ #
1864
+ # _@param_ `hard_line`
1865
+ #
1866
+ # _@param_ `width`
1867
+ def push_hard_line: (StyledString hard_line, Integer width) -> void
1868
+
1869
+ # Pops the last hard line, the corresponding cache entry, and the
1870
+ # physical rows that hard line contributed. Returns the popped
1871
+ # hard line.
1872
+ def pop_hard_line: () -> StyledString
1873
+
1874
+ # Splices `new_hard_lines` into the buffer in place of the `count`
1875
+ # hard lines starting at index `from`. Updates {@hard_lines},
1876
+ # {@hard_line_wrap_counts}, and {@physical_lines} consistently.
1877
+ # The starting physical-row offset is computed in O(`from`) integer
1878
+ # adds via the cache — no wraps of preceding hard lines. Wraps are
1879
+ # done only for the new content, so total cost is
1880
+ # `O(from + count + new_hard_lines.sum(&:display_width))`.
1881
+ #
1882
+ # _@param_ `from`
1883
+ #
1884
+ # _@param_ `count` — number of existing hard lines to remove.
1885
+ #
1886
+ # _@param_ `new_hard_lines`
1887
+ def splice_hard_lines: (Integer from, Integer count, ::Array[StyledString] new_hard_lines) -> void
1888
+
1889
+ # _@param_ `idx`
1890
+ #
1891
+ # _@return_ — the {@physical_lines} index where the hard line
1892
+ # at {@hard_lines}`[idx]` starts. O(`idx`) integer adds via the
1893
+ # wrap-count cache.
1894
+ def phys_offset_at: (Integer idx) -> Integer
1441
1895
 
1442
1896
  # Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
1443
1897
  # default-styled `"\n"` between hard lines. Called from the {#text}
@@ -1501,6 +1955,136 @@ module Tuile
1501
1955
  # `Size.new(0, 0)`. Maintained incrementally by {#text=} and
1502
1956
  # {#append}, so reads are O(1).
1503
1957
  attr_reader content_size: Size
1958
+
1959
+ # A logical section of a {TextView}'s text — a contiguous run of
1960
+ # hard lines the app wants to address as a unit (e.g. an LLM's
1961
+ # "thinking" output vs. its assistant message). The view always
1962
+ # has at least one region, an internal default that owns whatever
1963
+ # hard lines aren't claimed by an app-created region.
1964
+ #
1965
+ # Apps don't construct regions directly; call {TextView#create_region}
1966
+ # to get one. The handle stays valid as long as the region is
1967
+ # attached — i.e. until {TextView#text=} (or {TextView#clear}) wipes
1968
+ # the slate and installs a fresh internal default. Detached regions
1969
+ # raise {RuntimeError} on every mutator and reader.
1970
+ #
1971
+ # A region's position is derived from its sibling order and counts,
1972
+ # so growing or shrinking an earlier region implicitly shifts the
1973
+ # ranges of all later regions. Empty regions occupy zero rows but
1974
+ # still hold a position in the sequence; `region.text = ""` collapses
1975
+ # a region's visible footprint without detaching it. Pre-creating
1976
+ # empty placeholder regions is supported and is the natural pattern
1977
+ # for "I'll fill this in later" layouts.
1978
+ class Region
1979
+ # _@param_ `view` — the owning view (never `nil` at construction).
1980
+ #
1981
+ # _@param_ `line_count` — number of hard lines this region owns.
1982
+ def initialize: (TextView view, ?Integer line_count) -> void
1983
+
1984
+ # _@return_ — `true` while the region is owned by its
1985
+ # {TextView}. Becomes `false` permanently once detached
1986
+ # (typically by {TextView#text=} / {TextView#clear}).
1987
+ def attached?: () -> bool
1988
+
1989
+ # _@return_ — true iff the region owns zero hard lines.
1990
+ # Empty regions render nothing — they still hold a position in
1991
+ # the sequence, so subsequent mutations route to them as usual.
1992
+ def empty?: () -> bool
1993
+
1994
+ # _@return_ — the joined content of just this region's
1995
+ # hard lines. Empty regions return {StyledString::EMPTY}.
1996
+ def text: () -> StyledString
1997
+
1998
+ # Replaces all of this region's hard lines with the parsed content
1999
+ # of `value`. Accepts the same inputs as {TextView#text=}; empty
2000
+ # or `nil` content collapses the region to zero hard lines.
2001
+ #
2002
+ # _@param_ `value`
2003
+ def text=: ((String | StyledString)? value) -> void
2004
+
2005
+ # Verbatim append into this region's tail. Same semantics as
2006
+ # {TextView#append} but scoped to the region: embedded `"\n"`
2007
+ # creates new hard lines within the region, no-leading-newline
2008
+ # input extends the region's last hard line. Empty / `nil` input
2009
+ # is a no-op (but still raises when detached). When the region is
2010
+ # the spatial tail of the view, this uses the incremental
2011
+ # {TextView#append} path; mid-document regions splice the affected
2012
+ # slice of the physical-row buffer (lines outside the region are
2013
+ # not re-wrapped).
2014
+ #
2015
+ # _@param_ `str`
2016
+ def append: ((String | StyledString)? str) -> void
2017
+
2018
+ # _@return_ — the hard-line indices this region currently
2019
+ # occupies — `start...(start + line_count)`. Empty regions
2020
+ # return a degenerate exclusive range at their position (e.g.
2021
+ # `5...5`). The result is computed on each call and so always
2022
+ # reflects sibling mutations.
2023
+ def range: () -> ::Range[untyped]
2024
+
2025
+ # Removes this region from its view. The region's hard lines (if
2026
+ # any) are deleted from the buffer — subsequent regions' ranges
2027
+ # shift up by `line_count` — and the handle detaches permanently.
2028
+ # The view keeps its always-≥1-region invariant: if this was the
2029
+ # only remaining region, a fresh internal default is installed
2030
+ # (the app doesn't get a handle to it; call
2031
+ # {TextView#create_region} again to start tracking).
2032
+ #
2033
+ # Idempotent: calling `remove` on an already-detached region is a
2034
+ # silent no-op (unlike the other mutators, which raise). This
2035
+ # lets cleanup paths blindly call `remove` without first checking
2036
+ # {#attached?}.
2037
+ def remove: () -> void
2038
+
2039
+ # Appends `str` as a new entry in this region: starts a fresh
2040
+ # hard line first (when the region is non-empty), then appends
2041
+ # `str`. Scoped equivalent of {TextView#add_line}. On an empty
2042
+ # region behaves like {#append}.
2043
+ #
2044
+ # _@param_ `str`
2045
+ def add_line: ((String | StyledString)? str) -> void
2046
+
2047
+ # Replaces a contiguous range of this region's hard lines with the
2048
+ # parsed content of `str`. Region-scoped counterpart of
2049
+ # {TextView#replace}: indices are 0-based **within the region**
2050
+ # (so `replace(0, "x")` rewrites the region's first line, not
2051
+ # the buffer's). Same range conventions apply — `Integer`,
2052
+ # inclusive/exclusive `Range`, empty range as insertion at
2053
+ # `begin`, and `begin == line_count` for end-insertion.
2054
+ #
2055
+ # _@param_ `range` — region-relative hard-line indices.
2056
+ #
2057
+ # _@param_ `str` — replacement content.
2058
+ def replace: ((::Range[untyped] | Integer) range, (String | StyledString)? str) -> void
2059
+
2060
+ # Inserts `str` at region-relative hard-line index `at`.
2061
+ # Equivalent to `replace(at...at, str)`. Region-scoped counterpart
2062
+ # of {TextView#insert}; `at == line_count` is allowed and appends
2063
+ # at the region's tail.
2064
+ #
2065
+ # _@param_ `at` — region-relative index in `[0, line_count]`.
2066
+ #
2067
+ # _@param_ `str`
2068
+ def insert: (Integer at, (String | StyledString)? str) -> void
2069
+
2070
+ # Drops the last `n` hard lines from this region's tail.
2071
+ # Subsequent regions' ranges shift up by the number actually
2072
+ # dropped. `n` is clamped to {#line_count}, so passing a large
2073
+ # `n` empties the region — the handle stays attached (use
2074
+ # {#remove} when the goal is to drop the region itself).
2075
+ # `n == 0` and an already-empty region are no-ops.
2076
+ #
2077
+ # _@param_ `n`
2078
+ def remove_last_n_lines: (Integer n) -> void
2079
+
2080
+ def detach!: () -> void
2081
+
2082
+ def check_attached: () -> void
2083
+
2084
+ # _@return_ — number of hard lines this region owns. Safe to
2085
+ # read on a detached region (no error raised).
2086
+ attr_accessor line_count: (Integer | untyped)
2087
+ end
1504
2088
  end
1505
2089
 
1506
2090
  # Shows a log. Construct your logger pointed at a {LogWindow::IO} to route
@@ -1516,6 +2100,11 @@ module Tuile
1516
2100
  # _@param_ `caption`
1517
2101
  def initialize: (?String caption) -> void
1518
2102
 
2103
+ # Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
2104
+ #
2105
+ # _@param_ `string` — the line (or multiple lines) to log.
2106
+ def log: (String? string) -> void
2107
+
1519
2108
  # IO-shaped adapter that forwards each log line to the owning {LogWindow}.
1520
2109
  # Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
1521
2110
  # call `output.puts`, e.g. `TTY::Logger`).
@@ -1545,26 +2134,28 @@ module Tuile
1545
2134
  # The caret is a logical index in `0..text.length`. The hardware cursor is
1546
2135
  # positioned by {Screen} after each repaint cycle when this component is
1547
2136
  # focused; see {Component#cursor_position}.
1548
- class TextField < Component
2137
+ class TextField < Tuile::Component::TextInput
1549
2138
  ACTIVE_BG_SGR: String
1550
2139
  INACTIVE_BG_SGR: String
1551
2140
 
1552
2141
  def initialize: () -> void
1553
2142
 
1554
- def focusable?: () -> bool
1555
-
1556
- def tab_stop?: () -> bool
1557
-
1558
2143
  def cursor_position: () -> Point?
1559
2144
 
1560
- # _@param_ `key`
1561
- def handle_key: (String key) -> bool
1562
-
1563
2145
  # _@param_ `event`
1564
2146
  def handle_mouse: (MouseEvent event) -> void
1565
2147
 
1566
2148
  def repaint: () -> void
1567
2149
 
2150
+ # Truncate to fit `rect.width - 1` — single-line fields can't grow past
2151
+ # their width.
2152
+ #
2153
+ # _@param_ `new_text`
2154
+ def preprocess_text: (String new_text) -> String
2155
+
2156
+ # _@param_ `key`
2157
+ def handle_text_input_key: (String key) -> bool
2158
+
1568
2159
  def on_width_changed: () -> void
1569
2160
 
1570
2161
  # Maximum number of characters {#text} can hold given current width.
@@ -1573,12 +2164,110 @@ module Tuile
1573
2164
  # _@param_ `char`
1574
2165
  def insert: (String char) -> bool
1575
2166
 
2167
+ # Optional callback fired when the UP arrow key is pressed. When set, UP
2168
+ # is consumed by the field; when nil, UP falls through to the parent
2169
+ # (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
2170
+ # since `k` is a printable character inserted into {#text}.
2171
+ #
2172
+ # _@return_ — no-arg callable, or nil.
2173
+ attr_accessor on_key_up: (Proc | Method)?
2174
+
2175
+ # Optional callback fired when the DOWN arrow key is pressed. When set,
2176
+ # DOWN is consumed by the field; when nil, DOWN falls through to the
2177
+ # parent (default behavior). Only triggered by {Keys::DOWN_ARROW}, not by
2178
+ # `j`, since `j` is a printable character inserted into {#text}.
2179
+ #
2180
+ # _@return_ — no-arg callable, or nil.
2181
+ attr_accessor on_key_down: (Proc | Method)?
2182
+
2183
+ # Optional callback fired when ENTER is pressed. When set, ENTER is
2184
+ # consumed by the field; when nil, ENTER falls through to the parent
2185
+ # (default behavior).
2186
+ #
2187
+ # _@return_ — no-arg callable, or nil.
2188
+ attr_accessor on_enter: (Proc | Method)?
2189
+ end
2190
+
2191
+ # Abstract base for editable text components ({TextField}, {TextArea}).
2192
+ #
2193
+ # Holds the shared state — a mutable {#text} buffer, a {#caret} index,
2194
+ # {#on_change} and {#on_escape} callbacks — and the keyboard machinery
2195
+ # that single-line and multi-line inputs both need: ESC handling,
2196
+ # LEFT/RIGHT caret movement, CTRL+LEFT/CTRL+RIGHT word jumps, and the
2197
+ # `focusable?`/`tab_stop?` flags.
2198
+ #
2199
+ # Subclasses implement the layout-specific pieces ({#cursor_position},
2200
+ # {#repaint}) and add their own keys (HOME/END, ENTER, UP/DOWN,
2201
+ # printable insertion) by overriding the protected
2202
+ # {#handle_text_input_key} hook — `super` falls through to the common
2203
+ # navigation handling.
2204
+ #
2205
+ # The mutation pipeline is a template method: {#text=} and {#caret=}
2206
+ # detect no-ops, mutate state, fire {#on_change}, and invalidate.
2207
+ # Subclasses inject their own behavior via two protected hooks:
2208
+ #
2209
+ # - {#preprocess_text} — input filter (e.g. {TextField} truncates to
2210
+ # fit `rect.width - 1`).
2211
+ # - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
2212
+ # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
2213
+ # keep the caret visible).
2214
+ class TextInput < Component
2215
+ ACTIVE_BG_SGR: String
2216
+ INACTIVE_BG_SGR: String
2217
+
2218
+ def initialize: () -> void
2219
+
2220
+ # _@return_ — true iff {#text} is the empty string.
2221
+ def empty?: () -> bool
2222
+
2223
+ def focusable?: () -> bool
2224
+
2225
+ def tab_stop?: () -> bool
2226
+
2227
+ # Handles a key. Returns false when the component is inactive. Otherwise
2228
+ # first runs the {Component#handle_key} shortcut search via `super`, then
2229
+ # delegates to {#handle_text_input_key}.
2230
+ #
2231
+ # _@param_ `key`
2232
+ def handle_key: (String key) -> bool
2233
+
2234
+ # Input filter for {#text=}. Subclasses override to truncate or reject
2235
+ # invalid input. Default coerces to String.
2236
+ #
2237
+ # _@param_ `new_text`
2238
+ #
2239
+ # _@return_ — possibly transformed text.
2240
+ def preprocess_text: (String new_text) -> String
2241
+
2242
+ # Hook called after {#text} has been mutated, before invalidation /
2243
+ # {#on_change}. Default no-op. Subclasses use this to invalidate caches
2244
+ # ({TextArea}'s wrap cache) and update derived state.
2245
+ def on_text_mutated: () -> void
2246
+
2247
+ # Hook called after {#caret} has been mutated, before invalidation.
2248
+ # Default no-op. Subclasses use this to keep the caret visible
2249
+ # ({TextArea}'s vertical scroll).
2250
+ def on_caret_mutated: () -> void
2251
+
2252
+ # Dispatch hook for {#handle_key}. Handles ESC and the navigation keys
2253
+ # that have identical semantics in single-line and multi-line inputs:
2254
+ # LEFT/RIGHT arrows, CTRL+LEFT/CTRL+RIGHT for word jumps. Subclasses
2255
+ # override to add their own keys (HOME/END, UP/DOWN, ENTER, BACKSPACE/
2256
+ # DELETE, printable insertion) and call `super` to fall back to the
2257
+ # common navigation handling.
2258
+ #
2259
+ # _@param_ `key`
2260
+ #
2261
+ # _@return_ — true if the key was handled.
2262
+ def handle_text_input_key: (String key) -> bool
2263
+
1576
2264
  def delete_before_caret: () -> void
1577
2265
 
1578
2266
  def delete_at_caret: () -> void
1579
2267
 
1580
- # _@param_ `key`
1581
- def printable?: (String key) -> bool
2268
+ # Default {#on_escape} action: clear focus. Component deactivates; user
2269
+ # can re-focus by clicking or tabbing back in.
2270
+ def default_on_escape: () -> void
1582
2271
 
1583
2272
  # Caret target for ctrl+left: skip whitespace going left, then a run of
1584
2273
  # non-whitespace. Lands at the beginning of the current word, or the
@@ -1596,13 +2285,6 @@ module Tuile
1596
2285
  # _@return_ — caret index in `0..text.length`.
1597
2286
  attr_accessor caret: Integer
1598
2287
 
1599
- # Optional callback fired when ESC is pressed. When set, ESC is consumed
1600
- # by the field; when nil, ESC falls through to the parent (default
1601
- # behavior).
1602
- #
1603
- # _@return_ — no-arg callable, or nil.
1604
- attr_accessor on_escape: (Proc | Method)?
1605
-
1606
2288
  # Optional callback fired whenever {#text} changes. Receives the new text
1607
2289
  # as a single argument. Not fired by {#caret=} (text unchanged) and not
1608
2290
  # fired when a setter is a no-op.
@@ -1610,28 +2292,14 @@ module Tuile
1610
2292
  # _@return_ — one-arg callable, or nil.
1611
2293
  attr_accessor on_change: (Proc | Method)?
1612
2294
 
1613
- # Optional callback fired when the UP arrow key is pressed. When set, UP
1614
- # is consumed by the field; when nil, UP falls through to the parent
1615
- # (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
1616
- # since `k` is a printable character inserted into {#text}.
1617
- #
1618
- # _@return_ — no-arg callable, or nil.
1619
- attr_accessor on_key_up: (Proc | Method)?
1620
-
1621
- # Optional callback fired when the DOWN arrow key is pressed. When set,
1622
- # DOWN is consumed by the field; when nil, DOWN falls through to the
1623
- # parent (default behavior). Only triggered by {Keys::DOWN_ARROW}, not by
1624
- # `j`, since `j` is a printable character inserted into {#text}.
2295
+ # Callback fired when ESC is pressed. Defaults to a closure that clears
2296
+ # focus (`screen.focused = nil`) so ESC visibly cancels text entry instead
2297
+ # of bubbling to the parent — and, in particular, instead of reaching the
2298
+ # screen's default ESC-to-quit handler. Set to nil to let ESC fall through
2299
+ # to the parent again; set to any other callable to replace the default.
1625
2300
  #
1626
2301
  # _@return_ — no-arg callable, or nil.
1627
- attr_accessor on_key_down: (Proc | Method)?
1628
-
1629
- # Optional callback fired when ENTER is pressed. When set, ENTER is
1630
- # consumed by the field; when nil, ENTER falls through to the parent
1631
- # (default behavior).
1632
- #
1633
- # _@return_ — no-arg callable, or nil.
1634
- attr_accessor on_enter: (Proc | Method)?
2302
+ attr_accessor on_escape: (Proc | Method)?
1635
2303
  end
1636
2304
 
1637
2305
  # A mixin interface for a component with one child tops. The host must
@@ -1758,13 +2426,39 @@ module Tuile
1758
2426
  # Awaits until the event queue is empty (all events have been processed).
1759
2427
  def await_empty: () -> void
1760
2428
 
2429
+ # Schedules `block` to fire on the event-loop thread roughly `fps` times
2430
+ # per second, passing a 0-based monotonically increasing tick counter. Use
2431
+ # it for animations (e.g. a `/-\|` spinner in a {Component::Label}) or
2432
+ # periodic UI refresh from a background task.
2433
+ #
2434
+ # The returned {Ticker} controls the schedule — call {Ticker#cancel} to
2435
+ # stop it.
2436
+ #
2437
+ # **Errors:** if `block` raises, the {Ticker} cancels itself and the
2438
+ # exception flows through the normal event-loop error path — i.e.
2439
+ # {Screen#on_error} for the default Tuile setup. Auto-cancel prevents a
2440
+ # broken block from spamming `on_error` at the tick rate.
2441
+ #
2442
+ # Tickers reuse `concurrent-ruby`'s shared timer thread
2443
+ # ({Concurrent}.global_timer_set) — adding more tickers does not add more
2444
+ # threads, just more work on the shared scheduler.
2445
+ #
2446
+ # _@param_ `fps` — firings per second, must be positive. Fractional values are fine (`fps: 0.5` ⇒ one tick every two seconds).
2447
+ def tick: (Numeric fps) ?{ (Integer tick) -> void } -> Ticker
2448
+
1761
2449
  # Runs the event loop and blocks. Must be run from at most one thread at the
1762
2450
  # same time. Blocks until some thread calls {#stop}. Calls block for all
1763
- # events submitted via {#post}; the block is always called from the thread
1764
- # running this function.
2451
+ # events; the block is always called from the thread running this function.
1765
2452
  #
1766
- # Any exception raised by block is re-thrown, causing this function to
1767
- # terminate.
2453
+ # Any exception raised by the block is re-thrown, causing this function to
2454
+ # terminate. Wrap the block body in `rescue` if you want to handle errors
2455
+ # without tearing down the loop — see {Screen#event_loop} for an example.
2456
+ #
2457
+ # **Procs are yielded too.** A {#submit}ed block arrives as a `Proc` event;
2458
+ # the consumer is responsible for invoking it (typically `event.call`).
2459
+ # Yielding rather than dispatching inline means a raise inside the
2460
+ # submitted block flows through the consumer's `rescue` like any other
2461
+ # event-handler error, instead of bypassing it.
1768
2462
  def run_loop: () ?{ (Object event) -> void } -> void
1769
2463
 
1770
2464
  # _@return_ — true if this thread is running inside an event queue.
@@ -1835,6 +2529,36 @@ module Tuile
1835
2529
  class EmptyQueueEvent
1836
2530
  include Singleton
1837
2531
  end
2532
+
2533
+ # Handle returned by {EventQueue#tick}. Cancel a running ticker via
2534
+ # {#cancel}.
2535
+ #
2536
+ # Internally wraps a `Concurrent::TimerTask` whose firing posts a single
2537
+ # submit-block to the owning {EventQueue}; the user's block therefore
2538
+ # always runs on the event-loop thread and may freely mutate UI. If the
2539
+ # user block raises, the Ticker auto-cancels and the exception is
2540
+ # re-raised so it flows through the loop's normal error handling
2541
+ # ({Screen#on_error} for the default Tuile setup).
2542
+ class Ticker
2543
+ # _@param_ `event_queue` — queue to dispatch tick calls onto.
2544
+ #
2545
+ # _@param_ `fps` — firings per second (positive).
2546
+ #
2547
+ # _@param_ `block` — called as `block.call(tick_count)` on each fire.
2548
+ def initialize: (EventQueue event_queue, Numeric fps, Proc block) -> void
2549
+
2550
+ # _@return_ — true once {#cancel} has been called.
2551
+ def cancelled?: () -> bool
2552
+
2553
+ # Stops the ticker. Idempotent and safe to call from any thread,
2554
+ # including from inside the tick block. Any tick already queued on the
2555
+ # event loop at the moment of cancellation is dropped before the user
2556
+ # block runs.
2557
+ def cancel: () -> void
2558
+
2559
+ # Runs on the event-loop thread.
2560
+ def fire: () -> void
2561
+ end
1838
2562
  end
1839
2563
 
1840
2564
  # Testing only — a screen which doesn't paint anything and pretends that the
@@ -1881,7 +2605,7 @@ module Tuile
1881
2605
  #
1882
2606
  # @!attribute [r] button
1883
2607
  # @return [Symbol, nil] one of `:left`, `:middle`, `:right`, `:scroll_up`,
1884
- # `:scroll_down`; `nil` if not known.
2608
+ # `:scroll_down`, `:scroll_left`, `:scroll_right`; `nil` if not known.
1885
2609
  # @!attribute [r] x
1886
2610
  # @return [Integer] x coordinate, 0-based.
1887
2611
  # @!attribute [r] y
@@ -1890,14 +2614,29 @@ module Tuile
1890
2614
  # _@return_ — the event's position.
1891
2615
  def point: () -> Point
1892
2616
 
1893
- # Checks whether given key is a mouse event key
2617
+ # Checks whether given key is a mouse event key. Returns true on the X10
2618
+ # `\e[M` prefix regardless of length — {.parse} is the place that
2619
+ # validates the full 6-byte shape and raises on malformed input.
1894
2620
  #
1895
2621
  # _@param_ `key` — key read via {Keys.getkey}
1896
2622
  #
1897
2623
  # _@return_ — true if it is a mouse event
1898
2624
  def self.mouse_event?: (String key) -> bool
1899
2625
 
2626
+ # Parses an X10 mouse report (`\e[M` + 3 bytes: button, x, y).
2627
+ #
2628
+ # Raises {Tuile::Error} when `key` starts with the mouse prefix but is
2629
+ # not exactly 6 bytes long. Both shorter and longer inputs are bugs in
2630
+ # the upstream key-reader: a shorter prefix means the tail was lost on
2631
+ # the way in, and a longer one means we over-consumed into the next
2632
+ # escape sequence. We refuse to silently truncate either case because
2633
+ # the trailing `\e` of an over-read corrupts the *next* getkey, and the
2634
+ # corruption then surfaces as garbled keystrokes in focused inputs
2635
+ # rather than as a parser failure pointing at the actual cause.
2636
+ #
1900
2637
  # _@param_ `key` — key read via {Keys.getkey}
2638
+ #
2639
+ # _@return_ — `nil` if `key` is not a mouse event
1901
2640
  def self.parse: (String key) -> MouseEvent?
1902
2641
 
1903
2642
  def self.start_tracking: () -> String
@@ -1905,7 +2644,7 @@ module Tuile
1905
2644
  def self.stop_tracking: () -> String
1906
2645
 
1907
2646
  # _@return_ — one of `:left`, `:middle`, `:right`, `:scroll_up`,
1908
- # `:scroll_down`; `nil` if not known.
2647
+ # `:scroll_down`, `:scroll_left`, `:scroll_right`; `nil` if not known.
1909
2648
  attr_reader button: Symbol?
1910
2649
 
1911
2650
  # _@return_ — x coordinate, 0-based.
@@ -2167,8 +2906,16 @@ module Tuile
2167
2906
  # `underline`). Useful for row-level highlights — the new bg overlays
2168
2907
  # without dropping foreground colors the original styling carried.
2169
2908
  #
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
2909
+ # _@param_ `bg` — background color, coerced via {Color.coerce}. `nil` clears bg back to the terminal default.
2910
+ def with_bg: ((Color | Symbol | Integer | ::Array[Integer])? bg) -> StyledString
2911
+
2912
+ # Returns a new {StyledString} with `fg` applied to every span, preserving
2913
+ # each span's text and other style attributes (`bg`, `bold`, `italic`,
2914
+ # `underline`). The new fg overlays without dropping background colors or
2915
+ # text attributes the original styling carried.
2916
+ #
2917
+ # _@param_ `fg` — foreground color, coerced via {Color.coerce}. `nil` clears fg back to the terminal default.
2918
+ def with_fg: ((Color | Symbol | Integer | ::Array[Integer])? fg) -> StyledString
2172
2919
 
2173
2920
  def inspect: () -> String
2174
2921
 
@@ -2184,10 +2931,11 @@ module Tuile
2184
2931
 
2185
2932
  # _@param_ `color`
2186
2933
  #
2187
- # _@param_ `base` — base SGR code — 30 for fg, 40 for bg.
2934
+ # _@param_ `target` — `:fg` or `:bg`.
2188
2935
  #
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]
2936
+ # _@return_ — SGR codes; `[39]` / `[49]` for the "default" reset
2937
+ # when `color` is `nil`, otherwise delegated to {Color#sgr_codes}.
2938
+ def color_codes: (Color? color, target: Symbol) -> ::Array[Integer]
2191
2939
 
2192
2940
  # _@param_ `start_or_range`
2193
2941
  #
@@ -2240,18 +2988,16 @@ module Tuile
2240
2988
  class ParseError < Tuile::Error
2241
2989
  end
2242
2990
 
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
2991
+ # A frozen value type describing the visual style of a {Span}. Colors are
2992
+ # stored as {Color} instances (or `nil` for the terminal default); inputs
2993
+ # to {.new} and {#merge} are coerced via {Color.coerce}, so the four
2994
+ # accepted color forms — `nil`, Symbol, Integer 0..255, RGB Array work
2995
+ # transparently.
2250
2996
  #
2251
2997
  # @!attribute [r] fg
2252
- # @return [Symbol, Integer, Array<Integer>, nil]
2998
+ # @return [Color, nil]
2253
2999
  # @!attribute [r] bg
2254
- # @return [Symbol, Integer, Array<Integer>, nil]
3000
+ # @return [Color, nil]
2255
3001
  # @!attribute [r] bold
2256
3002
  # @return [Boolean]
2257
3003
  # @!attribute [r] italic
@@ -2259,12 +3005,11 @@ module Tuile
2259
3005
  # @!attribute [r] underline
2260
3006
  # @return [Boolean]
2261
3007
  class Style
2262
- COLOR_SYMBOLS: ::Array[Symbol]
2263
3008
  DEFAULT: Style
2264
3009
 
2265
- # _@param_ `fg`
3010
+ # _@param_ `fg` — coerced via {Color.coerce}.
2266
3011
  #
2267
- # _@param_ `bg`
3012
+ # _@param_ `bg` — coerced via {Color.coerce}.
2268
3013
  #
2269
3014
  # _@param_ `bold`
2270
3015
  #
@@ -2272,18 +3017,13 @@ module Tuile
2272
3017
  #
2273
3018
  # _@param_ `underline`
2274
3019
  def self.new: (
2275
- ?fg: (Symbol | Integer | ::Array[Integer])?,
2276
- ?bg: (Symbol | Integer | ::Array[Integer])?,
3020
+ ?fg: (Color | Symbol | Integer | ::Array[Integer])?,
3021
+ ?bg: (Color | Symbol | Integer | ::Array[Integer])?,
2277
3022
  ?bold: bool,
2278
3023
  ?italic: bool,
2279
3024
  ?underline: bool
2280
3025
  ) -> Style
2281
3026
 
2282
- # _@param_ `color`
2283
- #
2284
- # _@param_ `which`
2285
- def self.validate_color!: (Object color, Symbol which) -> void
2286
-
2287
3027
  def default?: () -> bool
2288
3028
 
2289
3029
  # Returns a new {Style} with the given attributes overridden.
@@ -2291,9 +3031,9 @@ module Tuile
2291
3031
  # _@param_ `overrides`
2292
3032
  def merge: (**::Hash[Symbol, Object] overrides) -> Style
2293
3033
 
2294
- attr_reader fg: (Symbol | Integer | ::Array[Integer])?
3034
+ attr_reader fg: Color?
2295
3035
 
2296
- attr_reader bg: (Symbol | Integer | ::Array[Integer])?
3036
+ attr_reader bg: Color?
2297
3037
 
2298
3038
  attr_reader bold: bool
2299
3039
 
@@ -2359,6 +3099,8 @@ module Tuile
2359
3099
  # A "synchronous" event queue – no loop is run, submitted blocks are run right
2360
3100
  # away and submitted events are thrown away. Intended for testing only.
2361
3101
  class FakeEventQueue
3102
+ def initialize: () -> void
3103
+
2362
3104
  def locked?: () -> bool
2363
3105
 
2364
3106
  def stop: () -> void
@@ -2371,6 +3113,42 @@ module Tuile
2371
3113
 
2372
3114
  # _@param_ `event`
2373
3115
  def post: (Object event) -> void
3116
+
3117
+ # Mirrors {EventQueue#tick} but timeless: returns a {FakeTicker} that
3118
+ # only fires when a test calls {#tick_once}. The `fps` argument is
3119
+ # validated the same way the real queue validates it, then discarded —
3120
+ # the fake has no clock, so frame cadence is up to the test.
3121
+ #
3122
+ # _@param_ `fps` — firings per second, must be positive. Validated for parity with {EventQueue#tick}; otherwise unused.
3123
+ def tick: (Numeric fps) ?{ (Integer tick) -> void } -> FakeTicker
3124
+
3125
+ # Test helper: fires every live ticker's user block once and prunes
3126
+ # cancelled tickers. No-op when no tickers are registered. Pumps once
3127
+ # per call regardless of any ticker's fps — the fake has no clock, so
3128
+ # tests pump N frames by calling this N times.
3129
+ def tick_once: () -> void
3130
+
3131
+ # Handle returned by {FakeEventQueue#tick}. Mirrors the public surface of
3132
+ # {EventQueue::Ticker} (`cancel`, `cancelled?`) but does not auto-fire —
3133
+ # the host {FakeEventQueue} drives firing via {FakeEventQueue#tick_once}.
3134
+ class FakeTicker
3135
+ # _@param_ `block` — called as `block.call(tick_count)` on each {#fire}.
3136
+ def initialize: (Proc block) -> void
3137
+
3138
+ # _@return_ — true once {#cancel} has been called.
3139
+ def cancelled?: () -> bool
3140
+
3141
+ # Marks the ticker cancelled. Idempotent. Subsequent {#fire} calls are
3142
+ # no-ops; {FakeEventQueue#tick_once} also prunes the ticker on its next
3143
+ # pass.
3144
+ def cancel: () -> void
3145
+
3146
+ # Invokes the user block with the current tick counter, then advances.
3147
+ # No-op when {#cancelled?}. Typically driven by
3148
+ # {FakeEventQueue#tick_once}; safe to call directly from a test that
3149
+ # wants to drive a single ticker.
3150
+ def fire: () -> void
3151
+ end
2374
3152
  end
2375
3153
 
2376
3154
  # A vertical scrollbar that computes which character to draw at each row.