tuile 0.12.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 +116 -27
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +2342 -158
- data/README.md +151 -505
- data/TERMINOLOGY.md +15 -5
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +28 -20
- data/book/05-focus.md +137 -19
- data/book/06-theming.md +103 -2
- data/book/07-components.md +567 -27
- data/book/08-testing.md +34 -4
- data/book/09-styled-text.md +3 -3
- data/book/README.md +7 -5
- data/examples/file_commander.rb +23 -17
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +527 -108
- data/ideas/arrow-key-navigation.md +17 -1
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +28 -27
- data/lib/tuile/ansi.rb +10 -0
- 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 +36 -0
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +12 -3
- 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.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +102 -11
- 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 +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +24 -39
- data/lib/tuile/component/overlay.rb +192 -0
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +61 -123
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +17 -7
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +231 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area.rb +5 -4
- data/lib/tuile/component/text_field.rb +23 -6
- data/lib/tuile/component/text_view.rb +8 -5
- data/lib/tuile/component/window.rb +22 -46
- data/lib/tuile/component.rb +186 -31
- data/lib/tuile/event_queue.rb +45 -1
- data/lib/tuile/fake_screen.rb +40 -2
- data/lib/tuile/keys.rb +72 -0
- data/lib/tuile/screen.rb +212 -113
- data/lib/tuile/screen_pane.rb +125 -41
- data/lib/tuile/styled_string.rb +80 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2527 -358
- metadata +13 -3
- data/mise.toml +0 -2
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,14 +15,16 @@ 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. |
|
|
22
|
+
| **left_column** | the content column currently painted in a widget's leftmost cell — the horizontal counterpart of `scroll_top_row`. Private wherever it exists (`TextField`, `Tabs`, `MenuBar`): what a caller relies on is the invariant it maintains — the caret, or the selected segment, is in view — not the number. |
|
|
22
23
|
| **viewport_rows** | how many rows of content are visible — always `rect.height`; kept private, since `rect.height` is the public form. |
|
|
23
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. |
|
|
24
25
|
| **caret_row** | the row a text input's caret sits in, counted from the content's first row. `TextArea` only. |
|
|
25
|
-
| **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
|
+
| **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. |
|
|
26
28
|
|
|
27
29
|
**Space rule 1.** An object with only one row space leaves `row` unqualified:
|
|
28
30
|
{Tuile::Buffer} *is* the grid, so its rows are screen rows;
|
|
@@ -40,10 +42,11 @@ content-space.
|
|
|
40
42
|
| **line_count** | a count of `\n` units (`TextView::Region#line_count`). Never a row count. |
|
|
41
43
|
| **item** | a domain object a widget holds and renders — `List#items`, and the enum widgets above it. |
|
|
42
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 `D_tabs` calls the "would a form save it?" test. |
|
|
43
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*. |
|
|
44
47
|
| **text** | the user-editable **value** of an input ({Tuile::Component::HasValue}, aliased as `text` on `AbstractStringField`). |
|
|
45
48
|
| **caption** | app-authored **chrome** text ({Tuile::Component::HasCaption}) — a `Window` title, a `Button` label. Never a value. |
|
|
46
|
-
| **chrome** | framework- or app-authored decoration around content: captions, borders, footers,
|
|
49
|
+
| **chrome** | framework- or app-authored decoration around content: captions, borders, footers, an app's status line. |
|
|
47
50
|
| **caret** | the index into an input's `text` where editing happens; always on a cluster boundary. Distinct from the *cursor*. |
|
|
48
51
|
|
|
49
52
|
## Tree, paint and theme
|
|
@@ -51,9 +54,16 @@ content-space.
|
|
|
51
54
|
| term | means |
|
|
52
55
|
|---|---|
|
|
53
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`". |
|
|
54
58
|
| **tile** / **tiled** | the non-popup part of the tree — `ScreenPane#content` and its descendants. Also *to tile*: to cover a rect completely. |
|
|
55
59
|
| **attached** | reachable from a {Tuile::ScreenPane} via the parent chain — the one axis `attached?` consults. |
|
|
56
|
-
| **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. |
|
|
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. |
|
|
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 `▸`. |
|
|
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. |
|
|
64
|
+
| **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. |
|
|
65
|
+
| **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. |
|
|
66
|
+
| **pane** | the component a {Tuile::Component::TabSheet} shows for the selected tab. The unselected ones are *detached*, which is how Tuile hides a component. |
|
|
57
67
|
| **invalidate** | record a component as needing repaint; the loop coalesces and repaints once per tick. |
|
|
58
68
|
| **cursor** | *(two senses, both live)* the hardware terminal cursor (`Screen#cursor_position`), and a `List::Cursor` — the selection position within a list. |
|
|
59
69
|
| **well** | the explicit background an input paints over its whole rect (`Theme#input_bg_color` / `#active_bg_color`), which opts it out of `bg_color` inheritance. |
|
data/book/01-first-app.md
CHANGED
|
@@ -104,11 +104,10 @@ window.focus
|
|
|
104
104
|
|
|
105
105
|
`screen.content = window` makes the window the screen's **tiled content**
|
|
106
106
|
— the component that fills the terminal. Setting it triggers a layout
|
|
107
|
-
pass: the window is handed a rectangle spanning the whole screen
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
down, every time the terminal is resized.
|
|
107
|
+
pass: the window is handed a rectangle spanning the whole screen, and it
|
|
108
|
+
in turn sizes the label inside its border. This is the top-down cascade in
|
|
109
|
+
miniature — the screen sizes the window, the window sizes its content —
|
|
110
|
+
and it re-runs, top down, every time the terminal is resized.
|
|
112
111
|
|
|
113
112
|
`window.focus` marks the window as the **focused** component: the one
|
|
114
113
|
that receives keystrokes. Focus flows down toward interactive content
|
|
@@ -117,30 +116,36 @@ the active thing." In a one-window app it's mostly cosmetic (it draws the
|
|
|
117
116
|
border in the active color); in a real app, focus is what routes the
|
|
118
117
|
keyboard, and it gets its own chapter (chapter 5).
|
|
119
118
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
119
|
+
Run it and you'll notice what *isn't* there: no status bar, no menu, no
|
|
120
|
+
title chrome beyond the border you asked for. **Tuile paints nothing you
|
|
121
|
+
didn't build.** Other TUI toolkits hand you a status row and a hint line
|
|
122
|
+
for free; Tuile gives your content the whole terminal and lets you decide
|
|
123
|
+
whether a bottom row is worth one of your rows. A status line is three
|
|
124
|
+
lines of layout when you want one — `examples/hello_world.rb` adds one,
|
|
125
|
+
and chapter 3 shows the mechanism.
|
|
126
|
+
|
|
127
|
+
That is the same instinct as top-down layout: the framework declines to
|
|
128
|
+
make sizing decisions on your behalf, here by declining to spend a row.
|
|
123
129
|
|
|
124
130
|
## The tree you didn't build
|
|
125
131
|
|
|
126
132
|
Your `window` isn't actually the root of the tree. The real root is a
|
|
127
133
|
structural node called the {Tuile::ScreenPane}, owned by the screen, and
|
|
128
|
-
it holds
|
|
134
|
+
it holds two things:
|
|
129
135
|
|
|
130
136
|
```
|
|
131
137
|
ScreenPane (structural root — paints nothing itself)
|
|
132
138
|
├── content your window (the tiled UI)
|
|
133
|
-
|
|
134
|
-
└── status_bar the bottom row (that "q quit" hint)
|
|
139
|
+
└── popups modal overlays, when you open them (none yet)
|
|
135
140
|
```
|
|
136
141
|
|
|
137
142
|
You only ever manage the `content` slot directly (via `screen.content=`);
|
|
138
|
-
the pane
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
143
|
+
the pane and the popup stack are the framework's. The reason everything —
|
|
144
|
+
including popups — lives under one parent is uniformity: focus traversal,
|
|
145
|
+
"is this component still on screen?", and cleanup when a component is
|
|
146
|
+
removed all work the same way for every node, with no special cases.
|
|
147
|
+
You'll meet popups in chapter 7; for now it's enough to know the pane is
|
|
148
|
+
up there, quietly being the root.
|
|
144
149
|
|
|
145
150
|
## Run the loop, and always close
|
|
146
151
|
|
data/book/02-repaint.md
CHANGED
|
@@ -136,14 +136,27 @@ Meeting that second half is easy, because the default
|
|
|
136
136
|
- A **leaf** component (no children) gets its background cleared
|
|
137
137
|
automatically, so you can paint your content and trust the rest is
|
|
138
138
|
blanked.
|
|
139
|
-
- A **container whose children exactly tile its rect** skips the clear —
|
|
140
|
-
the children will cover everything anyway.
|
|
141
139
|
- A **container with gaps** between its children (a form with
|
|
142
|
-
mixed-width fields, say) gets the background cleared
|
|
143
|
-
|
|
144
|
-
|
|
140
|
+
mixed-width fields, say) gets the background cleared, because those gap
|
|
141
|
+
cells are ones no child will paint over.
|
|
142
|
+
- A **container whose children exactly tile its rect** skips the clear —
|
|
143
|
+
the children cover every cell anyway, and blanking a cell you are about
|
|
144
|
+
to repaint would only mark it dirty for the flush.
|
|
145
|
+
- **Either way, a container re-invalidates its children.** That is what
|
|
146
|
+
makes gappy layouts safe without every container writing its own
|
|
145
147
|
damage-tracking pass.
|
|
146
148
|
|
|
149
|
+
That last point is worth a moment, because the obvious optimization is
|
|
150
|
+
wrong. A clear wipes the container's *whole* rect — every descendant's
|
|
151
|
+
cells, not just the gaps — but a container only ever notifies its own
|
|
152
|
+
direct children, so the notice has to keep travelling down on its own. A
|
|
153
|
+
tiling container that stayed quiet ("my children cover everything, nothing
|
|
154
|
+
to do") would be a dead end: its grandchildren would never learn their
|
|
155
|
+
cells had been blanked by an ancestor, and their content would vanish until
|
|
156
|
+
some unrelated event happened to invalidate them. Repainting more than
|
|
157
|
+
strictly necessary costs nothing here — identical glyphs leave a cell
|
|
158
|
+
unchanged, so the diff is still empty and nothing reaches the wire.
|
|
159
|
+
|
|
147
160
|
The practical rule for writing a component: **call `super` in your
|
|
148
161
|
`repaint`** to inherit that clearing, then paint your content. The only
|
|
149
162
|
components that skip `super` are the few that paint every cell of their
|
data/book/03-layout.md
CHANGED
|
@@ -40,9 +40,9 @@ rectangles they are given. If new content arrives that is too tall for
|
|
|
40
40
|
its pane, the pane scrolls or clips — it does not push back on the
|
|
41
41
|
parent to grow.
|
|
42
42
|
|
|
43
|
-
This is already how Tuile's tiled UI works today: `ScreenPane`
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
This is already how Tuile's tiled UI works today: `ScreenPane` hands your
|
|
44
|
+
content the whole terminal; every real layout you write positions its
|
|
45
|
+
children the same way. The chapter's job is to convince you that
|
|
46
46
|
this is a feature, then show you the few pieces of vocabulary that make
|
|
47
47
|
it comfortable.
|
|
48
48
|
|
|
@@ -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
|
|
@@ -342,16 +343,17 @@ The boxes are sugar, not a replacement, and they can't say everything. A **cap
|
|
|
342
343
|
on a proportion** is the case to recognise:
|
|
343
344
|
|
|
344
345
|
```ruby
|
|
345
|
-
list_width = (rect.width / 3).clamp(20, 40) # a third, but never <20 or >40
|
|
346
346
|
group_width = [16, rect.width / 3].min # a third, but never more than 16
|
|
347
|
+
list_width = (rect.width / 3).clamp(20, 40) # a third, but never <20 or >40
|
|
347
348
|
```
|
|
348
349
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
these layouts took it from 59
|
|
350
|
+
The first is in `examples/sampler.rb` twice — the sidebar in its CheckboxGroup
|
|
351
|
+
pane and the one in its List pane — and both keep a `rect=` override. That's
|
|
352
|
+
the intended division of labour rather than a gap to work around: use a box for
|
|
353
|
+
the stack, drop to `Absolute` for the region that genuinely needs arithmetic —
|
|
354
|
+
usually nesting one inside the other, so only the awkward part carries any. The
|
|
355
|
+
sampler does exactly that, and porting it to these layouts took it from 59
|
|
356
|
+
hand-written rectangles down to a handful (5 today).
|
|
355
357
|
|
|
356
358
|
## Geometry: `Point`, `Size`, `Rect`
|
|
357
359
|
|
|
@@ -395,24 +397,24 @@ class Fraction < Data.define(:width, :height) # each a float in 0.0..1.0
|
|
|
395
397
|
end
|
|
396
398
|
```
|
|
397
399
|
|
|
398
|
-
A popup's
|
|
400
|
+
A popup's box is set with `Popup#declared_size=`, which accepts either a
|
|
399
401
|
`Fraction` (resolved against the screen at layout time, so it tracks
|
|
400
402
|
resize) or an absolute `Size` (clamped to the screen):
|
|
401
403
|
|
|
402
404
|
```ruby
|
|
403
405
|
popup = Tuile::Component::Popup.new(content: some_window)
|
|
404
|
-
popup.
|
|
405
|
-
popup.
|
|
406
|
-
popup.
|
|
407
|
-
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
|
|
408
410
|
```
|
|
409
411
|
|
|
410
412
|
The default is `Fraction::HALF`, resolved on every layout pass, so a
|
|
411
413
|
popup you never size at all is half-screen and follows the terminal as
|
|
412
414
|
it resizes. `Fraction::FULL` is the fullscreen shorthand.
|
|
413
415
|
|
|
414
|
-
A subtle but important point: `
|
|
415
|
-
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.
|
|
416
418
|
There is no parent that might negotiate it downward — the screen simply
|
|
417
419
|
*applies* what you asked for (clamping an oversized absolute `Size` to
|
|
418
420
|
fit). Calling it a preference would invite a future "well, the parent
|
|
@@ -433,7 +435,7 @@ wrong for a paragraph.
|
|
|
433
435
|
> height) and it isn't even reliably pretty — a single long line
|
|
434
436
|
> collapses the popup to one row. Half-screen-and-wrap sidesteps all of
|
|
435
437
|
> it. When you genuinely know the right size — an autocomplete dropdown
|
|
436
|
-
> whose items you own — you set it yourself: `popup.
|
|
438
|
+
> whose items you own — you set it yourself: `popup.declared_size =
|
|
437
439
|
> Tuile::Size.new(longest_item, [items.size, 8].min)`. That's still
|
|
438
440
|
> caller-decides, top-down.
|
|
439
441
|
|
|
@@ -481,6 +483,12 @@ A component in the footer slot always fills the width — there is no
|
|
|
481
483
|
sizing policy to configure, because the window already knows its inner
|
|
482
484
|
width and that's the only dimension a bottom-row widget needs.
|
|
483
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
|
+
|
|
484
492
|
The two are mutually exclusive by precedence: if a `footer=` component
|
|
485
493
|
is present it occupies the bottom row and `footer_text` is hidden;
|
|
486
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.
|
|
@@ -125,6 +128,17 @@ scan of the scope for a component carrying a matching "shortcut key,"
|
|
|
125
128
|
which would jump focus to it. It's gone; the next section explains why the
|
|
126
129
|
bubble does that job better.
|
|
127
130
|
|
|
131
|
+
One thing does happen *after* the ladder, and it lives in the loop rather
|
|
132
|
+
than in dispatch: if nothing handled the key and it was `q` or ESC, the
|
|
133
|
+
loop stops and your program exits. That is what a `q quit` hint in an app's
|
|
134
|
+
status line is describing — not a binding anyone registered, but the fate
|
|
135
|
+
of an unclaimed quit key. It also explains why ESC means different
|
|
136
|
+
things in different places: an open {Tuile::Component::Popup} handles ESC
|
|
137
|
+
itself (dismissing is its job), so the loop never sees it and the popup
|
|
138
|
+
closes instead of the app. And a widget keeps a stray `q` from quitting
|
|
139
|
+
simply by consuming it, which a focused {Tuile::Component::TextField} was
|
|
140
|
+
doing anyway — it's a printable character.
|
|
141
|
+
|
|
128
142
|
## Scope-wide keys live on an ancestor
|
|
129
143
|
|
|
130
144
|
Two things every app wants: `1`/`2`/`3` to jump between panes, and Enter to
|
|
@@ -161,6 +175,7 @@ The same mechanism gives you a form's default button, one form per popup:
|
|
|
161
175
|
| `Button` | consumes it | activates *itself*, not the default |
|
|
162
176
|
| `Checkbox` | consumes it (toggles) | the form never sees it |
|
|
163
177
|
| `Select` | consumes it (opens, then commits) | the form never sees it |
|
|
178
|
+
| `Tabs` | declines | bubbles up → submit |
|
|
164
179
|
|
|
165
180
|
Because bubbling stops at the scope root, two forms in two popups each get
|
|
166
181
|
their own Enter — something a global registry structurally cannot do. This
|
|
@@ -172,6 +187,76 @@ child table, rather than each widget declaring its own mnemonic. That's a
|
|
|
172
187
|
fair trade: which key jumps where is a decision about the assembly, and it
|
|
173
188
|
reads well in one place.
|
|
174
189
|
|
|
190
|
+
## Paste is not a keystroke
|
|
191
|
+
|
|
192
|
+
Everything above is about keys. A paste looks like keys — and that
|
|
193
|
+
resemblance is a genuine problem, not a convenience.
|
|
194
|
+
|
|
195
|
+
Ask a terminal to paste eight lines and, by default, it types them at your
|
|
196
|
+
program: one byte at a time, with every line break converted to `\r`. That
|
|
197
|
+
`\r` is byte-identical to the Enter you press with your finger. So a prompt
|
|
198
|
+
that rebinds Enter to "submit" submits eight times, and no amount of
|
|
199
|
+
cleverness in `handle_key` can tell the two apart — by the time the key
|
|
200
|
+
arrives, the information is gone.
|
|
201
|
+
|
|
202
|
+
The fix has to happen one layer down, at the code that talks to the
|
|
203
|
+
terminal. {Tuile::Screen#run_event_loop} enables **bracketed paste** (DEC
|
|
204
|
+
private mode 2004), which asks the terminal to wrap pasted text in
|
|
205
|
+
`\e[200~` … `\e[201~` markers. Tuile's key thread recognizes the opening
|
|
206
|
+
marker, reads the payload raw up to the terminator, and posts it as a
|
|
207
|
+
single `PasteEvent` — which never enters the ladder at all:
|
|
208
|
+
|
|
209
|
+
- no Tab traversal, no global shortcuts, no `handle_key`;
|
|
210
|
+
- straight to {Tuile::Component#handle_paste}, delivered down the focus
|
|
211
|
+
chain and bubbling exactly like a key;
|
|
212
|
+
- the whole clipboard as one `String`, `\n`-normalized.
|
|
213
|
+
|
|
214
|
+
The default `handle_paste` returns `false` and the text is dropped.
|
|
215
|
+
{Tuile::Component::AbstractStringField} overrides it to insert at the caret
|
|
216
|
+
as **one** mutation — so `on_change` fires once for the paste rather than
|
|
217
|
+
once per character, and a subclass that claims Enter needs no paste code of
|
|
218
|
+
its own:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
class PromptTextArea < Tuile::Component::TextArea
|
|
222
|
+
protected
|
|
223
|
+
|
|
224
|
+
def handle_text_input_key(key)
|
|
225
|
+
return super unless key == Tuile::Keys::ENTER
|
|
226
|
+
|
|
227
|
+
submit(text) # a typed Enter, and only ever a typed Enter
|
|
228
|
+
self.text = ""
|
|
229
|
+
true
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Override `handle_paste` yourself when a paste should mean something other
|
|
235
|
+
than "insert this": collapsing a huge clipboard to a `[Pasted 230 lines]`
|
|
236
|
+
placeholder, say, or pulling a file path out of it.
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
def handle_paste(text)
|
|
240
|
+
return super if text.lines.size < 20
|
|
241
|
+
|
|
242
|
+
attach_as_file(text)
|
|
243
|
+
self.text = "#{text.lines.size} lines attached"
|
|
244
|
+
true
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Two smaller consequences worth knowing. Because the payload is read raw
|
|
249
|
+
rather than through {Tuile::Keys.getkey}, a pasted ESC or Tab stays payload
|
|
250
|
+
— unbracketed, a pasted Tab moves focus and a pasted ESC swallows the five
|
|
251
|
+
bytes behind it. And the line endings are normalized for you: terminals
|
|
252
|
+
disagree about whether a bracketed line break is `\r`, `\r\n` or `\n`, so
|
|
253
|
+
Tuile settles on `\n` before the text reaches a component.
|
|
254
|
+
|
|
255
|
+
`run_event_loop(bracketed_paste: false)` turns the mode off, the same way
|
|
256
|
+
`capture_mouse: false` turns off mouse tracking. Then a paste is keystrokes
|
|
257
|
+
again, with the ambiguity that implies — reach for it only if a terminal
|
|
258
|
+
mishandles the mode.
|
|
259
|
+
|
|
175
260
|
## Where the cursor comes in — and where it doesn't
|
|
176
261
|
|
|
177
262
|
A component signals cursor ownership through
|
|
@@ -188,28 +273,61 @@ needed. With dispatch resting on nothing but "did you return `true`," the
|
|
|
188
273
|
proxy is gone, and a component's decision to consume a key is the only
|
|
189
274
|
declaration in the system.
|
|
190
275
|
|
|
191
|
-
##
|
|
276
|
+
## Writing a status line
|
|
277
|
+
|
|
278
|
+
Chapter 1 pointed out that Tuile draws no status bar. This is the chapter
|
|
279
|
+
where you find out that's a decision about *ownership*, not an omission —
|
|
280
|
+
and that most status lines don't need this chapter's machinery at all.
|
|
281
|
+
|
|
282
|
+
A status line is a `Label` in your layout. That's the whole idea:
|
|
192
283
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
status
|
|
196
|
-
|
|
284
|
+
```ruby
|
|
285
|
+
status = Tuile::Component::Label.new
|
|
286
|
+
status.text = "q #{screen.theme.hint("quit")} Tab #{screen.theme.hint("Switch")}"
|
|
287
|
+
|
|
288
|
+
root = Tuile::Component::Layout::Vertical.new
|
|
289
|
+
root.add(main_ui, Tuile::Component::Layout::Expand[1])
|
|
290
|
+
root.add(status, Tuile::Component::Layout::Fixed[1])
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
If the keys your app offers are the same wherever the user is, you are
|
|
294
|
+
done — set the text once and never touch it again. `examples/file_commander.rb`
|
|
295
|
+
is exactly this: Tab, Enter and Backspace work in both panes, so its row is
|
|
296
|
+
a constant. Reaching for a focus callback there would be machinery computing
|
|
297
|
+
a value that never changes.
|
|
197
298
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
299
|
+
Two details about the text itself. `theme.hint(...)` styles the descriptive
|
|
300
|
+
half of a `key what` pair so hints look consistent (chapter 6), and it
|
|
301
|
+
**bakes the color in** — so a label built from it rebuilds itself from
|
|
302
|
+
`on_theme_changed` to follow a light/dark flip. And keys registered with
|
|
303
|
+
{Tuile::Screen#register_global_shortcut} don't advertise themselves: the
|
|
304
|
+
registry runs actions, it doesn't describe them, so a `^K menu` in your row
|
|
305
|
+
is text you write next to the registration.
|
|
202
306
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
307
|
+
### When the row does depend on focus
|
|
308
|
+
|
|
309
|
+
Some apps genuinely show different keys in different places — a window with
|
|
310
|
+
a search mode, or a pane whose commands only apply to it. For those,
|
|
311
|
+
{Tuile::Screen#on_focus_changed=} is the notification:
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
screen.on_focus_changed = -> { status.text = hint_for(screen.focused) }
|
|
315
|
+
```
|
|
207
316
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
317
|
+
It fires after every focus *change* — to and from `nil` included, and after
|
|
318
|
+
the repair that runs when a popup closes. It's edge-triggered, so
|
|
319
|
+
re-focusing what already has focus fires nothing and your callback can
|
|
320
|
+
rebuild the string unconditionally. Two things it must tolerate: `focused`
|
|
321
|
+
being `nil`, and firing during `screen.close`, which clears focus as it
|
|
322
|
+
unmounts.
|
|
323
|
+
|
|
324
|
+
What `hint_for` does is entirely yours — Tuile has no notion of a hint and
|
|
325
|
+
no method for one, so there is no interface here to conform to.
|
|
326
|
+
`examples/sampler.rb` names the focused component's class, which makes Tab
|
|
327
|
+
traversal visible as you walk a pane. An app with per-window keys usually
|
|
328
|
+
walks up the focus chain from `screen.focused` and takes the first answer,
|
|
329
|
+
because that mirrors the direction a key bubbles — but that's an app's
|
|
330
|
+
design decision, not a framework pattern.
|
|
213
331
|
|
|
214
332
|
---
|
|
215
333
|
|
data/book/06-theming.md
CHANGED
|
@@ -24,7 +24,7 @@ defaults for free.
|
|
|
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
26
|
active window, the resting "well" of a text field, the shortcut captions
|
|
27
|
-
in
|
|
27
|
+
in a status line you write. Those are the accents, and they are exactly the tokens
|
|
28
28
|
a {Tuile::Theme} carries — `active_bg_color`, `active_border_color`,
|
|
29
29
|
`input_bg_color`, `hint_color`. There is no global `bg` or `fg` token,
|
|
30
30
|
and that absence is intentional: adding one would mean painting over the
|
|
@@ -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
|
|
@@ -250,7 +348,10 @@ integer) that {Tuile::Color}.coerce accepts elsewhere. A theme is
|
|
|
250
348
|
declared once per app, so the extra verbosity buys self-documentation —
|
|
251
349
|
`Color.palette(130)` says "palette index," and the named constant
|
|
252
350
|
`Color::DARK_ORANGE3` says even more, where a bare `130` at the
|
|
253
|
-
declaration site says nothing.
|
|
351
|
+
declaration site says nothing. All 256 xterm palette names are there as
|
|
352
|
+
constants — `Color::DODGER_BLUE1`, `Color::GREY37` — and
|
|
353
|
+
`Color::PALETTE_NAMES` is the enumerable map behind them if you'd rather
|
|
354
|
+
browse than guess.
|
|
254
355
|
|
|
255
356
|
## When the theme changes under your content
|
|
256
357
|
|