tuile 0.10.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +109 -64
  3. data/DECISIONS.md +1299 -22
  4. data/README.md +18 -13
  5. data/TERMINOLOGY.md +61 -0
  6. data/book/02-repaint.md +1 -1
  7. data/book/03-layout.md +154 -9
  8. data/book/05-focus.md +2 -0
  9. data/book/06-theming.md +1 -1
  10. data/book/07-components.md +202 -37
  11. data/book/README.md +3 -1
  12. data/examples/file_commander.rb +5 -4
  13. data/examples/sampler.rb +320 -133
  14. data/ideas/arrow-key-navigation.md +205 -0
  15. data/ideas/new-components.md +26 -12
  16. data/lib/tuile/buffer.rb +7 -7
  17. data/lib/tuile/component/big_decimal_field.rb +199 -0
  18. data/lib/tuile/component/button.rb +1 -1
  19. data/lib/tuile/component/checkbox.rb +11 -10
  20. data/lib/tuile/component/checkbox_group.rb +31 -26
  21. data/lib/tuile/component/combo_box.rb +18 -33
  22. data/lib/tuile/component/float_field.rb +161 -0
  23. data/lib/tuile/component/info_window.rb +1 -1
  24. data/lib/tuile/component/label.rb +14 -14
  25. data/lib/tuile/component/layout/box.rb +316 -0
  26. data/lib/tuile/component/layout/horizontal.rb +40 -0
  27. data/lib/tuile/component/layout/vertical.rb +41 -0
  28. data/lib/tuile/component/layout.rb +149 -1
  29. data/lib/tuile/component/list.rb +291 -216
  30. data/lib/tuile/component/list_dropdown.rb +82 -24
  31. data/lib/tuile/component/notification.rb +317 -0
  32. data/lib/tuile/component/picker_window.rb +3 -3
  33. data/lib/tuile/component/popup.rb +8 -10
  34. data/lib/tuile/component/progress_bar.rb +1 -1
  35. data/lib/tuile/component/radio_group.rb +32 -30
  36. data/lib/tuile/component/select.rb +251 -0
  37. data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
  38. data/lib/tuile/component/text_area.rb +79 -273
  39. data/lib/tuile/component/text_field.rb +1 -1
  40. data/lib/tuile/component/text_view.rb +191 -177
  41. data/lib/tuile/component/window.rb +8 -8
  42. data/lib/tuile/component.rb +5 -5
  43. data/lib/tuile/screen.rb +1 -1
  44. data/lib/tuile/styled_string.rb +25 -15
  45. data/lib/tuile/version.rb +1 -1
  46. data/lib/tuile/vertical_scroll_bar.rb +6 -6
  47. data/lib/tuile.rb +4 -0
  48. data/sig/tuile.rbs +1670 -406
  49. metadata +11 -1
data/README.md CHANGED
@@ -49,6 +49,12 @@ gem "tuile", git: "https://github.com/mvysny/tuile.git"
49
49
 
50
50
  Tuile requires Ruby 3.3+.
51
51
 
52
+ One component — `Component::BigDecimalField` — additionally needs the
53
+ `bigdecimal` gem, which Tuile deliberately does *not* depend on (it has been a
54
+ bundled gem since Ruby 3.4, so Bundler no longer puts it on the load path for
55
+ free). Add `gem "bigdecimal"` to your Gemfile if you use that field; nothing
56
+ else in Tuile loads it.
57
+
52
58
  ## Documentation
53
59
 
54
60
  - **[The Tuile guide](book/README.md)** teaches Tuile cover to cover — the
@@ -126,7 +132,7 @@ bytes sent to the terminal. There is no clipping in between.
126
132
  `cursor_position` (e.g. into a focused text field).
127
133
 
128
134
  Components never write escape sequences to the terminal. They paint styled
129
- cells into a back buffer (`Tuile::Buffer`) via `set_line` / `fill` /
135
+ cells into a back buffer (`Tuile::Buffer`) via `set_text` / `fill` /
130
136
  `set_char`. When the pass finishes, `Buffer#flush` emits the **minimal diff**
131
137
  — only the cells that actually changed since the last flush — wrapped in one
132
138
  synchronized-output batch. That is what keeps repaint flicker-free on any
@@ -145,7 +151,7 @@ of its own and positions its children within its rect.
145
151
 
146
152
  `Tuile::Screen#run_event_loop` reads keys and mouse events on a worker thread,
147
153
  funnels them through `Tuile::EventQueue`, and processes them on the main
148
- thread. **All** UI mutations — `rect=`, `content=`, `add_line`, `invalidate`,
154
+ thread. **All** UI mutations — `rect=`, `content=`, `items=`, `invalidate`,
149
155
  `screen.focused=` — must run on that thread. Most UI methods will raise
150
156
  `"UI lock not held"` if you violate this.
151
157
 
@@ -380,7 +386,7 @@ Tuile::ThemeDef.default = APP_THEME # every Screen.fake now carries it
380
386
 
381
387
  Built-in components read `screen.theme` at paint time, so their accents
382
388
  restyle automatically. Content you rendered yourself does not: a
383
- `StyledString` stored in `Label#text` / `List#lines` / `TextView#text`
389
+ `StyledString` stored in `Label#text` / `List#lines=` / `TextView#text`
384
390
  has its colors baked in at construction, and only your app knows which of
385
391
  those were theme-derived (as opposed to inherent to the data — log-level
386
392
  colors, say). `Component#on_theme_changed` fires on every attached
@@ -472,15 +478,17 @@ focusable; focus delegates to content (or footer when active).
472
478
 
473
479
  ### `Component::List`
474
480
 
475
- 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.
476
484
 
477
485
  ```ruby
478
486
  list = Tuile::Component::List.new
479
487
  list.lines = ["alpha", "beta", "gamma"]
480
488
  list.cursor = Tuile::Component::List::Cursor.new
481
- list.on_item_chosen = ->(index, line) { Tuile.logger.info("picked #{line}") }
482
- list.auto_scroll = true # auto-scroll to bottom on add_line
483
- 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
484
492
  ```
485
493
 
486
494
  Cursor variants:
@@ -493,8 +501,8 @@ Cursor variants:
493
501
 
494
502
  Pressing Enter or left-clicking an item fires `on_item_chosen(index, line)`.
495
503
 
496
- Key API: `lines=`, `add_line`, `add_lines`, `cursor=`, `top_line=`,
497
- `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`,
498
506
  `select_next` / `select_prev` (search).
499
507
 
500
508
  ### `Component::TextField`
@@ -528,10 +536,7 @@ drawn on top of the tiled content; multiple popups stack.
528
536
  ```ruby
529
537
  window = Tuile::Component::Window.new("Help")
530
538
  window.content = help_list
531
- Tuile::Component::Popup.open(content: window, size: Tuile::Fraction::HALF)
532
- # or, equivalently:
533
- popup = Tuile::Component::Popup.new(content: window)
534
- popup.open
539
+ popup = Tuile::Component::Popup.new(content: window, size: Tuile::Fraction::HALF).open
535
540
  # popup.close, popup.open?
536
541
  ```
537
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
@@ -125,6 +125,15 @@ complex TUIs — tmux, neovim's splits, k9s, lazygit, htop — are all
125
125
  them needs flex grow/shrink/wrap/basis or a constraint solve. The
126
126
  hardest real terminal UIs already live comfortably inside "simple."
127
127
 
128
+ Be precise about what that validates, though: it's TUI *app architecture*,
129
+ not TUI *framework feature lists*. Several terminal frameworks do ship a
130
+ full engine — Textual has CSS, Ink embeds Yoga (the flexbox engine React
131
+ Native uses), ratatui runs a real Cassowary solver. The reason isn't that
132
+ terminals need one; it's that those frameworks never hand you a rectangle,
133
+ so an engine is the only way their users can lay anything out. Tuile hands
134
+ you coordinates, which is what makes richer layout *optional* here —
135
+ available where it helps, declinable everywhere else.
136
+
128
137
  It's stronger than "simple happens to work," though. Importing a CSS-like
129
138
  system would be *actively worse* on a terminal, for three concrete
130
139
  reasons:
@@ -204,6 +213,146 @@ That's the whole "responsive" story: plain Ruby, recomputed on a
204
213
  discrete resize event. No breakpoint DSL, no media queries — just the
205
214
  arithmetic you'd write anyway.
206
215
 
216
+ ## Stacks without the arithmetic: `Vertical` and `Horizontal`
217
+
218
+ `Absolute` is the right tool for genuinely two-dimensional geometry, and
219
+ tedious for the most common shape in any app: a stack. So Tuile ships two
220
+ *box* layouts that do that arithmetic for you. You declare what extent each
221
+ child should get, and the box hands down rectangles through the very same
222
+ `rect=`:
223
+
224
+ ```ruby
225
+ form = Tuile::Component::Layout::Vertical.new(spacing: 1)
226
+ form.add(prompt, Tuile::Component::Layout::Fixed[3]) # 3 rows
227
+ form.add(field, Tuile::Component::Layout::Fixed[1]) # 1 row
228
+ form.add(log, Tuile::Component::Layout::Expand[1]) # …all that's left
229
+ ```
230
+
231
+ `Horizontal` is the same with the axes swapped — the constraint is a width,
232
+ and `Expand` claims the rest of the row:
233
+
234
+ ```ruby
235
+ split = Tuile::Component::Layout::Horizontal.new
236
+ split.add(sidebar, Tuile::Component::Layout::Fixed[30])
237
+ split.add(main, Tuile::Component::Layout::Expand[1])
238
+ ```
239
+
240
+ Inside a subclass the constraint names need no prefix, since they live on
241
+ `Layout`, an ancestor:
242
+
243
+ ```ruby
244
+ class LoginForm < Tuile::Component::Layout::Vertical
245
+ def initialize
246
+ super(spacing: 1, padding: Insets[top: 1])
247
+ add(@user = Tuile::Component::TextField.new, Fixed[1], cross: Fixed[30])
248
+ add(@log = Tuile::Component::TextView.new, Expand[1])
249
+ end
250
+ end
251
+ ```
252
+
253
+ ### The three constraints
254
+
255
+ - **`Fixed[n]`** — exactly `n` cells, clamped to what's still unassigned.
256
+ - **`Percent[n]`** — `n`% of the space *available*, measured after padding
257
+ and the gaps between children come off. So two `Percent[50]` children fit
258
+ exactly instead of overflowing by the gap between them.
259
+ - **`Expand[weight]`** — a share of whatever is left once the `Fixed` and
260
+ `Percent` children have taken theirs, split in proportion to the weights.
261
+
262
+ That's the entire vocabulary, and the omission is the point: **there is no
263
+ `Auto`.** Nothing asks a child how big it would like to be. This is the same
264
+ rule as the rest of the chapter, wearing a friendlier face.
265
+
266
+ Two more knobs, both on the box rather than on each child: `spacing:` (blank
267
+ cells between adjacent children) and `padding:` (an inset from the box's own
268
+ rect — `Insets[top: 1, left: 2]`, or a plain integer for all four edges).
269
+
270
+ ### The cross axis, and alignment
271
+
272
+ Each child also gets a `cross:` constraint — its width in a `Vertical`, its
273
+ height in a `Horizontal`. It defaults to `Percent[100]`, so children fill the
274
+ box across the axis, which is usually what you want. Narrow one when it isn't:
275
+
276
+ ```ruby
277
+ form.add(field, Fixed[1], cross: Fixed[30]) # 30 columns
278
+ form.add(title, Fixed[1], cross: Percent[50], align: :center)
279
+ ```
280
+
281
+ `align:` is `:start`, `:center` or `:end` — axis-agnostic on purpose, since
282
+ `:start` means the left edge in a `Vertical` and the top edge in a
283
+ `Horizontal`. It does something only when the child is narrower than the
284
+ space available.
285
+
286
+ Alignment might look like it contradicts the top-down rule — surely centering
287
+ needs to know how wide the child is? It doesn't. It needs *a* width, and the
288
+ `cross:` constraint is where that width came from. Nothing gets measured.
289
+ (`Expand` is main-axis only for a related reason: across the axis a child has
290
+ no siblings to compete with, so a weight would have nothing to mean. Passing
291
+ one as `cross:` raises.)
292
+
293
+ ### Packing, starving, and remainders
294
+
295
+ Three behaviours worth knowing, because they are what you get *instead of* a
296
+ solver:
297
+
298
+ **Children pack from the start edge.** With no `Expand` among them the slack
299
+ is simply left at the end — there's no invisible filler to add, the way
300
+ Swing's `BoxLayout` needs glue.
301
+
302
+ **Over-subscription starves rather than raising.** If the children ask for
303
+ more than there is, they're satisfied in declaration order and whoever is
304
+ left over gets an empty rect — which, as chapter 2 established, paints
305
+ nothing. A pane too short for its content degrades quietly instead of
306
+ throwing or spilling outside its rect.
307
+
308
+ **A remainder goes to the earliest `Expand` children, one cell each.** Five
309
+ equal `Expand`s in 12 rows get `3, 3, 2, 2, 2` — never `2, 2, 2, 2, 4`, which
310
+ is what "give the leftover to the last one" produces. On a character grid a
311
+ doubled pane is plainly visible, so spare cells are spread rather than dumped.
312
+ One wrinkle, since this chapter showed you the hand-written version first: the
313
+ two-pane `Absolute` example above gives the odd column to the *right* pane,
314
+ while two `Expand[1]` children give it to the *left*. Both are deterministic;
315
+ they're just different code.
316
+
317
+ ### Varying the gap: nest, don't configure
318
+
319
+ `spacing` belongs to the box rather than to individual children, deliberately.
320
+ A gap sits *between* two children, so "whose gap is it?" has no good answer —
321
+ and both possible conventions confuse readers.
322
+
323
+ When you want tighter grouping, nest a box. A `spacing: 0` stack inside a
324
+ `spacing: 1` stack keeps two rows flush while the rest of the form breathes:
325
+
326
+ ```ruby
327
+ pair = Tuile::Component::Layout::Vertical.new # spacing: 0
328
+ pair.add(bar, Fixed[1])
329
+ pair.add(caption, Fixed[1]) # flush under the bar
330
+
331
+ form = Tuile::Component::Layout::Vertical.new(spacing: 1)
332
+ form.add(prompt, Fixed[4])
333
+ form.add(pair, Fixed[2]) # blank row around the pair
334
+ ```
335
+
336
+ That *states* the grouping instead of faking it with a per-child gap — boxes
337
+ within boxes, which is how the rest of Tuile composes anyway.
338
+
339
+ ### When to stay with `Absolute`
340
+
341
+ The boxes are sugar, not a replacement, and they can't say everything. A **cap
342
+ on a proportion** is the case to recognise:
343
+
344
+ ```ruby
345
+ list_width = (rect.width / 3).clamp(20, 40) # a third, but never <20 or >40
346
+ group_width = [16, rect.width / 3].min # a third, but never more than 16
347
+ ```
348
+
349
+ Both of these are in `examples/sampler.rb`, and both keep a `rect=`
350
+ override. That's the intended division of labour rather than a gap to work
351
+ around: use a box for the stack, drop to `Absolute` for the region that
352
+ genuinely needs arithmetic — usually nesting one inside the other, so only the
353
+ awkward part carries any. The sampler does exactly that, and porting it to
354
+ these layouts took it from 59 hand-written rectangles down to 7.
355
+
207
356
  ## Geometry: `Point`, `Size`, `Rect`
208
357
 
209
358
  The values you compute with are three small frozen types
@@ -294,7 +443,7 @@ A `Window`'s bottom border can carry one of two things, and they are
294
443
  genuinely different jobs, so they are two different members.
295
444
 
296
445
  **`footer_text=`** is *border chrome* — a styled string (a `String` is
297
- coerced) embedded into the bottom border line, mirroring the caption on
446
+ coerced) embedded into the bottom border row, mirroring the caption on
298
447
  the top line. It draws at its own width with the border's dashes filling
299
448
  the remainder, clipped to the inner width. Like the caption, it embeds
300
449
  with **no added padding** — it butts straight against the left corner:
@@ -369,11 +518,7 @@ own code and set the size top-down. Keep measurement opt-in and
369
518
  caller-side; the moment the framework starts consulting children for
370
519
  sizes automatically, it's on the road back to the constraint solver.
371
520
 
372
- And if you find yourself building many dynamic, user-draggable splits by
373
- hand and wishing for a `Layout.vertical([Length(3), Fill(1), …])`
374
- convenience — that's a known, *deliberately deferred* addition. It would
375
- be pure sugar: a rect producer running a small greedy 1-D pass and
376
- feeding results to the very same `rect=` setter you already use, with no
377
- change to the foundation. Absolute-first is the base; a descriptive
378
- split layer is an optional convenience on top, added if and when the
379
- convenience pays for itself.
521
+ Note that the box layouts above are not an exception to any of this. They
522
+ compute rectangles *for* you, but they compute them from constraints you
523
+ supplied, and they hand them down through the same `rect=`. No child is ever
524
+ consulted.
data/book/05-focus.md CHANGED
@@ -159,6 +159,8 @@ The same mechanism gives you a form's default button, one form per popup:
159
159
  | `TextField` with an `on_enter` | consumes it | no double-submit |
160
160
  | `TextField` without one | declines | bubbles up → submit |
161
161
  | `Button` | consumes it | activates *itself*, not the default |
162
+ | `Checkbox` | consumes it (toggles) | the form never sees it |
163
+ | `Select` | consumes it (opens, then commits) | the form never sees it |
162
164
 
163
165
  Because bubbling stops at the scope root, two forms in two popups each get
164
166
  their own Enter — something a global registry structurally cannot do. This
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