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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 23a6288632a551240d224975faee1f71d477bb1465126d70915950f67c187650
|
|
4
|
+
data.tar.gz: 33e26892d2a625e85d5d2f2fd058a04080ef12ea38825cb2f9727a346f36c6a2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 233960b007d8c81281e6726e605ab48e9bdc389399b796a4d9a19314e1be2bfab019a787ddc6ab8b421221010cb9402fdc102737fef085aaa326641b76275eed
|
|
7
|
+
data.tar.gz: 68b2425566e2a2586d686699df135a4a7fce835b0f2ca5f090204b3b82f6d3b6d7813375f9129e5256eea627be8df72e667694869d4ba475de55221c0db5ae8e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.4.0] - 2026-05-20
|
|
4
|
+
|
|
5
|
+
- Add `Screen#register_global_shortcut` for app-level hotkeys; registered shortcuts surface in the status bar via `hint:`.
|
|
6
|
+
- Add `Keys::CTRL_A..CTRL_Z` constants and `Keys.printable?` (extracted from `TextField`/`TextArea`/`Screen`).
|
|
7
|
+
- Extract `Component::TextInput` as the shared base of `TextField` and `TextArea`; add `#empty?`.
|
|
8
|
+
- `TextField`/`TextArea`: default `on_escape` to clear focus.
|
|
9
|
+
- `Screen#run_event_loop` accepts `capture_mouse:` (default `true`); pass `false` to skip xterm mouse tracking so the terminal's native select-to-copy keeps working.
|
|
10
|
+
- `StyledString`: add `#with_fg`, mirroring `#with_bg`.
|
|
11
|
+
- `Component::TextView`: add `#<<`, `#add_line`, `#empty?`, and `#remove_last_n_lines` for streaming-tail retraction.
|
|
12
|
+
- `MouseEvent`: map buttons 66/67 to `:scroll_left`/`:scroll_right`.
|
|
13
|
+
- `Component::LogWindow`: extract `#log` helper.
|
|
14
|
+
- `Component::List`: skip `auto_scroll` when rect is empty; re-snap on width change; snap cursor to last line on `auto_scroll`.
|
|
15
|
+
- Document `Component#repaint`'s attached-only call contract.
|
|
16
|
+
- Document keyboard input dispatch order and testing (`FakeScreen`, PTY system tests) in the README.
|
|
17
|
+
- **Breaking:** `Component::TextView#append` is now verbatim — chunks are concatenated onto the current last hard line, embedded `\n` becomes hard breaks, no implicit newline is inserted. Designed for streaming use (e.g. an LLM chat window feeding partial messages straight in). Aliased as `<<` for chainability. The old "add a new entry" behavior is now `Component::TextView#add_line`.
|
|
18
|
+
- **Breaking:** `MouseEvent.parse` raises on malformed input instead of silently truncating.
|
|
19
|
+
- Fix: `Component` gates `invalidate` and `repaint` on `attached?`, dropping the negative-rect relic.
|
|
20
|
+
- Fix: `Popup` recomputes size from content on every `#open`.
|
|
21
|
+
- Fix: `Keys.getkey` reads 5 trailing bytes after ESC, not 6.
|
|
22
|
+
- Fix: `Component::List#add_line` rejects `nil`.
|
|
23
|
+
|
|
24
|
+
## [0.3.0] - 2026-05-18
|
|
25
|
+
|
|
26
|
+
- Add `Component::TextView` — read-only scrollable wrapped prose with word wrap, incremental append, and a lazy text reader.
|
|
27
|
+
- 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.
|
|
28
|
+
- Model `Label`, `List`, and `TextView` text as `StyledString`; pre-pad clipped/physical lines.
|
|
29
|
+
- Extract `Tuile::Ansi` for shared ANSI helpers.
|
|
30
|
+
- `Window#scrollbar=` accepts any content that exposes `scrollbar_visibility=`.
|
|
31
|
+
- Document `TextView` in the README and `examples/sampler.rb`.
|
|
32
|
+
- Remove `Tuile::Wrap` (superseded by `StyledString#wrap`).
|
|
33
|
+
- Remove `Tuile::Truncate` (superseded by `StyledString#ellipsize`).
|
|
34
|
+
|
|
3
35
|
## [0.2.0] - 2026-05-15
|
|
4
36
|
|
|
5
37
|
- 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
|
|
|
@@ -143,7 +146,7 @@ posts a size event, runs layout, and invalidates the entire tree. Components
|
|
|
143
146
|
react by reassigning their child rectangles inside `rect=` — do not install
|
|
144
147
|
your own WINCH handler.
|
|
145
148
|
|
|
146
|
-
### Focus and
|
|
149
|
+
### Focus and keyboard input
|
|
147
150
|
|
|
148
151
|
`screen.focused = component` walks parent pointers up to the root, marks the
|
|
149
152
|
whole chain `active?`, and deactivates everything else. Click-to-focus and
|
|
@@ -151,10 +154,86 @@ whole chain `active?`, and deactivates everything else. Click-to-focus and
|
|
|
151
154
|
returns true, so clicking a `Label` inside a `Window` does not pull focus
|
|
152
155
|
away from the window's content.
|
|
153
156
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
157
|
+
When a key arrives, the screen dispatches it in this order — the first
|
|
158
|
+
mechanism that handles it wins:
|
|
159
|
+
|
|
160
|
+
1. **Tab / Shift+Tab** advance focus through `tab_stop?` components in the
|
|
161
|
+
current modal scope (the topmost popup if one is open, otherwise the
|
|
162
|
+
tiled content). They are intercepted at the screen level before anything
|
|
163
|
+
else sees them, so a focused `TextField` cannot swallow them.
|
|
164
|
+
|
|
165
|
+
2. **Global shortcuts** registered via `Screen#register_global_shortcut`.
|
|
166
|
+
These are app-level hotkeys for actions that don't belong to any
|
|
167
|
+
specific component — opening a log window, toggling help, etc.:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
screen.register_global_shortcut(Tuile::Keys::CTRL_L,
|
|
171
|
+
over_popups: true,
|
|
172
|
+
hint: "^L #{Rainbow('log').cadetblue}") do
|
|
173
|
+
log_popup.open
|
|
174
|
+
end
|
|
175
|
+
screen.unregister_global_shortcut(Tuile::Keys::CTRL_L)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Only unprintable keys are accepted (control characters, ESC, BACKSPACE,
|
|
179
|
+
arrows, F-keys); printable keys raise so they can't hijack typing into
|
|
180
|
+
a `TextField`. By default, the shortcut is suppressed while any popup
|
|
181
|
+
is open and the popup receives the key; pass `over_popups: true` to
|
|
182
|
+
pre-empt the popup.
|
|
183
|
+
|
|
184
|
+
Pass `hint:` to surface the shortcut in the status bar. It's a
|
|
185
|
+
preformatted string the caller fully owns (color it however the rest
|
|
186
|
+
of your app does). In the tiled case it appears right after `q quit`
|
|
187
|
+
and before the active window's hint; while a popup is open, only
|
|
188
|
+
`over_popups: true` hints show up, prepended before the popup's
|
|
189
|
+
`q Close`. Omit `hint:` to leave the shortcut silent in the status bar.
|
|
190
|
+
|
|
191
|
+
3. **`Component#key_shortcut`** — a declarative hotkey attached to a
|
|
192
|
+
component. The framework walks the focused component's subtree for a
|
|
193
|
+
match and focuses the winner. Good fit for "press F to focus the filter
|
|
194
|
+
field" or one-key tab pickers. The lookup is suppressed while the
|
|
195
|
+
focused component owns the hardware cursor (e.g. a `TextField` the user
|
|
196
|
+
is typing into) so editing isn't interrupted:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
filter_field.key_shortcut = "f"
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
4. **`Component#handle_key`** — override this on your own component when
|
|
203
|
+
it needs to react to keys directly (a list reacting to arrows, a custom
|
|
204
|
+
widget handling Enter, …). Return `true` to mark the key handled,
|
|
205
|
+
`false` to let the dispatcher keep walking. Call `super` to keep the
|
|
206
|
+
default `key_shortcut` subtree lookup; suppress it only when you
|
|
207
|
+
deliberately want this component to swallow everything:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
class Toggle < Tuile::Component
|
|
211
|
+
def handle_key(key)
|
|
212
|
+
if key == " "
|
|
213
|
+
@on = !@on
|
|
214
|
+
invalidate
|
|
215
|
+
true
|
|
216
|
+
else
|
|
217
|
+
super
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
If nothing handles the key and it's `q` or `ESC`, the event loop exits.
|
|
224
|
+
|
|
225
|
+
A component can advertise the keys it responds to by overriding
|
|
226
|
+
`keyboard_hint`. The status bar shows the active window's hint alongside
|
|
227
|
+
the global `q quit` prompt; while a popup is open, the popup's own hint
|
|
228
|
+
replaces it, prefixed with `q Close`:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
class FilterWindow < Tuile::Component::Window
|
|
232
|
+
def keyboard_hint
|
|
233
|
+
"f #{Rainbow('filter').cadetblue} Enter #{Rainbow('open').cadetblue}"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
```
|
|
158
237
|
|
|
159
238
|
## Components
|
|
160
239
|
|
|
@@ -349,6 +428,62 @@ Tuile.logger = TTY::Logger.new # duck-typed, works directly
|
|
|
349
428
|
Tuile.logger = Logger.new(Tuile::Component::LogWindow::IO.new(window))
|
|
350
429
|
```
|
|
351
430
|
|
|
431
|
+
## Testing
|
|
432
|
+
|
|
433
|
+
Tuile ships with a `Tuile::FakeScreen` that you install in place of the real
|
|
434
|
+
screen for unit tests. It fixes the viewport at 160×50, disables the UI lock,
|
|
435
|
+
collects every string the framework "would have printed" into an array, and
|
|
436
|
+
uses a synchronous `FakeEventQueue` (submitted blocks run inline; posted
|
|
437
|
+
events are discarded). No terminal IO happens, so the TTY running the tests
|
|
438
|
+
is never painted over.
|
|
439
|
+
|
|
440
|
+
The standard setup is `Screen.fake` / `Screen.close` as a before/after pair —
|
|
441
|
+
this resets the singleton between examples, so state can't leak across
|
|
442
|
+
tests:
|
|
443
|
+
|
|
444
|
+
```ruby
|
|
445
|
+
require "tuile"
|
|
446
|
+
|
|
447
|
+
module Tuile
|
|
448
|
+
describe Component::Label do
|
|
449
|
+
before { Screen.fake }
|
|
450
|
+
after { Screen.close }
|
|
451
|
+
|
|
452
|
+
it "renders text into its rect" do
|
|
453
|
+
label = Component::Label.new
|
|
454
|
+
label.rect = Rect.new(0, 0, 5, 1)
|
|
455
|
+
label.text = "hi"
|
|
456
|
+
label.repaint
|
|
457
|
+
assert_equal [TTY::Cursor.move_to(0, 0), "hi "], Screen.instance.prints
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
Key hooks:
|
|
464
|
+
|
|
465
|
+
- `Screen.instance.prints` — array of strings the screen would have written
|
|
466
|
+
to the terminal. Assert against it (or `.join`) for repaint output.
|
|
467
|
+
- `Screen.instance.repaint` — drive a repaint synchronously; production code
|
|
468
|
+
must not call this, but specs use it to flush the invalidated set after a
|
|
469
|
+
mutation.
|
|
470
|
+
- `Screen.instance.invalidated?(component)` / `invalidated_clear` — verify
|
|
471
|
+
that a mutation did (or did not) invalidate something. Setting a property
|
|
472
|
+
to its current value should typically *not* invalidate.
|
|
473
|
+
- `Screen.instance.clear` — drops accumulated `prints` without resetting
|
|
474
|
+
invalidation.
|
|
475
|
+
|
|
476
|
+
Because `FakeEventQueue#submit` runs the block immediately on the calling
|
|
477
|
+
thread, code paths that marshal work back via `screen.event_queue.submit { … }`
|
|
478
|
+
just work in tests. Posted events (`#post`) are dropped — if your test needs
|
|
479
|
+
to drive a real event loop, you are in system-test territory.
|
|
480
|
+
|
|
481
|
+
For end-to-end tests of a runnable script, spawn it in a pseudo-TTY with
|
|
482
|
+
`PTY.spawn`, wait for a known glyph to confirm the first paint landed, send
|
|
483
|
+
a key, and assert the exit status. `spec/examples/hello_world_spec.rb` is the
|
|
484
|
+
canonical template; PTY-based tests are Linux/macOS only since Ruby's stdlib
|
|
485
|
+
`PTY` isn't on Windows.
|
|
486
|
+
|
|
352
487
|
## Development
|
|
353
488
|
|
|
354
489
|
After checking out the repo, run `bin/setup` to install dependencies. Then,
|
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?
|
|
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
|