tuile 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/sig/tuile.rbs CHANGED
@@ -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.
@@ -216,6 +253,22 @@ module Tuile
216
253
  # _@param_ `component`
217
254
  def invalidate: (Component component) -> void
218
255
 
256
+ # Rebuild the status-bar text from the current focus and global-shortcut
257
+ # registry. Called from {#focused=} and whenever the global registry
258
+ # changes. Popups own their own "q Close" prefix in `#keyboard_hint`;
259
+ # for the tiled case Screen tacks on the global "q quit" instead.
260
+ # Global-shortcut hints get spliced in too — see {#global_shortcut_hints}
261
+ # for the over_popups filter rule.
262
+ def refresh_status_bar: () -> void
263
+
264
+ # Status-bar hints from currently-registered global shortcuts.
265
+ # When a popup is open, only `over_popups: true` shortcuts contribute —
266
+ # the rest don't fire in that context, so showing them would be a lie.
267
+ # Insertion order is preserved (Hash iteration order).
268
+ #
269
+ # _@param_ `popup_open`
270
+ def global_shortcut_hints: (popup_open: bool) -> ::Array[String]
271
+
219
272
  # Internal — use {Component::Popup#open} instead. Adds the popup to
220
273
  # {#pane}, centers and focuses it.
221
274
  #
@@ -224,7 +277,9 @@ module Tuile
224
277
 
225
278
  # Runs event loop – waits for keys and sends them to active window. The
226
279
  # function exits when the 'ESC' or 'q' key is pressed.
227
- def run_event_loop: () -> void
280
+ #
281
+ # _@param_ `capture_mouse` — when true (default), enables xterm mouse tracking so clicks and scroll wheel arrive as {MouseEvent}s and feed {Component#handle_mouse}. When false, no tracking escape sequence is written: the terminal keeps its native click handling, which is what you want if the app benefits more from select-to-copy than from click-to-focus. Components' `handle_mouse` is simply never invoked from the loop in that mode (the terminal stops sending the bytes).
282
+ def run_event_loop: (?capture_mouse: bool) -> void
228
283
 
229
284
  # Advances focus to the next {Component#tab_stop?} in tree order, wrapping
230
285
  # around. Scope is the topmost popup if one is open, otherwise {#content}
@@ -239,6 +294,51 @@ module Tuile
239
294
  # _@return_ — true if focus moved.
240
295
  def focus_previous: () -> bool
241
296
 
297
+ # Registers an app-level keyboard shortcut. When `key` arrives, the block
298
+ # is invoked on the event-loop thread (so it may freely mutate UI) before
299
+ # the key reaches any component. Re-registering the same key replaces the
300
+ # previous binding; use {#unregister_global_shortcut} to remove one.
301
+ #
302
+ # Only unprintable keys are accepted — control characters (Ctrl+letter,
303
+ # ESC, BACKSPACE, ENTER, …) and multi-character escape sequences (arrows,
304
+ # F-keys, …). Printable keys raise {ArgumentError}: they'd hijack typing
305
+ # into a {Component::TextField} and should be expressed as
306
+ # {Component#key_shortcut} instead, which the dispatcher suppresses while
307
+ # a text widget owns the hardware cursor. TAB and SHIFT_TAB are also
308
+ # rejected because {#handle_key} intercepts them for focus navigation
309
+ # before the global registry is consulted, so a binding on them would
310
+ # silently never fire.
311
+ #
312
+ # Pass `hint:` to surface the shortcut in the status bar. It's a
313
+ # preformatted string the caller fully owns (so colors and the key label
314
+ # style stay consistent with whatever the host app uses elsewhere). The
315
+ # framework splices it in like any other status hint: in the tiled case,
316
+ # right after `q quit` and before the active window's own hint; while a
317
+ # popup is open, only hints from `over_popups: true` shortcuts are
318
+ # shown, and they're prepended before the popup's `q Close`.
319
+ #
320
+ # Example — open a log popup with Ctrl+L from anywhere, even while a
321
+ # popup is already on screen:
322
+ #
323
+ # screen.register_global_shortcut(Keys::CTRL_L,
324
+ # over_popups: true,
325
+ # hint: "^L #{Rainbow("log").cadetblue}") do
326
+ # log_popup.open
327
+ # end
328
+ #
329
+ # _@param_ `key` — unprintable key (e.g. {Keys::CTRL_L}, {Keys::ESC}, {Keys::PAGE_UP}).
330
+ #
331
+ # _@param_ `over_popups` — when true, fires even while a modal popup is open (pre-empting the popup's own key handling). When false (default), the shortcut is suppressed while any popup is open and the popup gets the key instead.
332
+ #
333
+ # _@param_ `hint` — preformatted status-bar hint (e.g. `"^L #{Rainbow("log").cadetblue}"`). When nil (default) the shortcut is silent in the status bar.
334
+ def register_global_shortcut: (String key, ?over_popups: bool, ?hint: String?) -> void
335
+
336
+ # Removes a shortcut previously installed by {#register_global_shortcut}.
337
+ # No-op if `key` was not registered.
338
+ #
339
+ # _@param_ `key`
340
+ def unregister_global_shortcut: (String key) -> void
341
+
242
342
  # _@return_ — current active tiled component.
243
343
  def active_window: () -> Component?
244
344
 
@@ -330,10 +430,17 @@ module Tuile
330
430
  # A key has been pressed on the keyboard. Handle it, or forward to active
331
431
  # window.
332
432
  #
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.
433
+ # Dispatch order:
434
+ # 1. Tab / Shift+Tab reserved focus navigation, intercepted before
435
+ # anything else so a focused {Component::TextField} (which would
436
+ # otherwise swallow printable keys via cursor-owner suppression)
437
+ # doesn't trap them.
438
+ # 2. App-level shortcuts from {#register_global_shortcut}. An entry
439
+ # registered with `over_popups: true` always fires; one with the
440
+ # default `over_popups: false` fires only when no popup is open
441
+ # (otherwise the popup receives the key normally).
442
+ # 3. {ScreenPane#handle_key}, which routes to the topmost popup or
443
+ # tiled content.
337
444
  #
338
445
  # _@param_ `key`
339
446
  #
@@ -379,13 +486,29 @@ module Tuile
379
486
 
380
487
  # _@return_ — currently focused component.
381
488
  attr_accessor focused: Component?
489
+
490
+ # Entry in the global shortcut registry: the block to run, whether it
491
+ # pre-empts open popups, and an optional preformatted status-bar hint.
492
+ # @api private
493
+ class Shortcut < Data
494
+ # Returns the value of attribute block
495
+ attr_reader block: Object
496
+
497
+ # Returns the value of attribute over_popups
498
+ attr_reader over_popups: Object
499
+
500
+ # Returns the value of attribute hint
501
+ attr_reader hint: Object
502
+ end
382
503
  end
383
504
 
384
505
  # A UI component which is positioned on the screen and draws characters into
385
506
  # its bounding rectangle (in {#repaint}).
386
507
  #
387
- # Component is considered invisible if {#rect} is empty or one of left/top is
388
- # negative. The component won't draw when invisible.
508
+ # Painting is gated by attachment: a detached component (one whose {#root}
509
+ # isn't {Screen#pane}) is never enqueued for repaint via {#invalidate}, and
510
+ # any stale invalidation entries are filtered out at drain time. Subclasses
511
+ # can paint freely in {#repaint} without re-asserting attachment.
389
512
  class Component
390
513
  def initialize: () -> void
391
514
 
@@ -417,6 +540,8 @@ module Tuile
417
540
  # responsibility for {#rect}. Everything else should call super.
418
541
  #
419
542
  # A component must not draw outside of {#rect}.
543
+ #
544
+ # Only called when the component is attached.
420
545
  def repaint: () -> void
421
546
 
422
547
  # Called when a character is pressed on the keyboard.
@@ -537,6 +662,12 @@ module Tuile
537
662
 
538
663
  # Invalidates the component: {Screen} records this component as
539
664
  # needs-repaint and once all events are processed, will call {#repaint}.
665
+ #
666
+ # No-op when the component is not {#attached?} — a detached component has
667
+ # no place on the screen to paint to, so {Screen} must never end up
668
+ # repainting it. Callers don't need to guard their own `invalidate` calls;
669
+ # mutating a detached component (e.g. setting `lines=` on a {List} sitting
670
+ # inside a closed {Component::Popup}) is silent.
540
671
  def invalidate: () -> void
541
672
 
542
673
  # Whether direct children fully tile {#rect}. Used by the default
@@ -665,7 +796,11 @@ module Tuile
665
796
 
666
797
  # Rebuilds pre-padded lines when the wrap width changes. The wrap width
667
798
  # depends on {#rect}`.width` and the scrollbar gutter, both of which
668
- # trigger this hook.
799
+ # trigger this hook. Also re-evaluates {#auto_scroll}: if items were
800
+ # appended while the rect was empty (e.g. a {Popup}-wrapped list got
801
+ # `add_line` calls before the popup was opened), the auto-scroll update
802
+ # was skipped because there was no viewport — re-run it now that there
803
+ # is one, so the list snaps to the bottom on first paint.
669
804
  def on_width_changed: () -> void
670
805
 
671
806
  # Coerces and flattens a list of input entries into trimmed
@@ -744,7 +879,14 @@ module Tuile
744
879
  # _@param_ `delta` — negative scrolls up, positive scrolls down.
745
880
  def move_top_line_by: (Integer delta) -> void
746
881
 
747
- # If auto-scrolling, recalculate the top line.
882
+ # If auto-scrolling, recalculate the top line and snap the cursor to the
883
+ # last reachable position. Without the cursor snap the viewport gets
884
+ # yanked back to wherever the cursor sat on the next arrow press,
885
+ # negating the auto-scroll. Skipped when {#rect} is empty: without a
886
+ # viewport the "lines minus viewport" formula yields `@lines.size`,
887
+ # which would leave `top_line` past the last item once a real rect
888
+ # arrives. {#on_width_changed} re-runs this hook when the rect grows so
889
+ # the snap-to-bottom intent is preserved.
748
890
  def update_top_line_if_auto_scroll: () -> void
749
891
 
750
892
  # _@return_ — whether the scrollbar should be drawn right now.
@@ -853,6 +995,15 @@ module Tuile
853
995
  # _@return_ — true if the position changed.
854
996
  def go: (Integer new_position) -> bool
855
997
 
998
+ # Moves the cursor to the last reachable position. For base {Cursor},
999
+ # the last line; {Limited} clamps to the last allowed position; {None}
1000
+ # is a no-op.
1001
+ #
1002
+ # _@param_ `line_count` — number of lines in the list.
1003
+ #
1004
+ # _@return_ — true if the position changed.
1005
+ def go_to_last: (Integer line_count) -> bool
1006
+
856
1007
  # _@param_ `lines`
857
1008
  #
858
1009
  # _@param_ `line_count`
@@ -863,9 +1014,6 @@ module Tuile
863
1014
 
864
1015
  def go_to_first: () -> bool
865
1016
 
866
- # _@param_ `line_count`
867
- def go_to_last: (Integer line_count) -> bool
868
-
869
1017
  # _@return_ — 0-based line index of the current cursor position.
870
1018
  attr_reader position: Integer
871
1019
 
@@ -889,6 +1037,16 @@ module Tuile
889
1037
 
890
1038
  # _@param_ `_line_count`
891
1039
  def candidate_positions: (Integer _line_count) -> ::Array[Integer]
1040
+
1041
+ # Overridden so all movement funnels — base {Cursor#go_to_last},
1042
+ # {Cursor#go_to_first}, etc., which all call {#go} — become safe
1043
+ # no-ops on a disabled cursor. The instance is frozen, so a default
1044
+ # mutating {#go} would raise.
1045
+ #
1046
+ # _@param_ `_new_position`
1047
+ #
1048
+ # _@return_ — always false.
1049
+ def go: (Integer _new_position) -> bool
892
1050
  end
893
1051
 
894
1052
  # Cursor which can only land on specific allowed lines.
@@ -908,6 +1066,9 @@ module Tuile
908
1066
  # _@param_ `line_count`
909
1067
  def candidate_positions: (Integer line_count) -> ::Array[Integer]
910
1068
 
1069
+ # _@param_ `_line_count`
1070
+ def go_to_last: (Integer _line_count) -> bool
1071
+
911
1072
  # _@param_ `lines`
912
1073
  #
913
1074
  # _@param_ `line_count`
@@ -917,9 +1078,6 @@ module Tuile
917
1078
  def go_up_by: (Integer lines) -> bool
918
1079
 
919
1080
  def go_to_first: () -> bool
920
-
921
- # _@param_ `_line_count`
922
- def go_to_last: (Integer _line_count) -> bool
923
1081
  end
924
1082
  end
925
1083
  end
@@ -992,7 +1150,9 @@ module Tuile
992
1150
 
993
1151
  def focusable?: () -> bool
994
1152
 
995
- # Mounts this popup on the {Screen}.
1153
+ # Mounts this popup on the {Screen}. Recomputes the popup's size from
1154
+ # the current content first, so reopening a popup whose content has
1155
+ # grown or shrunk while closed picks up the new size.
996
1156
  def open: () -> void
997
1157
 
998
1158
  # Constructs and opens a popup in one call.
@@ -1150,8 +1310,9 @@ module Tuile
1150
1310
  #
1151
1311
  # The window's `content` is unset by default; assign one via {#content=}.
1152
1312
  #
1153
- # Window is considered invisible if {#rect} is empty or one of left/top is
1154
- # negative. The window won't draw when invisible.
1313
+ # Window is considered invisible if {#rect} is empty. The window won't
1314
+ # draw when invisible. (Repaint of detached windows is short-circuited
1315
+ # by {Component#invalidate}; subclasses don't need to re-check.)
1155
1316
  class Window < Component
1156
1317
  include Tuile::Component::HasContent
1157
1318
 
@@ -1180,10 +1341,6 @@ module Tuile
1180
1341
  # window has no content, footer, or caption.
1181
1342
  def content_size: () -> Size
1182
1343
 
1183
- # _@return_ — true if {#rect} is off screen and the window won't
1184
- # paint.
1185
- def visible?: () -> bool
1186
-
1187
1344
  # Fully repaints the window: both frame and contents.
1188
1345
  #
1189
1346
  # Window deliberately paints over its entire rect (border around the
@@ -1244,26 +1401,26 @@ module Tuile
1244
1401
  # Currently only {#on_change} is wired; Enter inserts a newline as in any
1245
1402
  # plain `<textarea>` or text editor. A future `on_enter`/`on_submit`
1246
1403
  # callback may opt out of that by consuming Enter instead.
1247
- class TextArea < Component
1404
+ class TextArea < Tuile::Component::TextInput
1248
1405
  ACTIVE_BG_SGR: String
1249
1406
  INACTIVE_BG_SGR: String
1250
1407
 
1251
1408
  def initialize: () -> void
1252
1409
 
1253
- def focusable?: () -> bool
1254
-
1255
- def tab_stop?: () -> bool
1256
-
1257
1410
  def cursor_position: () -> Point?
1258
1411
 
1259
- # _@param_ `key`
1260
- def handle_key: (String key) -> bool
1261
-
1262
1412
  # _@param_ `event`
1263
1413
  def handle_mouse: (MouseEvent event) -> void
1264
1414
 
1265
1415
  def repaint: () -> void
1266
1416
 
1417
+ def on_text_mutated: () -> void
1418
+
1419
+ def on_caret_mutated: () -> void
1420
+
1421
+ # _@param_ `key`
1422
+ def handle_text_input_key: (String key) -> bool
1423
+
1267
1424
  def on_width_changed: () -> void
1268
1425
 
1269
1426
  # _@return_ — cached wrap of {#text} for the
@@ -1306,38 +1463,11 @@ module Tuile
1306
1463
  # _@return_ — always true.
1307
1464
  def insert_char: (String char) -> bool
1308
1465
 
1309
- def delete_before_caret: () -> void
1310
-
1311
- def delete_at_caret: () -> void
1312
-
1313
1466
  # Keeps the caret visible by scrolling vertically.
1314
1467
  def adjust_top_display_row: () -> void
1315
1468
 
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
1469
  # _@return_ — index of the topmost display row currently visible.
1332
1470
  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
1471
  end
1342
1472
 
1343
1473
  # A read-only viewer for prose: chunks of formatted text that scroll
@@ -1350,9 +1480,19 @@ module Tuile
1350
1480
  # ANSI-as-bytes wrapping, color does *not* get dropped on continuation
1351
1481
  # rows). {#text=} accepts a {String} (parsed via {StyledString.parse},
1352
1482
  # 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.
1483
+ # always returns the {StyledString}.
1484
+ #
1485
+ # For incremental updates pick the right primitive: {#append} (aliased
1486
+ # as `<<`) is verbatim and stream-friendly — chunks are concatenated
1487
+ # straight onto the buffer, with embedded `\n` becoming hard breaks.
1488
+ # {#add_line} is the "log entry" convenience — it starts the content on
1489
+ # a fresh line by inserting a leading `\n` when the buffer is non-empty.
1490
+ # {#remove_last_n_lines} pops hard lines back off the tail — the
1491
+ # inverse of building up a region with {#append} / {#add_line}, so a
1492
+ # caller streaming reformattable content (e.g. partially-rendered
1493
+ # Markdown that may need to retract its last paragraph) can replace
1494
+ # the tail without rewriting the whole text. Turn on {#auto_scroll}
1495
+ # to keep the latest content in view.
1356
1496
  #
1357
1497
  # TextView is meant to be the content of a {Window} — focus indication and
1358
1498
  # keyboard-hint surfacing rely on the surrounding window chrome.
@@ -1376,19 +1516,58 @@ module Tuile
1376
1516
  # _@param_ `value`
1377
1517
  def text=: ((String | StyledString)? value) -> void
1378
1518
 
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=}.
1519
+ # _@return_ true iff {#text} is empty (no hard lines).
1520
+ def empty?: () -> bool
1521
+
1522
+ # Appends `str` verbatim. Embedded `\n` characters become hard line
1523
+ # breaks; otherwise the text is concatenated onto the current last
1524
+ # hard line. Designed for streaming use (e.g. an LLM chat window
1525
+ # receiving partial messages — feed each chunk straight in). Accepts
1526
+ # the same input forms as {#text=}; empty/`nil` input is a no-op.
1383
1527
  #
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.
1528
+ # For the "add an entry on a new line" pattern use {#add_line}.
1529
+ #
1530
+ # Cost is O(appended + width-of-current-last-hard-line) — the
1531
+ # previously last hard line is re-wrapped (because the extension may
1532
+ # cause it to wrap differently), any additional hard lines created by
1533
+ # embedded `\n` are wrapped fresh. The cached {#text} is invalidated
1534
+ # and rebuilt on demand.
1388
1535
  #
1389
1536
  # _@param_ `str`
1390
1537
  def append: ((String | StyledString)? str) -> void
1391
1538
 
1539
+ # Verbatim append, returning `self` for chainability (`view << a << b`).
1540
+ #
1541
+ # _@param_ `str`
1542
+ def <<: ((String | StyledString)? str) -> self
1543
+
1544
+ # Appends `str` as a new entry: starts a fresh hard line first (when
1545
+ # the buffer is non-empty) and then appends `str`. Equivalent to
1546
+ # `append("\n" + str)` on a non-empty buffer, or `append(str)` on an
1547
+ # empty one. `nil` and `""` produce a blank entry on a non-empty
1548
+ # buffer and a no-op on an empty buffer (matches the old `append`
1549
+ # semantics for "log line" callers).
1550
+ #
1551
+ # _@param_ `str`
1552
+ def add_line: ((String | StyledString)? str) -> void
1553
+
1554
+ # Drops the last `n` hard lines from the buffer. The inverse of
1555
+ # building up a tail region with {#append} / {#add_line}: a caller
1556
+ # streaming partially-rendered content whose tail must occasionally
1557
+ # be retracted (e.g. Markdown-to-ANSI where a new token reformats
1558
+ # the table being built) can call `remove_last_n_lines(k)` followed
1559
+ # by `append(new_tail)` to replace the damaged region in place.
1560
+ #
1561
+ # `n == 0` and the empty-buffer case are no-ops (no invalidation).
1562
+ # `n >= hard-line count` empties the buffer.
1563
+ #
1564
+ # Operates on **hard lines** (the `\n`-delimited entries the
1565
+ # buffer stores), not on wrapped physical rows — same granularity
1566
+ # as {#add_line}. Cost is O(rendered-rows of the popped lines).
1567
+ #
1568
+ # _@param_ `n` — number of hard lines to drop; must be >= 0.
1569
+ def remove_last_n_lines: (Integer n) -> void
1570
+
1392
1571
  # Clears the text. Equivalent to `text = ""`.
1393
1572
  def clear: () -> void
1394
1573
 
@@ -1439,6 +1618,17 @@ module Tuile
1439
1618
  # _@param_ `width`
1440
1619
  def append_physical_lines: (StyledString hard_line, Integer width) -> void
1441
1620
 
1621
+ # Pops from {@physical_lines} the rows that `hard_line` previously
1622
+ # contributed (the inverse of {#append_physical_lines} for the same
1623
+ # input). Used by {#append} when extending the last hard line: its
1624
+ # old wrapped rows are dropped, then the extended hard line is
1625
+ # re-wrapped and appended.
1626
+ #
1627
+ # _@param_ `hard_line`
1628
+ #
1629
+ # _@param_ `width`
1630
+ def drop_physical_rows_for: (StyledString hard_line, Integer width) -> void
1631
+
1442
1632
  # Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
1443
1633
  # default-styled `"\n"` between hard lines. Called from the {#text}
1444
1634
  # reader when the cache is cold. Cost is O(total spans).
@@ -1516,6 +1706,11 @@ module Tuile
1516
1706
  # _@param_ `caption`
1517
1707
  def initialize: (?String caption) -> void
1518
1708
 
1709
+ # Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
1710
+ #
1711
+ # _@param_ `string` — the line (or multiple lines) to log.
1712
+ def log: (String? string) -> void
1713
+
1519
1714
  # IO-shaped adapter that forwards each log line to the owning {LogWindow}.
1520
1715
  # Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
1521
1716
  # call `output.puts`, e.g. `TTY::Logger`).
@@ -1545,26 +1740,28 @@ module Tuile
1545
1740
  # The caret is a logical index in `0..text.length`. The hardware cursor is
1546
1741
  # positioned by {Screen} after each repaint cycle when this component is
1547
1742
  # focused; see {Component#cursor_position}.
1548
- class TextField < Component
1743
+ class TextField < Tuile::Component::TextInput
1549
1744
  ACTIVE_BG_SGR: String
1550
1745
  INACTIVE_BG_SGR: String
1551
1746
 
1552
1747
  def initialize: () -> void
1553
1748
 
1554
- def focusable?: () -> bool
1555
-
1556
- def tab_stop?: () -> bool
1557
-
1558
1749
  def cursor_position: () -> Point?
1559
1750
 
1560
- # _@param_ `key`
1561
- def handle_key: (String key) -> bool
1562
-
1563
1751
  # _@param_ `event`
1564
1752
  def handle_mouse: (MouseEvent event) -> void
1565
1753
 
1566
1754
  def repaint: () -> void
1567
1755
 
1756
+ # Truncate to fit `rect.width - 1` — single-line fields can't grow past
1757
+ # their width.
1758
+ #
1759
+ # _@param_ `new_text`
1760
+ def preprocess_text: (String new_text) -> String
1761
+
1762
+ # _@param_ `key`
1763
+ def handle_text_input_key: (String key) -> bool
1764
+
1568
1765
  def on_width_changed: () -> void
1569
1766
 
1570
1767
  # Maximum number of characters {#text} can hold given current width.
@@ -1573,12 +1770,110 @@ module Tuile
1573
1770
  # _@param_ `char`
1574
1771
  def insert: (String char) -> bool
1575
1772
 
1773
+ # Optional callback fired when the UP arrow key is pressed. When set, UP
1774
+ # is consumed by the field; when nil, UP falls through to the parent
1775
+ # (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
1776
+ # since `k` is a printable character inserted into {#text}.
1777
+ #
1778
+ # _@return_ — no-arg callable, or nil.
1779
+ attr_accessor on_key_up: (Proc | Method)?
1780
+
1781
+ # Optional callback fired when the DOWN arrow key is pressed. When set,
1782
+ # DOWN is consumed by the field; when nil, DOWN falls through to the
1783
+ # parent (default behavior). Only triggered by {Keys::DOWN_ARROW}, not by
1784
+ # `j`, since `j` is a printable character inserted into {#text}.
1785
+ #
1786
+ # _@return_ — no-arg callable, or nil.
1787
+ attr_accessor on_key_down: (Proc | Method)?
1788
+
1789
+ # Optional callback fired when ENTER is pressed. When set, ENTER is
1790
+ # consumed by the field; when nil, ENTER falls through to the parent
1791
+ # (default behavior).
1792
+ #
1793
+ # _@return_ — no-arg callable, or nil.
1794
+ attr_accessor on_enter: (Proc | Method)?
1795
+ end
1796
+
1797
+ # Abstract base for editable text components ({TextField}, {TextArea}).
1798
+ #
1799
+ # Holds the shared state — a mutable {#text} buffer, a {#caret} index,
1800
+ # {#on_change} and {#on_escape} callbacks — and the keyboard machinery
1801
+ # that single-line and multi-line inputs both need: ESC handling,
1802
+ # LEFT/RIGHT caret movement, CTRL+LEFT/CTRL+RIGHT word jumps, and the
1803
+ # `focusable?`/`tab_stop?` flags.
1804
+ #
1805
+ # Subclasses implement the layout-specific pieces ({#cursor_position},
1806
+ # {#repaint}) and add their own keys (HOME/END, ENTER, UP/DOWN,
1807
+ # printable insertion) by overriding the protected
1808
+ # {#handle_text_input_key} hook — `super` falls through to the common
1809
+ # navigation handling.
1810
+ #
1811
+ # The mutation pipeline is a template method: {#text=} and {#caret=}
1812
+ # detect no-ops, mutate state, fire {#on_change}, and invalidate.
1813
+ # Subclasses inject their own behavior via two protected hooks:
1814
+ #
1815
+ # - {#preprocess_text} — input filter (e.g. {TextField} truncates to
1816
+ # fit `rect.width - 1`).
1817
+ # - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
1818
+ # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
1819
+ # keep the caret visible).
1820
+ class TextInput < Component
1821
+ ACTIVE_BG_SGR: String
1822
+ INACTIVE_BG_SGR: String
1823
+
1824
+ def initialize: () -> void
1825
+
1826
+ # _@return_ — true iff {#text} is the empty string.
1827
+ def empty?: () -> bool
1828
+
1829
+ def focusable?: () -> bool
1830
+
1831
+ def tab_stop?: () -> bool
1832
+
1833
+ # Handles a key. Returns false when the component is inactive. Otherwise
1834
+ # first runs the {Component#handle_key} shortcut search via `super`, then
1835
+ # delegates to {#handle_text_input_key}.
1836
+ #
1837
+ # _@param_ `key`
1838
+ def handle_key: (String key) -> bool
1839
+
1840
+ # Input filter for {#text=}. Subclasses override to truncate or reject
1841
+ # invalid input. Default coerces to String.
1842
+ #
1843
+ # _@param_ `new_text`
1844
+ #
1845
+ # _@return_ — possibly transformed text.
1846
+ def preprocess_text: (String new_text) -> String
1847
+
1848
+ # Hook called after {#text} has been mutated, before invalidation /
1849
+ # {#on_change}. Default no-op. Subclasses use this to invalidate caches
1850
+ # ({TextArea}'s wrap cache) and update derived state.
1851
+ def on_text_mutated: () -> void
1852
+
1853
+ # Hook called after {#caret} has been mutated, before invalidation.
1854
+ # Default no-op. Subclasses use this to keep the caret visible
1855
+ # ({TextArea}'s vertical scroll).
1856
+ def on_caret_mutated: () -> void
1857
+
1858
+ # Dispatch hook for {#handle_key}. Handles ESC and the navigation keys
1859
+ # that have identical semantics in single-line and multi-line inputs:
1860
+ # LEFT/RIGHT arrows, CTRL+LEFT/CTRL+RIGHT for word jumps. Subclasses
1861
+ # override to add their own keys (HOME/END, UP/DOWN, ENTER, BACKSPACE/
1862
+ # DELETE, printable insertion) and call `super` to fall back to the
1863
+ # common navigation handling.
1864
+ #
1865
+ # _@param_ `key`
1866
+ #
1867
+ # _@return_ — true if the key was handled.
1868
+ def handle_text_input_key: (String key) -> bool
1869
+
1576
1870
  def delete_before_caret: () -> void
1577
1871
 
1578
1872
  def delete_at_caret: () -> void
1579
1873
 
1580
- # _@param_ `key`
1581
- def printable?: (String key) -> bool
1874
+ # Default {#on_escape} action: clear focus. Component deactivates; user
1875
+ # can re-focus by clicking or tabbing back in.
1876
+ def default_on_escape: () -> void
1582
1877
 
1583
1878
  # Caret target for ctrl+left: skip whitespace going left, then a run of
1584
1879
  # non-whitespace. Lands at the beginning of the current word, or the
@@ -1596,13 +1891,6 @@ module Tuile
1596
1891
  # _@return_ — caret index in `0..text.length`.
1597
1892
  attr_accessor caret: Integer
1598
1893
 
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
1894
  # Optional callback fired whenever {#text} changes. Receives the new text
1607
1895
  # as a single argument. Not fired by {#caret=} (text unchanged) and not
1608
1896
  # fired when a setter is a no-op.
@@ -1610,28 +1898,14 @@ module Tuile
1610
1898
  # _@return_ — one-arg callable, or nil.
1611
1899
  attr_accessor on_change: (Proc | Method)?
1612
1900
 
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}.
1901
+ # Callback fired when ESC is pressed. Defaults to a closure that clears
1902
+ # focus (`screen.focused = nil`) so ESC visibly cancels text entry instead
1903
+ # of bubbling to the parent — and, in particular, instead of reaching the
1904
+ # screen's default ESC-to-quit handler. Set to nil to let ESC fall through
1905
+ # to the parent again; set to any other callable to replace the default.
1625
1906
  #
1626
1907
  # _@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)?
1908
+ attr_accessor on_escape: (Proc | Method)?
1635
1909
  end
1636
1910
 
1637
1911
  # A mixin interface for a component with one child tops. The host must
@@ -1881,7 +2155,7 @@ module Tuile
1881
2155
  #
1882
2156
  # @!attribute [r] button
1883
2157
  # @return [Symbol, nil] one of `:left`, `:middle`, `:right`, `:scroll_up`,
1884
- # `:scroll_down`; `nil` if not known.
2158
+ # `:scroll_down`, `:scroll_left`, `:scroll_right`; `nil` if not known.
1885
2159
  # @!attribute [r] x
1886
2160
  # @return [Integer] x coordinate, 0-based.
1887
2161
  # @!attribute [r] y
@@ -1890,14 +2164,29 @@ module Tuile
1890
2164
  # _@return_ — the event's position.
1891
2165
  def point: () -> Point
1892
2166
 
1893
- # Checks whether given key is a mouse event key
2167
+ # Checks whether given key is a mouse event key. Returns true on the X10
2168
+ # `\e[M` prefix regardless of length — {.parse} is the place that
2169
+ # validates the full 6-byte shape and raises on malformed input.
1894
2170
  #
1895
2171
  # _@param_ `key` — key read via {Keys.getkey}
1896
2172
  #
1897
2173
  # _@return_ — true if it is a mouse event
1898
2174
  def self.mouse_event?: (String key) -> bool
1899
2175
 
2176
+ # Parses an X10 mouse report (`\e[M` + 3 bytes: button, x, y).
2177
+ #
2178
+ # Raises {Tuile::Error} when `key` starts with the mouse prefix but is
2179
+ # not exactly 6 bytes long. Both shorter and longer inputs are bugs in
2180
+ # the upstream key-reader: a shorter prefix means the tail was lost on
2181
+ # the way in, and a longer one means we over-consumed into the next
2182
+ # escape sequence. We refuse to silently truncate either case because
2183
+ # the trailing `\e` of an over-read corrupts the *next* getkey, and the
2184
+ # corruption then surfaces as garbled keystrokes in focused inputs
2185
+ # rather than as a parser failure pointing at the actual cause.
2186
+ #
1900
2187
  # _@param_ `key` — key read via {Keys.getkey}
2188
+ #
2189
+ # _@return_ — `nil` if `key` is not a mouse event
1901
2190
  def self.parse: (String key) -> MouseEvent?
1902
2191
 
1903
2192
  def self.start_tracking: () -> String
@@ -1905,7 +2194,7 @@ module Tuile
1905
2194
  def self.stop_tracking: () -> String
1906
2195
 
1907
2196
  # _@return_ — one of `:left`, `:middle`, `:right`, `:scroll_up`,
1908
- # `:scroll_down`; `nil` if not known.
2197
+ # `:scroll_down`, `:scroll_left`, `:scroll_right`; `nil` if not known.
1909
2198
  attr_reader button: Symbol?
1910
2199
 
1911
2200
  # _@return_ — x coordinate, 0-based.
@@ -2170,6 +2459,14 @@ module Tuile
2170
2459
  # _@param_ `bg` — background color, in any of the forms accepted by {Style.new}. `nil` clears bg back to the terminal default.
2171
2460
  def with_bg: ((Symbol | Integer | ::Array[Integer])? bg) -> StyledString
2172
2461
 
2462
+ # Returns a new {StyledString} with `fg` applied to every span, preserving
2463
+ # each span's text and other style attributes (`bg`, `bold`, `italic`,
2464
+ # `underline`). The new fg overlays without dropping background colors or
2465
+ # text attributes the original styling carried.
2466
+ #
2467
+ # _@param_ `fg` — foreground color, in any of the forms accepted by {Style.new}. `nil` clears fg back to the terminal default.
2468
+ def with_fg: ((Symbol | Integer | ::Array[Integer])? fg) -> StyledString
2469
+
2173
2470
  def inspect: () -> String
2174
2471
 
2175
2472
  def build_ansi: () -> String