tuile 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +137 -5
- data/lib/tuile/color.rb +127 -0
- data/lib/tuile/component/label.rb +34 -4
- data/lib/tuile/component/list.rb +43 -14
- data/lib/tuile/component/log_window.rb +12 -6
- data/lib/tuile/component/popup.rb +5 -5
- data/lib/tuile/component/text_area.rb +39 -134
- data/lib/tuile/component/text_field.rb +31 -148
- data/lib/tuile/component/text_input.rb +213 -0
- data/lib/tuile/component/text_view.rb +792 -48
- data/lib/tuile/component/window.rb +5 -10
- data/lib/tuile/component.rb +15 -3
- data/lib/tuile/event_queue.rb +104 -9
- data/lib/tuile/fake_event_queue.rb +69 -0
- data/lib/tuile/keys.rb +91 -8
- data/lib/tuile/mouse_event.rb +23 -4
- data/lib/tuile/screen.rb +156 -12
- data/lib/tuile/styled_string.rb +38 -58
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +932 -154
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bf132f33d9f0dfd061a502d0b974a9906436bbc585bf363eb0ec7d97a2b223ae
|
|
4
|
+
data.tar.gz: 2d91fe558079a4b5c43abd3818c3bb47cd50dc4ac3372c4afe011ef33dc97045
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8258e9e552143470a5642559f6db98e5292e14fa2fa52c30a06e26bf505e44ff1c889da73792bc262def396997861187d02d95d4189ced1f853ab91333e606b5
|
|
7
|
+
data.tar.gz: 99c03866f8d65ec2c6ca61381078b69616a3e23b3dada3cc11a3d4e2e1fc2973e1d15ac2ac0147e0e583edd20fae247e5be85ad974cf53e245a8cc109b3fe084
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.5.0] - 2026-05-21
|
|
4
|
+
|
|
5
|
+
- Add `Tuile::Color` — a value type wrapping the four color forms ANSI understands (named Symbol, 256-color Integer, RGB Array, or `nil`). Pre-defined constants `Color::RED`, `Color::BRIGHT_BLUE`, … cover the 16 named ANSI colors; `Color.coerce` accepts raw forms transparently.
|
|
6
|
+
- `Component::Label`: add `bg` accessor — applies a background color uniformly across every painted row (text, trailing pad, and blank rows past the last line). Accepts anything `Color.coerce` accepts.
|
|
7
|
+
- Add `Component::TextView::Region` — opaque handle to a contiguous run of hard lines, so apps can stream into logical sections without tracking line indices across sibling mutations. Create with `view.create_region`; mutate via `region.append`/`#<<`/`#text=`/`#add_line`/`#remove_last_n_lines`/`#replace`/`#insert`/`#remove`. Detached handles raise on every reader / mutator (except `#remove`, which is idempotent). `view.text=` / `clear` detach all region handles and install a fresh internal default.
|
|
8
|
+
- Add `Component::TextView#replace(range, str)` and `#insert(at, str)` for mid-buffer hard-line splices (Integer or Range, inclusive/exclusive end, empty range == insertion, `begin == hard-line count` valid for end-insertion).
|
|
9
|
+
- `Component::TextView`: incremental wrap via a per-hard-line row-count cache — mid-buffer mutations now re-wrap only the affected slice instead of the whole buffer. Speeds up the LLM streaming path (mid-document `region.append`, tombstone-style `region.text=`, `view.replace`/`view.insert`). `view.append` on the spatial tail keeps its existing fast path; `view.text=` and `on_width_changed` still do a full rewrap (now rebuilding the cache too).
|
|
10
|
+
- Add `EventQueue#tick(fps) { |n| ... }` returning a `Ticker` backed by `Concurrent::TimerTask`; fires on the event-loop thread with a 0-based monotonic counter. Intended for spinner animations, periodic refresh, or surfacing background-task progress. Auto-cancels on raise.
|
|
11
|
+
- Add `FakeEventQueue#tick` and `FakeTicker` — synchronous test double that drives ticks deterministically.
|
|
12
|
+
- **Breaking:** `StyledString::Style#fg` and `#bg` now return `Color` (or `nil`) instead of the raw `Symbol`/`Integer`/`Array`. `Style.new` and `#merge` continue to accept the raw forms via `Color.coerce`.
|
|
13
|
+
- **Breaking:** Remove `StyledString::Style::COLOR_SYMBOLS` — moved to `Color::COLOR_SYMBOLS`.
|
|
14
|
+
- **Breaking:** `EventQueue#run_loop` now yields submitted `Proc` events to its consumer block instead of dispatching them inline, so a raise from a `submit{}` block is routed through `Screen#on_error` like any other event. Custom `run_loop` consumers must `call` Procs in their case statement.
|
|
15
|
+
|
|
16
|
+
## [0.4.0] - 2026-05-20
|
|
17
|
+
|
|
18
|
+
- Add `Screen#register_global_shortcut` for app-level hotkeys; registered shortcuts surface in the status bar via `hint:`.
|
|
19
|
+
- Add `Keys::CTRL_A..CTRL_Z` constants and `Keys.printable?` (extracted from `TextField`/`TextArea`/`Screen`).
|
|
20
|
+
- Extract `Component::TextInput` as the shared base of `TextField` and `TextArea`; add `#empty?`.
|
|
21
|
+
- `TextField`/`TextArea`: default `on_escape` to clear focus.
|
|
22
|
+
- `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.
|
|
23
|
+
- `StyledString`: add `#with_fg`, mirroring `#with_bg`.
|
|
24
|
+
- `Component::TextView`: add `#<<`, `#add_line`, `#empty?`, and `#remove_last_n_lines` for streaming-tail retraction.
|
|
25
|
+
- `MouseEvent`: map buttons 66/67 to `:scroll_left`/`:scroll_right`.
|
|
26
|
+
- `Component::LogWindow`: extract `#log` helper.
|
|
27
|
+
- `Component::List`: skip `auto_scroll` when rect is empty; re-snap on width change; snap cursor to last line on `auto_scroll`.
|
|
28
|
+
- Document `Component#repaint`'s attached-only call contract.
|
|
29
|
+
- Document keyboard input dispatch order and testing (`FakeScreen`, PTY system tests) in the README.
|
|
30
|
+
- **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`.
|
|
31
|
+
- **Breaking:** `MouseEvent.parse` raises on malformed input instead of silently truncating.
|
|
32
|
+
- Fix: `Component` gates `invalidate` and `repaint` on `attached?`, dropping the negative-rect relic.
|
|
33
|
+
- Fix: `Popup` recomputes size from content on every `#open`.
|
|
34
|
+
- Fix: `Keys.getkey` reads 5 trailing bytes after ESC, not 6.
|
|
35
|
+
- Fix: `Component::List#add_line` rejects `nil`.
|
|
36
|
+
|
|
3
37
|
## [0.3.0] - 2026-05-18
|
|
4
38
|
|
|
5
39
|
- Add `Component::TextView` — read-only scrollable wrapped prose with word wrap, incremental append, and a lazy text reader.
|
data/README.md
CHANGED
|
@@ -146,7 +146,7 @@ posts a size event, runs layout, and invalidates the entire tree. Components
|
|
|
146
146
|
react by reassigning their child rectangles inside `rect=` — do not install
|
|
147
147
|
your own WINCH handler.
|
|
148
148
|
|
|
149
|
-
### Focus and
|
|
149
|
+
### Focus and keyboard input
|
|
150
150
|
|
|
151
151
|
`screen.focused = component` walks parent pointers up to the root, marks the
|
|
152
152
|
whole chain `active?`, and deactivates everything else. Click-to-focus and
|
|
@@ -154,10 +154,86 @@ whole chain `active?`, and deactivates everything else. Click-to-focus and
|
|
|
154
154
|
returns true, so clicking a `Label` inside a `Window` does not pull focus
|
|
155
155
|
away from the window's content.
|
|
156
156
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
+
```
|
|
161
237
|
|
|
162
238
|
## Components
|
|
163
239
|
|
|
@@ -352,6 +428,62 @@ Tuile.logger = TTY::Logger.new # duck-typed, works directly
|
|
|
352
428
|
Tuile.logger = Logger.new(Tuile::Component::LogWindow::IO.new(window))
|
|
353
429
|
```
|
|
354
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
|
+
|
|
355
487
|
## Development
|
|
356
488
|
|
|
357
489
|
After checking out the repo, run `bin/setup` to install dependencies. Then,
|
data/lib/tuile/color.rb
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# An immutable terminal color. Accepts the three forms ANSI/SGR understands:
|
|
5
|
+
#
|
|
6
|
+
# - a Symbol from {COLOR_SYMBOLS} — 8 standard + 8 bright named colors
|
|
7
|
+
# (SGR 30..37 / 90..97 for fg, 40..47 / 100..107 for bg)
|
|
8
|
+
# - an Integer 0..255 — the 256-color palette (SGR 38;5;N / 48;5;N)
|
|
9
|
+
# - an Array of three Integers 0..255 — 24-bit RGB (SGR 38;2;R;G;B / 48;2;R;G;B)
|
|
10
|
+
#
|
|
11
|
+
# A constant per named color is pre-defined (`Color::RED`, `Color::BRIGHT_BLUE`,
|
|
12
|
+
# …) so callers can reach for `Color::RED` instead of building one each time.
|
|
13
|
+
# {.coerce} accepts anything {.new} accepts plus `nil` (terminal default) and
|
|
14
|
+
# an existing {Color} (returned as-is), so APIs that accept colors typically
|
|
15
|
+
# take `[Color, nil]` and pass through {.coerce}.
|
|
16
|
+
#
|
|
17
|
+
# ```ruby
|
|
18
|
+
# Color.new(:red) # named
|
|
19
|
+
# Color.new(42) # 256-color palette
|
|
20
|
+
# Color.new([255, 100, 0]) # RGB
|
|
21
|
+
# Color::RED # constant
|
|
22
|
+
# Color.coerce(:red) # accepts raw forms, returns Color
|
|
23
|
+
# Color.coerce(nil) # nil → nil
|
|
24
|
+
# ```
|
|
25
|
+
#
|
|
26
|
+
# {#to_ansi} renders a full SGR escape (`"\e[31m"`); {#sgr_codes} returns the
|
|
27
|
+
# raw numeric codes so callers (notably {StyledString}) can combine them with
|
|
28
|
+
# other SGR attributes in a single sequence.
|
|
29
|
+
class Color
|
|
30
|
+
# Symbolic color names. Order is significant: indices 0..7 map to the
|
|
31
|
+
# standard ANSI colors (SGR 30..37 fg / 40..47 bg); indices 8..15 map to
|
|
32
|
+
# bright variants (SGR 90..97 / 100..107).
|
|
33
|
+
# @return [Array<Symbol>]
|
|
34
|
+
COLOR_SYMBOLS = %i[
|
|
35
|
+
black red green yellow blue magenta cyan white
|
|
36
|
+
bright_black bright_red bright_green bright_yellow
|
|
37
|
+
bright_blue bright_magenta bright_cyan bright_white
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
# Coerces the input to a {Color}. `nil` passes through unchanged (callers
|
|
41
|
+
# use `nil` for the terminal default); an existing {Color} is returned
|
|
42
|
+
# as-is; otherwise the value is fed to {.new}.
|
|
43
|
+
#
|
|
44
|
+
# @param value [Color, Symbol, Integer, Array<Integer>, nil]
|
|
45
|
+
# @return [Color, nil]
|
|
46
|
+
# @raise [ArgumentError] when `value` is not one of the accepted forms.
|
|
47
|
+
def self.coerce(value)
|
|
48
|
+
case value
|
|
49
|
+
when nil, Color then value
|
|
50
|
+
else new(value)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param value [Symbol, Integer, Array<Integer>] see class-level docs for
|
|
55
|
+
# the three accepted forms.
|
|
56
|
+
# @raise [ArgumentError] when `value` is not one of the accepted forms.
|
|
57
|
+
def initialize(value)
|
|
58
|
+
unless COLOR_SYMBOLS.include?(value) ||
|
|
59
|
+
(value.is_a?(Integer) && value.between?(0, 255)) ||
|
|
60
|
+
(value.is_a?(Array) && value.length == 3 &&
|
|
61
|
+
value.all? { |v| v.is_a?(Integer) && v.between?(0, 255) })
|
|
62
|
+
raise ArgumentError, "invalid color: #{value.inspect}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@value = value.is_a?(Array) ? value.dup.freeze : value
|
|
66
|
+
freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The underlying raw representation — a Symbol, Integer, or frozen
|
|
70
|
+
# Array<Integer>.
|
|
71
|
+
# @return [Symbol, Integer, Array<Integer>]
|
|
72
|
+
attr_reader :value
|
|
73
|
+
|
|
74
|
+
# SGR parameter codes for emitting this color as either a foreground
|
|
75
|
+
# (`target: :fg`) or background (`target: :bg`). Returned as an array so
|
|
76
|
+
# callers can splice them into a multi-attribute SGR (e.g. bold + color).
|
|
77
|
+
#
|
|
78
|
+
# @param target [Symbol] `:fg` or `:bg`.
|
|
79
|
+
# @return [Array<Integer>]
|
|
80
|
+
# @raise [ArgumentError] when `target` is neither `:fg` nor `:bg`.
|
|
81
|
+
def sgr_codes(target = :fg)
|
|
82
|
+
base, ext = case target
|
|
83
|
+
when :fg then [30, 38]
|
|
84
|
+
when :bg then [40, 48]
|
|
85
|
+
else raise ArgumentError, "target must be :fg or :bg, got #{target.inspect}"
|
|
86
|
+
end
|
|
87
|
+
case @value
|
|
88
|
+
when Symbol
|
|
89
|
+
idx = COLOR_SYMBOLS.index(@value)
|
|
90
|
+
idx < 8 ? [base + idx] : [base + 60 + (idx - 8)]
|
|
91
|
+
when Integer then [ext, 5, @value]
|
|
92
|
+
when Array then [ext, 2, *@value]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Full SGR escape sequence for this color (e.g. `"\e[31m"`). Useful for
|
|
97
|
+
# `print`-style direct emission; for composing with other attributes use
|
|
98
|
+
# {#sgr_codes} instead.
|
|
99
|
+
#
|
|
100
|
+
# @param target [Symbol] `:fg` or `:bg`.
|
|
101
|
+
# @return [String]
|
|
102
|
+
def to_ansi(target = :fg)
|
|
103
|
+
"\e[#{sgr_codes(target).join(";")}m"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @param other [Object]
|
|
107
|
+
# @return [Boolean]
|
|
108
|
+
def ==(other)
|
|
109
|
+
other.is_a?(Color) && @value == other.value
|
|
110
|
+
end
|
|
111
|
+
alias eql? ==
|
|
112
|
+
|
|
113
|
+
# @return [Integer]
|
|
114
|
+
def hash
|
|
115
|
+
[self.class, @value].hash
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @return [String]
|
|
119
|
+
def inspect
|
|
120
|
+
"#<#{self.class.name} #{@value.inspect}>"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
COLOR_SYMBOLS.each do |sym|
|
|
124
|
+
const_set(sym.upcase, new(sym))
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -11,6 +11,7 @@ module Tuile
|
|
|
11
11
|
def initialize
|
|
12
12
|
super
|
|
13
13
|
@text = StyledString::EMPTY
|
|
14
|
+
@bg = nil
|
|
14
15
|
@clipped_lines = []
|
|
15
16
|
@blank_line = ""
|
|
16
17
|
end
|
|
@@ -19,6 +20,11 @@ module Tuile
|
|
|
19
20
|
# {StyledString}.
|
|
20
21
|
attr_reader :text
|
|
21
22
|
|
|
23
|
+
# @return [Color, nil] background color applied uniformly across every
|
|
24
|
+
# painted row (including padding past the text). `nil` (default)
|
|
25
|
+
# leaves whatever bg the text's own styling carries.
|
|
26
|
+
attr_reader :bg
|
|
27
|
+
|
|
22
28
|
# Replaces the text. A `String` is parsed via {StyledString.parse}
|
|
23
29
|
# (embedded ANSI is honored); a `StyledString` is used as-is; `nil` is
|
|
24
30
|
# coerced to an empty {StyledString}. Lines wider than {#rect} are
|
|
@@ -35,6 +41,23 @@ module Tuile
|
|
|
35
41
|
invalidate
|
|
36
42
|
end
|
|
37
43
|
|
|
44
|
+
# Sets the background color. Coerced via {Color.coerce}, so a Symbol,
|
|
45
|
+
# Integer, Array, {Color}, or `nil` all work. `nil` clears the override
|
|
46
|
+
# — the label paints with whatever bg the text's own styling provides.
|
|
47
|
+
# Otherwise the bg overlays every span (including the trailing pad and
|
|
48
|
+
# blank rows past the last text line).
|
|
49
|
+
#
|
|
50
|
+
# @param value [Color, Symbol, Integer, Array<Integer>, nil]
|
|
51
|
+
# @return [void]
|
|
52
|
+
def bg=(value)
|
|
53
|
+
new_bg = Color.coerce(value)
|
|
54
|
+
return if @bg == new_bg
|
|
55
|
+
|
|
56
|
+
@bg = new_bg
|
|
57
|
+
update_clipped_lines
|
|
58
|
+
invalidate
|
|
59
|
+
end
|
|
60
|
+
|
|
38
61
|
# @return [Size] longest hard-line's display width × number of hard
|
|
39
62
|
# lines. Reported on the *unclipped* text — sizing is intrinsic to
|
|
40
63
|
# the content, not the viewport. Empty text returns `Size.new(0, 0)`.
|
|
@@ -57,7 +80,7 @@ module Tuile
|
|
|
57
80
|
# wipe.
|
|
58
81
|
# @return [void]
|
|
59
82
|
def repaint
|
|
60
|
-
return if rect.empty?
|
|
83
|
+
return if rect.empty?
|
|
61
84
|
|
|
62
85
|
(0...rect.height).each do |row|
|
|
63
86
|
line = @clipped_lines[row] || @blank_line
|
|
@@ -79,12 +102,19 @@ module Tuile
|
|
|
79
102
|
# Each line is ellipsized to fit, padded with trailing spaces out to
|
|
80
103
|
# the full width, and pre-rendered to ANSI so {#repaint} is just a
|
|
81
104
|
# lookup + screen.print per row. {@blank_line} covers rows past the
|
|
82
|
-
# last text line.
|
|
105
|
+
# last text line. When {#bg} is set, every produced line (and the
|
|
106
|
+
# blank row) has the bg applied uniformly.
|
|
83
107
|
# @return [void]
|
|
84
108
|
def update_clipped_lines
|
|
85
109
|
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 }
|
|
110
|
+
@blank_line = apply_bg(StyledString.plain(" " * width)).to_ansi
|
|
111
|
+
@clipped_lines = @text.lines.map { |line| apply_bg(pad_to(line.ellipsize(width), width)).to_ansi }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @param line [StyledString]
|
|
115
|
+
# @return [StyledString]
|
|
116
|
+
def apply_bg(line)
|
|
117
|
+
@bg ? line.with_bg(@bg) : line
|
|
88
118
|
end
|
|
89
119
|
|
|
90
120
|
# @param line [StyledString]
|
data/lib/tuile/component/list.rb
CHANGED
|
@@ -167,6 +167,7 @@ module Tuile
|
|
|
167
167
|
# @param line [String, StyledString, #to_s]
|
|
168
168
|
# @return [void]
|
|
169
169
|
def add_line(line)
|
|
170
|
+
raise ArgumentError, "line is nil" if line.nil?
|
|
170
171
|
add_lines [line]
|
|
171
172
|
end
|
|
172
173
|
|
|
@@ -326,6 +327,16 @@ module Tuile
|
|
|
326
327
|
def candidate_positions(_line_count)
|
|
327
328
|
[]
|
|
328
329
|
end
|
|
330
|
+
|
|
331
|
+
# Overridden so all movement funnels — base {Cursor#go_to_last},
|
|
332
|
+
# {Cursor#go_to_first}, etc., which all call {#go} — become safe
|
|
333
|
+
# no-ops on a disabled cursor. The instance is frozen, so a default
|
|
334
|
+
# mutating {#go} would raise.
|
|
335
|
+
# @param _new_position [Integer]
|
|
336
|
+
# @return [Boolean] always false.
|
|
337
|
+
def go(_new_position)
|
|
338
|
+
false
|
|
339
|
+
end
|
|
329
340
|
end
|
|
330
341
|
|
|
331
342
|
# @return [Integer] 0-based line index of the current cursor position.
|
|
@@ -384,6 +395,15 @@ module Tuile
|
|
|
384
395
|
true
|
|
385
396
|
end
|
|
386
397
|
|
|
398
|
+
# Moves the cursor to the last reachable position. For base {Cursor},
|
|
399
|
+
# the last line; {Limited} clamps to the last allowed position; {None}
|
|
400
|
+
# is a no-op.
|
|
401
|
+
# @param line_count [Integer] number of lines in the list.
|
|
402
|
+
# @return [Boolean] true if the position changed.
|
|
403
|
+
def go_to_last(line_count)
|
|
404
|
+
go(line_count - 1)
|
|
405
|
+
end
|
|
406
|
+
|
|
387
407
|
protected
|
|
388
408
|
|
|
389
409
|
# @param lines [Integer]
|
|
@@ -404,12 +424,6 @@ module Tuile
|
|
|
404
424
|
go(0)
|
|
405
425
|
end
|
|
406
426
|
|
|
407
|
-
# @param line_count [Integer]
|
|
408
|
-
# @return [Boolean]
|
|
409
|
-
def go_to_last(line_count)
|
|
410
|
-
go(line_count - 1)
|
|
411
|
-
end
|
|
412
|
-
|
|
413
427
|
# Cursor which can only land on specific allowed lines.
|
|
414
428
|
class Limited < Cursor
|
|
415
429
|
# @param positions [Array<Integer>] allowed positions. Must not be
|
|
@@ -442,6 +456,12 @@ module Tuile
|
|
|
442
456
|
@positions.select { it < line_count }
|
|
443
457
|
end
|
|
444
458
|
|
|
459
|
+
# @param _line_count [Integer]
|
|
460
|
+
# @return [Boolean]
|
|
461
|
+
def go_to_last(_line_count)
|
|
462
|
+
go(@positions.last)
|
|
463
|
+
end
|
|
464
|
+
|
|
445
465
|
protected
|
|
446
466
|
|
|
447
467
|
# @param lines [Integer]
|
|
@@ -467,12 +487,6 @@ module Tuile
|
|
|
467
487
|
def go_to_first
|
|
468
488
|
go(@positions.first)
|
|
469
489
|
end
|
|
470
|
-
|
|
471
|
-
# @param _line_count [Integer]
|
|
472
|
-
# @return [Boolean]
|
|
473
|
-
def go_to_last(_line_count)
|
|
474
|
-
go(@positions.last)
|
|
475
|
-
end
|
|
476
490
|
end
|
|
477
491
|
end
|
|
478
492
|
|
|
@@ -480,11 +494,16 @@ module Tuile
|
|
|
480
494
|
|
|
481
495
|
# Rebuilds pre-padded lines when the wrap width changes. The wrap width
|
|
482
496
|
# depends on {#rect}`.width` and the scrollbar gutter, both of which
|
|
483
|
-
# trigger this hook.
|
|
497
|
+
# trigger this hook. Also re-evaluates {#auto_scroll}: if items were
|
|
498
|
+
# appended while the rect was empty (e.g. a {Popup}-wrapped list got
|
|
499
|
+
# `add_line` calls before the popup was opened), the auto-scroll update
|
|
500
|
+
# was skipped because there was no viewport — re-run it now that there
|
|
501
|
+
# is one, so the list snaps to the bottom on first paint.
|
|
484
502
|
# @return [void]
|
|
485
503
|
def on_width_changed
|
|
486
504
|
super
|
|
487
505
|
rebuild_padded_lines
|
|
506
|
+
update_top_line_if_auto_scroll
|
|
488
507
|
end
|
|
489
508
|
|
|
490
509
|
private
|
|
@@ -638,10 +657,20 @@ module Tuile
|
|
|
638
657
|
invalidate
|
|
639
658
|
end
|
|
640
659
|
|
|
641
|
-
# If auto-scrolling, recalculate the top line
|
|
660
|
+
# If auto-scrolling, recalculate the top line and snap the cursor to the
|
|
661
|
+
# last reachable position. Without the cursor snap the viewport gets
|
|
662
|
+
# yanked back to wherever the cursor sat on the next arrow press,
|
|
663
|
+
# negating the auto-scroll. Skipped when {#rect} is empty: without a
|
|
664
|
+
# viewport the "lines minus viewport" formula yields `@lines.size`,
|
|
665
|
+
# which would leave `top_line` past the last item once a real rect
|
|
666
|
+
# arrives. {#on_width_changed} re-runs this hook when the rect grows so
|
|
667
|
+
# the snap-to-bottom intent is preserved.
|
|
642
668
|
# @return [void]
|
|
643
669
|
def update_top_line_if_auto_scroll
|
|
644
670
|
return unless @auto_scroll
|
|
671
|
+
return if rect.empty?
|
|
672
|
+
|
|
673
|
+
notify_cursor_changed if @cursor.go_to_last(@lines.size)
|
|
645
674
|
|
|
646
675
|
new_top_line = (@lines.size - viewport_lines).clamp(0, nil)
|
|
647
676
|
return unless @top_line != new_top_line
|
|
@@ -23,6 +23,16 @@ module Tuile
|
|
|
23
23
|
self.scrollbar = true
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
|
|
27
|
+
# @param string [String, nil] the line (or multiple lines) to log.
|
|
28
|
+
# @return [void]
|
|
29
|
+
def log(string)
|
|
30
|
+
return if string.nil?
|
|
31
|
+
screen.event_queue.submit do
|
|
32
|
+
content.add_line(string)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
26
36
|
# IO-shaped adapter that forwards each log line to the owning {LogWindow}.
|
|
27
37
|
# Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
|
|
28
38
|
# call `output.puts`, e.g. `TTY::Logger`).
|
|
@@ -35,17 +45,13 @@ module Tuile
|
|
|
35
45
|
# @param string [String]
|
|
36
46
|
# @return [void]
|
|
37
47
|
def write(string)
|
|
38
|
-
@window.
|
|
39
|
-
@window.content.add_line(string.chomp)
|
|
40
|
-
end
|
|
48
|
+
@window.log(string.chomp)
|
|
41
49
|
end
|
|
42
50
|
|
|
43
51
|
# @param string [String]
|
|
44
52
|
# @return [void]
|
|
45
53
|
def puts(string)
|
|
46
|
-
@window.
|
|
47
|
-
@window.content.add_line(string)
|
|
48
|
-
end
|
|
54
|
+
@window.log(string)
|
|
49
55
|
end
|
|
50
56
|
|
|
51
57
|
# Stdlib `Logger` only treats an object as an IO target when it
|
|
@@ -30,17 +30,17 @@ module Tuile
|
|
|
30
30
|
def initialize(content: nil)
|
|
31
31
|
super()
|
|
32
32
|
@content = nil
|
|
33
|
-
# Off-screen sentinel until the content sets a real size and the popup
|
|
34
|
-
# is centered on open.
|
|
35
|
-
@rect = Rect.new(-1, -1, 0, 0)
|
|
36
33
|
self.content = content unless content.nil?
|
|
37
34
|
end
|
|
38
35
|
|
|
39
36
|
def focusable? = true
|
|
40
37
|
|
|
41
|
-
# Mounts this popup on the {Screen}.
|
|
38
|
+
# Mounts this popup on the {Screen}. Recomputes the popup's size from
|
|
39
|
+
# the current content first, so reopening a popup whose content has
|
|
40
|
+
# grown or shrunk while closed picks up the new size.
|
|
42
41
|
# @return [void]
|
|
43
42
|
def open
|
|
43
|
+
update_rect unless @content.nil?
|
|
44
44
|
screen.add_popup(self)
|
|
45
45
|
end
|
|
46
46
|
|
|
@@ -119,7 +119,7 @@ module Tuile
|
|
|
119
119
|
def update_rect
|
|
120
120
|
size = @content.content_size.clamp_height(max_height)
|
|
121
121
|
size = size.clamp(Size.new(screen.size.width * 4 / 5, screen.size.height * 4 / 5))
|
|
122
|
-
self.rect = Rect.new(
|
|
122
|
+
self.rect = Rect.new(0, 0, size.width, size.height)
|
|
123
123
|
center if open?
|
|
124
124
|
end
|
|
125
125
|
end
|