tuile 0.4.0 → 0.6.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 +28 -0
- data/README.md +150 -4
- data/examples/file_commander.rb +4 -3
- data/examples/sampler.rb +1 -0
- data/lib/tuile/ansi.rb +4 -3
- data/lib/tuile/color.rb +249 -0
- data/lib/tuile/component/button.rb +9 -5
- data/lib/tuile/component/label.rb +44 -16
- data/lib/tuile/component/list.rb +29 -19
- data/lib/tuile/component/picker_window.rb +2 -2
- data/lib/tuile/component/popup.rb +11 -1
- data/lib/tuile/component/text_area.rb +1 -2
- data/lib/tuile/component/text_field.rb +1 -2
- data/lib/tuile/component/text_input.rb +10 -15
- data/lib/tuile/component/text_view.rb +696 -58
- data/lib/tuile/component/window.rb +70 -16
- data/lib/tuile/component.rb +74 -5
- data/lib/tuile/event_queue.rb +130 -11
- data/lib/tuile/fake_event_queue.rb +69 -0
- data/lib/tuile/fake_screen.rb +8 -0
- data/lib/tuile/keys.rb +10 -0
- data/lib/tuile/screen.rb +98 -4
- data/lib/tuile/sizing.rb +59 -0
- data/lib/tuile/styled_string.rb +28 -61
- data/lib/tuile/terminal_background.rb +137 -0
- data/lib/tuile/theme.rb +202 -0
- data/lib/tuile/theme_def.rb +85 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +0 -1
- data/sig/tuile.rbs +1160 -93
- metadata +6 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fdf4dfc626b692fdeeb9dabbcd08fe94a74045ae24340b24074d601856b87bf8
|
|
4
|
+
data.tar.gz: a38a8d8f04c53341a1bf6adfbf551209fc8e917f6957076bcf291bb4c2f7837f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ee942fd7bd9c9c35212f20ae78d043df5c2fe5c9dfb3e3ca5b1e3acd98a1dfd9d7708c5a8d481f49b690c50c56b8d9f81f771adb668937ae0b45e5244f84fd71
|
|
7
|
+
data.tar.gz: 2735212649ff50b35814125ce0d91043f91bba7e4a8c4aa4a35b730708f66473536e2c240c70f45197bd89ef36154694119c8dfa0c70527f28e8c6520419dcd5
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.6.0] - 2026-06-07
|
|
4
|
+
|
|
5
|
+
- Add `Tuile::Theme` — semantic color tokens for the accents built-in components paint (the list-cursor/focused-input highlight `active_bg_color`, the inactive input well `input_bg_color`, the active window border `active_border_color`, the status-bar `hint_color`), with `DARK`/`LIGHT` presets and rendering helpers (`#active_bg`, `#active_border`, `#input_bg`, `#hint`). The current theme lives at `Screen#theme`; assigning restyles the whole UI in a single invalidate-everything pass. Everything that isn't an accent keeps inheriting the terminal's own default fg/bg.
|
|
6
|
+
- Auto-detect the light/dark terminal background at startup: `Screen.new` queries the terminal via OSC 11 (`COLORFGBG` fallback, dark when inconclusive) and picks `Theme::LIGHT`/`Theme::DARK` to match.
|
|
7
|
+
- Follow OS light/dark appearance flips live via mode 2031 (kitty, foot, contour, ghostty, …): the screen re-picks the matching theme and repaints everything.
|
|
8
|
+
- Add app-specific theme tokens: `Theme#custom` (`Hash{Symbol => Color}`), looked up fail-fast via `Theme#[]` (`KeyError` on typos) and rendered via the generic `#fg`/`#bg` helpers. Subclass `Theme` to add one semantic coloring function per custom token — `Data#with` preserves the subclass. Theme tokens are strictly `Color` instances; `Color` gains the `Color.palette`/`Color.rgb` named constructors.
|
|
9
|
+
- Add `Tuile::ThemeDef` — an app's dark/light `Theme` pair. Assigning `Screen#theme_def=` is the durable way to theme an app: the screen picks the member matching the detected background at startup and on every appearance flip, where a bare `theme=` assignment is transient. Construction validates that both members declare the same custom key set.
|
|
10
|
+
- Add `ThemeDef.default` — the definition newly-constructed screens start from (initially `ThemeDef::DEFAULT`). Reassign it once in `spec_helper.rb` and every `Screen.fake` carries the app's custom tokens, instead of repeating `theme_def=` in each `before` block.
|
|
11
|
+
- Name the 256-color palette: a constant per standard xterm chart name for palette indices 16..255 (`Color::CADET_BLUE` is `Color.palette(72)`; `Color::DODGER_BLUE1`, `Color::GREY37`, …) — exact palette cells, no quantization, listed in `Color::PALETTE_NAMES`. Where the chart names several cells identically, the first cell wins the constant; indices 0..15 keep the symbolic `Color::RED`/`Color::BRIGHT_BLUE`/… constants, which respect the terminal's own scheme.
|
|
12
|
+
- Add `Color.hex` — a 24-bit RGB color from a CSS-style hex string (`Color.hex("#333333") == Color.rgb(51, 51, 51)`; leading `#` optional, case-insensitive, 3-digit shorthand expands as in CSS). Alpha forms (`#rgba`/`#rrggbbaa`) are rejected — SGR has no alpha channel. `Color.coerce` stays string-free; `.hex` is the explicit entry point.
|
|
13
|
+
- Add `Component#on_theme_changed` — fired pre-order across the attached tree on every theme change, so apps can rebuild styled content whose colors were derived from the old theme. Override it (calling `super`) or assign the `on_theme_changed=` proc.
|
|
14
|
+
- Add `Tuile::Sizing` (`FILL` / `WRAP_CONTENT` / `Sizing.fixed(n)`) and `Window#footer_sizing` — the footer slot is sized per policy against the inner width; a `WRAP_CONTENT` footer re-lays-out live as its content changes. The footer is excluded from `Window#content_size`: it is decoration overlaying the border and must not drive window size.
|
|
15
|
+
- `Component#content_size` is now maintained eagerly: content mutators assign via the protected `content_size=` setter, which fires `parent.on_child_content_size_changed(self)` only when the value actually changed. Fixes a `Popup` staleness — an open popup now re-sizes and recenters when its content grows.
|
|
16
|
+
- **Breaking:** `rainbow` is no longer a runtime dependency (nothing under `lib/` uses it — `Theme`/`StyledString`/`Color` produce all SGR output). Apps that style text with Rainbow must add it to their own Gemfile.
|
|
17
|
+
|
|
18
|
+
## [0.5.0] - 2026-05-21
|
|
19
|
+
|
|
20
|
+
- 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.
|
|
21
|
+
- `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.
|
|
22
|
+
- 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.
|
|
23
|
+
- 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).
|
|
24
|
+
- `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).
|
|
25
|
+
- 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.
|
|
26
|
+
- Add `FakeEventQueue#tick` and `FakeTicker` — synchronous test double that drives ticks deterministically.
|
|
27
|
+
- **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`.
|
|
28
|
+
- **Breaking:** Remove `StyledString::Style::COLOR_SYMBOLS` — moved to `Color::COLOR_SYMBOLS`.
|
|
29
|
+
- **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.
|
|
30
|
+
|
|
3
31
|
## [0.4.0] - 2026-05-20
|
|
4
32
|
|
|
5
33
|
- Add `Screen#register_global_shortcut` for app-level hotkeys; registered shortcuts surface in the status bar via `hint:`.
|
data/README.md
CHANGED
|
@@ -169,7 +169,7 @@ mechanism that handles it wins:
|
|
|
169
169
|
```ruby
|
|
170
170
|
screen.register_global_shortcut(Tuile::Keys::CTRL_L,
|
|
171
171
|
over_popups: true,
|
|
172
|
-
hint: "^L #{
|
|
172
|
+
hint: "^L #{screen.theme.hint('log')}") do
|
|
173
173
|
log_popup.open
|
|
174
174
|
end
|
|
175
175
|
screen.unregister_global_shortcut(Tuile::Keys::CTRL_L)
|
|
@@ -230,11 +230,155 @@ replaces it, prefixed with `q Close`:
|
|
|
230
230
|
```ruby
|
|
231
231
|
class FilterWindow < Tuile::Component::Window
|
|
232
232
|
def keyboard_hint
|
|
233
|
-
"f #{
|
|
233
|
+
"f #{screen.theme.hint('filter')} Enter #{screen.theme.hint('open')}"
|
|
234
234
|
end
|
|
235
235
|
end
|
|
236
236
|
```
|
|
237
237
|
|
|
238
|
+
### Theming
|
|
239
|
+
|
|
240
|
+
The accent colors built-in components paint with — the list-cursor /
|
|
241
|
+
focused-input highlight, the inactive input "well", the active window
|
|
242
|
+
border, the status-bar hint color — come from a `Tuile::Theme`, a frozen
|
|
243
|
+
value type of semantic color tokens. The current theme lives at
|
|
244
|
+
`screen.theme`.
|
|
245
|
+
|
|
246
|
+
The theme is picked automatically when the screen is constructed:
|
|
247
|
+
`Screen.new` queries the terminal's background color (OSC 11, with a
|
|
248
|
+
`COLORFGBG` fallback) and selects `Theme::LIGHT` on light backgrounds,
|
|
249
|
+
`Theme::DARK` (the colors Tuile has always used) otherwise. While the
|
|
250
|
+
event loop runs, terminals supporting mode 2031 (kitty, foot, contour,
|
|
251
|
+
ghostty, …) push appearance changes, and the screen follows OS
|
|
252
|
+
light/dark flips live, repainting everything in the matching theme.
|
|
253
|
+
Override it any time:
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
screen.theme = Tuile::Theme::LIGHT
|
|
257
|
+
# or tweak a single token (tokens are strict: `Color` instances only):
|
|
258
|
+
screen.theme = Tuile::Theme::DARK.with(active_border_color: Tuile::Color::CYAN)
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Note a bare `theme=` assignment is transient: the next OS appearance flip
|
|
262
|
+
re-picks from the screen's `ThemeDef` and replaces it. To theme an app
|
|
263
|
+
durably, see [App themes](#app-themes) below.
|
|
264
|
+
|
|
265
|
+
The theme's primary API is its rendering helpers — `active_bg(text)`,
|
|
266
|
+
`active_border(text)`, `input_bg(text)`, `hint(text)` — which return the
|
|
267
|
+
text wrapped in the token's color:
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
screen.theme.hint("quit") # => "\e[38;5;109mquit\e[0m"
|
|
271
|
+
screen.theme.active_bg("[ Ok ]") # => "\e[48;5;59m[ Ok ]\e[0m"
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
The raw colors are also readable via the `*_color` counterparts
|
|
275
|
+
(`active_bg_color`, …) for span-aware styling with `StyledString`.
|
|
276
|
+
|
|
277
|
+
Assigning a theme invalidates every component, so the whole UI restyles on
|
|
278
|
+
the next repaint. One caveat: strings with colors already baked in (global
|
|
279
|
+
shortcut `hint:`s, `Theme#hint` output you cached) don't restyle —
|
|
280
|
+
rebuild them in `Component#on_theme_changed` (see
|
|
281
|
+
[Reacting to theme changes](#reacting-to-theme-changes)).
|
|
282
|
+
|
|
283
|
+
Everything that isn't an accent deliberately inherits the terminal's own
|
|
284
|
+
default foreground/background, which already matches the user's terminal
|
|
285
|
+
theme — so there is no global `bg`/`fg` token to configure.
|
|
286
|
+
|
|
287
|
+
### App themes
|
|
288
|
+
|
|
289
|
+
Your app's own colors belong in the theme too, so they restyle in the same
|
|
290
|
+
invalidate-everything pass and stay legible on both terminal backgrounds.
|
|
291
|
+
Beyond the built-in tokens, a theme carries app-specific tokens in
|
|
292
|
+
`custom` — a `Hash{Symbol => Color}`. Look them up with `theme[:token]`
|
|
293
|
+
(fail-fast: a typo raises `KeyError` instead of quietly painting a
|
|
294
|
+
default) and render with the generic `fg` / `bg` helpers:
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
theme = Tuile::Theme::DARK.with(custom: { accent: Tuile::Color::DARK_ORANGE })
|
|
298
|
+
theme[:accent] # => Color — e.g. for StyledString#with_fg
|
|
299
|
+
theme.fg(:accent, "NEW") # => "\e[38;5;208mNEW\e[0m"
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`Color::DARK_ORANGE` is `Color.palette(208)` — the 256-color palette
|
|
303
|
+
carries a constant per standard xterm chart name (`CADET_BLUE`,
|
|
304
|
+
`DODGER_BLUE1`, `GREY37`, …; see `Color::PALETTE_NAMES`), so a theme
|
|
305
|
+
declaration can say which color it means instead of citing a bare index.
|
|
306
|
+
|
|
307
|
+
The recommended shape is a `Theme` subclass that implements one coloring
|
|
308
|
+
function per custom token, mirroring the built-in helpers (`hint`,
|
|
309
|
+
`active_bg`, …) — call sites then read `theme.added("+42")` instead of
|
|
310
|
+
`theme.fg(:added, "+42")`. `Data#with` preserves the subclass, so an
|
|
311
|
+
`AppTheme` stays an `AppTheme` through `with`:
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
class AppTheme < Tuile::Theme
|
|
315
|
+
# one coloring function per custom token
|
|
316
|
+
def added(text) = fg(:added, text)
|
|
317
|
+
def removed(text) = fg(:removed, text)
|
|
318
|
+
end
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Build both appearance variants and pair them in a `Tuile::ThemeDef`
|
|
322
|
+
assigned to `screen.theme_def=`. This is the durable way to theme an app:
|
|
323
|
+
the screen picks the member matching the detected background at startup
|
|
324
|
+
and re-picks on every OS appearance flip, so your definition survives
|
|
325
|
+
light/dark toggles where a bare `theme=` assignment would be replaced.
|
|
326
|
+
`ThemeDef.new` enforces that both members declare the same custom key
|
|
327
|
+
set — a token present in only one variant fails at construction instead
|
|
328
|
+
of raising `KeyError` at the unpredictable moment the user flips
|
|
329
|
+
appearance:
|
|
330
|
+
|
|
331
|
+
```ruby
|
|
332
|
+
APP_THEME = Tuile::ThemeDef.new(
|
|
333
|
+
dark: AppTheme.new(**Tuile::Theme::DARK.to_h,
|
|
334
|
+
custom: { added: Tuile::Color::DARK_SEA_GREEN,
|
|
335
|
+
removed: Tuile::Color::LIGHT_PINK3 }),
|
|
336
|
+
light: AppTheme.new(**Tuile::Theme::LIGHT.to_h,
|
|
337
|
+
custom: { added: Tuile::Color::SPRING_GREEN4,
|
|
338
|
+
removed: Tuile::Color::INDIAN_RED })
|
|
339
|
+
)
|
|
340
|
+
screen.theme_def = APP_THEME
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
In tests, a fresh `Screen.fake` per example starts from the built-in
|
|
344
|
+
definition, so a component reading `theme[:added]` would `KeyError`.
|
|
345
|
+
Instead of repeating `Screen.instance.theme_def = APP_THEME` in every
|
|
346
|
+
`before` block, point the construction-time default at your definition
|
|
347
|
+
once, in `spec_helper.rb`:
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
Tuile::ThemeDef.default = APP_THEME # every Screen.fake now carries it
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### Reacting to theme changes
|
|
354
|
+
|
|
355
|
+
Built-in components read `screen.theme` at paint time, so their accents
|
|
356
|
+
restyle automatically. Content you rendered yourself does not: a
|
|
357
|
+
`StyledString` stored in `Label#text` / `List#lines` / `TextView#text`
|
|
358
|
+
has its colors baked in at construction, and only your app knows which of
|
|
359
|
+
those were theme-derived (as opposed to inherent to the data — log-level
|
|
360
|
+
colors, say). `Component#on_theme_changed` fires on every attached
|
|
361
|
+
component when the theme changes (assignment or appearance flip); rebuild
|
|
362
|
+
theme-derived content there by re-running the code that rendered it
|
|
363
|
+
initially. Consume it either way:
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
# composition style — assembling stock components:
|
|
367
|
+
label.on_theme_changed = -> { label.text = render_status_line }
|
|
368
|
+
|
|
369
|
+
# subclass style — call `super` so an assigned listener keeps firing:
|
|
370
|
+
class DiffView < Tuile::Component::TextView
|
|
371
|
+
def on_theme_changed
|
|
372
|
+
super
|
|
373
|
+
self.text = render_diff # screen.theme already returns the new theme
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
The hook runs on the UI thread and repaint coalesces per tick, so
|
|
379
|
+
mutating content inside it is safe. Don't assign `screen.theme=` from
|
|
380
|
+
inside the hook.
|
|
381
|
+
|
|
238
382
|
## Components
|
|
239
383
|
|
|
240
384
|
All components live under `Tuile::Component::*`. Each one is documented below
|
|
@@ -244,11 +388,13 @@ YARD output (`bundle exec rake yard`).
|
|
|
244
388
|
### `Component::Label`
|
|
245
389
|
|
|
246
390
|
Static text. No word-wrapping; long lines are clipped to `rect.width`. Lines
|
|
247
|
-
may contain
|
|
391
|
+
may contain ANSI SGR formatting — theme helper output, a `StyledString`,
|
|
392
|
+
or any SGR-emitting library (e.g. Rainbow, which is no longer a Tuile
|
|
393
|
+
dependency — add it to your own Gemfile if you use it).
|
|
248
394
|
|
|
249
395
|
```ruby
|
|
250
396
|
label = Tuile::Component::Label.new
|
|
251
|
-
label.text = "Hello, #{
|
|
397
|
+
label.text = "Hello, #{screen.theme.hint('world')}!"
|
|
252
398
|
```
|
|
253
399
|
|
|
254
400
|
Key API: `text=`, `content_size`.
|
data/examples/file_commander.rb
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
#
|
|
15
15
|
# Press q or ESC to exit.
|
|
16
16
|
|
|
17
|
+
require "rainbow"
|
|
17
18
|
require "tuile"
|
|
18
19
|
|
|
19
20
|
module FileCommanderExample
|
|
@@ -111,9 +112,9 @@ module FileCommanderExample
|
|
|
111
112
|
# land in one place.
|
|
112
113
|
class PaneWindow < Tuile::Component::Window
|
|
113
114
|
def keyboard_hint
|
|
114
|
-
"Tab #{
|
|
115
|
-
"Enter #{
|
|
116
|
-
"Bksp #{
|
|
115
|
+
"Tab #{screen.theme.hint("Switch")} " \
|
|
116
|
+
"Enter #{screen.theme.hint("Open")} " \
|
|
117
|
+
"Bksp #{screen.theme.hint("Up")}"
|
|
117
118
|
end
|
|
118
119
|
end
|
|
119
120
|
|
data/examples/sampler.rb
CHANGED
data/lib/tuile/ansi.rb
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
# ANSI escape sequence constants. Tuile emits colors and text attributes
|
|
5
|
-
# via
|
|
6
|
-
# Rendition", `ESC [ <params> m` — e.g. `\e[31m` red, `\e[1m`
|
|
7
|
-
# `\e[0m` reset).
|
|
5
|
+
# via {StyledString} / {Color}, which produce **SGR** sequences ("Select
|
|
6
|
+
# Graphic Rendition", `ESC [ <params> m` — e.g. `\e[31m` red, `\e[1m`
|
|
7
|
+
# bold, `\e[0m` reset). Host apps may also use Rainbow, which emits the
|
|
8
|
+
# same form.
|
|
8
9
|
module Ansi
|
|
9
10
|
# SGR reset (`ESC [ 0 m`). Restores the terminal's default foreground,
|
|
10
11
|
# background, and text attributes.
|
data/lib/tuile/color.rb
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
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
|
+
# The 256-color palette gets the same treatment via {PALETTE_NAMES}:
|
|
14
|
+
# `Color::CADET_BLUE`, `Color::DODGER_BLUE1`, `Color::GREY37`, … — the
|
|
15
|
+
# standard xterm chart names for indices 16..255, each an exact palette cell.
|
|
16
|
+
# {.coerce} accepts anything {.new} accepts plus `nil` (terminal default) and
|
|
17
|
+
# an existing {Color} (returned as-is), so APIs that accept colors typically
|
|
18
|
+
# take `[Color, nil]` and pass through {.coerce}.
|
|
19
|
+
#
|
|
20
|
+
# ```ruby
|
|
21
|
+
# Color.new(:red) # named
|
|
22
|
+
# Color.new(42) # 256-color palette
|
|
23
|
+
# Color.new([255, 100, 0]) # RGB
|
|
24
|
+
# Color::RED # constant
|
|
25
|
+
# Color.palette(42) # 256-color palette, explicit
|
|
26
|
+
# Color.rgb(255, 100, 0) # 24-bit RGB, explicit
|
|
27
|
+
# Color.hex("#ff6400") # 24-bit RGB from a CSS-style hex string
|
|
28
|
+
# Color.coerce(:red) # accepts raw forms, returns Color
|
|
29
|
+
# Color.coerce(nil) # nil → nil
|
|
30
|
+
# ```
|
|
31
|
+
#
|
|
32
|
+
# Which entry point to use is a deliberate policy split. High-traffic
|
|
33
|
+
# call sites ({StyledString} and friends) stay lenient and {.coerce} raw
|
|
34
|
+
# forms — you don't want factory ceremony on every styled span.
|
|
35
|
+
# Declaration sites ({Theme}, defined once per app) are strict and take
|
|
36
|
+
# only {Color} instances, where `Color.palette(130)` documents itself in
|
|
37
|
+
# a way the bare `130` (palette index? RGB channel?) does not.
|
|
38
|
+
#
|
|
39
|
+
# {#to_ansi} renders a full SGR escape (`"\e[31m"`); {#sgr_codes} returns the
|
|
40
|
+
# raw numeric codes so callers (notably {StyledString}) can combine them with
|
|
41
|
+
# other SGR attributes in a single sequence.
|
|
42
|
+
class Color
|
|
43
|
+
# Symbolic color names. Order is significant: indices 0..7 map to the
|
|
44
|
+
# standard ANSI colors (SGR 30..37 fg / 40..47 bg); indices 8..15 map to
|
|
45
|
+
# bright variants (SGR 90..97 / 100..107).
|
|
46
|
+
# @return [Array<Symbol>]
|
|
47
|
+
COLOR_SYMBOLS = %i[
|
|
48
|
+
black red green yellow blue magenta cyan white
|
|
49
|
+
bright_black bright_red bright_green bright_yellow
|
|
50
|
+
bright_blue bright_magenta bright_cyan bright_white
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
# Coerces the input to a {Color}. `nil` passes through unchanged (callers
|
|
54
|
+
# use `nil` for the terminal default); an existing {Color} is returned
|
|
55
|
+
# as-is; otherwise the value is fed to {.new}.
|
|
56
|
+
#
|
|
57
|
+
# @param value [Color, Symbol, Integer, Array<Integer>, nil]
|
|
58
|
+
# @return [Color, nil]
|
|
59
|
+
# @raise [ArgumentError] when `value` is not one of the accepted forms.
|
|
60
|
+
def self.coerce(value)
|
|
61
|
+
case value
|
|
62
|
+
when nil, Color then value
|
|
63
|
+
else new(value)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A color from the 256-color palette (SGR 38;5;N / 48;5;N). Same as
|
|
68
|
+
# `Color.new(index)`, but the name says what the bare integer is.
|
|
69
|
+
#
|
|
70
|
+
# @param index [Integer] palette index, 0..255.
|
|
71
|
+
# @return [Color]
|
|
72
|
+
# @raise [ArgumentError] when `index` is not an Integer in 0..255.
|
|
73
|
+
def self.palette(index)
|
|
74
|
+
raise ArgumentError, "invalid palette index: #{index.inspect}" unless index.is_a?(Integer)
|
|
75
|
+
|
|
76
|
+
new(index)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A 24-bit RGB color (SGR 38;2;R;G;B / 48;2;R;G;B). Same as
|
|
80
|
+
# `Color.new([r, g, b])`, but with the channels spelled out.
|
|
81
|
+
#
|
|
82
|
+
# @param red [Integer] 0..255.
|
|
83
|
+
# @param green [Integer] 0..255.
|
|
84
|
+
# @param blue [Integer] 0..255.
|
|
85
|
+
# @return [Color]
|
|
86
|
+
# @raise [ArgumentError] when a channel is not an Integer in 0..255.
|
|
87
|
+
def self.rgb(red, green, blue)
|
|
88
|
+
new([red, green, blue])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A 24-bit RGB color from a CSS-style hex string — for when the value
|
|
92
|
+
# comes from a hex source (a designer's palette, a CSS variable). The
|
|
93
|
+
# leading `#` is optional, digits are case-insensitive, and the CSS
|
|
94
|
+
# 3-digit shorthand expands as in CSS (`"#345"` → `"#334455"`).
|
|
95
|
+
# 4/8-digit alpha forms are rejected: SGR has no alpha channel, and
|
|
96
|
+
# silently dropping it would lie about the rendered color.
|
|
97
|
+
#
|
|
98
|
+
# @param string [String] e.g. `"#333333"`, `"5F9EA0"`, `"#333"`.
|
|
99
|
+
# @return [Color] same value form as {.rgb} — `Color.hex("#333") ==
|
|
100
|
+
# Color.rgb(51, 51, 51)`.
|
|
101
|
+
# @raise [ArgumentError] when `string` is not 3 or 6 hex digits with
|
|
102
|
+
# an optional leading `#`.
|
|
103
|
+
def self.hex(string)
|
|
104
|
+
digits = string.delete_prefix("#") if string.is_a?(String)
|
|
105
|
+
raise ArgumentError, "invalid hex color: #{string.inspect}" unless digits&.match?(/\A(\h{3}|\h{6})\z/)
|
|
106
|
+
|
|
107
|
+
digits = digits.gsub(/\h/) { |d| d * 2 } if digits.length == 3
|
|
108
|
+
new(digits.scan(/\h{2}/).map { |channel| channel.to_i(16) })
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @param value [Symbol, Integer, Array<Integer>] see class-level docs for
|
|
112
|
+
# the three accepted forms.
|
|
113
|
+
# @raise [ArgumentError] when `value` is not one of the accepted forms.
|
|
114
|
+
def initialize(value)
|
|
115
|
+
unless COLOR_SYMBOLS.include?(value) ||
|
|
116
|
+
(value.is_a?(Integer) && value.between?(0, 255)) ||
|
|
117
|
+
(value.is_a?(Array) && value.length == 3 &&
|
|
118
|
+
value.all? { |v| v.is_a?(Integer) && v.between?(0, 255) })
|
|
119
|
+
raise ArgumentError, "invalid color: #{value.inspect}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
@value = value.is_a?(Array) ? value.dup.freeze : value
|
|
123
|
+
freeze
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The underlying raw representation — a Symbol, Integer, or frozen
|
|
127
|
+
# Array<Integer>.
|
|
128
|
+
# @return [Symbol, Integer, Array<Integer>]
|
|
129
|
+
attr_reader :value
|
|
130
|
+
|
|
131
|
+
# SGR parameter codes for emitting this color as either a foreground
|
|
132
|
+
# (`target: :fg`) or background (`target: :bg`). Returned as an array so
|
|
133
|
+
# callers can splice them into a multi-attribute SGR (e.g. bold + color).
|
|
134
|
+
#
|
|
135
|
+
# @param target [Symbol] `:fg` or `:bg`.
|
|
136
|
+
# @return [Array<Integer>]
|
|
137
|
+
# @raise [ArgumentError] when `target` is neither `:fg` nor `:bg`.
|
|
138
|
+
def sgr_codes(target = :fg)
|
|
139
|
+
base, ext = case target
|
|
140
|
+
when :fg then [30, 38]
|
|
141
|
+
when :bg then [40, 48]
|
|
142
|
+
else raise ArgumentError, "target must be :fg or :bg, got #{target.inspect}"
|
|
143
|
+
end
|
|
144
|
+
case @value
|
|
145
|
+
when Symbol
|
|
146
|
+
idx = COLOR_SYMBOLS.index(@value)
|
|
147
|
+
idx < 8 ? [base + idx] : [base + 60 + (idx - 8)]
|
|
148
|
+
when Integer then [ext, 5, @value]
|
|
149
|
+
when Array then [ext, 2, *@value]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Full SGR escape sequence for this color (e.g. `"\e[31m"`). Useful for
|
|
154
|
+
# `print`-style direct emission; for composing with other attributes use
|
|
155
|
+
# {#sgr_codes} instead.
|
|
156
|
+
#
|
|
157
|
+
# @param target [Symbol] `:fg` or `:bg`.
|
|
158
|
+
# @return [String]
|
|
159
|
+
def to_ansi(target = :fg)
|
|
160
|
+
"\e[#{sgr_codes(target).join(";")}m"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# @param other [Object]
|
|
164
|
+
# @return [Boolean]
|
|
165
|
+
def ==(other)
|
|
166
|
+
other.is_a?(Color) && @value == other.value
|
|
167
|
+
end
|
|
168
|
+
alias eql? ==
|
|
169
|
+
|
|
170
|
+
# @return [Integer]
|
|
171
|
+
def hash
|
|
172
|
+
[self.class, @value].hash
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# @return [String]
|
|
176
|
+
def inspect
|
|
177
|
+
"#<#{self.class.name} #{@value.inspect}>"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
COLOR_SYMBOLS.each do |sym|
|
|
181
|
+
const_set(sym.upcase, new(sym))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Names for the 256-color palette indices 16..255, from the standard
|
|
185
|
+
# xterm chart (<https://www.ditig.com/256-colors-cheat-sheet>). A constant
|
|
186
|
+
# per entry is pre-defined, an exact palette cell — no quantization:
|
|
187
|
+
# `Color::CADET_BLUE == Color.palette(72)`. The chart names some cells
|
|
188
|
+
# identically (`DeepSkyBlue4` covers 23, 24 *and* 25); the first
|
|
189
|
+
# occurrence wins the constant and the remaining cells stay reachable via
|
|
190
|
+
# {.palette}. Indices 0..15 are covered by the {COLOR_SYMBOLS} constants
|
|
191
|
+
# instead — the symbolic SGR form respects the user's terminal scheme,
|
|
192
|
+
# which a hard palette cell would not.
|
|
193
|
+
# @return [Hash{Symbol => Integer}]
|
|
194
|
+
PALETTE_NAMES = {
|
|
195
|
+
GREY0: 16, NAVY_BLUE: 17, DARK_BLUE: 18, BLUE3: 19, BLUE1: 21,
|
|
196
|
+
DARK_GREEN: 22, DEEP_SKY_BLUE4: 23, DODGER_BLUE3: 26, DODGER_BLUE2: 27,
|
|
197
|
+
GREEN4: 28, SPRING_GREEN4: 29, TURQUOISE4: 30, DEEP_SKY_BLUE3: 31,
|
|
198
|
+
DODGER_BLUE1: 33, GREEN3: 34, SPRING_GREEN3: 35, DARK_CYAN: 36,
|
|
199
|
+
LIGHT_SEA_GREEN: 37, DEEP_SKY_BLUE2: 38, DEEP_SKY_BLUE1: 39,
|
|
200
|
+
SPRING_GREEN2: 42, CYAN3: 43, DARK_TURQUOISE: 44, TURQUOISE2: 45,
|
|
201
|
+
GREEN1: 46, SPRING_GREEN1: 48, MEDIUM_SPRING_GREEN: 49, CYAN2: 50,
|
|
202
|
+
CYAN1: 51, DARK_RED: 52, DEEP_PINK4: 53, PURPLE4: 54, PURPLE3: 56,
|
|
203
|
+
BLUE_VIOLET: 57, ORANGE4: 58, GREY37: 59, MEDIUM_PURPLE4: 60,
|
|
204
|
+
SLATE_BLUE3: 61, ROYAL_BLUE1: 63, CHARTREUSE4: 64, DARK_SEA_GREEN4: 65,
|
|
205
|
+
PALE_TURQUOISE4: 66, STEEL_BLUE: 67, STEEL_BLUE3: 68,
|
|
206
|
+
CORNFLOWER_BLUE: 69, CHARTREUSE3: 70, CADET_BLUE: 72, SKY_BLUE3: 74,
|
|
207
|
+
STEEL_BLUE1: 75, PALE_GREEN3: 77, SEA_GREEN3: 78, AQUAMARINE3: 79,
|
|
208
|
+
MEDIUM_TURQUOISE: 80, CHARTREUSE2: 82, SEA_GREEN2: 83, SEA_GREEN1: 84,
|
|
209
|
+
AQUAMARINE1: 86, DARK_SLATE_GRAY2: 87, DARK_MAGENTA: 90, DARK_VIOLET: 92,
|
|
210
|
+
PURPLE: 93, LIGHT_PINK4: 95, PLUM4: 96, MEDIUM_PURPLE3: 97,
|
|
211
|
+
SLATE_BLUE1: 99, YELLOW4: 100, WHEAT4: 101, GREY53: 102,
|
|
212
|
+
LIGHT_SLATE_GREY: 103, MEDIUM_PURPLE: 104, LIGHT_SLATE_BLUE: 105,
|
|
213
|
+
DARK_OLIVE_GREEN3: 107, DARK_SEA_GREEN: 108, LIGHT_SKY_BLUE3: 109,
|
|
214
|
+
SKY_BLUE2: 111, DARK_SEA_GREEN3: 115, DARK_SLATE_GRAY3: 116,
|
|
215
|
+
SKY_BLUE1: 117, CHARTREUSE1: 118, LIGHT_GREEN: 119, PALE_GREEN1: 121,
|
|
216
|
+
DARK_SLATE_GRAY1: 123, RED3: 124, MEDIUM_VIOLET_RED: 126, MAGENTA3: 127,
|
|
217
|
+
DARK_ORANGE3: 130, INDIAN_RED: 131, HOT_PINK3: 132, MEDIUM_ORCHID3: 133,
|
|
218
|
+
MEDIUM_ORCHID: 134, MEDIUM_PURPLE2: 135, DARK_GOLDENROD: 136,
|
|
219
|
+
LIGHT_SALMON3: 137, ROSY_BROWN: 138, GREY63: 139, MEDIUM_PURPLE1: 141,
|
|
220
|
+
GOLD3: 142, DARK_KHAKI: 143, NAVAJO_WHITE3: 144, GREY69: 145,
|
|
221
|
+
LIGHT_STEEL_BLUE3: 146, LIGHT_STEEL_BLUE: 147, YELLOW3: 148,
|
|
222
|
+
DARK_SEA_GREEN2: 151, LIGHT_CYAN3: 152, LIGHT_SKY_BLUE1: 153,
|
|
223
|
+
GREEN_YELLOW: 154, DARK_OLIVE_GREEN2: 155, DARK_SEA_GREEN1: 158,
|
|
224
|
+
PALE_TURQUOISE1: 159, DEEP_PINK3: 161, MAGENTA2: 165, HOT_PINK2: 169,
|
|
225
|
+
ORCHID: 170, MEDIUM_ORCHID1: 171, ORANGE3: 172, LIGHT_PINK3: 174,
|
|
226
|
+
PINK3: 175, PLUM3: 176, VIOLET: 177, LIGHT_GOLDENROD3: 179, TAN: 180,
|
|
227
|
+
MISTY_ROSE3: 181, THISTLE3: 182, PLUM2: 183, KHAKI3: 185,
|
|
228
|
+
LIGHT_GOLDENROD2: 186, LIGHT_YELLOW3: 187, GREY84: 188,
|
|
229
|
+
LIGHT_STEEL_BLUE1: 189, YELLOW2: 190, DARK_OLIVE_GREEN1: 191,
|
|
230
|
+
HONEYDEW2: 194, LIGHT_CYAN1: 195, RED1: 196, DEEP_PINK2: 197,
|
|
231
|
+
DEEP_PINK1: 198, MAGENTA1: 201, ORANGE_RED1: 202, INDIAN_RED1: 203,
|
|
232
|
+
HOT_PINK: 205, DARK_ORANGE: 208, SALMON1: 209, LIGHT_CORAL: 210,
|
|
233
|
+
PALE_VIOLET_RED1: 211, ORCHID2: 212, ORCHID1: 213, ORANGE1: 214,
|
|
234
|
+
SANDY_BROWN: 215, LIGHT_SALMON1: 216, LIGHT_PINK1: 217, PINK1: 218,
|
|
235
|
+
PLUM1: 219, GOLD1: 220, NAVAJO_WHITE1: 223, MISTY_ROSE1: 224,
|
|
236
|
+
THISTLE1: 225, YELLOW1: 226, LIGHT_GOLDENROD1: 227, KHAKI1: 228,
|
|
237
|
+
WHEAT1: 229, CORNSILK1: 230, GREY100: 231, GREY3: 232, GREY7: 233,
|
|
238
|
+
GREY11: 234, GREY15: 235, GREY19: 236, GREY23: 237, GREY27: 238,
|
|
239
|
+
GREY30: 239, GREY35: 240, GREY39: 241, GREY42: 242, GREY46: 243,
|
|
240
|
+
GREY50: 244, GREY54: 245, GREY58: 246, GREY62: 247, GREY66: 248,
|
|
241
|
+
GREY70: 249, GREY74: 250, GREY78: 251, GREY82: 252, GREY85: 253,
|
|
242
|
+
GREY89: 254, GREY93: 255
|
|
243
|
+
}.freeze
|
|
244
|
+
|
|
245
|
+
PALETTE_NAMES.each do |name, index|
|
|
246
|
+
const_set(name, new(index))
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -20,6 +20,7 @@ module Tuile
|
|
|
20
20
|
super()
|
|
21
21
|
@caption = caption.to_s
|
|
22
22
|
@on_click = on_click
|
|
23
|
+
self.content_size = natural_size
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
# @return [String] the button's label.
|
|
@@ -38,16 +39,13 @@ module Tuile
|
|
|
38
39
|
|
|
39
40
|
@caption = new_caption
|
|
40
41
|
invalidate
|
|
42
|
+
self.content_size = natural_size
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
def focusable? = true
|
|
44
46
|
|
|
45
47
|
def tab_stop? = true
|
|
46
48
|
|
|
47
|
-
# @return [Size] natural width is `caption.length + 4` to fit
|
|
48
|
-
# `[ caption ]`; height is 1.
|
|
49
|
-
def content_size = Size.new(@caption.length + 4, 1)
|
|
50
|
-
|
|
51
49
|
# @param key [String]
|
|
52
50
|
# @return [Boolean]
|
|
53
51
|
def handle_key(key)
|
|
@@ -78,9 +76,15 @@ module Tuile
|
|
|
78
76
|
return if rect.empty?
|
|
79
77
|
|
|
80
78
|
label = "[ #{@caption} ]"[0, rect.width]
|
|
81
|
-
styled = active? ?
|
|
79
|
+
styled = active? ? screen.theme.active_bg(label) : label
|
|
82
80
|
screen.print TTY::Cursor.move_to(rect.left, rect.top), styled
|
|
83
81
|
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Natural width is `caption.length + 4` to fit `[ caption ]`; height 1.
|
|
86
|
+
# @return [Size]
|
|
87
|
+
def natural_size = Size.new(@caption.length + 4, 1)
|
|
84
88
|
end
|
|
85
89
|
end
|
|
86
90
|
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
|
|
@@ -30,23 +36,26 @@ module Tuile
|
|
|
30
36
|
return if @text == new_text
|
|
31
37
|
|
|
32
38
|
@text = new_text
|
|
33
|
-
@content_size = nil
|
|
34
39
|
update_clipped_lines
|
|
35
40
|
invalidate
|
|
41
|
+
self.content_size = compute_content_size
|
|
36
42
|
end
|
|
37
43
|
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
50
59
|
end
|
|
51
60
|
|
|
52
61
|
# Paints the text into {#rect}.
|
|
@@ -75,16 +84,35 @@ module Tuile
|
|
|
75
84
|
|
|
76
85
|
private
|
|
77
86
|
|
|
87
|
+
# Natural size: longest hard-line's display width × number of hard
|
|
88
|
+
# lines. Computed on the *unclipped* text — sizing is intrinsic to the
|
|
89
|
+
# content, not the viewport. Empty text yields {Size::ZERO}.
|
|
90
|
+
# @return [Size]
|
|
91
|
+
def compute_content_size
|
|
92
|
+
return Size::ZERO if @text.empty?
|
|
93
|
+
|
|
94
|
+
hard_lines = @text.lines
|
|
95
|
+
width = hard_lines.map(&:display_width).max || 0
|
|
96
|
+
Size.new(width, hard_lines.size)
|
|
97
|
+
end
|
|
98
|
+
|
|
78
99
|
# Recomputes {@clipped_lines} for the current text and rect width.
|
|
79
100
|
# Each line is ellipsized to fit, padded with trailing spaces out to
|
|
80
101
|
# the full width, and pre-rendered to ANSI so {#repaint} is just a
|
|
81
102
|
# lookup + screen.print per row. {@blank_line} covers rows past the
|
|
82
|
-
# last text line.
|
|
103
|
+
# last text line. When {#bg} is set, every produced line (and the
|
|
104
|
+
# blank row) has the bg applied uniformly.
|
|
83
105
|
# @return [void]
|
|
84
106
|
def update_clipped_lines
|
|
85
107
|
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 }
|
|
108
|
+
@blank_line = apply_bg(StyledString.plain(" " * width)).to_ansi
|
|
109
|
+
@clipped_lines = @text.lines.map { |line| apply_bg(pad_to(line.ellipsize(width), width)).to_ansi }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @param line [StyledString]
|
|
113
|
+
# @return [StyledString]
|
|
114
|
+
def apply_bg(line)
|
|
115
|
+
@bg ? line.with_bg(@bg) : line
|
|
88
116
|
end
|
|
89
117
|
|
|
90
118
|
# @param line [StyledString]
|