tuile 0.13.0 → 0.15.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 +150 -37
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +4266 -226
- data/README.md +44 -24
- data/TERMINOLOGY.md +22 -7
- data/book/03-layout.md +17 -10
- data/book/05-focus.md +67 -3
- data/book/06-theming.md +153 -7
- data/book/07-components.md +643 -67
- data/book/08-testing.md +94 -0
- data/book/09-styled-text.md +3 -3
- data/book/10-locale.md +216 -0
- data/book/README.md +14 -5
- data/examples/file_commander.rb +1 -1
- data/examples/sampler.rb +402 -62
- data/ideas/arrow-key-navigation.md +2 -2
- data/ideas/binder.md +177 -0
- data/ideas/composite-field.md +77 -0
- data/ideas/focus-accent.md +116 -0
- data/ideas/form-layout.md +151 -0
- data/ideas/hover/probe.rb +241 -0
- data/ideas/hover/probe_spec.rb +82 -0
- data/ideas/hover.md +909 -0
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +49 -29
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/abstract_string_field.rb +106 -58
- data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/checkbox_group.rb +36 -20
- data/lib/tuile/component/combo_box.rb +68 -33
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/date_field.rb +322 -0
- data/lib/tuile/component/float_field.rb +57 -82
- data/lib/tuile/component/has_bad_input.rb +88 -0
- data/lib/tuile/component/has_caption.rb +8 -0
- data/lib/tuile/component/has_content.rb +43 -11
- data/lib/tuile/component/has_placeholder.rb +62 -0
- data/lib/tuile/component/has_validation.rb +115 -0
- data/lib/tuile/component/has_value.rb +28 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +6 -38
- data/lib/tuile/component/layout/box.rb +87 -19
- data/lib/tuile/component/layout.rb +13 -13
- data/lib/tuile/component/list.rb +11 -6
- data/lib/tuile/component/list_dropdown.rb +22 -10
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +3 -3
- data/lib/tuile/component/menu_bar.rb +5 -5
- data/lib/tuile/component/notification.rb +16 -34
- data/lib/tuile/component/overlay.rb +209 -0
- data/lib/tuile/component/popup.rb +59 -187
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +39 -22
- data/lib/tuile/component/select.rb +26 -10
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +0 -11
- data/lib/tuile/component/tabs.rb +5 -5
- data/lib/tuile/component/text_area.rb +14 -8
- data/lib/tuile/component/text_field.rb +42 -15
- data/lib/tuile/component/text_view.rb +25 -8
- data/lib/tuile/component/time_field.rb +454 -0
- data/lib/tuile/component/window.rb +48 -59
- data/lib/tuile/component.rb +580 -54
- data/lib/tuile/event_queue.rb +21 -1
- data/lib/tuile/fake_screen.rb +37 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/keys.rb +7 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/screen.rb +251 -55
- data/lib/tuile/screen_pane.rb +50 -44
- data/lib/tuile/styled_string.rb +40 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +100 -10
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +88 -12
- data/lib/tuile.rb +1 -0
- data/sig/tuile.rbs +4545 -770
- metadata +25 -1
data/README.md
CHANGED
|
@@ -6,25 +6,16 @@ and Tuile runs a single-threaded event loop that dispatches keys and mouse
|
|
|
6
6
|
events, then repaints everything that was invalidated since the last tick. The
|
|
7
7
|
name is French for "roof tile": small pieces that compose into a larger whole.
|
|
8
8
|
|
|
9
|
-
The design philosophy — "
|
|
9
|
+
The design philosophy — "composing components in layouts to create something bigger" that talk via listeners and data
|
|
10
10
|
providers — is described in
|
|
11
11
|
[component-oriented programming](https://mvysny.github.io/component-oriented-programming/).
|
|
12
12
|
Tuile is that approach applied to a terminal.
|
|
13
13
|
|
|
14
|
-
If you have looked at the alternatives:
|
|
15
|
-
|
|
16
|
-
- [tty-toolkit](https://ttytoolkit.org/) (`tty-prompt`, `tty-cursor`, …) is a
|
|
17
|
-
set of low-level building blocks rather than a framework: there is no
|
|
18
|
-
component tree, no event loop, no invalidation. Tuile sits on top of
|
|
19
|
-
`tty-cursor`/`tty-screen` and adds the framework layer.
|
|
20
|
-
- [vedeu](https://github.com/gavinlaking/vedeu) is the closest Ruby comparable
|
|
21
|
-
but is no longer maintained (last release 2017).
|
|
22
|
-
- [ratatui](https://github.com/ratatui/ratatui) is the popular TUI framework
|
|
23
|
-
in the Rust ecosystem; its immediate-mode API is closer to `tty-prompt` than
|
|
24
|
-
to Tuile's retained component tree.
|
|
25
|
-
|
|
26
14
|
Tuile is the only actively maintained component-oriented TUI framework for
|
|
27
|
-
Ruby that we are aware of.
|
|
15
|
+
Ruby that we are aware of. If you have looked at the alternatives —
|
|
16
|
+
tty-toolkit, vedeu, ratatui, or the curses bindings your distro packages —
|
|
17
|
+
[COMPARISON.md](COMPARISON.md) sizes each one up and says which of them you
|
|
18
|
+
can actually reach from Ruby.
|
|
28
19
|
|
|
29
20
|
## Installation
|
|
30
21
|
|
|
@@ -64,6 +55,9 @@ else in Tuile loads it.
|
|
|
64
55
|
- **API reference:** every public class and method carries YARD headers —
|
|
65
56
|
browse them at <https://rubydoc.info/gems/tuile>, or run
|
|
66
57
|
`bundle exec rake yard` for a local site.
|
|
58
|
+
- **[COMPARISON.md](COMPARISON.md)** places Tuile among the neighbouring
|
|
59
|
+
toolkits, and answers what a Ruby program can reach without writing
|
|
60
|
+
bindings first.
|
|
67
61
|
|
|
68
62
|
## Hello world
|
|
69
63
|
|
|
@@ -149,9 +143,20 @@ status-bar hints — plus whatever `custom` tokens your app adds. Everything
|
|
|
149
143
|
else inherits the terminal's own foreground and background, so Tuile looks at
|
|
150
144
|
home in the user's palette instead of fighting it. Tuile probes the terminal
|
|
151
145
|
background at startup, pairs a dark and a light theme in a `ThemeDef`, and
|
|
152
|
-
re-picks on a live OS appearance flip.
|
|
146
|
+
re-picks on a live OS appearance flip. The probed background is also yours to
|
|
147
|
+
read — `Screen#background_color` — for panes tinted a few percent off the
|
|
148
|
+
terminal's own, LazyVim-style.
|
|
153
149
|
→ [chapter 6](book/06-theming.md)
|
|
154
150
|
|
|
151
|
+
**A `Locale` holds conventions, never prose.** Date formats, the calendar,
|
|
152
|
+
month and weekday names, the decimal separator — detected from `locale(1)` at
|
|
153
|
+
startup, but only when the environment actually asked for something, since the
|
|
154
|
+
POSIX default is American and "said nothing" is indistinguishable from "wants
|
|
155
|
+
American". Everything else falls back to `Locale::ISO`. Tuile ships no message
|
|
156
|
+
catalogue and no per-country presets: this is the formatting half of what POSIX
|
|
157
|
+
splits, and the wording half stays your app's.
|
|
158
|
+
→ [chapter 10](book/10-locale.md)
|
|
159
|
+
|
|
155
160
|
## Components
|
|
156
161
|
|
|
157
162
|
Every component lives under `Tuile::Component::*`, and every one of them is a
|
|
@@ -174,6 +179,7 @@ carries the per-method reference: `bundle exec rake yard`, or
|
|
|
174
179
|
| component | what it is |
|
|
175
180
|
|---|---|
|
|
176
181
|
| `Window` | A frame with a `caption`, one content slot, and a border that lights up while the window is on the focus chain. `footer_text=` decorates the bottom border; `footer=` mounts a real component in it; `scrollbar=` reclaims the right border column. |
|
|
182
|
+
| `Slot` | A one-child region for content that may be absent, arrive late, or be swapped. Give a multi-region container one per region and the tree stays honest — the occupant fills the slot's rect, and an empty slot holds its place rather than collapsing. |
|
|
177
183
|
| `MenuBar` | A one-row strip of menu captions, each dropping a cascade of submenus that nests without limit. Items are handles from `#add_item`, each with its own `on_click`. See [Menus](book/07-components.md#menus). |
|
|
178
184
|
| `Tabs` | A one-row strip of captions with one selected, Left/Right switching immediately. Knows nothing about content — pair it with `TabSheet`, or drive your own view swap from `on_tab_selected`. |
|
|
179
185
|
| `TabSheet` | A `Tabs` strip plus the pane belonging to the selected tab. Unselected panes are *detached*, so they keep their state and stay out of the Tab cycle. See [Switching between views](book/07-components.md#switching-between-views). |
|
|
@@ -201,6 +207,8 @@ carries the per-method reference: `bundle exec rake yard`, or
|
|
|
201
207
|
| `IntegerField` | A one-row field whose `value` is an `Integer` or `nil`, filtering input to digits and one leading `-`. |
|
|
202
208
|
| `FloatField` | The same, one Ruby type over: `value` is a `Float` or `nil`. |
|
|
203
209
|
| `BigDecimalField` | The same for money, where a binary `Float` is the wrong answer. Tuile's one optional dependency — add `bigdecimal` yourself if you name this component. |
|
|
210
|
+
| `DateField` | A one-row field whose `value` is a `Date` or `nil`, over a list of strftime formats taken from `Screen#locale`: it accepts any of them and writes the first one back when you leave the field. Manual entry — there is no calendar popup yet. |
|
|
211
|
+
| `TimeField` | A one-row field whose `value` is a time of day — a `Time` on a fixed epoch date, or `nil` — spelled the way `Screen#locale` says. `step` is both the Up/Down stride and the precision: it shows seconds only when set below a minute. |
|
|
204
212
|
|
|
205
213
|
### Choosing from a set — [book ch7](book/07-components.md#choosing-from-a-set)
|
|
206
214
|
|
|
@@ -224,17 +232,23 @@ carries the per-method reference: `bundle exec rake yard`, or
|
|
|
224
232
|
|
|
225
233
|
| component | what it is |
|
|
226
234
|
|---|---|
|
|
227
|
-
| `
|
|
235
|
+
| `Overlay` | The bare floating layer: it wraps any component, paints nothing itself, and sits at the rect you assign it. Takes no focus and no keys — the building block for anchored panels and toasts. |
|
|
236
|
+
| `Popup` | The modal dialog: an `Overlay` that centers itself, grabs focus, scopes keys to its own subtree and blocks clicks beneath it. Sized by `declared_size=` (a `Size` or a `Fraction` of the screen) rather than by its content; ESC or `q` dismisses. |
|
|
228
237
|
| `Notification` | A transient corner toast — `Notification.show("Saved")` — stacking messages in one box that a single ticker drains. Non-modal, and it never takes focus. |
|
|
229
|
-
| `
|
|
238
|
+
| `ConfirmWindow` | The confirm dialog: a message and a row of buttons in a popup sized to fit. `alert` / `confirm` / `yes_no` cover the common shapes; `#button` builds any other. Every button closes; ESC, `q` or an outside click fire `on_dismiss`. See [The confirm dialog](book/07-components.md#the-confirm-dialog). |
|
|
239
|
+
| `InfoWindow` | A `Window` with a read-only body, tiled or popped up: prose that wraps (`message=`), or rows that don't (`lines=`). |
|
|
230
240
|
| `PickerWindow` | A `Window` of options identified by single keystrokes, firing a callback with the key that was pressed. |
|
|
231
|
-
| `
|
|
241
|
+
| `LogTextView` | An auto-scrolling `TextView` for log output. Point your logger at a `LogTextView::IO` and lines land here from any thread, marshalled through the event queue. |
|
|
242
|
+
| `LogWindow` | A `Window` framing a `LogTextView` — the framed log pane. |
|
|
232
243
|
|
|
233
244
|
The mixins those share — `HasValue` (the `value` / `empty?` / `clear` /
|
|
234
|
-
`on_value_change` seam every input speaks), `
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
245
|
+
`on_value_change` seam every input speaks), `HasValidation` (`error_message`,
|
|
246
|
+
the verdict a validator writes and the field shows as a red well), `HasBadInput`
|
|
247
|
+
(`bad_input?`, for a field whose input can be something its value cannot
|
|
248
|
+
represent — a lone `-` in a number), `HasContent` (a primary child the caller
|
|
249
|
+
populates, named `content`) and
|
|
250
|
+
`HasCaption` (app-authored chrome text) — are the seams to include when you
|
|
251
|
+
write your own; chapter 7's "value seam" section is the walkthrough.
|
|
238
252
|
|
|
239
253
|
## Geometry primitives
|
|
240
254
|
|
|
@@ -252,13 +266,13 @@ interface:
|
|
|
252
266
|
```ruby
|
|
253
267
|
Tuile.logger = Logger.new($stderr) # or:
|
|
254
268
|
Tuile.logger = TTY::Logger.new # duck-typed, works directly
|
|
255
|
-
Tuile.logger = Logger.new(Tuile::Component::
|
|
269
|
+
Tuile.logger = Logger.new(Tuile::Component::LogTextView::IO.new(view))
|
|
256
270
|
```
|
|
257
271
|
|
|
258
272
|
## Testing
|
|
259
273
|
|
|
260
274
|
Tuile ships with a `Tuile::FakeScreen` that you install in place of the real
|
|
261
|
-
screen for unit tests. It fixes the viewport at 160×50,
|
|
275
|
+
screen for unit tests. It fixes the viewport at 160×50,
|
|
262
276
|
paints into an in-memory back buffer (assert on it for painted content) while
|
|
263
277
|
capturing cursor/housekeeping escapes into an array, and uses a synchronous
|
|
264
278
|
`FakeEventQueue` (submitted blocks run inline; posted events are discarded).
|
|
@@ -303,6 +317,12 @@ Key hooks:
|
|
|
303
317
|
to its current value should typically *not* invalidate.
|
|
304
318
|
- `Screen.instance.clear` — drops accumulated `prints` without resetting
|
|
305
319
|
invalidation.
|
|
320
|
+
- `Tuile::Testing.get` / `.find` — locate a component to drive, by class,
|
|
321
|
+
mixin, `Component#id`, caption or a block:
|
|
322
|
+
`Testing.get(Component::Button, caption: "Save")`. `get` demands exactly
|
|
323
|
+
one match and prints the tree it searched when it doesn't get one; `find`
|
|
324
|
+
returns all matches and takes `count:`. Both take `in:` to scope the
|
|
325
|
+
search, and default to the whole screen. Call them qualified.
|
|
306
326
|
|
|
307
327
|
Because `FakeEventQueue#submit` runs the block immediately on the calling
|
|
308
328
|
thread, code paths that marshal work back via `screen.event_queue.submit { … }`
|
data/TERMINOLOGY.md
CHANGED
|
@@ -4,7 +4,7 @@ Tuile's house vocabulary — one line per term, looked up by word.
|
|
|
4
4
|
|
|
5
5
|
This file owns **definitions only**. The *rules that bite* live in AGENTS.md
|
|
6
6
|
("Nomenclature" and the sections each word belongs to); the *why we chose a word
|
|
7
|
-
and not its synonym* lives in DECISIONS.md (`
|
|
7
|
+
and not its synonym* lives in DECISIONS.md (`D_scroll_nomenclature` for the
|
|
8
8
|
row/line/item split); the *concepts* live in the book. When a definition here
|
|
9
9
|
needs a paragraph of justification, that paragraph belongs in one of those three.
|
|
10
10
|
|
|
@@ -15,7 +15,7 @@ needs a paragraph of justification, that paragraph belongs in one of those three
|
|
|
15
15
|
| **row** | one row of the terminal grid — the framework's only word for it. A wrapped unit of text *is* a row; wrapping is what turns text into rows. |
|
|
16
16
|
| **column** | one cell-column of the terminal grid; the unit `display_width` counts. |
|
|
17
17
|
| **cell** | one grid position: a grapheme plus a {Tuile::StyledString::Style}, in {Tuile::Buffer}. |
|
|
18
|
-
| **glyph** | what the terminal draws in one or more cells. Ambiguous-width glyphs count as **one** column (the bet in `
|
|
18
|
+
| **glyph** | what the terminal draws in one or more cells. Ambiguous-width glyphs count as **one** column (the bet in `D_ambiguous_width`). |
|
|
19
19
|
| **cluster** | a grapheme cluster — the unit measurement, slicing, caret motion and deletion all work in. Never `each_char`. |
|
|
20
20
|
| **row_in_viewport** | a row measured `0...rect.height`, i.e. relative to a component's own rect. |
|
|
21
21
|
| **scroll_top_row** | the content row currently sitting at the top of the viewport. |
|
|
@@ -23,7 +23,9 @@ needs a paragraph of justification, that paragraph belongs in one of those three
|
|
|
23
23
|
| **viewport_rows** | how many rows of content are visible — always `rect.height`; kept private, since `rect.height` is the public form. |
|
|
24
24
|
| **row_count** | how many rows the wrapped content occupies. Public on `TextArea` (with `caret_row`, its companion); also on the private `WrappedText` and as `VerticalScrollBar.new(row_count:)`. Not on `TextView` / `List`, which have no caller for it. |
|
|
25
25
|
| **caret_row** | the row a text input's caret sits in, counted from the content's first row. `TextArea` only. |
|
|
26
|
-
| **extent** | the
|
|
26
|
+
| **extent** | the `Size` a widget actually paints inside the `rect` it was given — `Component#extent`, `nil` unless declared, always at the rect's top-left (`Component#extent_rect` positions it). What the widget clears outside of, hit-tests, highlights and anchors its dropdown to. The arithmetic is each widget's own (a `Checkbox`'s glyph plus caption; a `Tabs` strip's segments and separators). Distinct from a *slot extent*. |
|
|
27
|
+
| **handle** | the moving part of a {Tuile::VerticalScrollBar} — the rows standing for the slice of content in view (`handle_start` … `handle_end`, `handle_char`). CSS calls it the *thumb*; Tuile does not. Not drawn at all when the content fits. |
|
|
28
|
+
| **track** | the scrollbar's fixed part: the full viewport height the handle moves within, and the glyph (`track_char`) painted on the rows the handle doesn't cover. Never the bar's *column*, which is "the scrollbar column" (`D_scrollbar_reserve`). |
|
|
27
29
|
| **segment** | one tab's span on a {Tuile::Component::Tabs} strip: its caption plus a padding column either side. The unit a click resolves to; the separator column between two segments belongs to neither. |
|
|
28
30
|
|
|
29
31
|
**Space rule 1.** An object with only one row space leaves `row` unqualified:
|
|
@@ -42,7 +44,7 @@ content-space.
|
|
|
42
44
|
| **line_count** | a count of `\n` units (`TextView::Region#line_count`). Never a row count. |
|
|
43
45
|
| **item** | a domain object a widget holds and renders — `List#items`, and the enum widgets above it. |
|
|
44
46
|
| **renderer** | the `item -> row` proc a generic component uses to render an item it knows nothing about. |
|
|
45
|
-
| **selection** | which item or tab a selector currently points at. *View state* when nothing would save it ({Tuile::Component::Tabs}`#selected`), a *value* when a form would (`RadioGroup#value`) — the split `
|
|
47
|
+
| **selection** | which item or tab a selector currently points at. *View state* when nothing would save it ({Tuile::Component::Tabs}`#selected`), a *value* when a form would (`RadioGroup#value`) — the split `D_tabs` calls the "would a form save it?" test. |
|
|
46
48
|
| **item_count** / **item_index** | how a `List::Cursor` counts and addresses; equal to a row count in a `List`, but the cursor indexes *items*. |
|
|
47
49
|
| **text** | the user-editable **value** of an input ({Tuile::Component::HasValue}, aliased as `text` on `AbstractStringField`). |
|
|
48
50
|
| **caption** | app-authored **chrome** text ({Tuile::Component::HasCaption}) — a `Window` title, a `Button` label. Never a value. |
|
|
@@ -54,17 +56,30 @@ content-space.
|
|
|
54
56
|
| term | means |
|
|
55
57
|
|---|---|
|
|
56
58
|
| **component** | a node of the UI tree ({Tuile::Component}); the only thing that paints. |
|
|
59
|
+
| **slot extent** | in a `Layout::Box`, the size a parent *allocates* a child along an axis — what `Fixed` / `Percent` / `Expand` declare, and what `main_extent` / `cross_extent` measure. The parent's allocation, where a component's *extent* is the child's own painted region; `D_extent` turns on the two being different. Here `slot` is the box's allocation for one child and has **nothing** to do with {Tuile::Component::Slot} — the phrase is glossary-only (the code says `main_extent` / `cross_extent`), so read it as one term, never as "the extent of a `Slot`". |
|
|
57
60
|
| **tile** / **tiled** | the non-popup part of the tree — `ScreenPane#content` and its descendants. Also *to tile*: to cover a rect completely. |
|
|
58
61
|
| **attached** | reachable from a {Tuile::ScreenPane} via the parent chain — the one axis `attached?` consults. |
|
|
59
|
-
| **
|
|
62
|
+
| **wrapping field** | a field that owns and hides one *inner editor* and carries a typed value over it — {Tuile::Component::AbstractWrappingField} and its subclasses. The editor is private machinery: no public accessor, `children` the only way in. |
|
|
63
|
+
| **inner editor** | the {Tuile::Component::AbstractStringField} a *wrapping field* wraps. Always this phrase — never "the wrapped field", which would name the wrong one of the two fields in play. |
|
|
64
|
+
| **slot** | a named region of a container, reached by identity (`content`, `footer`) as well as through `children`. Two forms: a plain named child the caller populates directly (`HasContent#content`, the primary one); or a {Tuile::Component::Slot}, the one-child region component, wired once so its occupant may be absent or swapped (`Window#footer`). Capital-`S` `Slot` always means the class. |
|
|
60
65
|
| **cascade** | the stack of open {Tuile::Component::ListDropdown} panels a {Tuile::Component::MenuBar} drives, one per level, the last deepest. Each is an overlay on the pane, not a child of the bar. |
|
|
61
66
|
| **submenu** | a menu item that opens a further panel instead of doing something — `MenuBar::Item#submenu?`, true iff the item has children. Painted with a trailing `▸`. |
|
|
62
67
|
| **mnemonic** | a letter that activates one {Tuile::Component::MenuBar} item, underlined in its caption. Always *level-scoped*: matched against the top-level items while the cascade is closed and the deepest open panel while it is open, never across the two. |
|
|
63
68
|
| **strip** | the one-row {Tuile::Component::Tabs} component: captions, one selected, no content of its own. A {Tuile::Component::MenuBar} has one too — same word, and the same extent-based hit testing, deliberately not the same look. |
|
|
64
69
|
| **tab** | a {Tuile::Component::Tabs::Tab} — a caption plus an identity, minted and owned by the strip. Not a component (it never paints itself) and not an *item* (it holds per-element state, and the set is never assigned whole). Say "a tab" and "the Tab key"; never let the two words touch. |
|
|
65
|
-
| **pane** | the component a {Tuile::Component::TabSheet} shows for the selected tab. The unselected ones are *detached
|
|
70
|
+
| **pane** | the component a {Tuile::Component::TabSheet} shows for the selected tab. The unselected ones are *detached* — one of the two ways to take something off the screen, and the one that fires the lifecycle hooks. |
|
|
71
|
+
| **hidden** | carrying `visible? == false` — the component's own flag. *Gone*, not merely unpainted: as if detached, but still in the tree, so no lifecycle hook fires. Says nothing about the ancestors. |
|
|
72
|
+
| **shown** | reachable by the user: this component and every ancestor visible. The effective, ancestor-inclusive state, and always the walk's word (`on_shown_tree`, `Box#shown_children`) — there is deliberately no `shown?` reader. |
|
|
66
73
|
| **invalidate** | record a component as needing repaint; the loop coalesces and repaints once per tick. |
|
|
67
74
|
| **cursor** | *(two senses, both live)* the hardware terminal cursor (`Screen#cursor_position`), and a `List::Cursor` — the selection position within a list. |
|
|
68
|
-
| **well** | the
|
|
75
|
+
| **well** | the background an input paints over its whole extent (`Theme#input_bg_color` / `#active_bg_color`), declared as its `default_bg_color`. It terminates inheritance — an ancestor's tint doesn't reach it — but loses to a `bg_color` set on the input itself. Exactly one per widget: a composed field owns the well and marks the field it wraps `Component::BG_INHERIT`. |
|
|
69
76
|
| **token** | a semantic colour name on {Tuile::Theme} — an accent, never a global fg/bg. |
|
|
70
77
|
| **scheme** | `:dark` or `:light`; a {Tuile::ThemeDef} pairs one {Tuile::Theme} per scheme. |
|
|
78
|
+
|
|
79
|
+
## Locale
|
|
80
|
+
|
|
81
|
+
| term | means |
|
|
82
|
+
|---|---|
|
|
83
|
+
| **conventions** | the formatting facts a {Tuile::Locale} carries — how a value is *rendered and parsed* (date formats, calendar, month and weekday names, decimal separator). Deliberately the opposite pole from *prose*, which a `Locale` never holds. |
|
|
84
|
+
| **prose** | wording: a message in one language, belonging to one component. Outside `Locale` by rule, and outside Tuile by default — the wording fork of `D_bad_input` is where a translated one arrives. |
|
|
85
|
+
| **primary format** | `formats.first` of a date field or a {Tuile::Locale#date_formats} list — the one a value is *written* in, and the only one that must survive a `strftime`/`strptime` round-trip. The rest only ever parse. |
|
data/book/03-layout.md
CHANGED
|
@@ -160,8 +160,9 @@ common case, debuggability, and auditability all at once.
|
|
|
160
160
|
|
|
161
161
|
The place you actually write layout code is a `rect=` override. The base
|
|
162
162
|
class for this is `Tuile::Component::Layout::Absolute`: it inherits all
|
|
163
|
-
the focus
|
|
164
|
-
that you position your children whenever your own rectangle
|
|
163
|
+
the focus, key-dispatch and mouse-routing wiring, paints nothing itself,
|
|
164
|
+
and asks only that you position your children whenever your own rectangle
|
|
165
|
+
is assigned —
|
|
165
166
|
which happens once at startup and again on every resize.
|
|
166
167
|
|
|
167
168
|
```ruby
|
|
@@ -396,24 +397,24 @@ class Fraction < Data.define(:width, :height) # each a float in 0.0..1.0
|
|
|
396
397
|
end
|
|
397
398
|
```
|
|
398
399
|
|
|
399
|
-
A popup's
|
|
400
|
+
A popup's box is set with `Popup#declared_size=`, which accepts either a
|
|
400
401
|
`Fraction` (resolved against the screen at layout time, so it tracks
|
|
401
402
|
resize) or an absolute `Size` (clamped to the screen):
|
|
402
403
|
|
|
403
404
|
```ruby
|
|
404
405
|
popup = Tuile::Component::Popup.new(content: some_window)
|
|
405
|
-
popup.
|
|
406
|
-
popup.
|
|
407
|
-
popup.
|
|
408
|
-
popup.
|
|
406
|
+
popup.declared_size = Tuile::Fraction::HALF # the default — half the screen, centered
|
|
407
|
+
popup.declared_size = Tuile::Fraction::FULL # fullscreen
|
|
408
|
+
popup.declared_size = Tuile::Fraction.new(0.8, 0.5) # 80% wide, half tall
|
|
409
|
+
popup.declared_size = Tuile::Size.new(50, 12) # exact, clamped to screen
|
|
409
410
|
```
|
|
410
411
|
|
|
411
412
|
The default is `Fraction::HALF`, resolved on every layout pass, so a
|
|
412
413
|
popup you never size at all is half-screen and follows the terminal as
|
|
413
414
|
it resizes. `Fraction::FULL` is the fullscreen shorthand.
|
|
414
415
|
|
|
415
|
-
A subtle but important point: `
|
|
416
|
-
preference**. The name
|
|
416
|
+
A subtle but important point: `declared_size=` is **authoritative, not a
|
|
417
|
+
preference**. The name says *declared*, not *preferred*, on purpose.
|
|
417
418
|
There is no parent that might negotiate it downward — the screen simply
|
|
418
419
|
*applies* what you asked for (clamping an oversized absolute `Size` to
|
|
419
420
|
fit). Calling it a preference would invite a future "well, the parent
|
|
@@ -434,7 +435,7 @@ wrong for a paragraph.
|
|
|
434
435
|
> height) and it isn't even reliably pretty — a single long line
|
|
435
436
|
> collapses the popup to one row. Half-screen-and-wrap sidesteps all of
|
|
436
437
|
> it. When you genuinely know the right size — an autocomplete dropdown
|
|
437
|
-
> whose items you own — you set it yourself: `popup.
|
|
438
|
+
> whose items you own — you set it yourself: `popup.declared_size =
|
|
438
439
|
> Tuile::Size.new(longest_item, [items.size, 8].min)`. That's still
|
|
439
440
|
> caller-decides, top-down.
|
|
440
441
|
|
|
@@ -482,6 +483,12 @@ A component in the footer slot always fills the width — there is no
|
|
|
482
483
|
sizing policy to configure, because the window already knows its inner
|
|
483
484
|
width and that's the only dimension a bottom-row widget needs.
|
|
484
485
|
|
|
486
|
+
The word *slot* is literal here: the footer is a
|
|
487
|
+
{Tuile::Component::Slot}, the one-child region you'd use for the same job
|
|
488
|
+
in a container of your own (chapter 7). That's why `footer=` needs no
|
|
489
|
+
sizing argument and why setting it to `nil` restores the border cleanly —
|
|
490
|
+
the region stays in the tree either way, occupied or not.
|
|
491
|
+
|
|
485
492
|
The two are mutually exclusive by precedence: if a `footer=` component
|
|
486
493
|
is present it occupies the bottom row and `footer_text` is hidden;
|
|
487
494
|
otherwise `footer_text` embeds into the border. No window needs both at
|
data/book/05-focus.md
CHANGED
|
@@ -45,7 +45,10 @@ decoration; clicking one shouldn't yank focus away from the window around
|
|
|
45
45
|
it. Controls that accept input (a text field, a list, a button) override
|
|
46
46
|
it to `true`. This gate is what makes click-to-focus sane: clicking lands
|
|
47
47
|
focus on the component under the cursor *only if it's focusable*,
|
|
48
|
-
otherwise the click is ignored for focus purposes.
|
|
48
|
+
otherwise the click is ignored for focus purposes. A click descends the
|
|
49
|
+
tree — every component whose rectangle contains the point sees it, outermost
|
|
50
|
+
first — so "the component under the cursor" is really all of them, and focus
|
|
51
|
+
settles on the deepest focusable one. The same rule governs
|
|
49
52
|
the automatic focus-forwarding a container does when it's focused — a
|
|
50
53
|
window handed focus passes it down to its content, but only if that
|
|
51
54
|
content is focusable.
|
|
@@ -70,6 +73,24 @@ that scope in tree order and advances by one, wrapping around; Shift+Tab
|
|
|
70
73
|
walks backward. This is what keeps Tab from escaping an open dialog — the
|
|
71
74
|
scope is the dialog, so cycling stays inside it.
|
|
72
75
|
|
|
76
|
+
There is a third gate, and it isn't a predicate you override:
|
|
77
|
+
{Tuile::Component#visible?}. A hidden component — or any component under a
|
|
78
|
+
hidden one — is skipped by Tab, by click-to-focus, and by the forwarding a
|
|
79
|
+
container does, because it is skipped by the walk that collects candidates
|
|
80
|
+
at all. You never write a `visible?` check yourself; chapter 7 covers what
|
|
81
|
+
the flag is for.
|
|
82
|
+
|
|
83
|
+
The rule to know here is what happens when you hide the thing that
|
|
84
|
+
currently *has* focus. Focus does not stay there, and it does not become
|
|
85
|
+
nothing either: it moves to the hidden component's parent, which forwards
|
|
86
|
+
it on to the first field it can reach — exactly what happens when a
|
|
87
|
+
component is removed from the tree. Nothing is stashed, so bringing the
|
|
88
|
+
component back does not bring focus back with it. That is the same
|
|
89
|
+
behaviour a browser and every desktop toolkit settle on, and the reason
|
|
90
|
+
Tuile can't simply drop focus is worth knowing: with focus at nothing, keys
|
|
91
|
+
reach nobody at all, so an unhandled `q` would fall through to the event
|
|
92
|
+
loop and quit your app.
|
|
93
|
+
|
|
73
94
|
## The dispatch order
|
|
74
95
|
|
|
75
96
|
When a key arrives, it's offered to the tree in a fixed order, and the
|
|
@@ -204,8 +225,10 @@ marker, reads the payload raw up to the terminator, and posts it as a
|
|
|
204
225
|
single `PasteEvent` — which never enters the ladder at all:
|
|
205
226
|
|
|
206
227
|
- no Tab traversal, no global shortcuts, no `handle_key`;
|
|
207
|
-
- straight to {Tuile::Component#handle_paste}
|
|
208
|
-
|
|
228
|
+
- straight to {Tuile::Component#handle_paste} on the focused component —
|
|
229
|
+
and *only* it: unlike a key, a paste does not bubble to ancestors, because
|
|
230
|
+
the reasons a key does are all about scope-wide bindings and none of them
|
|
231
|
+
wants a clipboard;
|
|
209
232
|
- the whole clipboard as one `String`, `\n`-normalized.
|
|
210
233
|
|
|
211
234
|
The default `handle_paste` returns `false` and the text is dropped.
|
|
@@ -270,6 +293,47 @@ needed. With dispatch resting on nothing but "did you return `true`," the
|
|
|
270
293
|
proxy is gone, and a component's decision to consume a key is the only
|
|
271
294
|
declaration in the system.
|
|
272
295
|
|
|
296
|
+
## A component that reacts to its own focus
|
|
297
|
+
|
|
298
|
+
Two hooks tell a component about itself, and a component overrides them —
|
|
299
|
+
nothing else calls them. {Tuile::Component#on_focus} fires when it gains
|
|
300
|
+
focus; {Tuile::Component#on_blur} fires when it loses it. Both fire on that
|
|
301
|
+
one component, never on the ancestors that light up and go dark with it.
|
|
302
|
+
|
|
303
|
+
`on_focus` is the forwarding hook. It's how a {Tuile::Component::Window}
|
|
304
|
+
handed focus passes it to its content, and how a {Tuile::Component::Layout}
|
|
305
|
+
skips ahead to the first tab stop underneath it — which is also why it fires
|
|
306
|
+
on *every* assignment, even one that re-focuses what's already focused.
|
|
307
|
+
|
|
308
|
+
`on_blur` is the commit point. Nothing else in Tuile is one: Tab is
|
|
309
|
+
unconditional, so a user leaving a half-finished field usually leaves by a
|
|
310
|
+
key no component ever sees, and `on_enter` never fires. If your field wants
|
|
311
|
+
to tidy up what was typed, this is where:
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
class TrimmedField < Tuile::Component::TextField
|
|
315
|
+
protected def on_blur = (self.text = text.strip)
|
|
316
|
+
end
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Both hooks fire on one component, so a *composed* widget — a
|
|
320
|
+
{Tuile::Component::ComboBox}, whose inner field is what actually holds focus —
|
|
321
|
+
gets the blur on the child, not on itself. When the question is "has focus left
|
|
322
|
+
this whole widget", override `active=` instead and compare before and after;
|
|
323
|
+
that's exactly what the combo box does to close its dropdown when you tab away.
|
|
324
|
+
|
|
325
|
+
It is a notification, not a veto — focus has already moved by the time you
|
|
326
|
+
hear about it, and a handler that tries to hold focus is picking a fight with
|
|
327
|
+
the one key nothing can suppress. It also fires wherever focus is *dropped*,
|
|
328
|
+
not only where a user moved it: when a popup closes, the component being
|
|
329
|
+
blurred has usually been detached already, so an `invalidate` there does
|
|
330
|
+
nothing; and `screen.close` blurs the focused component on its way out. Keep
|
|
331
|
+
the handler cheap and it won't matter.
|
|
332
|
+
|
|
333
|
+
The framework sends both hooks with `__send__`, so declare them public,
|
|
334
|
+
protected or private as you like — the example above groups `on_blur` under
|
|
335
|
+
`protected`, which is where framework-invoked plumbing belongs.
|
|
336
|
+
|
|
273
337
|
## Writing a status line
|
|
274
338
|
|
|
275
339
|
Chapter 1 pointed out that Tuile draws no status bar. This is the chapter
|
data/book/06-theming.md
CHANGED
|
@@ -23,18 +23,28 @@ defaults for free.
|
|
|
23
23
|
|
|
24
24
|
What Tuile *does* color is the small set of cues that signal
|
|
25
25
|
interaction: the highlight behind the focused list row, the border of the
|
|
26
|
-
active window, the resting "well" of a text field, the
|
|
26
|
+
active window, the resting "well" of a text field, the scrollbar down a
|
|
27
|
+
scrollable pane's edge, the shortcut captions
|
|
27
28
|
in a status line you write. Those are the accents, and they are exactly the tokens
|
|
28
29
|
a {Tuile::Theme} carries — `active_bg_color`, `active_border_color`,
|
|
29
|
-
`input_bg_color`, `hint_color
|
|
30
|
+
`input_bg_color`, `scrollbar_color`, `hint_color`, the three that mark a field
|
|
31
|
+
invalid (`error_color` and the two error wells), and `placeholder_color` for
|
|
32
|
+
the hint an empty field paints into itself. There is no global `bg` or `fg` token,
|
|
30
33
|
and that absence is intentional: adding one would mean painting over the
|
|
31
34
|
terminal's defaults everywhere, which is precisely the thing that makes a
|
|
32
35
|
TUI look wrong on someone else's color scheme. The theme touches only
|
|
33
36
|
what the framework must color to be legible, and leaves the rest to the
|
|
34
37
|
terminal.
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
Two of them are worth a second look, because they pull in opposite
|
|
40
|
+
directions. `hint_color` is an *accent* — a blue that draws the eye to a
|
|
41
|
+
shortcut caption you want noticed. `placeholder_color` is its temperamental
|
|
42
|
+
opposite: a grey tuned to sit just above invisible, because a placeholder is
|
|
43
|
+
a hint the reader is welcome to miss. Reaching for the wrong one of the two
|
|
44
|
+
makes an empty field louder than a filled one.
|
|
45
|
+
|
|
46
|
+
So a {Tuile::Theme} is a frozen value type — a `Data.define` of colors
|
|
47
|
+
plus an app-extensible `custom` hash — and that's all. Two are
|
|
38
48
|
built in: {Tuile::Theme::DARK}, the colors Tuile has always used, and
|
|
39
49
|
{Tuile::Theme::LIGHT}, counterparts legible on a pale background.
|
|
40
50
|
|
|
@@ -66,9 +76,47 @@ terminal default is just the root of that chain, which is why an unset
|
|
|
66
76
|
|
|
67
77
|
A widget with a background of its *own* keeps it. A text field paints its
|
|
68
78
|
well across its whole rect, so dropping one into a tinted panel shows the
|
|
69
|
-
field in its own well, not the panel tint — the
|
|
70
|
-
|
|
71
|
-
|
|
79
|
+
field in its own well, not the panel tint — the terminal equivalent of a CSS
|
|
80
|
+
element that sets its own `background`. That is a *default*, though, not a
|
|
81
|
+
refusal: set `bg_color` on the field itself and it wins, because the chain
|
|
82
|
+
asks three questions in order — what did the app set on this component, what
|
|
83
|
+
background does this widget claim of its own, and what surrounds it.
|
|
84
|
+
|
|
85
|
+
Which is also how you get a field that reads as plain text inside a tinted
|
|
86
|
+
prompt. You *can* name the panel's colour again on the field — but there is a
|
|
87
|
+
shorter way to say "I have no background of my own, use whatever is behind me":
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
field.bg_color = Component::BG_INHERIT
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
That is CSS's `background: inherit`, and it is different from leaving
|
|
94
|
+
`bg_color` unset: unset means "ask *my* default first", which for a field is
|
|
95
|
+
its well. `BG_INHERIT` skips the well and goes straight to what surrounds it.
|
|
96
|
+
|
|
97
|
+
It is the same mechanism Tuile uses internally. A
|
|
98
|
+
{Tuile::Component::ComboBox} is one widget with one surface, built out of a
|
|
99
|
+
{Tuile::Component::TextField} plus a `▾` — so the ComboBox paints the well and
|
|
100
|
+
marks its inner field `BG_INHERIT`. Exactly one well per widget, which is what
|
|
101
|
+
lets you tint the ComboBox and have the tint reach the cells the field draws.
|
|
102
|
+
|
|
103
|
+
Backgrounds can differ by state. An input is brighter while it holds focus,
|
|
104
|
+
and a flat `bg_color` replaces *both* shades — fine for a field, which shows a
|
|
105
|
+
caret when focused, and a deliberate choice for something like a
|
|
106
|
+
{Tuile::Component::Select}, which has no caret and nothing else to indicate
|
|
107
|
+
focus with. Name the states when you want to keep the distinction:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
field.bg_color = grey # flat: focused or not
|
|
111
|
+
field.bg_color = { normal: grey, active: blue } # your own pair
|
|
112
|
+
field.bg_color = { active: blue } # keep the widget's own well,
|
|
113
|
+
# override only the focus shade
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The keys are a small closed set (`:normal`, `:active`) that Tuile defines —
|
|
117
|
+
a state with no key simply isn't answered there, and the question falls
|
|
118
|
+
through to the next level of the chain, which is what makes the third line
|
|
119
|
+
above mean what it reads as.
|
|
72
120
|
|
|
73
121
|
`bg_color` takes either a concrete {Tuile::Color} or a *live theme
|
|
74
122
|
reference* — `Theme.ref(:panel_bg)` — that names one of your app's custom
|
|
@@ -170,6 +218,104 @@ theme. From your code's perspective a live appearance flip and a startup
|
|
|
170
218
|
detection are the same thing arriving through the same channel — which is
|
|
171
219
|
exactly the single-threaded-loop payoff chapter 4 promised.
|
|
172
220
|
|
|
221
|
+
## Building on the terminal's own background
|
|
222
|
+
|
|
223
|
+
Everything so far picks colors to sit *against* the background. Some
|
|
224
|
+
designs want the opposite: a color derived *from* it. The borderless-pane
|
|
225
|
+
idiom — LazyVim's editor-versus-explorer split is the one most people
|
|
226
|
+
have seen — leaves the primary pane at the terminal's own background and
|
|
227
|
+
tints the secondary panes a few percent off it. No borders, no boxes; the
|
|
228
|
+
panes separate because one is very slightly lighter than the other.
|
|
229
|
+
|
|
230
|
+
You cannot do that with a fixed color. A tint tuned against `#1e1e2e`
|
|
231
|
+
looks like a deliberate panel against `#000000` and disappears entirely
|
|
232
|
+
against `#282c34`. What the effect needs is the terminal's *actual*
|
|
233
|
+
background, and Tuile has it: the OSC 11 reply carries the RGB, and
|
|
234
|
+
{Tuile::Screen}`#background_color` hands it to you as a
|
|
235
|
+
{Tuile::Color}.
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
bg = Tuile::Screen.instance.background_color
|
|
239
|
+
sidebar.bg_color =
|
|
240
|
+
bg ? Tuile::Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
That `FALLBACK_TINT` is not defensive padding — it's the branch you
|
|
244
|
+
should expect to hit. Plenty of terminals answer neither probe, and the
|
|
245
|
+
`COLORFGBG` fallback reports a palette *index* with no RGB behind it, so
|
|
246
|
+
`background_color` is nil for every one of them. The fixed near-neutral
|
|
247
|
+
you would have shipped anyway becomes the fallback; the reported color is
|
|
248
|
+
the upgrade for terminals that can support it.
|
|
249
|
+
|
|
250
|
+
The value stays honest across an appearance flip, and doing so takes one
|
|
251
|
+
more round trip than you might expect. The mode-2031 report says only
|
|
252
|
+
"the OS is light now" — it carries no RGB — so when the screen sees one,
|
|
253
|
+
it writes the OSC 11 query again, and the reply comes back through the
|
|
254
|
+
key thread as another event. The new color therefore lands a frame after
|
|
255
|
+
the new theme. When it does, Tuile fires
|
|
256
|
+
{Tuile::Component}`#on_theme_changed` across the tree exactly as a theme
|
|
257
|
+
swap does, on the reasoning that a tint derived from the background *is*
|
|
258
|
+
a theme-derived color, and that hook is already where you rebuild those.
|
|
259
|
+
So the same override handles both halves of a flip, and you don't need to
|
|
260
|
+
know which one woke you.
|
|
261
|
+
|
|
262
|
+
## Not every terminal can show what you computed
|
|
263
|
+
|
|
264
|
+
There is a catch hiding in that last section, and it is worth seeing
|
|
265
|
+
clearly because it applies to every color you *compute* rather than
|
|
266
|
+
declare.
|
|
267
|
+
|
|
268
|
+
A 24-bit color goes out as `\e[48;2;30;30;34m`. That sequence assumes the
|
|
269
|
+
terminal on the other end understands 24-bit color — and plenty don't.
|
|
270
|
+
A `TERM=xterm-256color` session understands only the 256-color palette; a
|
|
271
|
+
Linux console understands sixteen colors; tmux without
|
|
272
|
+
`terminal-features "*:RGB"` mangles or approximates whatever passes
|
|
273
|
+
through it. When you *declared* your colors, this was somebody else's
|
|
274
|
+
problem: you picked them by eye, in a terminal you were looking at, and
|
|
275
|
+
if they came out wrong you picked different ones. A tint computed at
|
|
276
|
+
runtime from the reported background has nobody to eyeball it.
|
|
277
|
+
|
|
278
|
+
So Tuile detects what the terminal can show, and degrades on the way out.
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
Tuile::Screen.instance.color_depth # => :truecolor, :palette256, or :ansi16
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Detection reads the environment — `COLORTERM`, then `TERM` — and never
|
|
285
|
+
asks the terminal anything, so unlike the background probe there is no
|
|
286
|
+
timing to respect and no staleness to worry about: the depth is settled
|
|
287
|
+
at construction and stays put. Terminals do lie, in both directions, and
|
|
288
|
+
`COLORTERM` in particular tends not to survive ssh or tmux. Two things
|
|
289
|
+
make that survivable. Misdetection lands *conservatively* — a truecolor
|
|
290
|
+
tmux advertising only `tmux-256color` reads as `:palette256`, which
|
|
291
|
+
renders coarser but never garbled — and `TUILE_COLOR_DEPTH` overrides the
|
|
292
|
+
detection outright, which is what you reach for when a terminal reports
|
|
293
|
+
itself wrong.
|
|
294
|
+
|
|
295
|
+
The part that matters for your code is that **you don't have to do
|
|
296
|
+
anything about it**. The degradation happens inside
|
|
297
|
+
{Tuile::Buffer}`#flush`, at the moment cells become bytes: every color is
|
|
298
|
+
mapped to the nearest one the terminal can actually show, and the RGB
|
|
299
|
+
you computed is what stays in the component. Paint `Color.rgb(30, 30, 34)`
|
|
300
|
+
on a 256-color terminal and the wire carries palette cell 234; read the
|
|
301
|
+
component back and it still holds your RGB. Nothing you store is ever
|
|
302
|
+
quantized — which is the point, because a stored palette cell has
|
|
303
|
+
forgotten what it was derived from, and the next tint you compute from it
|
|
304
|
+
would compound the error.
|
|
305
|
+
|
|
306
|
+
That leaves one thing worth doing deliberately, and only sometimes. If
|
|
307
|
+
you want to know what a color will *become* — checking that a computed
|
|
308
|
+
tint still contrasts with the background after both round to the same
|
|
309
|
+
coarse palette — ask it:
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
tint.quantize(Tuile::Screen.instance.color_depth) # => the color the terminal will show
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
This is a question, not a step you owe the framework. It returns the
|
|
316
|
+
receiver unchanged whenever the depth can show the color as-is, so it is
|
|
317
|
+
also the cheapest way to ask "would this degrade at all?".
|
|
318
|
+
|
|
173
319
|
## Theming an app durably
|
|
174
320
|
|
|
175
321
|
Detection picks between *Tuile's* two themes. To give your app its own
|