tuile 0.13.0 → 0.14.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 +81 -37
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +1095 -195
- data/README.md +19 -19
- data/TERMINOLOGY.md +6 -5
- data/book/03-layout.md +17 -10
- data/book/05-focus.md +4 -1
- data/book/06-theming.md +98 -0
- data/book/07-components.md +169 -19
- data/book/08-testing.md +16 -0
- data/book/09-styled-text.md +3 -3
- data/examples/file_commander.rb +1 -1
- data/examples/sampler.rb +143 -43
- data/ideas/arrow-key-navigation.md +2 -2
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +24 -24
- 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/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +9 -2
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/has_content.rb +22 -9
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/layout.rb +0 -10
- data/lib/tuile/component/list_dropdown.rb +18 -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 +192 -0
- data/lib/tuile/component/popup.rb +59 -187
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +14 -6
- 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/window.rb +22 -46
- data/lib/tuile/component.rb +149 -19
- data/lib/tuile/event_queue.rb +21 -1
- data/lib/tuile/fake_screen.rb +26 -2
- data/lib/tuile/keys.rb +7 -0
- data/lib/tuile/screen.rb +120 -38
- data/lib/tuile/screen_pane.rb +37 -35
- data/lib/tuile/styled_string.rb +40 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1157 -368
- metadata +8 -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,7 +143,9 @@ 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
|
|
|
155
151
|
## Components
|
|
@@ -174,6 +170,7 @@ carries the per-method reference: `bundle exec rake yard`, or
|
|
|
174
170
|
| component | what it is |
|
|
175
171
|
|---|---|
|
|
176
172
|
| `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. |
|
|
173
|
+
| `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
174
|
| `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
175
|
| `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
176
|
| `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). |
|
|
@@ -224,11 +221,14 @@ carries the per-method reference: `bundle exec rake yard`, or
|
|
|
224
221
|
|
|
225
222
|
| component | what it is |
|
|
226
223
|
|---|---|
|
|
227
|
-
| `
|
|
224
|
+
| `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. |
|
|
225
|
+
| `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
226
|
| `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
|
-
| `
|
|
227
|
+
| `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). |
|
|
228
|
+
| `InfoWindow` | A `Window` with a read-only body, tiled or popped up: prose that wraps (`message=`), or rows that don't (`lines=`). |
|
|
230
229
|
| `PickerWindow` | A `Window` of options identified by single keystrokes, firing a callback with the key that was pressed. |
|
|
231
|
-
| `
|
|
230
|
+
| `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. |
|
|
231
|
+
| `LogWindow` | A `Window` framing a `LogTextView` — the framed log pane. |
|
|
232
232
|
|
|
233
233
|
The mixins those share — `HasValue` (the `value` / `empty?` / `clear` /
|
|
234
234
|
`on_value_change` seam every input speaks), `HasContent` (one-child
|
|
@@ -252,7 +252,7 @@ interface:
|
|
|
252
252
|
```ruby
|
|
253
253
|
Tuile.logger = Logger.new($stderr) # or:
|
|
254
254
|
Tuile.logger = TTY::Logger.new # duck-typed, works directly
|
|
255
|
-
Tuile.logger = Logger.new(Tuile::Component::
|
|
255
|
+
Tuile.logger = Logger.new(Tuile::Component::LogTextView::IO.new(view))
|
|
256
256
|
```
|
|
257
257
|
|
|
258
258
|
## Testing
|
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,7 @@ 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
27
|
| **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
28
|
|
|
29
29
|
**Space rule 1.** An object with only one row space leaves `row` unqualified:
|
|
@@ -42,7 +42,7 @@ content-space.
|
|
|
42
42
|
| **line_count** | a count of `\n` units (`TextView::Region#line_count`). Never a row count. |
|
|
43
43
|
| **item** | a domain object a widget holds and renders — `List#items`, and the enum widgets above it. |
|
|
44
44
|
| **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 `
|
|
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 `D_tabs` calls the "would a form save it?" test. |
|
|
46
46
|
| **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
47
|
| **text** | the user-editable **value** of an input ({Tuile::Component::HasValue}, aliased as `text` on `AbstractStringField`). |
|
|
48
48
|
| **caption** | app-authored **chrome** text ({Tuile::Component::HasCaption}) — a `Window` title, a `Button` label. Never a value. |
|
|
@@ -54,9 +54,10 @@ content-space.
|
|
|
54
54
|
| term | means |
|
|
55
55
|
|---|---|
|
|
56
56
|
| **component** | a node of the UI tree ({Tuile::Component}); the only thing that paints. |
|
|
57
|
+
| **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
58
|
| **tile** / **tiled** | the non-popup part of the tree — `ScreenPane#content` and its descendants. Also *to tile*: to cover a rect completely. |
|
|
58
59
|
| **attached** | reachable from a {Tuile::ScreenPane} via the parent chain — the one axis `attached?` consults. |
|
|
59
|
-
| **slot** | a named
|
|
60
|
+
| **slot** | a named region of a container, reached by identity (`content`, `footer`) as well as through `children`. Two forms: a plain named child, when the occupant is permanent and integral (`HasContent#content`); or a {Tuile::Component::Slot}, the one-child region component, when the occupant may be absent or swapped (`Window#footer`). Capital-`S` `Slot` always means the class. |
|
|
60
61
|
| **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
62
|
| **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
63
|
| **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. |
|
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.
|
data/book/06-theming.md
CHANGED
|
@@ -170,6 +170,104 @@ theme. From your code's perspective a live appearance flip and a startup
|
|
|
170
170
|
detection are the same thing arriving through the same channel — which is
|
|
171
171
|
exactly the single-threaded-loop payoff chapter 4 promised.
|
|
172
172
|
|
|
173
|
+
## Building on the terminal's own background
|
|
174
|
+
|
|
175
|
+
Everything so far picks colors to sit *against* the background. Some
|
|
176
|
+
designs want the opposite: a color derived *from* it. The borderless-pane
|
|
177
|
+
idiom — LazyVim's editor-versus-explorer split is the one most people
|
|
178
|
+
have seen — leaves the primary pane at the terminal's own background and
|
|
179
|
+
tints the secondary panes a few percent off it. No borders, no boxes; the
|
|
180
|
+
panes separate because one is very slightly lighter than the other.
|
|
181
|
+
|
|
182
|
+
You cannot do that with a fixed color. A tint tuned against `#1e1e2e`
|
|
183
|
+
looks like a deliberate panel against `#000000` and disappears entirely
|
|
184
|
+
against `#282c34`. What the effect needs is the terminal's *actual*
|
|
185
|
+
background, and Tuile has it: the OSC 11 reply carries the RGB, and
|
|
186
|
+
{Tuile::Screen}`#background_color` hands it to you as a
|
|
187
|
+
{Tuile::Color}.
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
bg = Tuile::Screen.instance.background_color
|
|
191
|
+
sidebar.bg_color =
|
|
192
|
+
bg ? Tuile::Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
That `FALLBACK_TINT` is not defensive padding — it's the branch you
|
|
196
|
+
should expect to hit. Plenty of terminals answer neither probe, and the
|
|
197
|
+
`COLORFGBG` fallback reports a palette *index* with no RGB behind it, so
|
|
198
|
+
`background_color` is nil for every one of them. The fixed near-neutral
|
|
199
|
+
you would have shipped anyway becomes the fallback; the reported color is
|
|
200
|
+
the upgrade for terminals that can support it.
|
|
201
|
+
|
|
202
|
+
The value stays honest across an appearance flip, and doing so takes one
|
|
203
|
+
more round trip than you might expect. The mode-2031 report says only
|
|
204
|
+
"the OS is light now" — it carries no RGB — so when the screen sees one,
|
|
205
|
+
it writes the OSC 11 query again, and the reply comes back through the
|
|
206
|
+
key thread as another event. The new color therefore lands a frame after
|
|
207
|
+
the new theme. When it does, Tuile fires
|
|
208
|
+
{Tuile::Component}`#on_theme_changed` across the tree exactly as a theme
|
|
209
|
+
swap does, on the reasoning that a tint derived from the background *is*
|
|
210
|
+
a theme-derived color, and that hook is already where you rebuild those.
|
|
211
|
+
So the same override handles both halves of a flip, and you don't need to
|
|
212
|
+
know which one woke you.
|
|
213
|
+
|
|
214
|
+
## Not every terminal can show what you computed
|
|
215
|
+
|
|
216
|
+
There is a catch hiding in that last section, and it is worth seeing
|
|
217
|
+
clearly because it applies to every color you *compute* rather than
|
|
218
|
+
declare.
|
|
219
|
+
|
|
220
|
+
A 24-bit color goes out as `\e[48;2;30;30;34m`. That sequence assumes the
|
|
221
|
+
terminal on the other end understands 24-bit color — and plenty don't.
|
|
222
|
+
A `TERM=xterm-256color` session understands only the 256-color palette; a
|
|
223
|
+
Linux console understands sixteen colors; tmux without
|
|
224
|
+
`terminal-features "*:RGB"` mangles or approximates whatever passes
|
|
225
|
+
through it. When you *declared* your colors, this was somebody else's
|
|
226
|
+
problem: you picked them by eye, in a terminal you were looking at, and
|
|
227
|
+
if they came out wrong you picked different ones. A tint computed at
|
|
228
|
+
runtime from the reported background has nobody to eyeball it.
|
|
229
|
+
|
|
230
|
+
So Tuile detects what the terminal can show, and degrades on the way out.
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
Tuile::Screen.instance.color_depth # => :truecolor, :palette256, or :ansi16
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Detection reads the environment — `COLORTERM`, then `TERM` — and never
|
|
237
|
+
asks the terminal anything, so unlike the background probe there is no
|
|
238
|
+
timing to respect and no staleness to worry about: the depth is settled
|
|
239
|
+
at construction and stays put. Terminals do lie, in both directions, and
|
|
240
|
+
`COLORTERM` in particular tends not to survive ssh or tmux. Two things
|
|
241
|
+
make that survivable. Misdetection lands *conservatively* — a truecolor
|
|
242
|
+
tmux advertising only `tmux-256color` reads as `:palette256`, which
|
|
243
|
+
renders coarser but never garbled — and `TUILE_COLOR_DEPTH` overrides the
|
|
244
|
+
detection outright, which is what you reach for when a terminal reports
|
|
245
|
+
itself wrong.
|
|
246
|
+
|
|
247
|
+
The part that matters for your code is that **you don't have to do
|
|
248
|
+
anything about it**. The degradation happens inside
|
|
249
|
+
{Tuile::Buffer}`#flush`, at the moment cells become bytes: every color is
|
|
250
|
+
mapped to the nearest one the terminal can actually show, and the RGB
|
|
251
|
+
you computed is what stays in the component. Paint `Color.rgb(30, 30, 34)`
|
|
252
|
+
on a 256-color terminal and the wire carries palette cell 234; read the
|
|
253
|
+
component back and it still holds your RGB. Nothing you store is ever
|
|
254
|
+
quantized — which is the point, because a stored palette cell has
|
|
255
|
+
forgotten what it was derived from, and the next tint you compute from it
|
|
256
|
+
would compound the error.
|
|
257
|
+
|
|
258
|
+
That leaves one thing worth doing deliberately, and only sometimes. If
|
|
259
|
+
you want to know what a color will *become* — checking that a computed
|
|
260
|
+
tint still contrasts with the background after both round to the same
|
|
261
|
+
coarse palette — ask it:
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
tint.quantize(Tuile::Screen.instance.color_depth) # => the color the terminal will show
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
This is a question, not a step you owe the framework. It returns the
|
|
268
|
+
receiver unchanged whenever the depth can show the color as-is, so it is
|
|
269
|
+
also the cheapest way to ask "would this degrade at all?".
|
|
270
|
+
|
|
173
271
|
## Theming an app durably
|
|
174
272
|
|
|
175
273
|
Detection picks between *Tuile's* two themes. To give your app its own
|
data/book/07-components.md
CHANGED
|
@@ -674,6 +674,39 @@ window.content = Component::List.new.tap { _1.lines = entries }
|
|
|
674
674
|
window.scrollbar = true
|
|
675
675
|
```
|
|
676
676
|
|
|
677
|
+
### Reserving a region: `Slot`
|
|
678
|
+
|
|
679
|
+
A `Window` has one content region. When *you* build a container with
|
|
680
|
+
several — a dialog with a message, a button row and maybe a header — give
|
|
681
|
+
each region a {Tuile::Component::Slot}: a component whose whole job is to
|
|
682
|
+
hold one child and size it to itself.
|
|
683
|
+
|
|
684
|
+
```ruby
|
|
685
|
+
@message = Component::Slot.new
|
|
686
|
+
add(@message, Expand[1]) # the region, wired once at construction
|
|
687
|
+
@message.content = Component::Label.new("Delete this file?") # the occupant
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
The reason to bother is an arithmetic problem you'd otherwise have to
|
|
691
|
+
solve. Children are ordered, and order decides paint order and Tab order —
|
|
692
|
+
so if you held the message and the buttons as direct children, "where does
|
|
693
|
+
the message get inserted?" would depend on whether the header happens to be
|
|
694
|
+
present right now. Inside a slot the answer is always index 0, because the
|
|
695
|
+
slot itself never leaves the tree. Add regions, reorder them, leave some
|
|
696
|
+
empty: none of it changes a swap.
|
|
697
|
+
|
|
698
|
+
Which leads to the one thing that surprises people: **an empty slot doesn't
|
|
699
|
+
collapse.** It keeps the rectangle its parent gave it and clears it, so a
|
|
700
|
+
dialog with no message shows the hole — exactly as it would with an *empty*
|
|
701
|
+
message. If you want the gap closed, that's the parent's arithmetic (give
|
|
702
|
+
the slot a zero extent), which is the same top-down rule as everything else
|
|
703
|
+
in chapter 3. Don't detach the slot to make it go away; that hands you back
|
|
704
|
+
the insert-index problem it exists to remove.
|
|
705
|
+
|
|
706
|
+
A slot is invisible to input: it can't take focus, clicks pass straight
|
|
707
|
+
through to the occupant, and when an occupant leaves, the focus repair is
|
|
708
|
+
handed up to your container rather than stranding focus on the slot.
|
|
709
|
+
|
|
677
710
|
## Switching between views
|
|
678
711
|
|
|
679
712
|
When a screen has more content than fits and the parts are *alternatives*
|
|
@@ -1008,22 +1041,24 @@ every GUI dismisses its menus on a window resize too.
|
|
|
1008
1041
|
The popup itself paints nothing — it's a transparent host that wraps any
|
|
1009
1042
|
component as its content and manages the lifecycle (`open` / `close`,
|
|
1010
1043
|
ESC/`q` to dismiss). Crucially, and per chapter 3, **it does not size
|
|
1011
|
-
itself to its content**: its box is
|
|
1044
|
+
itself to its content**: its box is set by `declared_size` — a `Fraction`
|
|
1012
1045
|
(default `Fraction::HALF`, half the screen, re-resolved on every resize)
|
|
1013
1046
|
or an absolute `Size`. The content then fills that box, so use content
|
|
1014
1047
|
that can cope with overflow — a TextView or TextArea that scrolls, not a
|
|
1015
1048
|
bare Label that only truncates.
|
|
1016
1049
|
|
|
1017
|
-
A popup is **modal
|
|
1050
|
+
A popup is **always modal**: centered, it grabs focus, eats keys, and
|
|
1018
1051
|
blocks clicks beneath it — that's what makes an open dialog trap Tab and
|
|
1019
|
-
input inside itself.
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1052
|
+
input inside itself. For a layer that floats *without* taking focus — the
|
|
1053
|
+
autocomplete-list case from earlier, where the caller positions it against a
|
|
1054
|
+
field's caret and drives it from app code — use its base class, `Overlay`,
|
|
1055
|
+
directly. An `Overlay` is a Popup minus the modality: same open/close
|
|
1056
|
+
lifecycle, same outside-click dismissal, but it sits at the rect you assign
|
|
1057
|
+
and never disturbs focus or key dispatch.
|
|
1058
|
+
|
|
1059
|
+
**A left click outside an overlay closes it**, modal or not — the same light
|
|
1060
|
+
dismissal a desktop dialog gives you. It's a per-overlay switch,
|
|
1061
|
+
`close_on_outside_click`, on by default; one that must survive stray
|
|
1027
1062
|
clicks turns it off, as a Notification does. The click still reaches
|
|
1028
1063
|
whatever was beneath it, unless an open modal swallowed it — in which case
|
|
1029
1064
|
the first click dismisses and a second one acts.
|
|
@@ -1050,6 +1085,116 @@ A nested TextField still swallows printable keys first, so typing `q` into
|
|
|
1050
1085
|
a field inside a popup doesn't dismiss it — the popup's own `q` handler sits
|
|
1051
1086
|
on the ancestor, and only sees keys the field declined.
|
|
1052
1087
|
|
|
1088
|
+
## The confirm dialog
|
|
1089
|
+
|
|
1090
|
+
That Window-in-a-Popup assembly is how you build any dialog. One dialog is
|
|
1091
|
+
so common it comes pre-assembled: {Tuile::Component::ConfirmWindow}, the
|
|
1092
|
+
"are you sure?" box — a caption, a short message, a row of buttons.
|
|
1093
|
+
|
|
1094
|
+
```ruby
|
|
1095
|
+
Component::ConfirmWindow.confirm("Delete Report Q4?", "This cannot be undone.",
|
|
1096
|
+
confirm: "Delete") { delete! }
|
|
1097
|
+
```
|
|
1098
|
+
|
|
1099
|
+
```
|
|
1100
|
+
┌Delete Report Q4?───────────┐
|
|
1101
|
+
│ This cannot be undone. │
|
|
1102
|
+
│ │
|
|
1103
|
+
│ [ Delete ] [ Cancel ] │
|
|
1104
|
+
└────────────────────────────┘
|
|
1105
|
+
```
|
|
1106
|
+
|
|
1107
|
+
Three factories cover the shapes you'll actually write: `alert(caption,
|
|
1108
|
+
message)` is the one-button acknowledgement, `confirm` the two-button
|
|
1109
|
+
question — its labels are keywords, so it is also your OK/Cancel and
|
|
1110
|
+
Delete/Cancel — and `yes_no` the other canonical phrasing. That's
|
|
1111
|
+
deliberately the whole list: Windows' `MessageBoxButtons` enum grew six
|
|
1112
|
+
values by naming every label pair, and any set the factories don't cover is
|
|
1113
|
+
a few lines of the builder below.
|
|
1114
|
+
|
|
1115
|
+
### Why a block, and not an answer
|
|
1116
|
+
|
|
1117
|
+
Everyone's first instinct here is the blocking call — `if confirm?("Delete?")`
|
|
1118
|
+
— because that's what Swing's `JOptionPane`, tkinter's `askyesno` and GTK's
|
|
1119
|
+
`dialog.run` all offer. Tuile can't, and it's worth understanding why: the
|
|
1120
|
+
whole UI runs on one thread (chapter 4), so a call that *waits* for the
|
|
1121
|
+
answer would have to nest a second event loop inside the first, re-entering
|
|
1122
|
+
raw mode under a key thread that's already reading stdin. The dialog
|
|
1123
|
+
therefore takes callbacks: the block is the action, and the dialog returns
|
|
1124
|
+
immediately.
|
|
1125
|
+
|
|
1126
|
+
### One kind of way out
|
|
1127
|
+
|
|
1128
|
+
Every button closes the dialog. A button *with* a block then fires it; a
|
|
1129
|
+
button *without* one is a Cancel. And ESC, `q`, a click outside the box and
|
|
1130
|
+
that Cancel button are all the same event — `on_dismiss`, fired exactly
|
|
1131
|
+
once, and only when no action button was chosen. You never write a `case`
|
|
1132
|
+
over outcomes, and you never have to enumerate the ways out: there is the
|
|
1133
|
+
action you asked about, and there is "do nothing", however the user spells
|
|
1134
|
+
it.
|
|
1135
|
+
|
|
1136
|
+
There is deliberately no way to keep the dialog open after a press. A
|
|
1137
|
+
dialog that leads somewhere — "Copy files" showing a progress window —
|
|
1138
|
+
opens the next window *from its callback*, which is safe because the block
|
|
1139
|
+
fires after the dialog has already closed and focus has been repaired.
|
|
1140
|
+
|
|
1141
|
+
For any other button set, the component is its own builder — buttons are
|
|
1142
|
+
declared one at a time, each a caption and an optional block:
|
|
1143
|
+
|
|
1144
|
+
```ruby
|
|
1145
|
+
dialog = Component::ConfirmWindow.new("Unsaved changes")
|
|
1146
|
+
dialog.message = "Save your changes before leaving?"
|
|
1147
|
+
dialog.button("Save") { save! }
|
|
1148
|
+
dialog.button("Discard") { discard! }
|
|
1149
|
+
dialog.button("Cancel") # no block: pressing it dismisses
|
|
1150
|
+
dialog.on_dismiss = -> { stay_put }
|
|
1151
|
+
dialog.open
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
### Keys, and the underlined letters
|
|
1155
|
+
|
|
1156
|
+
Focus opens on the first button — which, since Enter presses the *focused*
|
|
1157
|
+
button, makes it the default. Left/Right and Tab walk the row; Enter or
|
|
1158
|
+
Space press.
|
|
1159
|
+
|
|
1160
|
+
Each button also answers to a **mnemonic**: a letter, underlined in its
|
|
1161
|
+
caption, that presses the button from anywhere in the dialog. By default
|
|
1162
|
+
it's the caption's first letter (Save gets `s`, Discard `d`), matched
|
|
1163
|
+
case-insensitively; pass `mnemonic:` to pick another letter — the case you
|
|
1164
|
+
give chooses which occurrence gets the underline — or `nil` for none. The
|
|
1165
|
+
underline isn't decoration: Tuile draws no status bar to advertise keys in,
|
|
1166
|
+
so the caption *is* the advertisement.
|
|
1167
|
+
|
|
1168
|
+
Three letters are never mnemonics. `q` is unconditionally the do-nothing
|
|
1169
|
+
route out — a dialog that forces a choice only thinks it does, since the
|
|
1170
|
+
user can always Ctrl+C, and pretending there's no escape route just trains
|
|
1171
|
+
them to reach for it. And `g`/`G` belong to the message: the body scrolls
|
|
1172
|
+
*without taking focus* — Up/Down, PgUp/PgDn, Ctrl+U/D, Home/End and the
|
|
1173
|
+
less-style `g`/`G` are handed to it while a button keeps focus, so a long
|
|
1174
|
+
message reads without any focus gymnastics. The body is also a tab stop:
|
|
1175
|
+
Shift+Tab reaches it, so overflowing prose is visibly reachable, not
|
|
1176
|
+
secretly scrollable.
|
|
1177
|
+
|
|
1178
|
+
### The popup that sizes itself
|
|
1179
|
+
|
|
1180
|
+
Chapter 3 was firm that nothing in Tuile sizes itself to its content, and a
|
|
1181
|
+
plain Popup takes half the screen whatever it wraps — absurd around a
|
|
1182
|
+
one-line "Delete?". The confirm dialog is the sanctioned exception *shape*:
|
|
1183
|
+
it measures **content it owns** — its caption, its message, its buttons —
|
|
1184
|
+
and asks the screen for exactly that box, still capped at half the screen
|
|
1185
|
+
(a message longer than the cap wraps and scrolls). Assign a `Component` as
|
|
1186
|
+
the message and the measuring honestly gives up: injected content is not
|
|
1187
|
+
the dialog's to measure, so the popup takes the full half-screen box.
|
|
1188
|
+
|
|
1189
|
+
### What it deliberately isn't
|
|
1190
|
+
|
|
1191
|
+
The message is prose — a `String` or {Tuile::StyledString}, which on a TTY
|
|
1192
|
+
already covers color, emphasis and iconography — not a content slot. A
|
|
1193
|
+
dialog collecting *input* is not a confirm dialog: the moment you want a
|
|
1194
|
+
form, a picker or a diff view in there, you've outgrown the sugar, and the
|
|
1195
|
+
general mechanism is one line away — `Popup.new(content: your_layout)`,
|
|
1196
|
+
exactly as in the previous section.
|
|
1197
|
+
|
|
1053
1198
|
## Notifications
|
|
1054
1199
|
|
|
1055
1200
|
{Tuile::Component::Notification} is the one overlay you don't assemble at
|
|
@@ -1105,18 +1250,23 @@ The last three components are conveniences: common Window-plus-content
|
|
|
1105
1250
|
assemblies you'd otherwise build by hand. Each works tiled (add it to a
|
|
1106
1251
|
layout) *or* as a popup (via a class-level `open`).
|
|
1107
1252
|
|
|
1108
|
-
- {Tuile::Component::InfoWindow} — a Window
|
|
1109
|
-
|
|
1110
|
-
`
|
|
1253
|
+
- {Tuile::Component::InfoWindow} — a Window with a read-only body, in one
|
|
1254
|
+
of two presentations: `message=` is *prose*, wrapped by a scrollable
|
|
1255
|
+
TextView; `lines=` is *rows*, a List keeping one item per row and
|
|
1256
|
+
truncating — the choice for columnar output, where a wrap would destroy
|
|
1257
|
+
the alignment. `InfoWindow.open(caption, body)` pops it up, picking the
|
|
1258
|
+
presentation from the body's type (an Array is rows, text is prose).
|
|
1111
1259
|
- {Tuile::Component::PickerWindow} — a menu of options each bound to a
|
|
1112
1260
|
single key, firing your block with the picked key. Popped up via `open`,
|
|
1113
1261
|
it closes itself after a pick; ESC/`q` cancels without firing.
|
|
1114
|
-
- {Tuile::Component::LogWindow} — a Window
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1262
|
+
- {Tuile::Component::LogWindow} — a Window framing a
|
|
1263
|
+
{Tuile::Component::LogTextView}: an auto-scrolling, scrollbar-equipped
|
|
1264
|
+
TextView purpose-built for log output. The view is where the behavior
|
|
1265
|
+
lives — compose it bare into a layout when you don't want the frame.
|
|
1266
|
+
Its `log` method is **thread-safe** — it marshals the append back onto
|
|
1267
|
+
the UI thread via the event queue (chapter 4), so background work can
|
|
1268
|
+
log freely. And the view carries an `IO`-shaped adapter so you can point
|
|
1269
|
+
a stdlib `Logger` (or a `TTY::Logger`) straight at either of them:
|
|
1120
1270
|
|
|
1121
1271
|
```ruby
|
|
1122
1272
|
window = Component::LogWindow.new
|
data/book/08-testing.md
CHANGED
|
@@ -108,9 +108,25 @@ key, so you assert on that too:
|
|
|
108
108
|
|
|
109
109
|
```ruby
|
|
110
110
|
list.handle_key(Keys::DOWN_ARROW) # exercises the cursor directly
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**A mouse test needs the component mounted, where a key test doesn't.** A
|
|
114
|
+
click doesn't only *do* something, it also *focuses* — and
|
|
115
|
+
{Tuile::Screen#focused=} refuses a component that isn't on the pane, so
|
|
116
|
+
`handle_mouse` on a component you never attached raises "is not attached to
|
|
117
|
+
this screen". Give it a tree first:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
screen.content = list # a click focuses; focus needs a tree
|
|
121
|
+
list.rect = Rect.new(0, 0, 10, 5)
|
|
111
122
|
list.handle_mouse(MouseEvent.new(:left, 5, 2))
|
|
112
123
|
```
|
|
113
124
|
|
|
125
|
+
That applies to containers too, and to more of them than you might expect:
|
|
126
|
+
a click descends to every child whose rect contains the point, so testing a
|
|
127
|
+
window's footer by clicking it exercises the window, the footer's slot and
|
|
128
|
+
the footer, all of which want to be attached.
|
|
129
|
+
|
|
114
130
|
**High: go through the pane.** {Tuile::ScreenPane#handle_key} runs the
|
|
115
131
|
dispatch rung from chapter 5 that routing is actually about: delivery to
|
|
116
132
|
{Tuile::Screen#focused}, then the bubble up its ancestor chain to the scope
|
data/book/09-styled-text.md
CHANGED
|
@@ -29,7 +29,7 @@ tangle has to be re-untangled on every edit.
|
|
|
29
29
|
{Tuile::StyledString} untangles it once, structurally. A styled string is
|
|
30
30
|
a sequence of **spans**, each a maximal run of characters that share one
|
|
31
31
|
complete {Tuile::StyledString::Style} — foreground, background, bold,
|
|
32
|
-
italic, underline, strikethrough. The spans are non-overlapping and tile
|
|
32
|
+
italic, underline, strikethrough, inverse. The spans are non-overlapping and tile
|
|
33
33
|
the whole string: every character belongs to exactly one span, and that
|
|
34
34
|
span's `style` *is* the character's style. There are no overlay layers to
|
|
35
35
|
merge, no running state to reconstruct. "What's the style at column 5?"
|
|
@@ -100,8 +100,8 @@ by default.**
|
|
|
100
100
|
|
|
101
101
|
Strict means it recognizes exactly the SGR codes that map to a
|
|
102
102
|
{Tuile::StyledString::Style}'s attributes — the foreground and background
|
|
103
|
-
colors, bold, italic, underline, strikethrough — and *raises* on
|
|
104
|
-
else. An unmodeled attribute like blink or
|
|
103
|
+
colors, bold, italic, underline, strikethrough, inverse — and *raises* on
|
|
104
|
+
anything else. An unmodeled attribute like blink or conceal, an unknown SGR
|
|
105
105
|
code, a non-SGR escape like a cursor move or an OSC sequence: all of them
|
|
106
106
|
are a {Tuile::StyledString::ParseError}, not a shrug. The reason is a
|
|
107
107
|
contract worth protecting: `parse(to_ansi(x)) == x`. If parsing silently
|
data/examples/file_commander.rb
CHANGED
|
@@ -82,7 +82,7 @@ module FileCommanderExample
|
|
|
82
82
|
@on_cwd_changed&.call
|
|
83
83
|
rescue SystemCallError => e
|
|
84
84
|
@cwd = previous
|
|
85
|
-
Tuile::Component::InfoWindow.open("Cannot open",
|
|
85
|
+
Tuile::Component::InfoWindow.open("Cannot open", "#{path}\n#{e.message}")
|
|
86
86
|
end
|
|
87
87
|
|
|
88
88
|
def load_entries
|