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