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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/DECISIONS.md +680 -8
  4. data/README.md +12 -13
  5. data/TERMINOLOGY.md +61 -0
  6. data/book/02-repaint.md +1 -1
  7. data/book/03-layout.md +1 -1
  8. data/book/06-theming.md +1 -1
  9. data/book/07-components.md +97 -27
  10. data/examples/file_commander.rb +5 -4
  11. data/examples/sampler.rb +38 -1
  12. data/ideas/new-components.md +9 -4
  13. data/lib/tuile/buffer.rb +7 -7
  14. data/lib/tuile/component/button.rb +1 -1
  15. data/lib/tuile/component/checkbox.rb +1 -1
  16. data/lib/tuile/component/checkbox_group.rb +31 -26
  17. data/lib/tuile/component/combo_box.rb +10 -7
  18. data/lib/tuile/component/info_window.rb +1 -1
  19. data/lib/tuile/component/label.rb +14 -14
  20. data/lib/tuile/component/list.rb +291 -216
  21. data/lib/tuile/component/list_dropdown.rb +14 -7
  22. data/lib/tuile/component/notification.rb +317 -0
  23. data/lib/tuile/component/picker_window.rb +3 -3
  24. data/lib/tuile/component/popup.rb +8 -10
  25. data/lib/tuile/component/progress_bar.rb +1 -1
  26. data/lib/tuile/component/radio_group.rb +32 -30
  27. data/lib/tuile/component/select.rb +7 -7
  28. data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
  29. data/lib/tuile/component/text_area.rb +79 -273
  30. data/lib/tuile/component/text_field.rb +1 -1
  31. data/lib/tuile/component/text_view.rb +191 -177
  32. data/lib/tuile/component/window.rb +8 -8
  33. data/lib/tuile/component.rb +5 -5
  34. data/lib/tuile/screen.rb +1 -1
  35. data/lib/tuile/styled_string.rb +12 -12
  36. data/lib/tuile/version.rb +1 -1
  37. data/lib/tuile/vertical_scroll_bar.rb +6 -6
  38. data/sig/tuile.rbs +788 -377
  39. 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 `set_line` / `fill` /
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=`, `add_line`, `invalidate`,
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` / `TextView#text`
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 strings with optional cursor and scrollbar.
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, line) { Tuile.logger.info("picked #{line}") }
488
- list.auto_scroll = true # auto-scroll to bottom on add_line
489
- list.add_line("delta")
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: `lines=`, `add_line`, `add_lines`, `cursor=`, `top_line=`,
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.open(content: window, size: Tuile::Fraction::HALF)
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.set_line(x, y, styled_string) # a run of text
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 line, mirroring the caption on
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 line, cursor moves and
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
@@ -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
- {Tuile::StyledString} lines, ellipsized (spans preserved) when too wide.
224
- What makes it flexible is that its *cursor behavior is a pluggable object*
225
- rather than a boolean. Assign one of three {Tuile::Component::List::Cursor}
226
- variants to fit the interaction:
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. `on_item_chosen` fires when
238
- the user commits to the cursor's row — Enter or a left-click — and is the
239
- "open this" signal. `on_cursor_changed` fires when the highlighted row
240
- *changes*, which is exactly what you wire to keep a details pane in sync
241
- with the selection. For a tailing list — a live log — set `auto_scroll`;
242
- it pins to the bottom as lines arrive, but politely stops yanking you down
243
- the moment you scroll up to read history, and resumes once you scroll back
244
- (`following?` tells you which). A scrollbar is one assignment
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. It's the value seam doing real work — its
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} and gets the
360
- cursor, the scrolling, the scrollbar and the per-row mouse handling for
361
- free, in the same "wrap a generic component to make a domain one" way the
362
- combo box wraps a text field. That inheritance goes further than
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 line — chrome, mirroring the caption on
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
@@ -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, line)
64
- target = File.expand_path(File.join(@cwd, Rainbow.uncolor(line).chomp("/")))
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.top_line = 0
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.lines = entries.map { |e| Rainbow(e[:display]).color(TYPE_COLORS[e[:type]]) }
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.lines.size} of #{entries.size} lines"
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" \
@@ -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} is line-based, with no typed items and no
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` | needs corner-anchored (non-centered) popup placement |
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 {#set_line} / {#set_char} / {#fill}) instead of writing escape
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} / {#set_line} so dirty tracking stays correct), or nil when
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 physical line.
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 set_line(x, y, styled)
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
- # `set_line` of the whole row would have printed. Intended for tests that
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 `set_line` over
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
- # {#set_line} computes each width once while advancing the column and passes
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
- draw_line(rect.left, rect.top, label)
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
- draw_line(rect.left, rect.top, label)
130
+ draw_text(rect.left, rect.top, label)
131
131
  end
132
132
  end
133
133
  end