tuile 0.11.0 → 0.12.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 +32 -0
- data/DECISIONS.md +680 -8
- data/README.md +12 -13
- data/TERMINOLOGY.md +61 -0
- data/book/02-repaint.md +1 -1
- data/book/03-layout.md +1 -1
- data/book/06-theming.md +1 -1
- data/book/07-components.md +97 -27
- data/examples/file_commander.rb +5 -4
- data/examples/sampler.rb +38 -1
- data/ideas/new-components.md +9 -4
- data/lib/tuile/buffer.rb +7 -7
- data/lib/tuile/component/button.rb +1 -1
- data/lib/tuile/component/checkbox.rb +1 -1
- data/lib/tuile/component/checkbox_group.rb +31 -26
- data/lib/tuile/component/combo_box.rb +10 -7
- data/lib/tuile/component/info_window.rb +1 -1
- data/lib/tuile/component/label.rb +14 -14
- data/lib/tuile/component/list.rb +291 -216
- data/lib/tuile/component/list_dropdown.rb +14 -7
- data/lib/tuile/component/notification.rb +317 -0
- data/lib/tuile/component/picker_window.rb +3 -3
- data/lib/tuile/component/popup.rb +8 -10
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +32 -30
- data/lib/tuile/component/select.rb +7 -7
- data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
- data/lib/tuile/component/text_area.rb +79 -273
- data/lib/tuile/component/text_field.rb +1 -1
- data/lib/tuile/component/text_view.rb +191 -177
- data/lib/tuile/component/window.rb +8 -8
- data/lib/tuile/component.rb +5 -5
- data/lib/tuile/screen.rb +1 -1
- data/lib/tuile/styled_string.rb +12 -12
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +6 -6
- data/sig/tuile.rbs +788 -377
- metadata +4 -1
data/README.md
CHANGED
|
@@ -132,7 +132,7 @@ bytes sent to the terminal. There is no clipping in between.
|
|
|
132
132
|
`cursor_position` (e.g. into a focused text field).
|
|
133
133
|
|
|
134
134
|
Components never write escape sequences to the terminal. They paint styled
|
|
135
|
-
cells into a back buffer (`Tuile::Buffer`) via `
|
|
135
|
+
cells into a back buffer (`Tuile::Buffer`) via `set_text` / `fill` /
|
|
136
136
|
`set_char`. When the pass finishes, `Buffer#flush` emits the **minimal diff**
|
|
137
137
|
— only the cells that actually changed since the last flush — wrapped in one
|
|
138
138
|
synchronized-output batch. That is what keeps repaint flicker-free on any
|
|
@@ -151,7 +151,7 @@ of its own and positions its children within its rect.
|
|
|
151
151
|
|
|
152
152
|
`Tuile::Screen#run_event_loop` reads keys and mouse events on a worker thread,
|
|
153
153
|
funnels them through `Tuile::EventQueue`, and processes them on the main
|
|
154
|
-
thread. **All** UI mutations — `rect=`, `content=`, `
|
|
154
|
+
thread. **All** UI mutations — `rect=`, `content=`, `items=`, `invalidate`,
|
|
155
155
|
`screen.focused=` — must run on that thread. Most UI methods will raise
|
|
156
156
|
`"UI lock not held"` if you violate this.
|
|
157
157
|
|
|
@@ -386,7 +386,7 @@ Tuile::ThemeDef.default = APP_THEME # every Screen.fake now carries it
|
|
|
386
386
|
|
|
387
387
|
Built-in components read `screen.theme` at paint time, so their accents
|
|
388
388
|
restyle automatically. Content you rendered yourself does not: a
|
|
389
|
-
`StyledString` stored in `Label#text` / `List#lines
|
|
389
|
+
`StyledString` stored in `Label#text` / `List#lines=` / `TextView#text`
|
|
390
390
|
has its colors baked in at construction, and only your app knows which of
|
|
391
391
|
those were theme-derived (as opposed to inherent to the data — log-level
|
|
392
392
|
colors, say). `Component#on_theme_changed` fires on every attached
|
|
@@ -478,15 +478,17 @@ focusable; focus delegates to content (or footer when active).
|
|
|
478
478
|
|
|
479
479
|
### `Component::List`
|
|
480
480
|
|
|
481
|
-
A scrollable list of
|
|
481
|
+
A scrollable list of items — one row each, rendered by a `renderer` — with
|
|
482
|
+
optional cursor and scrollbar. `lines=` is the shortcut for items that are
|
|
483
|
+
their own rendering.
|
|
482
484
|
|
|
483
485
|
```ruby
|
|
484
486
|
list = Tuile::Component::List.new
|
|
485
487
|
list.lines = ["alpha", "beta", "gamma"]
|
|
486
488
|
list.cursor = Tuile::Component::List::Cursor.new
|
|
487
|
-
list.on_item_chosen = ->(index,
|
|
488
|
-
list.auto_scroll = true # auto-scroll to bottom
|
|
489
|
-
list.
|
|
489
|
+
list.on_item_chosen = ->(index, item) { Tuile.logger.info("picked #{item}") }
|
|
490
|
+
list.auto_scroll = true # auto-scroll to bottom as the list grows
|
|
491
|
+
list.lines = list.items + ["delta"] # no appenders: assign the items whole
|
|
490
492
|
```
|
|
491
493
|
|
|
492
494
|
Cursor variants:
|
|
@@ -499,8 +501,8 @@ Cursor variants:
|
|
|
499
501
|
|
|
500
502
|
Pressing Enter or left-clicking an item fires `on_item_chosen(index, line)`.
|
|
501
503
|
|
|
502
|
-
Key API: `
|
|
503
|
-
`auto_scroll=`, `scrollbar_visibility=`, `on_item_chosen`,
|
|
504
|
+
Key API: `items=`, `renderer=`, `lines=`, `build_lines`, `cursor=`,
|
|
505
|
+
`scroll_top_row=`, `auto_scroll=`, `scrollbar_visibility=`, `on_item_chosen`,
|
|
504
506
|
`select_next` / `select_prev` (search).
|
|
505
507
|
|
|
506
508
|
### `Component::TextField`
|
|
@@ -534,10 +536,7 @@ drawn on top of the tiled content; multiple popups stack.
|
|
|
534
536
|
```ruby
|
|
535
537
|
window = Tuile::Component::Window.new("Help")
|
|
536
538
|
window.content = help_list
|
|
537
|
-
Tuile::Component::Popup.
|
|
538
|
-
# or, equivalently:
|
|
539
|
-
popup = Tuile::Component::Popup.new(content: window)
|
|
540
|
-
popup.open
|
|
539
|
+
popup = Tuile::Component::Popup.new(content: window, size: Tuile::Fraction::HALF).open
|
|
541
540
|
# popup.close, popup.open?
|
|
542
541
|
```
|
|
543
542
|
|
data/TERMINOLOGY.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# TERMINOLOGY.md
|
|
2
|
+
|
|
3
|
+
Tuile's house vocabulary — one line per term, looked up by word.
|
|
4
|
+
|
|
5
|
+
This file owns **definitions only**. The *rules that bite* live in AGENTS.md
|
|
6
|
+
("Nomenclature" and the sections each word belongs to); the *why we chose a word
|
|
7
|
+
and not its synonym* lives in DECISIONS.md (`D-scroll-nomenclature` for the
|
|
8
|
+
row/line/item split); the *concepts* live in the book. When a definition here
|
|
9
|
+
needs a paragraph of justification, that paragraph belongs in one of those three.
|
|
10
|
+
|
|
11
|
+
## The grid
|
|
12
|
+
|
|
13
|
+
| term | means |
|
|
14
|
+
|---|---|
|
|
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
|
+
| **column** | one cell-column of the terminal grid; the unit `display_width` counts. |
|
|
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 `D-ambiguous-width`). |
|
|
19
|
+
| **cluster** | a grapheme cluster — the unit measurement, slicing, caret motion and deletion all work in. Never `each_char`. |
|
|
20
|
+
| **row_in_viewport** | a row measured `0...rect.height`, i.e. relative to a component's own rect. |
|
|
21
|
+
| **scroll_top_row** | the content row currently sitting at the top of the viewport. |
|
|
22
|
+
| **viewport_rows** | how many rows of content are visible — always `rect.height`; kept private, since `rect.height` is the public form. |
|
|
23
|
+
| **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
|
+
| **caret_row** | the row a text input's caret sits in, counted from the content's first row. `TextArea` only. |
|
|
25
|
+
| **extent** | the sub-rect a one-row caption widget actually occupies (`min(caption width + 4, rect.width)`), used for its highlight and hit test — narrower than its `rect`. |
|
|
26
|
+
|
|
27
|
+
**Space rule 1.** An object with only one row space leaves `row` unqualified:
|
|
28
|
+
{Tuile::Buffer} *is* the grid, so its rows are screen rows;
|
|
29
|
+
`TextArea::WrappedText` is content, so its rows are content rows.
|
|
30
|
+
|
|
31
|
+
**Space rule 2.** A component holding both spaces qualifies the viewport one
|
|
32
|
+
(`row_in_viewport`); its unqualified `row` and its `scroll_top_row` are
|
|
33
|
+
content-space.
|
|
34
|
+
|
|
35
|
+
## Text and content
|
|
36
|
+
|
|
37
|
+
| term | means |
|
|
38
|
+
|---|---|
|
|
39
|
+
| **line** | a `\n`-delimited unit of a String — exactly what `String#lines` returns. **Never a coordinate.** |
|
|
40
|
+
| **line_count** | a count of `\n` units (`TextView::Region#line_count`). Never a row count. |
|
|
41
|
+
| **item** | a domain object a widget holds and renders — `List#items`, and the enum widgets above it. |
|
|
42
|
+
| **renderer** | the `item -> row` proc a generic component uses to render an item it knows nothing about. |
|
|
43
|
+
| **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
|
+
| **text** | the user-editable **value** of an input ({Tuile::Component::HasValue}, aliased as `text` on `AbstractStringField`). |
|
|
45
|
+
| **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, the status bar. |
|
|
47
|
+
| **caret** | the index into an input's `text` where editing happens; always on a cluster boundary. Distinct from the *cursor*. |
|
|
48
|
+
|
|
49
|
+
## Tree, paint and theme
|
|
50
|
+
|
|
51
|
+
| term | means |
|
|
52
|
+
|---|---|
|
|
53
|
+
| **component** | a node of the UI tree ({Tuile::Component}); the only thing that paints. |
|
|
54
|
+
| **tile** / **tiled** | the non-popup part of the tree — `ScreenPane#content` and its descendants. Also *to tile*: to cover a rect completely. |
|
|
55
|
+
| **attached** | reachable from a {Tuile::ScreenPane} via the parent chain — the one axis `attached?` consults. |
|
|
56
|
+
| **slot** | a named child a container holds by identity (`content`, `footer`) as well as in `children`. |
|
|
57
|
+
| **invalidate** | record a component as needing repaint; the loop coalesces and repaints once per tick. |
|
|
58
|
+
| **cursor** | *(two senses, both live)* the hardware terminal cursor (`Screen#cursor_position`), and a `List::Cursor` — the selection position within a list. |
|
|
59
|
+
| **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. |
|
|
60
|
+
| **token** | a semantic colour name on {Tuile::Theme} — an accent, never a global fg/bg. |
|
|
61
|
+
| **scheme** | `:dark` or `:light`; a {Tuile::ThemeDef} pairs one {Tuile::Theme} per scheme. |
|
data/book/02-repaint.md
CHANGED
|
@@ -50,7 +50,7 @@ cells into the screen's **back buffer** — a {Tuile::Buffer}, an in-memory
|
|
|
50
50
|
grid of styled cells mirroring the terminal — through three methods:
|
|
51
51
|
|
|
52
52
|
```ruby
|
|
53
|
-
screen.buffer.
|
|
53
|
+
screen.buffer.set_text(x, y, styled_string) # a run of text
|
|
54
54
|
screen.buffer.set_char(x, y, grapheme, style) # one cell
|
|
55
55
|
screen.buffer.fill(rect, style) # a blank region
|
|
56
56
|
```
|
data/book/03-layout.md
CHANGED
|
@@ -443,7 +443,7 @@ A `Window`'s bottom border can carry one of two things, and they are
|
|
|
443
443
|
genuinely different jobs, so they are two different members.
|
|
444
444
|
|
|
445
445
|
**`footer_text=`** is *border chrome* — a styled string (a `String` is
|
|
446
|
-
coerced) embedded into the bottom border
|
|
446
|
+
coerced) embedded into the bottom border row, mirroring the caption on
|
|
447
447
|
the top line. It draws at its own width with the border's dashes filling
|
|
448
448
|
the remainder, clipped to the inner width. Like the caption, it embeds
|
|
449
449
|
with **no added padding** — it butts straight against the left corner:
|
data/book/06-theming.md
CHANGED
|
@@ -121,7 +121,7 @@ For plain chrome — a border string, a status-bar hint — the theme's
|
|
|
121
121
|
right channel for the token's role (a `*_bg` token wraps as a background,
|
|
122
122
|
a hint as a foreground) and passes the content through verbatim, so the
|
|
123
123
|
string may already contain other escape sequences — which is how
|
|
124
|
-
{Tuile::Component::Window} feeds its whole border
|
|
124
|
+
{Tuile::Component::Window} feeds its whole border row, cursor moves and
|
|
125
125
|
all, through `active_border`.
|
|
126
126
|
|
|
127
127
|
But chrome text is flat. Content is not. A list row or a label may be a
|
data/book/07-components.md
CHANGED
|
@@ -219,11 +219,38 @@ read-only or required flag yet. Room left for that layer to grow into.
|
|
|
219
219
|
|
|
220
220
|
## Choosing from a set
|
|
221
221
|
|
|
222
|
-
{Tuile::Component::List} is the workhorse: a scrollable column of
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
222
|
+
{Tuile::Component::List} is the workhorse: a scrollable column of *items*
|
|
223
|
+
— objects of whatever type your app deals in — one row each. You give it
|
|
224
|
+
the items and a `renderer` that turns one item into a row, and it does the
|
|
225
|
+
rest: ellipsizing a row too wide for the viewport (spans preserved),
|
|
226
|
+
scrolling, and handing your callbacks back **the item itself** rather than
|
|
227
|
+
the text it drew for it.
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
list = Component::List.new
|
|
231
|
+
list.items = User.all
|
|
232
|
+
list.renderer = ->(u) { "#{u.name} #{u.email}" }
|
|
233
|
+
list.cursor = Component::List::Cursor.new
|
|
234
|
+
list.on_item_chosen = ->(_index, user) { open(user) }
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
That's the same bargain the value seam struck earlier in this chapter: the
|
|
238
|
+
component speaks in your objects, and nothing has to map a row of text
|
|
239
|
+
back to the thing it stood for. When your items *are* the text, skip the
|
|
240
|
+
renderer entirely — `list.lines = entries` takes strings (or
|
|
241
|
+
{Tuile::StyledString}s, or anything with a `to_s`), splits them on
|
|
242
|
+
newlines, and shows each as its own row.
|
|
243
|
+
|
|
244
|
+
The renderer runs when a row is *painted*, and only for the rows actually
|
|
245
|
+
on screen: a hundred-thousand-item list renders the twenty you can see.
|
|
246
|
+
That's what makes a long list cheap, and it comes with one rule — keep the
|
|
247
|
+
renderer a pure function of its item. It may be called on any frame, so it
|
|
248
|
+
is the wrong place to reach for a database; do that work when you build
|
|
249
|
+
the items.
|
|
250
|
+
|
|
251
|
+
What makes the list flexible beyond that is that its *cursor behavior is a
|
|
252
|
+
pluggable object* rather than a boolean. Assign one of three
|
|
253
|
+
{Tuile::Component::List::Cursor} variants to fit the interaction:
|
|
227
254
|
|
|
228
255
|
- **`Cursor::None`** (the default) — no cursor at all. The list is a
|
|
229
256
|
read-only scroll region: a log, a static report.
|
|
@@ -234,29 +261,24 @@ variants to fit the interaction:
|
|
|
234
261
|
lines. For a list where only some rows are selectable (headers
|
|
235
262
|
interspersed with items, say), it skips the rest.
|
|
236
263
|
|
|
237
|
-
Two callbacks cover the events you care about
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
(`scrollbar_visibility`).
|
|
246
|
-
|
|
247
|
-
```ruby
|
|
248
|
-
list = Component::List.new
|
|
249
|
-
list.lines = entries
|
|
250
|
-
list.cursor = Component::List::Cursor.new
|
|
251
|
-
list.on_item_chosen = ->(index, line) { open(entries[index]) }
|
|
252
|
-
```
|
|
264
|
+
Two callbacks cover the events you care about, and both are handed the
|
|
265
|
+
`(index, item)` pair. `on_item_chosen` fires when the user commits to the
|
|
266
|
+
cursor's row — Enter or a left-click — and is the "open this" signal.
|
|
267
|
+
`on_cursor_changed` fires when the highlighted row *changes*, which is
|
|
268
|
+
exactly what you wire to keep a details pane in sync with the selection.
|
|
269
|
+
For a tailing list — a live log — set `auto_scroll`; it pins to the bottom
|
|
270
|
+
as items arrive, but politely stops yanking you down the moment you scroll
|
|
271
|
+
up to read history, and resumes once you scroll back (`following?` tells
|
|
272
|
+
you which). A scrollbar is one assignment (`scrollbar_visibility`).
|
|
253
273
|
|
|
254
274
|
When the set is long and the user roughly knows what they want, a plain
|
|
255
275
|
list makes them scroll for it. {Tuile::Component::ComboBox} is the answer:
|
|
256
276
|
a text field with a dropdown that filters as you type. Hand it `items` (of
|
|
257
277
|
any type) and, when their `to_s` isn't what you want shown, an
|
|
258
278
|
`item_label` strategy to render each one; type to narrow, arrow to move,
|
|
259
|
-
Enter or click to accept.
|
|
279
|
+
Enter or click to accept. (The domain widgets all call that strategy
|
|
280
|
+
`item_label`, where a bare list calls it `renderer` — a label is text the
|
|
281
|
+
widget then decorates, a row is the whole rendering.) It's the value seam doing real work — its
|
|
260
282
|
`value` is the selected *item*, the object and not its label, so a combo
|
|
261
283
|
over `User`s hands back a `User`. The field's text is merely a transient
|
|
262
284
|
query: it reverts to the selection's label when you dismiss the dropdown,
|
|
@@ -356,10 +378,11 @@ coerced), and let the widget's own toggling build the new sets for you.
|
|
|
356
378
|
Here the cursor and the selection are genuinely two different things — the
|
|
357
379
|
cursor says *where you are*, the checkmarks say *what you picked* — and
|
|
358
380
|
that shape is exactly what a list already provides. So a checkbox group
|
|
359
|
-
doesn't paint rows itself; it holds a {Tuile::Component::List}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
381
|
+
doesn't paint rows itself; it holds a {Tuile::Component::List} of the
|
|
382
|
+
items, supplies the renderer that puts a `[x]` or `[ ]` in front of each
|
|
383
|
+
label, and gets the cursor, the scrolling, the scrollbar and the per-row
|
|
384
|
+
mouse handling for free, in the same "wrap a generic component to make a
|
|
385
|
+
domain one" way the combo box wraps a text field. That inheritance goes further than
|
|
363
386
|
convenience: a click anywhere on a row toggles it, and Enter toggles the
|
|
364
387
|
cursor's row, because those are the list's own gestures for choosing an
|
|
365
388
|
item.
|
|
@@ -592,7 +615,7 @@ popups are for.
|
|
|
592
615
|
|
|
593
616
|
The bottom border has two mutually exclusive uses, and the distinction is
|
|
594
617
|
the top-down-layout principle from chapter 3 made concrete. `footer_text=`
|
|
595
|
-
embeds decoration into the border
|
|
618
|
+
embeds decoration into the border row — chrome, mirroring the caption on
|
|
596
619
|
top, not focusable. `footer=` mounts a *real focusable component* spanning
|
|
597
620
|
the full inner width — the search-field-in-the-border case. A footer
|
|
598
621
|
component present takes the row and hides the text; neither drives the
|
|
@@ -639,6 +662,53 @@ A nested TextField still swallows printable keys first, so typing `q` into
|
|
|
639
662
|
a field inside a popup doesn't dismiss it — the popup's own `q` handler sits
|
|
640
663
|
on the ancestor, and only sees keys the field declined.
|
|
641
664
|
|
|
665
|
+
## Notifications
|
|
666
|
+
|
|
667
|
+
{Tuile::Component::Notification} is the one overlay you don't assemble at
|
|
668
|
+
all. It's the TTY toast: a message in the top-right corner that shows up,
|
|
669
|
+
holds for three seconds, and removes itself.
|
|
670
|
+
|
|
671
|
+
```ruby
|
|
672
|
+
Component::Notification.show("Saved")
|
|
673
|
+
Component::Notification.show("Disk almost full", color: Color::RED)
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
That class method is the *only* way in — `new` is private. The reason is
|
|
677
|
+
worth understanding, because it's the design in one line: there is never
|
|
678
|
+
more than one notification box on screen. `show` looks for the live one in
|
|
679
|
+
the popups stack and appends to it, so a burst of messages stacks as
|
|
680
|
+
entries inside a single frame:
|
|
681
|
+
|
|
682
|
+
```
|
|
683
|
+
┌──────────────┐
|
|
684
|
+
│Job 1 finished│ ← goes in 3 s
|
|
685
|
+
│Job 2 finished│ ← then this one
|
|
686
|
+
│Job 3 finished│
|
|
687
|
+
└──────────────┘
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
They then leave **one at a time**, oldest first, three seconds apart. This
|
|
691
|
+
is the interesting half of the design. Five notifications raised in the
|
|
692
|
+
same instant would, given five independent timers, appear and vanish
|
|
693
|
+
together — a flash you have no chance of reading. Draining them one per
|
|
694
|
+
tick means the burst takes fifteen seconds to clear and you read it in
|
|
695
|
+
peace. A message arriving mid-cycle just waits its turn rather than
|
|
696
|
+
restarting the clock, which is also what stops a steady trickle of
|
|
697
|
+
notifications from keeping the box alive forever.
|
|
698
|
+
|
|
699
|
+
Everything else follows from "a toast must not interrupt": it's a non-modal
|
|
700
|
+
popup, so it takes no focus, receives no keys (not even the `q` a normal
|
|
701
|
+
popup would claim), and blocks no click outside its own box. You keep
|
|
702
|
+
typing into whatever you were typing into, and the notification appears and
|
|
703
|
+
leaves around you. A left-click on the box dismisses the whole thing early.
|
|
704
|
+
|
|
705
|
+
Two limits are worth knowing before you reach them. A long message wraps to
|
|
706
|
+
at most three rows and is then ellipsized — the box is capped at 40 % of
|
|
707
|
+
the screen — and at most five messages are held, after which the newest is
|
|
708
|
+
dropped and reported to `Tuile.logger`. Both are deliberate: a notification
|
|
709
|
+
is a glance, not a document, and an app with more to say than five short
|
|
710
|
+
lines wants a LogWindow, which is next.
|
|
711
|
+
|
|
642
712
|
## Batteries-included windows
|
|
643
713
|
|
|
644
714
|
The last three components are conveniences: common Window-plus-content
|
data/examples/file_commander.rb
CHANGED
|
@@ -33,6 +33,7 @@ module FileCommanderExample
|
|
|
33
33
|
def initialize(start_dir)
|
|
34
34
|
super()
|
|
35
35
|
self.cursor = Tuile::Component::List::Cursor.new
|
|
36
|
+
self.renderer = ->(entry) { Rainbow(entry[:display]).color(TYPE_COLORS[entry[:type]]) }
|
|
36
37
|
@cwd = File.expand_path(start_dir)
|
|
37
38
|
@on_cwd_changed = nil
|
|
38
39
|
load_entries
|
|
@@ -60,8 +61,8 @@ module FileCommanderExample
|
|
|
60
61
|
|
|
61
62
|
private
|
|
62
63
|
|
|
63
|
-
def descend(_index,
|
|
64
|
-
target = File.expand_path(File.join(@cwd,
|
|
64
|
+
def descend(_index, entry)
|
|
65
|
+
target = File.expand_path(File.join(@cwd, entry[:name]))
|
|
65
66
|
change_to(target) if File.directory?(target)
|
|
66
67
|
end
|
|
67
68
|
|
|
@@ -75,7 +76,7 @@ module FileCommanderExample
|
|
|
75
76
|
@cwd = path
|
|
76
77
|
load_entries
|
|
77
78
|
self.cursor = Tuile::Component::List::Cursor.new
|
|
78
|
-
self.
|
|
79
|
+
self.scroll_top_row = 0
|
|
79
80
|
@on_cwd_changed&.call
|
|
80
81
|
rescue SystemCallError => e
|
|
81
82
|
@cwd = previous
|
|
@@ -89,7 +90,7 @@ module FileCommanderExample
|
|
|
89
90
|
{ name: name, type: classify(path), display: is_dir ? "#{name}/" : name, dir_first: is_dir ? 0 : 1 }
|
|
90
91
|
end
|
|
91
92
|
entries.sort_by! { |e| [e[:dir_first], e[:name].downcase] }
|
|
92
|
-
self.
|
|
93
|
+
self.items = entries
|
|
93
94
|
end
|
|
94
95
|
|
|
95
96
|
# Classify by symlink first so a symlink-to-dir still reads as a link.
|
data/examples/sampler.rb
CHANGED
|
@@ -134,6 +134,7 @@ module SamplerExample
|
|
|
134
134
|
["ProgressBar", :build_progress_bar],
|
|
135
135
|
["Background", :build_background],
|
|
136
136
|
["Layout", :build_layout],
|
|
137
|
+
["Notification", :build_notification_launcher],
|
|
137
138
|
["Popup", :build_popup_launcher],
|
|
138
139
|
["InfoWindow", :build_info_launcher],
|
|
139
140
|
["PickerWindow", :build_picker_launcher],
|
|
@@ -561,7 +562,7 @@ module SamplerExample
|
|
|
561
562
|
# The Set iterates in *toggle* order, so intersect with items to report
|
|
562
563
|
# it in the order the rows are shown — the documented idiom.
|
|
563
564
|
shown = (LOG_LEVELS & selected.to_a).map(&:label)
|
|
564
|
-
status.text = "value: {#{shown.join(", ")}} — #{log.
|
|
565
|
+
status.text = "value: {#{shown.join(", ")}} — #{log.items.size} of #{entries.size} lines"
|
|
565
566
|
end
|
|
566
567
|
refresh.call
|
|
567
568
|
group.on_value_change = ->(_set) { refresh.call }
|
|
@@ -812,6 +813,42 @@ module SamplerExample
|
|
|
812
813
|
|
|
813
814
|
# --- Modal launchers ---------------------------------------------------
|
|
814
815
|
|
|
816
|
+
# Four buttons, because the interesting things about a notification are all
|
|
817
|
+
# about *several* of them: one short toast shows the box hugging its content
|
|
818
|
+
# in the corner, a burst shows the stack draining one message every three
|
|
819
|
+
# seconds (and the grow-only width), and a long one shows the three-row wrap
|
|
820
|
+
# ending in an ellipsis. Focus stays on whichever button you pressed
|
|
821
|
+
# throughout — that is the whole point of the widget.
|
|
822
|
+
def build_notification_launcher
|
|
823
|
+
label = Tuile::Component::Label.new
|
|
824
|
+
label.text = "Notification.show puts a toast in the top-right corner for 3 seconds.\n" \
|
|
825
|
+
"It never takes focus; a left-click on the box dismisses it.\n" \
|
|
826
|
+
"Raise several and watch them drain one at a time."
|
|
827
|
+
counter = 0
|
|
828
|
+
buttons = [
|
|
829
|
+
Tuile::Component::Button.new("Short") { Tuile::Component::Notification.show("Saved") },
|
|
830
|
+
Tuile::Component::Button.new("Burst") do
|
|
831
|
+
5.times { Tuile::Component::Notification.show("Job #{counter += 1} finished") }
|
|
832
|
+
end,
|
|
833
|
+
Tuile::Component::Button.new("Long") do
|
|
834
|
+
Tuile::Component::Notification.show(
|
|
835
|
+
"Could not connect to the build server at 10.0.0.1: connection refused after " \
|
|
836
|
+
"three attempts, giving up and falling back to the local cache"
|
|
837
|
+
)
|
|
838
|
+
end,
|
|
839
|
+
Tuile::Component::Button.new("Colored") do
|
|
840
|
+
Tuile::Component::Notification.show("Disk almost full", color: Tuile::Color::RED)
|
|
841
|
+
end
|
|
842
|
+
]
|
|
843
|
+
strip = row do |r|
|
|
844
|
+
buttons.each { |b| r.add(b, Fixed[button_width(b)]) }
|
|
845
|
+
end
|
|
846
|
+
form do |f|
|
|
847
|
+
f.add(label, Fixed[3])
|
|
848
|
+
f.add(strip, Fixed[1])
|
|
849
|
+
end
|
|
850
|
+
end
|
|
851
|
+
|
|
815
852
|
def build_popup_launcher
|
|
816
853
|
launcher(
|
|
817
854
|
"Popup is a modal overlay wrapping any Component.\n" \
|
data/ideas/new-components.md
CHANGED
|
@@ -25,8 +25,9 @@ Seven of the 54 have a counterpart: Button, Text Field, Text Area,
|
|
|
25
25
|
Integer Field, Combo Box (1:1), Dialog ({Tuile::Component::Popup} plus
|
|
26
26
|
`Window`/`InfoWindow`/`PickerWindow`), Themable Mixin
|
|
27
27
|
({Tuile::Theme}/{Tuile::ThemeDef}). **List Box** is half-there:
|
|
28
|
-
{Tuile::Component::List}
|
|
29
|
-
multi-select
|
|
28
|
+
{Tuile::Component::List} takes typed items and a renderer since 2026-08-14
|
|
29
|
+
(`D-list-items`), but has no multi-select — {Tuile::Component::CheckboxGroup}
|
|
30
|
+
is the nearest thing.
|
|
30
31
|
|
|
31
32
|
Tuile also has pieces Vaadin doesn't name as components — `ListDropdown`,
|
|
32
33
|
`TextView`, `LogWindow`, `VerticalScrollBar` — so the gap is not
|
|
@@ -46,7 +47,7 @@ That leaves ~46 gaps.
|
|
|
46
47
|
| ~~Password Field~~ | `TextField` | **built** 2026-08-02 (`D-integer-field`'s taxonomy — subclass, since a password's value *is* its text; mask default in `D-ambiguous-width`); a `display_text` seam, one mask glyph per character |
|
|
47
48
|
| ~~Number Field~~ | `IntegerField` twin | **built** 2026-08-07 as `FloatField` (`D-float-field`) and `BigDecimalField` (`D-bigdecimal-field`, on Tuile's first optional dep); each named for its Ruby value type, deliberate copies of `IntegerField` |
|
|
48
49
|
| ~~Progress Bar~~ | `draw_line` + `EventQueue#tick_fps` | **built** 2026-08-02 (`D-progress-bar`, book ch7); a `value` that stays out of `HasValue`, ticker synced from `attached? && indeterminate?` |
|
|
49
|
-
| Notification | `Popup` + `Ticker` |
|
|
50
|
+
| ~~Notification~~ | `Popup` + `Ticker` | **built** 2026-08-17 (`D-notification`, book ch7); one non-modal top-right box, N messages, one 3 s ticker retiring the oldest. Corner anchor is its own `reposition` override, so `Popup` was untouched — and the `Popover` extraction still waits for a second *kind* of anchoring |
|
|
50
51
|
| Confirm Dialog | `Popup`+`Window`+`Button` | fold `PickerWindow` in |
|
|
51
52
|
| Details → Accordion | `HasContent` | Details is the atom, Accordion the group |
|
|
52
53
|
| Tabs → Tabsheet | `HasValue` (index) + `HasContent` | strip, then strip + content swap |
|
|
@@ -104,7 +105,11 @@ file when its cluster comes up:
|
|
|
104
105
|
5. **Mouse motion/drag** (modes 1002/1006, release events) → Split
|
|
105
106
|
divider, Slider drag, scrollbar drag.
|
|
106
107
|
6. **Typed items + data provider on `List`** → List Box, Grid, Virtual
|
|
107
|
-
List.
|
|
108
|
+
List. **Half done** 2026-08-14 (`D-list-items`): `List` takes `items` +
|
|
109
|
+
a `renderer` and renders only the visible rows, and the five composers
|
|
110
|
+
are folded onto it. The remaining half is *sourcing* items lazily (a
|
|
111
|
+
data provider behind `items`), which lazy rendering was chosen to keep
|
|
112
|
+
reachable without a redesign.
|
|
108
113
|
|
|
109
114
|
Vaadin's `Binder` is the natural companion for the forms cluster but is
|
|
110
115
|
not a component; `D-has-value` already parks the forms-layer questions
|
data/lib/tuile/buffer.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Tuile
|
|
4
4
|
# An in-memory grid of styled cells mirroring the terminal screen. This is
|
|
5
5
|
# the back buffer behind flicker-free rendering: components paint into it
|
|
6
|
-
# (via {#
|
|
6
|
+
# (via {#set_text} / {#set_char} / {#fill}) instead of writing escape
|
|
7
7
|
# sequences straight to the terminal, and {#flush} emits the minimal escape
|
|
8
8
|
# string needed to bring a terminal — one that already matches the buffer's
|
|
9
9
|
# state as of the previous flush — up to date. Only cells that actually
|
|
@@ -133,7 +133,7 @@ module Tuile
|
|
|
133
133
|
# @param x [Integer] column.
|
|
134
134
|
# @param y [Integer] row.
|
|
135
135
|
# @return [Cell, nil] the live cell at `(x, y)` (do not mutate — paint via
|
|
136
|
-
# {#set_char} / {#
|
|
136
|
+
# {#set_char} / {#set_text} so dirty tracking stays correct), or nil when
|
|
137
137
|
# out of bounds.
|
|
138
138
|
def cell(x, y)
|
|
139
139
|
return nil unless in_bounds?(x, y)
|
|
@@ -160,12 +160,12 @@ module Tuile
|
|
|
160
160
|
|
|
161
161
|
# Writes a {StyledString} starting at `(x, y)`, advancing by each grapheme's
|
|
162
162
|
# display width and clipping at the right edge. Newlines are not handled —
|
|
163
|
-
# pass one
|
|
163
|
+
# pass the text of one row.
|
|
164
164
|
# @param x [Integer] starting column.
|
|
165
165
|
# @param y [Integer] row.
|
|
166
166
|
# @param styled [StyledString]
|
|
167
167
|
# @return [void]
|
|
168
|
-
def
|
|
168
|
+
def set_text(x, y, styled)
|
|
169
169
|
col = x
|
|
170
170
|
styled.spans.each do |span|
|
|
171
171
|
span.text.grapheme_clusters.each do |g|
|
|
@@ -281,7 +281,7 @@ module Tuile
|
|
|
281
281
|
# @param y [Integer] row.
|
|
282
282
|
# @return [String] row `y` rendered to ANSI across its full width — the
|
|
283
283
|
# minimal-SGR encoding of its cells, equivalent to what a component's
|
|
284
|
-
# `
|
|
284
|
+
# `set_text` of the whole row would have printed. Intended for tests that
|
|
285
285
|
# assert on styled output (see {FakeScreen}); empty for an out-of-range row.
|
|
286
286
|
def row_ansi(y)
|
|
287
287
|
return "" unless y >= 0 && y < @height
|
|
@@ -304,7 +304,7 @@ module Tuile
|
|
|
304
304
|
|
|
305
305
|
# @param rect [Rect]
|
|
306
306
|
# @return [Array<String>] each row within `rect` rendered to ANSI, top to
|
|
307
|
-
# bottom — byte-identical to what a component's per-row `
|
|
307
|
+
# bottom — byte-identical to what a component's per-row `set_text` over
|
|
308
308
|
# that rect emitted. The region equivalent of {#row_ansi}. Intended for
|
|
309
309
|
# tests asserting styled output.
|
|
310
310
|
def region_ansi(rect)
|
|
@@ -316,7 +316,7 @@ module Tuile
|
|
|
316
316
|
private
|
|
317
317
|
|
|
318
318
|
# Core of {#set_char} with the grapheme's display width already known.
|
|
319
|
-
# {#
|
|
319
|
+
# {#set_text} computes each width once while advancing the column and passes
|
|
320
320
|
# it straight through, so the paint hot path measures every grapheme exactly
|
|
321
321
|
# once (and that once is a {.display_width} memo read). See {#set_char} for
|
|
322
322
|
# the wide-glyph / clipping / out-of-bounds contract.
|
|
@@ -76,7 +76,7 @@ module Tuile
|
|
|
76
76
|
|
|
77
77
|
label = (StyledString.plain("[ ") + caption + StyledString.plain(" ]")).ellipsize(rect.width)
|
|
78
78
|
label = label.with_bg(screen.theme.active_bg_color) if active?
|
|
79
|
-
|
|
79
|
+
draw_text(rect.left, rect.top, label)
|
|
80
80
|
end
|
|
81
81
|
end
|
|
82
82
|
end
|
|
@@ -127,7 +127,7 @@ module Tuile
|
|
|
127
127
|
|
|
128
128
|
label = (StyledString.plain(value ? "[x] " : "[ ] ") + caption).ellipsize(rect.width)
|
|
129
129
|
label = label.with_bg(screen.theme.active_bg_color) if active?
|
|
130
|
-
|
|
130
|
+
draw_text(rect.left, rect.top, label)
|
|
131
131
|
end
|
|
132
132
|
end
|
|
133
133
|
end
|