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
@@ -175,7 +175,25 @@ qty.on_value_change = ->(n) { recompute(n) } # n is an Integer, or nil
175
175
  qty.value = 3
176
176
  ```
177
177
 
178
- Both the combo box and the integer field are built the same way, and it's
178
+ {Tuile::Component::FloatField} is the same field one type over — it also
179
+ accepts a single decimal point, and its value is a `Float`. That naming is
180
+ a small rule worth knowing, because it tells you what you're getting: a
181
+ typed field is named after the Ruby class of its value, so `IntegerField`
182
+ hands back an `Integer` and `FloatField` a `Float` — a binary double, which
183
+ makes it exactly the wrong field for money. It parses generously while you
184
+ type: a buffer of `1.` already reads as `1.0`, so reaching for the decimal
185
+ point doesn't blink the value to `nil` and back in the listener you wired.
186
+
187
+ Money gets {Tuile::Component::BigDecimalField}, the same field once more with
188
+ an exact decimal inside — type `0.1` and it is `0.1`, not the `0.1000…0055`
189
+ a binary double stores. It is strict about how that exactness is preserved:
190
+ assigning a `Float` raises rather than quietly converting, because by the time
191
+ `19.99` reaches the setter it is already not `19.99`. This is the one
192
+ component with a dependency Tuile itself doesn't carry — `bigdecimal` has
193
+ been a *bundled* gem since Ruby 3.4, so an app that uses this field names it
194
+ in its own `Gemfile`, and an app that doesn't never loads it.
195
+
196
+ The combo box and the two numeric fields are built the same way, and it's
179
197
  worth seeing why: each *wraps* a text field rather than *being* one. A
180
198
  subclass would inherit the text field's `String`-typed value and wear it
181
199
  on its face right next to the real typed one — two conflicting answers to
@@ -201,11 +219,38 @@ read-only or required flag yet. Room left for that layer to grow into.
201
219
 
202
220
  ## Choosing from a set
203
221
 
204
- {Tuile::Component::List} is the workhorse: a scrollable column of
205
- {Tuile::StyledString} lines, ellipsized (spans preserved) when too wide.
206
- What makes it flexible is that its *cursor behavior is a pluggable object*
207
- rather than a boolean. Assign one of three {Tuile::Component::List::Cursor}
208
- 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:
209
254
 
210
255
  - **`Cursor::None`** (the default) — no cursor at all. The list is a
211
256
  read-only scroll region: a log, a static report.
@@ -216,29 +261,24 @@ variants to fit the interaction:
216
261
  lines. For a list where only some rows are selectable (headers
217
262
  interspersed with items, say), it skips the rest.
218
263
 
219
- Two callbacks cover the events you care about. `on_item_chosen` fires when
220
- the user commits to the cursor's row — Enter or a left-click — and is the
221
- "open this" signal. `on_cursor_changed` fires when the highlighted row
222
- *changes*, which is exactly what you wire to keep a details pane in sync
223
- with the selection. For a tailing list — a live log — set `auto_scroll`;
224
- it pins to the bottom as lines arrive, but politely stops yanking you down
225
- the moment you scroll up to read history, and resumes once you scroll back
226
- (`following?` tells you which). A scrollbar is one assignment
227
- (`scrollbar_visibility`).
228
-
229
- ```ruby
230
- list = Component::List.new
231
- list.lines = entries
232
- list.cursor = Component::List::Cursor.new
233
- list.on_item_chosen = ->(index, line) { open(entries[index]) }
234
- ```
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`).
235
273
 
236
274
  When the set is long and the user roughly knows what they want, a plain
237
275
  list makes them scroll for it. {Tuile::Component::ComboBox} is the answer:
238
276
  a text field with a dropdown that filters as you type. Hand it `items` (of
239
277
  any type) and, when their `to_s` isn't what you want shown, an
240
278
  `item_label` strategy to render each one; type to narrow, arrow to move,
241
- 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
242
282
  `value` is the selected *item*, the object and not its label, so a combo
243
283
  over `User`s hands back a `User`. The field's text is merely a transient
244
284
  query: it reverts to the selection's label when you dismiss the dropdown,
@@ -272,15 +312,22 @@ cb.toggle # unchecks it, firing the listener with false
272
312
 
273
313
  Two of its choices are worth understanding, because they're really
274
314
  statements about how Tuile widgets behave in general. The first: **Enter
275
- does nothing.** A checkbox has no action to confirm — Space is the native
276
- gesture for flipping one — so Enter is left unhandled, and by chapter 5's
277
- rules it bubbles up to an ancestor. It's tempting to read that as a
278
- guarantee, as though the framework kept Enter clear for a form's
279
- submit button. It doesn't, and chapter 5's table shows why: a text area
280
- claims Enter for a newline, a button claims it to activate itself. Whether
281
- Enter reaches your form depends on the widget that has focus. A checkbox
282
- declines it because it has nothing to do with it — which is a fact about
283
- this widget, not a promise about all of them.
315
+ toggles it, just as Space does.** Space is the native gesture for flipping
316
+ a checkbox, and for a while Enter was deliberately left alone — a checkbox
317
+ has no action to confirm. What settled it is the checkbox group further
318
+ down this chapter: a checkable row inside a list flips on Enter, because
319
+ Enter is how a list chooses the row under its cursor. Had the standalone
320
+ widget stayed silent, the same `[ ] Verbose` would have responded to Enter
321
+ in a group and ignored it in a form, which is a distinction the person at
322
+ the keyboard has no way to see.
323
+
324
+ The consequence is worth stating plainly, because it's the general rule
325
+ hiding behind the specific choice: a focused checkbox *consumes* Enter, so
326
+ a form's submit button on an ancestor won't see it. That's not a
327
+ regression from some guarantee — the framework never kept Enter clear, and
328
+ chapter 5's table shows why it can't: a text area claims Enter for a
329
+ newline, a button claims it to activate itself. Whether Enter reaches your
330
+ form always depends on the widget that has focus.
284
331
 
285
332
  The second is about *where the widget actually is*. A form column will
286
333
  happily hand a checkbox forty columns for a caption that needs twenty-two,
@@ -331,10 +378,11 @@ coerced), and let the widget's own toggling build the new sets for you.
331
378
  Here the cursor and the selection are genuinely two different things — the
332
379
  cursor says *where you are*, the checkmarks say *what you picked* — and
333
380
  that shape is exactly what a list already provides. So a checkbox group
334
- doesn't paint rows itself; it holds a {Tuile::Component::List} and gets the
335
- cursor, the scrolling, the scrollbar and the per-row mouse handling for
336
- free, in the same "wrap a generic component to make a domain one" way the
337
- 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
338
386
  convenience: a click anywhere on a row toggles it, and Enter toggles the
339
387
  cursor's row, because those are the list's own gestures for choosing an
340
388
  item.
@@ -412,6 +460,76 @@ one cell, and keeps the set of characters riding on that bet small enough
412
460
  to enumerate, so a new widget reaches for ASCII and offers the pretty
413
461
  glyph only where someone can opt in knowing their terminal.
414
462
 
463
+ A radio group spends a row per option, permanently. When the form has six
464
+ of these and a terminal has twenty-four rows, that arithmetic stops
465
+ working, and {Tuile::Component::Select} is the same single answer on *one*
466
+ row: the selected label plus a `▾`, with the options appearing only while
467
+ you're choosing between them.
468
+
469
+ ```ruby
470
+ level = Component::Select.new(items: %w[debug info warn error], value: "warn")
471
+ level.on_value_change = ->(l) { logger.level = l }
472
+ ```
473
+
474
+ Enter, Space or Down opens the dropdown, the arrows move the highlight,
475
+ Enter or Space commits, ESC closes it having changed nothing. `value` is
476
+ the selected item as always, `nil` while nothing is selected — and that
477
+ `nil` is a perfectly ordinary state here, which is why there's no
478
+ placeholder text: an optional enum field simply shows a blank face.
479
+
480
+ So when do you reach for which? The temptation is to decide by item count,
481
+ and that's the wrong axis. Ask instead **who wrote the labels**:
482
+
483
+ | The options are… | Widget | Why |
484
+ |---|---|---|
485
+ | a developer-authored enum, on one form row | `Select` | one row; borrows *n* transiently |
486
+ | the same enum, worth comparing side by side | `RadioGroup` | spends *n* rows permanently |
487
+ | supplied by the app, open-ended, labels you don't control | `ComboBox` | filtering *is* the navigation |
488
+ | an enum, several of which apply | `CheckboxGroup` | a frozen `Set` value |
489
+
490
+ A select is for a closed set you knew when you wrote the code — log level,
491
+ sort order, line endings, Yes/No/Ask. A combo box is for countries, users,
492
+ branches: data. A twelve-value enum is still a select, and a three-row
493
+ country list loaded from a database is still a combo box, because next
494
+ release it's two hundred rows and the widget you chose shouldn't have to
495
+ change. Count is a symptom; authorship is the criterion.
496
+
497
+ Which brings up the property that really separates the two, and it's not
498
+ the filtering. **A select claims no printable key but Space.** Every other
499
+ letter and digit bubbles straight past it, up the focus chain, to your
500
+ application — so a form's `s`-to-save, or a layout's `1`/`2`/`3` jumps
501
+ between panes, keep working while focus sits in a select. A combo box can
502
+ never offer that: its field must eat every printable, because every
503
+ printable is potentially part of the query. Add the fact that a select has
504
+ no caret, and the two together are the whole case for the component. A
505
+ caret is the strongest promise a terminal can make about what a widget
506
+ does, and spending it on "you may type free text here" over a four-value
507
+ enum is a lie the user then has to discover.
508
+
509
+ Space is the one exception, and it's a safe one precisely because Space was
510
+ never yours to begin with: every activatable widget in Tuile already claims
511
+ it — a button, a checkbox, a radio group. Home and End, by contrast, are
512
+ declined, so they stay available for you to bind app-wide.
513
+
514
+ You may be waiting for type-ahead — press `f` and jump to the first item
515
+ starting with `f`, the way desktop lists do. It isn't there, deliberately.
516
+ The single-key version is silently wrong: with Finland, Fiji and Jamaica in
517
+ the list, typing `fij` selects *Jamaica*, because each key is a fresh
518
+ one-character match. The fix everyone reaches for next is a small
519
+ accumulating buffer that clears after a second of idleness — and that
520
+ buffer *is* the combo box's query with the display removed. If you're
521
+ holding query state, showing it is strictly better than hiding it, and
522
+ showing it is a combo box. On a terminal it's worse still: the timer leans
523
+ on inter-keystroke gaps, and gaps are exactly what a laggy SSH link or a
524
+ paste destroys.
525
+
526
+ One small nicety worth noticing: the dropdown is never narrower than the
527
+ select itself, and grows past it when a label needs the room — so its edges
528
+ line up with the face you clicked, and the labels are never the thing that
529
+ gets ellipsized. It opens below the select, flips above near the bottom of
530
+ the screen, slides left rather than running off the right edge, and grows a
531
+ scrollbar when there are more options than it can show.
532
+
415
533
  For a discrete action rather than a selection, {Tuile::Component::Button}
416
534
  is a one-row `[ caption ]` that fires `on_click` on Enter, Space, or a
417
535
  left-click, highlighting its background while focused. It's a tab stop, so
@@ -497,7 +615,7 @@ popups are for.
497
615
 
498
616
  The bottom border has two mutually exclusive uses, and the distinction is
499
617
  the top-down-layout principle from chapter 3 made concrete. `footer_text=`
500
- embeds decoration into the border line — chrome, mirroring the caption on
618
+ embeds decoration into the border row — chrome, mirroring the caption on
501
619
  top, not focusable. `footer=` mounts a *real focusable component* spanning
502
620
  the full inner width — the search-field-in-the-border case. A footer
503
621
  component present takes the row and hides the text; neither drives the
@@ -544,6 +662,53 @@ A nested TextField still swallows printable keys first, so typing `q` into
544
662
  a field inside a popup doesn't dismiss it — the popup's own `q` handler sits
545
663
  on the ancestor, and only sees keys the field declined.
546
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
+
547
712
  ## Batteries-included windows
548
713
 
549
714
  The last three components are conveniences: common Window-plus-content
data/book/README.md CHANGED
@@ -52,7 +52,9 @@ one, not to fill an outline.
52
52
  the design. Top-down, absolute, integer coordinates; a parent
53
53
  assigns its children's `rect` and components never negotiate a size.
54
54
  The C64 argument for *why simple layouting is enough* on a character
55
- grid, `Layout::Absolute` and the `rect=` override, `Fraction` for
55
+ grid, `Layout::Absolute` and the `rect=` override, the `Vertical` /
56
+ `Horizontal` box layouts and their three constraints (`Fixed` /
57
+ `Percent` / `Expand`) as sugar over that same rule, `Fraction` for
56
58
  sizing a popup against the screen, and resize as a discrete
57
59
  recompute. Geometry primitives (`Point` / `Size` / `Rect`) live here.
58
60
  4. **[The event loop and background work](04-event-loop.md).** The
@@ -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.