tuile 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +4 -1
- 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 +155 -69
- data/lib/tuile/component/text_area.rb +1 -3
- data/lib/tuile/component/text_field.rb +1 -4
- data/lib/tuile/component/text_view.rb +351 -0
- data/lib/tuile/component/window.rb +2 -2
- data/lib/tuile/styled_string.rb +761 -0
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +640 -85
- metadata +4 -2
- data/lib/tuile/truncate.rb +0 -83
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fd5711addbd65a00c8d471204d50973da93ac9be65ba197114ac04c94cace526
|
|
4
|
+
data.tar.gz: 4505d93153dc96fd439e5d69a7b1506e985fc9094f8428fe092ebc6247d62b8a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 23c69343d8a0cc87143b12cd1a4a9a11862c770debf9c5381cabfcb59b55786f8be143ef362581e0a79bb1dc7f9ce512f47d755f603cb486bf4058b3487d4399
|
|
7
|
+
data.tar.gz: '0974d322289b63b43bdd498e172f446d7cc56df656645b8e6826fed388154dda4a287110819d305572dbbf1c1ee23011fc4e0cb208f6459d67535e2df5c1d2b1'
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-05-18
|
|
4
|
+
|
|
5
|
+
- Add `Component::TextView` — read-only scrollable wrapped prose with word wrap, incremental append, and a lazy text reader.
|
|
6
|
+
- Add `Tuile::StyledString` for span-modeled ANSI styling, with `#wrap` (span-preserving word wrap), `#ellipsize` (width-bounded truncation), `#with_bg`, and an `EMPTY` shared instance.
|
|
7
|
+
- Model `Label`, `List`, and `TextView` text as `StyledString`; pre-pad clipped/physical lines.
|
|
8
|
+
- Extract `Tuile::Ansi` for shared ANSI helpers.
|
|
9
|
+
- `Window#scrollbar=` accepts any content that exposes `scrollbar_visibility=`.
|
|
10
|
+
- Document `TextView` in the README and `examples/sampler.rb`.
|
|
11
|
+
- Remove `Tuile::Wrap` (superseded by `StyledString#wrap`).
|
|
12
|
+
- Remove `Tuile::Truncate` (superseded by `StyledString#ellipsize`).
|
|
13
|
+
|
|
3
14
|
## [0.2.0] - 2026-05-15
|
|
4
15
|
|
|
5
16
|
- Add `Component::TextArea` with multi-line editing, word navigation, and VT220-style Home/End handling.
|
data/README.md
CHANGED
|
@@ -79,7 +79,10 @@ Save it as `hello.rb` and run `ruby hello.rb`. Press `q` or `ESC` to exit.
|
|
|
79
79
|
|
|
80
80
|
A larger demo lives in [`examples/file_commander.rb`](examples/file_commander.rb):
|
|
81
81
|
a two-pane file browser with cursor navigation, header label, and a layout
|
|
82
|
-
that follows terminal resize.
|
|
82
|
+
that follows terminal resize. For a tour of every shipped component, run
|
|
83
|
+
[`examples/sampler.rb`](examples/sampler.rb): a two-pane sampler where the
|
|
84
|
+
left pane lists demos and the right pane loads the highlighted one. Tab /
|
|
85
|
+
Shift+Tab move focus between the list and the demo's widgets.
|
|
83
86
|
|
|
84
87
|
## How it works
|
|
85
88
|
|
data/examples/sampler.rb
CHANGED
|
@@ -66,6 +66,7 @@ module SamplerExample
|
|
|
66
66
|
["Label", :build_label],
|
|
67
67
|
["TextField", :build_text_field],
|
|
68
68
|
["TextArea", :build_text_area],
|
|
69
|
+
["TextView", :build_text_view],
|
|
69
70
|
["Button", :build_buttons],
|
|
70
71
|
["List", :build_list],
|
|
71
72
|
["Layout", :build_layout],
|
|
@@ -133,6 +134,38 @@ module SamplerExample
|
|
|
133
134
|
end
|
|
134
135
|
end
|
|
135
136
|
|
|
137
|
+
def build_text_view
|
|
138
|
+
prompt = Tuile::Component::Label.new
|
|
139
|
+
prompt.text = "Read-only viewer for prose. Word-wraps to width; ANSI formatting passes through.\n" \
|
|
140
|
+
"Tab here, then: ↑↓ / jk scroll a line; PgUp/PgDn a page; Ctrl+U/D half a page; " \
|
|
141
|
+
"Home/End / g/G jump to the edges."
|
|
142
|
+
window = Tuile::Component::Window.new("Excerpt")
|
|
143
|
+
view = Tuile::Component::TextView.new
|
|
144
|
+
view.text = "#{Rainbow("Tuile").green} is a small component-oriented terminal-UI framework built on top of " \
|
|
145
|
+
"the TTY toolkit. Apps build a tree of Components under a singleton Screen; the screen runs " \
|
|
146
|
+
"an event loop, dispatches keys and mouse events, and repaints invalidated components in " \
|
|
147
|
+
"batch.\n\n" \
|
|
148
|
+
"The name is #{Rainbow("French").cyan} for #{Rainbow("\"roof tile\"").yellow} — small pieces " \
|
|
149
|
+
"that compose into a larger whole. This excerpt wraps to the viewer's current width; resize " \
|
|
150
|
+
"the terminal to see the wrap recompute, and scroll to see the rest.\n\n" \
|
|
151
|
+
"Components do not paint immediately. They call invalidate (which records them in the " \
|
|
152
|
+
"Screen's pending-repaint set); after an event-loop tick drains the queue, Screen#repaint " \
|
|
153
|
+
"walks the set, sorts by depth, and paints parents before children. Popups deliberately " \
|
|
154
|
+
"overdraw the tiled tree on top.\n\n" \
|
|
155
|
+
"All UI mutations must run on the thread that owns Screen#run_event_loop. Background work " \
|
|
156
|
+
"marshals back via screen.event_queue.submit { … }. Most UI methods check the lock and " \
|
|
157
|
+
"raise if you violate the contract; FakeScreen short-circuits the check so tests can mutate " \
|
|
158
|
+
"freely."
|
|
159
|
+
window.content = view
|
|
160
|
+
window.scrollbar = true
|
|
161
|
+
panel(prompt, window) do |r|
|
|
162
|
+
inner = inner_rect(r)
|
|
163
|
+
prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
|
|
164
|
+
view_height = [inner.height - 5, 4].max
|
|
165
|
+
window.rect = Tuile::Rect.new(inner.left, inner.top + 4, inner.width, view_height)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
136
169
|
def build_buttons
|
|
137
170
|
label = Tuile::Component::Label.new
|
|
138
171
|
label.text = "Buttons fire on Enter, Space, or a left-click. Tab to focus, then activate."
|
data/lib/tuile/ansi.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# ANSI escape sequence constants. Tuile emits colors and text attributes
|
|
5
|
+
# via Rainbow, which produces **SGR** sequences ("Select Graphic
|
|
6
|
+
# Rendition", `ESC [ <params> m` — e.g. `\e[31m` red, `\e[1m` bold,
|
|
7
|
+
# `\e[0m` reset).
|
|
8
|
+
module Ansi
|
|
9
|
+
# SGR reset (`ESC [ 0 m`). Restores the terminal's default foreground,
|
|
10
|
+
# background, and text attributes.
|
|
11
|
+
# @return [String]
|
|
12
|
+
RESET = "\e[0m"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -2,38 +2,66 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
|
-
# A label which shows static text. No word-wrapping;
|
|
5
|
+
# A label which shows static text. No word-wrapping; long lines are
|
|
6
|
+
# truncated with an ellipsis. Text is modeled as a {StyledString};
|
|
7
|
+
# {#text=} accepts a {String} (parsed via {StyledString.parse}, so
|
|
8
|
+
# embedded ANSI is honored) or a {StyledString} directly. {#text}
|
|
9
|
+
# always returns the {StyledString}.
|
|
6
10
|
class Label < Component
|
|
7
11
|
def initialize
|
|
8
12
|
super
|
|
9
|
-
@
|
|
13
|
+
@text = StyledString::EMPTY
|
|
10
14
|
@clipped_lines = []
|
|
15
|
+
@blank_line = ""
|
|
11
16
|
end
|
|
12
17
|
|
|
13
|
-
# @
|
|
14
|
-
#
|
|
18
|
+
# @return [StyledString] the current text. Defaults to an empty
|
|
19
|
+
# {StyledString}.
|
|
20
|
+
attr_reader :text
|
|
21
|
+
|
|
22
|
+
# Replaces the text. A `String` is parsed via {StyledString.parse}
|
|
23
|
+
# (embedded ANSI is honored); a `StyledString` is used as-is; `nil` is
|
|
24
|
+
# coerced to an empty {StyledString}. Lines wider than {#rect} are
|
|
25
|
+
# truncated with an ellipsis at paint time.
|
|
26
|
+
# @param value [String, StyledString, nil]
|
|
15
27
|
# @return [void]
|
|
16
|
-
def text=(
|
|
17
|
-
|
|
28
|
+
def text=(value)
|
|
29
|
+
new_text = StyledString.parse(value)
|
|
30
|
+
return if @text == new_text
|
|
31
|
+
|
|
32
|
+
@text = new_text
|
|
18
33
|
@content_size = nil
|
|
19
|
-
|
|
34
|
+
update_clipped_lines
|
|
35
|
+
invalidate
|
|
20
36
|
end
|
|
21
37
|
|
|
22
|
-
# @return [Size]
|
|
38
|
+
# @return [Size] longest hard-line's display width × number of hard
|
|
39
|
+
# lines. Reported on the *unclipped* text — sizing is intrinsic to
|
|
40
|
+
# the content, not the viewport. Empty text returns `Size.new(0, 0)`.
|
|
23
41
|
def content_size
|
|
24
|
-
@content_size ||=
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
42
|
+
@content_size ||=
|
|
43
|
+
if @text.empty?
|
|
44
|
+
Size::ZERO
|
|
45
|
+
else
|
|
46
|
+
hard_lines = @text.lines
|
|
47
|
+
width = hard_lines.map(&:display_width).max || 0
|
|
48
|
+
Size.new(width, hard_lines.size)
|
|
49
|
+
end
|
|
28
50
|
end
|
|
29
51
|
|
|
52
|
+
# Paints the text into {#rect}.
|
|
53
|
+
#
|
|
54
|
+
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
55
|
+
# painted explicitly (with pre-padded blanks past the last line), so
|
|
56
|
+
# the "fully draw over your rect" contract is met without an upfront
|
|
57
|
+
# wipe.
|
|
30
58
|
# @return [void]
|
|
31
59
|
def repaint
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
screen.print TTY::Cursor.move_to(rect.left, rect.top +
|
|
60
|
+
return if rect.empty? || rect.left.negative? || rect.top.negative?
|
|
61
|
+
|
|
62
|
+
(0...rect.height).each do |row|
|
|
63
|
+
line = @clipped_lines[row] || @blank_line
|
|
64
|
+
screen.print TTY::Cursor.move_to(rect.left, rect.top + row), line
|
|
37
65
|
end
|
|
38
66
|
end
|
|
39
67
|
|
|
@@ -42,21 +70,31 @@ module Tuile
|
|
|
42
70
|
# @return [void]
|
|
43
71
|
def on_width_changed
|
|
44
72
|
super
|
|
45
|
-
|
|
73
|
+
update_clipped_lines
|
|
46
74
|
end
|
|
47
75
|
|
|
48
76
|
private
|
|
49
77
|
|
|
78
|
+
# Recomputes {@clipped_lines} for the current text and rect width.
|
|
79
|
+
# Each line is ellipsized to fit, padded with trailing spaces out to
|
|
80
|
+
# the full width, and pre-rendered to ANSI so {#repaint} is just a
|
|
81
|
+
# lookup + screen.print per row. {@blank_line} covers rows past the
|
|
82
|
+
# last text line.
|
|
50
83
|
# @return [void]
|
|
51
|
-
def
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return if @clipped_lines == clipped
|
|
84
|
+
def update_clipped_lines
|
|
85
|
+
width = rect.width.clamp(0, nil)
|
|
86
|
+
@blank_line = " " * width
|
|
87
|
+
@clipped_lines = @text.lines.map { |line| pad_to(line.ellipsize(width), width).to_ansi }
|
|
88
|
+
end
|
|
57
89
|
|
|
58
|
-
|
|
59
|
-
|
|
90
|
+
# @param line [StyledString]
|
|
91
|
+
# @param width [Integer]
|
|
92
|
+
# @return [StyledString]
|
|
93
|
+
def pad_to(line, width)
|
|
94
|
+
diff = width - line.display_width
|
|
95
|
+
return line if diff <= 0
|
|
96
|
+
|
|
97
|
+
line + StyledString.plain(" " * diff)
|
|
60
98
|
end
|
|
61
99
|
end
|
|
62
100
|
end
|
data/lib/tuile/component/list.rb
CHANGED
|
@@ -2,20 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
|
-
# A scrollable list of
|
|
5
|
+
# A scrollable list of items with cursor support.
|
|
6
6
|
#
|
|
7
|
-
# Items are
|
|
8
|
-
#
|
|
9
|
-
# {#
|
|
10
|
-
#
|
|
7
|
+
# Items are modeled as {StyledString}s and painted directly into the
|
|
8
|
+
# component's {#rect}. Lines wider than the viewport are ellipsized via
|
|
9
|
+
# {StyledString#ellipsize} (span styles are preserved across the cut —
|
|
10
|
+
# unlike the older ANSI-as-bytes truncation, color does *not* get
|
|
11
|
+
# dropped on the surviving characters). Vertical scrolling is supported
|
|
12
|
+
# via {#top_line}; the list can also automatically scroll to the bottom
|
|
13
|
+
# if {#auto_scroll} is enabled.
|
|
11
14
|
#
|
|
12
15
|
# Cursor is supported; call {#cursor=} to change cursor behavior. The
|
|
13
|
-
# cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the
|
|
14
|
-
# automatically.
|
|
16
|
+
# cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the
|
|
17
|
+
# list automatically. The cursor highlight overlays a dark background
|
|
18
|
+
# while preserving each span's foreground color.
|
|
15
19
|
class List < Component
|
|
20
|
+
# 256-color SGR index for the cursor-row background highlight. Matches
|
|
21
|
+
# what `Rainbow(...).bg(:darkslategray)` emits.
|
|
22
|
+
# @return [Integer]
|
|
23
|
+
CURSOR_BG = 59
|
|
24
|
+
|
|
16
25
|
def initialize
|
|
17
26
|
super
|
|
18
27
|
@lines = []
|
|
28
|
+
@padded_lines = []
|
|
29
|
+
@blank_padded = StyledString::EMPTY
|
|
19
30
|
@auto_scroll = false
|
|
20
31
|
@top_line = 0
|
|
21
32
|
@cursor = Cursor::None.new
|
|
@@ -28,19 +39,19 @@ module Tuile
|
|
|
28
39
|
|
|
29
40
|
# @return [Proc, nil] callback fired when an item is chosen — by pressing
|
|
30
41
|
# Enter on the cursor's item, or by left-clicking an item. Called as
|
|
31
|
-
# `proc.call(index, line)` with the chosen 0-based index and its
|
|
32
|
-
# Never fires when the cursor's position is
|
|
33
|
-
# {Cursor::None}, or empty content).
|
|
42
|
+
# `proc.call(index, line)` with the chosen 0-based index and its
|
|
43
|
+
# {StyledString} line. Never fires when the cursor's position is
|
|
44
|
+
# outside the content (e.g. {Cursor::None}, or empty content).
|
|
34
45
|
attr_accessor :on_item_chosen
|
|
35
46
|
|
|
36
47
|
# @return [Proc, nil] callback fired when the `(index, line)` tuple under
|
|
37
48
|
# the cursor changes. Called as `proc.call(index, line)` where `line`
|
|
38
|
-
# is `nil` when the cursor is
|
|
39
|
-
# or `index` past the last
|
|
40
|
-
#
|
|
41
|
-
# at the cursor's index
|
|
42
|
-
# flips). Useful for
|
|
43
|
-
# highlighted row.
|
|
49
|
+
# is the {StyledString} at the cursor, or `nil` when the cursor is
|
|
50
|
+
# off-content ({Cursor::None}, empty list, or `index` past the last
|
|
51
|
+
# line). Fires on cursor moves (key, mouse, search), on {#cursor=},
|
|
52
|
+
# and on {#lines=}/{#add_lines} when the line at the cursor's index
|
|
53
|
+
# changes (or its in-range/out-of-range status flips). Useful for
|
|
54
|
+
# keeping a details pane in sync with the highlighted row.
|
|
44
55
|
attr_accessor :on_cursor_changed
|
|
45
56
|
|
|
46
57
|
# @return [Boolean] if true and a line is added or new content is set,
|
|
@@ -77,6 +88,7 @@ module Tuile
|
|
|
77
88
|
return if @scrollbar_visibility == value
|
|
78
89
|
|
|
79
90
|
@scrollbar_visibility = value
|
|
91
|
+
rebuild_padded_lines
|
|
80
92
|
invalidate
|
|
81
93
|
end
|
|
82
94
|
|
|
@@ -109,16 +121,22 @@ module Tuile
|
|
|
109
121
|
invalidate
|
|
110
122
|
end
|
|
111
123
|
|
|
112
|
-
# Sets new lines. Each entry is coerced
|
|
113
|
-
#
|
|
114
|
-
# {
|
|
115
|
-
#
|
|
124
|
+
# Sets new lines. Each entry is coerced into a {StyledString} (a
|
|
125
|
+
# `String` is parsed via {StyledString.parse}, so embedded ANSI is
|
|
126
|
+
# honored; a {StyledString} is used as-is; anything else is stringified
|
|
127
|
+
# via `#to_s` first), then split on `\n` into separate lines via
|
|
128
|
+
# {StyledString#lines}, with trailing empty pieces dropped and trailing
|
|
129
|
+
# ASCII whitespace stripped — symmetric with {#add_lines}, so the
|
|
130
|
+
# stored `@lines` is always `Array<StyledString>`.
|
|
131
|
+
# @param lines [Array] entries are `String`, `StyledString`, or anything
|
|
132
|
+
# that responds to `#to_s`.
|
|
116
133
|
# @return [void]
|
|
117
134
|
def lines=(lines)
|
|
118
135
|
raise TypeError, "expected Array, got #{lines.inspect}" unless lines.is_a? Array
|
|
119
136
|
|
|
120
|
-
@lines = lines
|
|
137
|
+
@lines = parse_input_lines(lines)
|
|
121
138
|
@content_size = nil
|
|
139
|
+
rebuild_padded_lines
|
|
122
140
|
update_top_line_if_auto_scroll
|
|
123
141
|
notify_cursor_changed
|
|
124
142
|
invalidate
|
|
@@ -132,9 +150,11 @@ module Tuile
|
|
|
132
150
|
# end
|
|
133
151
|
# ```
|
|
134
152
|
# @yield [buffer]
|
|
135
|
-
# @yieldparam buffer [Array
|
|
153
|
+
# @yieldparam buffer [Array] mutable buffer to push lines into. Each
|
|
154
|
+
# entry is parsed the same way as the items passed to {#lines=}.
|
|
136
155
|
# @yieldreturn [void]
|
|
137
|
-
# @return [Array<
|
|
156
|
+
# @return [Array<StyledString>] current lines (when called without a
|
|
157
|
+
# block).
|
|
138
158
|
def lines
|
|
139
159
|
return @lines unless block_given?
|
|
140
160
|
|
|
@@ -144,21 +164,24 @@ module Tuile
|
|
|
144
164
|
end
|
|
145
165
|
|
|
146
166
|
# Adds a line.
|
|
147
|
-
# @param line [String]
|
|
167
|
+
# @param line [String, StyledString, #to_s]
|
|
148
168
|
# @return [void]
|
|
149
169
|
def add_line(line)
|
|
150
170
|
add_lines [line]
|
|
151
171
|
end
|
|
152
172
|
|
|
153
|
-
# Appends given lines. Each entry is
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
# @param lines [Array] entries
|
|
173
|
+
# Appends given lines. Each entry is parsed the same way as in
|
|
174
|
+
# {#lines=}: coerced to a {StyledString}, split on `\n`, with trailing
|
|
175
|
+
# empty pieces dropped and trailing ASCII whitespace stripped.
|
|
176
|
+
# @param lines [Array] entries are `String`, `StyledString`, or anything
|
|
177
|
+
# that responds to `#to_s`.
|
|
157
178
|
# @return [void]
|
|
158
179
|
def add_lines(lines)
|
|
159
180
|
screen.check_locked
|
|
160
|
-
|
|
181
|
+
new_lines = parse_input_lines(lines)
|
|
182
|
+
@lines += new_lines
|
|
161
183
|
@content_size = nil
|
|
184
|
+
@padded_lines += new_lines.map { |line| pad_to_row(line) }
|
|
162
185
|
update_top_line_if_auto_scroll
|
|
163
186
|
notify_cursor_changed
|
|
164
187
|
invalidate
|
|
@@ -167,8 +190,8 @@ module Tuile
|
|
|
167
190
|
# @return [Size]
|
|
168
191
|
def content_size
|
|
169
192
|
@content_size ||= begin
|
|
170
|
-
|
|
171
|
-
width = @lines.empty? ? 0 :
|
|
193
|
+
content_w = @lines.map(&:display_width).max || 0
|
|
194
|
+
width = @lines.empty? ? 0 : content_w + 2
|
|
172
195
|
Size.new(width, @lines.size)
|
|
173
196
|
end
|
|
174
197
|
end
|
|
@@ -206,6 +229,8 @@ module Tuile
|
|
|
206
229
|
# Moves the cursor to the next line whose text contains `query`
|
|
207
230
|
# (case-insensitive substring match). Search wraps around the end of the
|
|
208
231
|
# list. Only lines reachable by the current {#cursor} are considered.
|
|
232
|
+
# Matching uses the line's plain text — span styles do not affect the
|
|
233
|
+
# match.
|
|
209
234
|
#
|
|
210
235
|
# @param query [String] substring to match. Empty query never matches.
|
|
211
236
|
# @param include_current [Boolean] when true, the current cursor position
|
|
@@ -250,21 +275,19 @@ module Tuile
|
|
|
250
275
|
# Paints the list items into {#rect}.
|
|
251
276
|
#
|
|
252
277
|
# Skips the {Component#repaint} default's auto-clear: every row of
|
|
253
|
-
# {#rect} is painted below (with
|
|
278
|
+
# {#rect} is painted below (with blank padding past the last item),
|
|
254
279
|
# so the parent contract — "fully draw over your rect" — is met
|
|
255
280
|
# without an upfront wipe.
|
|
256
281
|
# @return [void]
|
|
257
282
|
def repaint
|
|
258
283
|
return if rect.empty?
|
|
259
284
|
|
|
260
|
-
width = rect.width
|
|
261
285
|
scrollbar = if scrollbar_visible?
|
|
262
286
|
VerticalScrollBar.new(rect.height, line_count: @lines.size, top_line: @top_line)
|
|
263
287
|
end
|
|
264
|
-
(0
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
screen.print TTY::Cursor.move_to(rect.left, line_no + rect.top), line
|
|
288
|
+
(0...rect.height).each do |row|
|
|
289
|
+
line = paintable_line(row + @top_line, row, scrollbar)
|
|
290
|
+
screen.print TTY::Cursor.move_to(rect.left, row + rect.top), line
|
|
268
291
|
end
|
|
269
292
|
end
|
|
270
293
|
|
|
@@ -453,8 +476,56 @@ module Tuile
|
|
|
453
476
|
end
|
|
454
477
|
end
|
|
455
478
|
|
|
479
|
+
protected
|
|
480
|
+
|
|
481
|
+
# Rebuilds pre-padded lines when the wrap width changes. The wrap width
|
|
482
|
+
# depends on {#rect}`.width` and the scrollbar gutter, both of which
|
|
483
|
+
# trigger this hook.
|
|
484
|
+
# @return [void]
|
|
485
|
+
def on_width_changed
|
|
486
|
+
super
|
|
487
|
+
rebuild_padded_lines
|
|
488
|
+
end
|
|
489
|
+
|
|
456
490
|
private
|
|
457
491
|
|
|
492
|
+
# Coerces and flattens a list of input entries into trimmed
|
|
493
|
+
# {StyledString} lines. Each entry becomes a {StyledString} (String
|
|
494
|
+
# via {StyledString.parse}, StyledString passed through, anything else
|
|
495
|
+
# via `#to_s`), then split on `\n` via {StyledString#lines} — with
|
|
496
|
+
# trailing empty pieces dropped (matching `String#split("\n")`'s
|
|
497
|
+
# default behavior, so `add_line ""` is a no-op) — and trailing ASCII
|
|
498
|
+
# whitespace stripped on each resulting line.
|
|
499
|
+
# @param entries [Array]
|
|
500
|
+
# @return [Array<StyledString>]
|
|
501
|
+
def parse_input_lines(entries)
|
|
502
|
+
entries.flat_map { |entry| split_to_lines(entry) }
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# @param entry [Object]
|
|
506
|
+
# @return [Array<StyledString>]
|
|
507
|
+
def split_to_lines(entry)
|
|
508
|
+
styled = entry.is_a?(StyledString) ? entry : StyledString.parse(entry.to_s)
|
|
509
|
+
parts = styled.lines
|
|
510
|
+
parts.pop while parts.last && parts.last.empty?
|
|
511
|
+
parts.map { |line| rstrip_styled(line) }
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Returns `line` with trailing ASCII whitespace (space/tab) dropped,
|
|
515
|
+
# preserving span styles on the surviving prefix. Whitespace chars are
|
|
516
|
+
# all single-column ASCII, so byte-count delta equals column-count
|
|
517
|
+
# delta and {StyledString#slice} can do the cut.
|
|
518
|
+
# @param line [StyledString]
|
|
519
|
+
# @return [StyledString]
|
|
520
|
+
def rstrip_styled(line)
|
|
521
|
+
plain = line.to_s
|
|
522
|
+
trailing = plain.length - plain.rstrip.length
|
|
523
|
+
return line if trailing.zero?
|
|
524
|
+
return StyledString::EMPTY if trailing == plain.length
|
|
525
|
+
|
|
526
|
+
line.slice(0, line.display_width - trailing)
|
|
527
|
+
end
|
|
528
|
+
|
|
458
529
|
# @return [Boolean] true if the cursor sits on a real content line.
|
|
459
530
|
def cursor_on_item?
|
|
460
531
|
pos = @cursor.position
|
|
@@ -469,8 +540,9 @@ module Tuile
|
|
|
469
540
|
@on_item_chosen&.call(pos, @lines[pos])
|
|
470
541
|
end
|
|
471
542
|
|
|
472
|
-
# @return [Array((Integer,
|
|
473
|
-
# with `line` nil when the cursor is
|
|
543
|
+
# @return [Array((Integer, StyledString, nil))]
|
|
544
|
+
# `[position, line_at_position]`, with `line` nil when the cursor is
|
|
545
|
+
# off-content.
|
|
474
546
|
def cursor_state
|
|
475
547
|
pos = @cursor.position
|
|
476
548
|
line = pos >= 0 && pos < @lines.size ? @lines[pos] : nil
|
|
@@ -500,7 +572,7 @@ module Tuile
|
|
|
500
572
|
|
|
501
573
|
ordered = order_for_search(candidates, @cursor.position, include_current: include_current, reverse: reverse)
|
|
502
574
|
query_lc = query.downcase
|
|
503
|
-
match = ordered.find { |idx|
|
|
575
|
+
match = ordered.find { |idx| @lines[idx].to_s.downcase.include?(query_lc) }
|
|
504
576
|
return false unless match
|
|
505
577
|
|
|
506
578
|
@cursor.go(match)
|
|
@@ -584,42 +656,56 @@ module Tuile
|
|
|
584
656
|
@scrollbar_visibility == :visible
|
|
585
657
|
end
|
|
586
658
|
|
|
587
|
-
#
|
|
588
|
-
#
|
|
589
|
-
#
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
return " " * width if str.empty?
|
|
659
|
+
# @return [Integer] column width available for line content (rect width
|
|
660
|
+
# minus the scrollbar gutter, when visible). `0` when {#rect}'s width
|
|
661
|
+
# is non-positive.
|
|
662
|
+
def content_width
|
|
663
|
+
return 0 if rect.width <= 0
|
|
593
664
|
|
|
594
|
-
|
|
595
|
-
|
|
665
|
+
rect.width - (scrollbar_visible? ? 1 : 0)
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# Recomputes {@padded_lines} for the current rect width and scrollbar
|
|
669
|
+
# visibility. Each line is ellipsized to fit and pre-padded with
|
|
670
|
+
# single-space gutters on each side, so {#paintable_line} only has to
|
|
671
|
+
# apply the cursor highlight (if any) and append the scrollbar glyph.
|
|
672
|
+
# @return [void]
|
|
673
|
+
def rebuild_padded_lines
|
|
674
|
+
@padded_lines = @lines.map { |line| pad_to_row(line) }
|
|
675
|
+
@blank_padded = pad_to_row(StyledString::EMPTY)
|
|
676
|
+
end
|
|
596
677
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
678
|
+
# Pads `line` to one full row of the viewport (scrollbar gutter
|
|
679
|
+
# excluded). Lines wider than the content area are ellipsized via
|
|
680
|
+
# {StyledString#ellipsize} (span styles survive the cut); shorter
|
|
681
|
+
# lines are padded with default-styled spaces.
|
|
682
|
+
# @param line [StyledString]
|
|
683
|
+
# @return [StyledString] exactly {#content_width} display columns wide
|
|
684
|
+
# (or {StyledString::EMPTY} when content_width is non-positive).
|
|
685
|
+
def pad_to_row(line)
|
|
686
|
+
cw = content_width
|
|
687
|
+
return StyledString::EMPTY if cw <= 0
|
|
688
|
+
return StyledString.plain(" " * cw) if cw < 2
|
|
689
|
+
|
|
690
|
+
text_width = cw - 2
|
|
691
|
+
body = line.ellipsize(text_width)
|
|
692
|
+
fill = cw - 2 - body.display_width
|
|
693
|
+
StyledString.plain(" ") + body + StyledString.plain(" " * (fill + 1))
|
|
600
694
|
end
|
|
601
695
|
|
|
602
696
|
# @param index [Integer] 0-based index into {#lines}.
|
|
603
697
|
# @param row_in_viewport [Integer] 0-based row within the viewport.
|
|
604
|
-
# @param
|
|
605
|
-
#
|
|
606
|
-
#
|
|
607
|
-
#
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
content_width = scrollbar ? width - 1 : width
|
|
611
|
-
line = @lines[index] || ""
|
|
612
|
-
line = trim_to(line, content_width - 2)
|
|
613
|
-
line = " #{line} "
|
|
698
|
+
# @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil
|
|
699
|
+
# if not shown.
|
|
700
|
+
# @return [String] paintable ANSI-encoded line exactly `rect.width`
|
|
701
|
+
# columns wide; highlighted if cursor is here.
|
|
702
|
+
def paintable_line(index, row_in_viewport, scrollbar)
|
|
703
|
+
base = index < @lines.size ? @padded_lines[index] : @blank_padded
|
|
614
704
|
is_cursor = (active? || @show_cursor_when_inactive) && index < @lines.size && @cursor.position == index
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
end
|
|
620
|
-
return line unless scrollbar
|
|
621
|
-
|
|
622
|
-
line + scrollbar.scrollbar_char(row_in_viewport)
|
|
705
|
+
styled = is_cursor ? base.with_bg(CURSOR_BG) : base
|
|
706
|
+
out = styled.to_ansi
|
|
707
|
+
out += scrollbar.scrollbar_char(row_in_viewport) if scrollbar
|
|
708
|
+
out
|
|
623
709
|
end
|
|
624
710
|
end
|
|
625
711
|
end
|
|
@@ -138,8 +138,6 @@ module Tuile
|
|
|
138
138
|
ACTIVE_BG_SGR = TextField::ACTIVE_BG_SGR
|
|
139
139
|
# @return [String]
|
|
140
140
|
INACTIVE_BG_SGR = TextField::INACTIVE_BG_SGR
|
|
141
|
-
# @return [String]
|
|
142
|
-
SGR_RESET = TextField::SGR_RESET
|
|
143
141
|
|
|
144
142
|
# @return [void]
|
|
145
143
|
def repaint
|
|
@@ -156,7 +154,7 @@ module Tuile
|
|
|
156
154
|
chunk = @text[r[:start], r[:length]] || ""
|
|
157
155
|
chunk + (" " * (rect.width - r[:length]))
|
|
158
156
|
end
|
|
159
|
-
screen.print TTY::Cursor.move_to(rect.left, rect.top + screen_row), bg, line,
|
|
157
|
+
screen.print TTY::Cursor.move_to(rect.left, rect.top + screen_row), bg, line, Ansi::RESET
|
|
160
158
|
end
|
|
161
159
|
end
|
|
162
160
|
|
|
@@ -158,9 +158,6 @@ module Tuile
|
|
|
158
158
|
# (terminal black), so we emit the escape directly to reach the ramp.
|
|
159
159
|
# @return [String]
|
|
160
160
|
INACTIVE_BG_SGR = "\e[48;5;238m"
|
|
161
|
-
# SGR reset.
|
|
162
|
-
# @return [String]
|
|
163
|
-
SGR_RESET = "\e[0m"
|
|
164
161
|
|
|
165
162
|
# @return [void]
|
|
166
163
|
def repaint
|
|
@@ -168,7 +165,7 @@ module Tuile
|
|
|
168
165
|
|
|
169
166
|
bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
|
|
170
167
|
padded = @text + (" " * (rect.width - @text.length))
|
|
171
|
-
screen.print TTY::Cursor.move_to(rect.left, rect.top), bg, padded,
|
|
168
|
+
screen.print TTY::Cursor.move_to(rect.left, rect.top), bg, padded, Ansi::RESET
|
|
172
169
|
end
|
|
173
170
|
|
|
174
171
|
protected
|