tuile 0.11.0 → 0.13.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/DECISIONS.md +1970 -14
  4. data/README.md +136 -491
  5. data/TERMINOLOGY.md +70 -0
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +19 -6
  8. data/book/03-layout.md +12 -11
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +6 -3
  11. data/book/07-components.md +498 -38
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +27 -20
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +422 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +16 -10
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/buffer.rb +7 -7
  21. data/lib/tuile/component/abstract_string_field.rb +36 -0
  22. data/lib/tuile/component/button.rb +1 -1
  23. data/lib/tuile/component/checkbox.rb +1 -1
  24. data/lib/tuile/component/checkbox_group.rb +31 -26
  25. data/lib/tuile/component/combo_box.rb +13 -8
  26. data/lib/tuile/component/info_window.rb +1 -1
  27. data/lib/tuile/component/label.rb +14 -14
  28. data/lib/tuile/component/list.rb +313 -216
  29. data/lib/tuile/component/list_dropdown.rb +100 -10
  30. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  31. data/lib/tuile/component/menu_bar.rb +582 -0
  32. data/lib/tuile/component/notification.rb +320 -0
  33. data/lib/tuile/component/picker_window.rb +3 -8
  34. data/lib/tuile/component/popup.rb +83 -19
  35. data/lib/tuile/component/progress_bar.rb +1 -1
  36. data/lib/tuile/component/radio_group.rb +32 -30
  37. data/lib/tuile/component/select.rb +10 -8
  38. data/lib/tuile/component/tab_sheet.rb +242 -0
  39. data/lib/tuile/component/tabs.rb +528 -0
  40. data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
  41. data/lib/tuile/component/text_area.rb +84 -277
  42. data/lib/tuile/component/text_field.rb +24 -7
  43. data/lib/tuile/component/text_view.rb +197 -180
  44. data/lib/tuile/component/window.rb +8 -8
  45. data/lib/tuile/component.rb +43 -18
  46. data/lib/tuile/event_queue.rb +25 -1
  47. data/lib/tuile/fake_screen.rb +14 -0
  48. data/lib/tuile/keys.rb +65 -0
  49. data/lib/tuile/screen.rb +95 -78
  50. data/lib/tuile/screen_pane.rb +109 -27
  51. data/lib/tuile/styled_string.rb +52 -12
  52. data/lib/tuile/version.rb +1 -1
  53. data/lib/tuile/vertical_scroll_bar.rb +6 -6
  54. data/sig/tuile.rbs +2307 -516
  55. metadata +9 -3
  56. data/mise.toml +0 -2
@@ -71,10 +71,11 @@ That cap counts *characters*, and the distinction matters more than it
71
71
  looks. A field position is either an index into the text or a column on the
72
72
  terminal, and the two coincide only while every glyph is one column wide —
73
73
  a fullwidth CJK character is two columns, a combining mark zero. So `caret`
74
- and `max_text_length` speak indices, while `rect`, `left_column` and a mouse
75
- click speak columns, and the field converts between them rather than
76
- assuming they're the same number. You don't need to think about this to use
77
- a TextField; you do the moment you write a component that paints text.
74
+ and `max_text_length` speak indices, while `rect`, the field's horizontal
75
+ scroll offset and a mouse click speak columns, and the field converts between
76
+ them rather than assuming they're the same number. You don't need to think
77
+ about this to use a TextField; you do the moment you write a component that
78
+ paints text.
78
79
 
79
80
  There's a third unit hiding in there, and it's the one your *user* thinks
80
81
  in: the glyph they see. A single visible character can be several characters
@@ -219,11 +220,38 @@ read-only or required flag yet. Room left for that layer to grow into.
219
220
 
220
221
  ## Choosing from a set
221
222
 
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:
223
+ {Tuile::Component::List} is the workhorse: a scrollable column of *items*
224
+ — objects of whatever type your app deals in — one row each. You give it
225
+ the items and a `renderer` that turns one item into a row, and it does the
226
+ rest: ellipsizing a row too wide for the viewport (spans preserved),
227
+ scrolling, and handing your callbacks back **the item itself** rather than
228
+ the text it drew for it.
229
+
230
+ ```ruby
231
+ list = Component::List.new
232
+ list.items = User.all
233
+ list.renderer = ->(u) { "#{u.name} #{u.email}" }
234
+ list.cursor = Component::List::Cursor.new
235
+ list.on_item_chosen = ->(_index, user) { open(user) }
236
+ ```
237
+
238
+ That's the same bargain the value seam struck earlier in this chapter: the
239
+ component speaks in your objects, and nothing has to map a row of text
240
+ back to the thing it stood for. When your items *are* the text, skip the
241
+ renderer entirely — `list.lines = entries` takes strings (or
242
+ {Tuile::StyledString}s, or anything with a `to_s`), splits them on
243
+ newlines, and shows each as its own row.
244
+
245
+ The renderer runs when a row is *painted*, and only for the rows actually
246
+ on screen: a hundred-thousand-item list renders the twenty you can see.
247
+ That's what makes a long list cheap, and it comes with one rule — keep the
248
+ renderer a pure function of its item. It may be called on any frame, so it
249
+ is the wrong place to reach for a database; do that work when you build
250
+ the items.
251
+
252
+ What makes the list flexible beyond that is that its *cursor behavior is a
253
+ pluggable object* rather than a boolean. Assign one of three
254
+ {Tuile::Component::List::Cursor} variants to fit the interaction:
227
255
 
228
256
  - **`Cursor::None`** (the default) — no cursor at all. The list is a
229
257
  read-only scroll region: a log, a static report.
@@ -234,29 +262,24 @@ variants to fit the interaction:
234
262
  lines. For a list where only some rows are selectable (headers
235
263
  interspersed with items, say), it skips the rest.
236
264
 
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
- ```
265
+ Two callbacks cover the events you care about, and both are handed the
266
+ `(index, item)` pair. `on_item_chosen` fires when the user commits to the
267
+ cursor's row — Enter or a left-click — and is the "open this" signal.
268
+ `on_cursor_changed` fires when the highlighted row *changes*, which is
269
+ exactly what you wire to keep a details pane in sync with the selection.
270
+ For a tailing list — a live log — set `auto_scroll`; it pins to the bottom
271
+ as items arrive, but politely stops yanking you down the moment you scroll
272
+ up to read history, and resumes once you scroll back (`following?` tells
273
+ you which). A scrollbar is one assignment (`scrollbar_visibility`).
253
274
 
254
275
  When the set is long and the user roughly knows what they want, a plain
255
276
  list makes them scroll for it. {Tuile::Component::ComboBox} is the answer:
256
277
  a text field with a dropdown that filters as you type. Hand it `items` (of
257
278
  any type) and, when their `to_s` isn't what you want shown, an
258
279
  `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
280
+ Enter or click to accept. (The domain widgets all call that strategy
281
+ `item_label`, where a bare list calls it `renderer` — a label is text the
282
+ widget then decorates, a row is the whole rendering.) It's the value seam doing real work — its
260
283
  `value` is the selected *item*, the object and not its label, so a combo
261
284
  over `User`s hands back a `User`. The field's text is merely a transient
262
285
  query: it reverts to the selection's label when you dismiss the dropdown,
@@ -356,10 +379,11 @@ coerced), and let the widget's own toggling build the new sets for you.
356
379
  Here the cursor and the selection are genuinely two different things — the
357
380
  cursor says *where you are*, the checkmarks say *what you picked* — and
358
381
  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
382
+ doesn't paint rows itself; it holds a {Tuile::Component::List} of the
383
+ items, supplies the renderer that puts a `[x]` or `[ ]` in front of each
384
+ label, and gets the cursor, the scrolling, the scrollbar and the per-row
385
+ mouse handling for free, in the same "wrap a generic component to make a
386
+ domain one" way the combo box wraps a text field. That inheritance goes further than
363
387
  convenience: a click anywhere on a row toggles it, and Enter toggles the
364
388
  cursor's row, because those are the list's own gestures for choosing an
365
389
  item.
@@ -507,16 +531,59 @@ gets ellipsized. It opens below the select, flips above near the bottom of
507
531
  the screen, slides left rather than running off the right edge, and grows a
508
532
  scrollbar when there are more options than it can show.
509
533
 
510
- For a discrete action rather than a selection, {Tuile::Component::Button}
511
- is a one-row `[ caption ]` that fires `on_click` on Enter, Space, or a
512
- left-click, highlighting its background while focused. It's a tab stop, so
513
- it joins the normal Tab cycle.
534
+ ## Taking an action
535
+
536
+ Every component so far *holds* something — a value, a selection, a cursor
537
+ into a list. {Tuile::Component::Button} holds nothing. It runs a block:
538
+
539
+ ```ruby
540
+ save = Component::Button.new("Save") { form.submit }
541
+ save.on_click = -> { form.submit } # or assign it afterwards
542
+ ```
543
+
544
+ It paints as `[ Save ]` on one row, highlights its background while it is on
545
+ the focus chain, and is a tab stop, so Tab reaches it like any field. Enter,
546
+ Space and a left click all fire `on_click`, and the callback takes no
547
+ arguments — a button has nothing to report, because that it was pressed *is*
548
+ the event.
549
+
550
+ **Sizing it is your job**, exactly as chapter 3 promised: there is no channel
551
+ for a component to advertise the width it would like, so the caller does the
552
+ arithmetic. For a button that's the caption plus the four columns `[ ` and
553
+ ` ]` occupy:
554
+
555
+ ```ruby
556
+ # inside a Layout subclass, where the constraint names are already in scope
557
+ add(save, Fixed[save.caption.display_width + 4])
558
+ ```
559
+
560
+ Get it wrong in either direction and the failure is graceful rather than
561
+ broken: a narrower rect ellipsizes the label, and a wider one leaves a tail
562
+ that *focuses* but doesn't fire — the same `extent` rule the checkbox above
563
+ spells out, and buttons follow it identically. (The sampler keeps a one-line
564
+ `button_width` helper for this, which is what "the app does the arithmetic"
565
+ looks like in practice.)
566
+
567
+ **A focused button consumes Enter**, and that matters the moment you have
568
+ more than one. Enter on a focused `Save` activates *that* button — not some
569
+ form-wide default, because Tuile has no notion of a default button at all.
570
+ The form's Enter-to-submit is a `handle_key` on the ancestor that owns the
571
+ form (chapter 5), and it only ever sees Enter when the focused widget
572
+ declined it. So a dialog's two buttons are just two widgets, and which one
573
+ Enter hits is simply which one has focus.
574
+
575
+ One thing you will look for and not find: **there is no disabled state.**
576
+ Tuile has no enabled/disabled seam on any component, so a button that
577
+ shouldn't be pressable yet is one you don't add to the tree, or one whose
578
+ block checks the precondition and says why. That's less of a gap than it
579
+ sounds on a TTY, where a greyed-out control is hard to distinguish from a
580
+ styled one anyway.
514
581
 
515
582
  ## Reporting progress
516
583
 
517
- Everything so far either shows text or captures input.
518
- {Tuile::Component::ProgressBar} does neither: it reports, and it is the
519
- first component in this tour you never focus and never type into. A run of
584
+ Everything so far either shows text, captures input, or acts on it.
585
+ {Tuile::Component::ProgressBar} does none of those: it reports, and it is
586
+ the first component in this tour you never focus and never type into. A run of
520
587
  `█` grows left to right over a `░` track, measured against a range you set:
521
588
 
522
589
  ```ruby
@@ -592,7 +659,7 @@ popups are for.
592
659
 
593
660
  The bottom border has two mutually exclusive uses, and the distinction is
594
661
  the top-down-layout principle from chapter 3 made concrete. `footer_text=`
595
- embeds decoration into the border line — chrome, mirroring the caption on
662
+ embeds decoration into the border row — chrome, mirroring the caption on
596
663
  top, not focusable. `footer=` mounts a *real focusable component* spanning
597
664
  the full inner width — the search-field-in-the-border case. A footer
598
665
  component present takes the row and hides the text; neither drives the
@@ -607,6 +674,334 @@ window.content = Component::List.new.tap { _1.lines = entries }
607
674
  window.scrollbar = true
608
675
  ```
609
676
 
677
+ ## Switching between views
678
+
679
+ When a screen has more content than fits and the parts are *alternatives*
680
+ rather than neighbours — a settings dialog's General / Network / Advanced,
681
+ a monitor's Requests / Errors / Config — you want one visible at a time and
682
+ a way to pick. That's {Tuile::Component::TabSheet}: a one-row strip of
683
+ captions across the top, and below it the pane belonging to whichever tab
684
+ is selected.
685
+
686
+ ```ruby
687
+ sheet = Component::TabSheet.new
688
+ sheet.add_tab("Details", details_form) # the first tab is selected
689
+ sheet.add_tab("Payment", payment_form)
690
+ sheet.on_tab_selected = ->(index, tab) { status.text = "on #{tab&.caption}" }
691
+ ```
692
+
693
+ The strip is a component in its own right, {Tuile::Component::Tabs}, and
694
+ you can use it alone when the thing being switched isn't a pane you want
695
+ the sheet to own — repointing a `TextView` at a different document, say, or
696
+ driving a swap somewhere else entirely on the screen. `TabSheet` is the
697
+ convenience of "strip plus the pane that goes with it"; `Tabs` is the
698
+ selector by itself.
699
+
700
+ ### A selection is not a value
701
+
702
+ `Tabs` is not a {Tuile::Component::HasValue} field, and the test that
703
+ tells you why is worth carrying to your own components: **would a form save
704
+ it?** A {Tuile::Component::RadioGroup}'s selection *is* the datum being
705
+ edited — it goes in the record — so it's a value. A tab's selection is
706
+ where the user happens to be looking. Nothing saves it, nothing validates
707
+ it, and a form iterating its fields should never find it. So the strip
708
+ speaks in its own words — `selected`, `selected_index`, `on_tab_selected`
709
+ — and stays out of the seam the value section earlier in this chapter set
710
+ up.
711
+
712
+ `on_tab_selected` reports that the selection *changed*, not that the user
713
+ pressed something: arrows, a click, an assignment from your own code, the
714
+ autoselect of the very first tab, and the re-selection that follows
715
+ removing the selected tab all reach it. When the last tab goes it fires
716
+ with `(nil, nil)`, which matters if you render from it — you have to be
717
+ told to render *nothing*, or the departed tab's content sits there with no
718
+ tab pointing at it.
719
+
720
+ ### The strip is one tab stop, and arrows switch immediately
721
+
722
+ Tab lands on the strip, and Tab again enters the pane — the browser's
723
+ order, and you get it for free: the strip is the sheet's first child, and
724
+ Tab collects the tab stops in tree order (chapter 5). Within the strip,
725
+ Left and Right switch tabs. Tab itself
726
+ never moves *between* tabs: it means "leave this widget" everywhere in
727
+ Tuile, and a strip is one widget.
728
+
729
+ Switching is immediate — there is no cursor to walk across the strip and no
730
+ Enter to confirm — and that is a design choice with a visible payoff.
731
+ Manual activation would need two states on one row, the tab you're on and
732
+ the tab you're pointing at, and therefore two ways of marking them. Making
733
+ the arrows *be* the selection leaves exactly one thing highlighted, which
734
+ is why the strip can spend both of its visual channels on saying where you
735
+ are: the selected caption is **bold always**, and it additionally sits on
736
+ the theme's `active_bg_color` while the strip has focus. Bold is the one
737
+ that survives focus moving away — the strip is the map of where you are in
738
+ the app, and a map shouldn't go blank when you look somewhere else.
739
+
740
+ Everything else bubbles. Enter, Space, Home, End, the vertical arrows and
741
+ every printable pass straight through to your ancestors, so a form's
742
+ default button and an app's own keys keep working while the strip has
743
+ focus. If you want a key elsewhere in the app to drive the strip — a
744
+ `Ctrl+PageDown` habit from your editor — bind it yourself and call
745
+ `select_next` / `select_previous`. Tuile ships no such binding, because
746
+ "one key, anywhere, meaning switch tab" is a statement about your app, and
747
+ with nested sheets it would be ambiguous about *which* sheet.
748
+
749
+ ### Tabs are handles, not items
750
+
751
+ Everywhere else in this chapter, a widget takes a collection: `items=`,
752
+ `lines=`. A strip doesn't. `add_tab` mints a {Tuile::Component::Tabs::Tab}
753
+ and hands it back, and you keep it:
754
+
755
+ ```ruby
756
+ payment = sheet.add_tab("Payment", payment_form)
757
+ payment.caption = "Payment ⚠" # repaints the strip
758
+ payment.remove # the handle raises from here on
759
+ ```
760
+
761
+ The difference isn't cosmetic. An item is an element of a collection
762
+ somebody else owns — assign the whole array and the widget renders what it
763
+ finds. A tab is an identity with its own state, and it is what a
764
+ `TabSheet` keys its pane mapping by. Handing the strip a fresh array would
765
+ destroy those identities and the mapping with them, so the operation
766
+ simply isn't offered: you add and remove tabs one at a time. Captions are
767
+ mutable through the handle, and a removed one raises on every mutation and
768
+ on anything it would have to ask the strip — better than quietly answering
769
+ about a tab that is no longer there. Its caption stays readable, so an
770
+ error message can still name it.
771
+
772
+ ### Hiding a component means detaching it
773
+
774
+ Here is the part with consequences beyond this widget. **Tuile has no
775
+ visibility flag.** There is no `visible?`, no `display`; the empty rect you
776
+ met in chapter 2 gates *painting* and nothing else — an "invisible" widget
777
+ with an empty rect is still in the Tab cycle, still a target of the focus
778
+ cascades, still answering for the cursor. So hiding, in Tuile, means taking
779
+ something out of the tree, and that is exactly what a `TabSheet` does: only
780
+ the selected tab's pane is a child of the sheet, and the rest are detached.
781
+
782
+ Three things fall out of that, and they're the reason it's the right
783
+ mechanism rather than a workaround for a missing feature:
784
+
785
+ - **A hidden pane is invisible to everything** — the Tab cycle, focus,
786
+ repaint, the cursor, tree walks. Not because anything checks a flag, but
787
+ because it isn't there. There is no gate to get wrong.
788
+ - **Its state survives, because state is ivars.** Scroll position, caret,
789
+ list cursor, typed text: all exactly as the user left them. You can go on
790
+ mutating a hidden pane too, with no special handling and no "am I
791
+ visible?" check — `invalidate` on a detached component is a silent no-op,
792
+ and the sheet assigns it a rect and invalidates it when it comes back.
793
+ - **The lifecycle hooks fire on every switch.** `on_detached` when a pane
794
+ goes away, `on_attached` when it returns — so a
795
+ {Tuile::Component::ProgressBar} in a hidden tab stops its ticker and
796
+ restarts it on return, with no bookkeeping from you.
797
+
798
+ The cost is the mirror image of that last point: a pane that must keep
799
+ something *alive* while hidden can't, because chapter 4's
800
+ `on_attached`/`on_detached` contract is exactly what detachment triggers. The
801
+ way out is to move the thing that must not stop: give the resource to the
802
+ model your pane renders rather than to the pane, and let the pane pick up
803
+ its current state on return. That is usually the better shape anyway — if
804
+ you're reluctant to let a hidden pane's poller or subscription die, it
805
+ probably wanted to outlive the view all along.
806
+
807
+ The `TabSheet` pane in `examples/sampler.rb` demonstrates the state part
808
+ directly: scroll the prose tab, switch away, come back, and the status line
809
+ under the sheet reports the row you left it on.
810
+
811
+ ### When the strip is too narrow
812
+
813
+ Give a strip the width its captions need and there is nothing here to think
814
+ about; with the three to five tabs this shape is actually for, that is the
815
+ normal case. When a layout can't give it that width — a narrow terminal, a
816
+ caption that grew a badge — the strip **scrolls** rather than putting some of
817
+ its tabs out of reach. It keeps the selected segment whole in view and moves
818
+ its window by the smallest amount that does so, so arrowing along an
819
+ overflowing strip walks the selection off one edge and the strip follows it,
820
+ a tab at a time.
821
+
822
+ Two things tell you there is more strip than you can see. The captions at the
823
+ edges are cut mid-word, which is the oldest overflow hint there is; and a `<`
824
+ or `>` is painted over the edge column itself. The cue is there because the
825
+ cut alone isn't reliable — scroll to just the right place and a segment
826
+ boundary lands exactly on the edge, leaving clean space that reads as "that's
827
+ all of them". The cues are ASCII, they are painted whether or not the strip
828
+ has focus (overflow is a fact about the captions and the rect, not about
829
+ where you are), and they are not buttons: a click on one lands on the
830
+ half-visible segment underneath, which selects that tab and pulls it into
831
+ view — the direction the cue was pointing anyway.
832
+
833
+ The one thing a strip cannot do is show a caption wider than the whole rect.
834
+ There it gives you the head and clips the tail, on the grounds that the start
835
+ of a word identifies it and the end usually doesn't.
836
+
837
+ ## Menus
838
+
839
+ A menu bar is the other way to say "the app can do these things", and it
840
+ answers a different question from tabs. Tabs are a *map*: they show where
841
+ you are, and switching one changes what you're looking at. A menu is a
842
+ *catalogue of verbs*: it shows what you can do, it closes again the moment
843
+ you pick, and it leaves the screen exactly as it was. If the caption names
844
+ a place, use tabs; if it names an action, use a menu.
845
+
846
+ {Tuile::Component::MenuBar} is one row of captions, and each of them drops
847
+ open a menu:
848
+
849
+ ```ruby
850
+ bar = Component::MenuBar.new
851
+
852
+ file = bar.add_item("File")
853
+ file.add_item("New") { new_document }
854
+ file.add_item("Open") { open_dialog }
855
+ recent = file.add_item("Open recent") # no block ⇒ a submenu
856
+ recent.add_item("notes.txt") { open("notes.txt") }
857
+
858
+ bar.add_item("About") { show_about } # a top-level leaf: a button
859
+ ```
860
+
861
+ Two things about that snippet do most of the work. First, `add_item` is the
862
+ *same* method on the bar and on an item, so nesting needs no new vocabulary
863
+ — and because an item can hold items, submenus go as deep as you build
864
+ them. Second, whether an item is a submenu or an action is not something
865
+ you declare: an item with children *is* a submenu, and its own block (if
866
+ you gave it one) is simply dead. That's why `recent` above takes no block
867
+ and `"New"` does. A top-level item with no children isn't a menu at all —
868
+ it's a button on the bar, which is exactly how you get a single "About" or
869
+ "Help" entry without inventing a one-item menu for it.
870
+
871
+ An item with neither children nor a block is legal, and does nothing. It
872
+ highlights, Enter closes the menu, and nothing happens. That is a deliberate
873
+ non-decision: a half-built menu is a programming error you'll see the moment
874
+ you run the app, and it isn't worth an exception that fires while you're
875
+ still assembling the thing.
876
+
877
+ ### The bar keeps focus the whole time
878
+
879
+ This is the part worth understanding, because it explains everything else.
880
+ The open menus are **overlays**, not children of the bar — they're mounted on
881
+ the screen pane, floating above whatever they cover, and they never take
882
+ focus. Focus stays on the strip from the moment you open a menu until it
883
+ closes, however deep you drill. So the bar receives every keystroke and
884
+ decides what to do with it; the panels are things it draws and drives.
885
+
886
+ That is the same arrangement {Tuile::Component::Select} uses for its
887
+ dropdown (chapter 5 has the key-dispatch ladder this rests on), and it
888
+ buys two properties. The whole widget is one tab stop — Tab moves *past*
889
+ the bar, never into a menu. And nothing about menus needed adding to the
890
+ framework's key handling: a menu is not a mode.
891
+
892
+ The keyboard map is the one every menu bar has had since Turbo Vision, and
893
+ it's worth learning once because Vaadin, the web's ARIA pattern and every
894
+ other TUI toolkit agree on it:
895
+
896
+ | While the bar has focus | |
897
+ |---|---|
898
+ | Left / Right | move along the strip |
899
+ | Enter, Space, Down | open the highlighted menu |
900
+ | a mnemonic letter | open that menu (see below) |
901
+ | anything else | bubbles to your app |
902
+ | **Inside an open menu** | |
903
+ | Up / Down (PgUp/PgDn, Ctrl+U/D) | move the highlight |
904
+ | Right, Enter, Space | open the submenu under the highlight |
905
+ | Enter, Space | activate a row that has no submenu |
906
+ | Left | back to the previous menu |
907
+ | Left at the first level, Right on a plain row | step to the neighbouring menu |
908
+ | a mnemonic letter | activate that row of *this* menu |
909
+ | ESC | close one level |
910
+
911
+ Stepping sideways *shows* the neighbour's menu; it never presses anything. So
912
+ arrowing onto a top-level button — an item with a listener and no menu — closes
913
+ whatever was open and highlights it, and it fires only when you press Enter or
914
+ Space. Otherwise walking the strip would trigger every button on it.
915
+
916
+ The last row of the first block matters for real apps: while the bar merely
917
+ has focus, every other key **bubbles past it**, so a form's `s`-to-save or
918
+ a layout's `1`/`2`/`3` pane jumps keep working. An *open* menu is different
919
+ — it swallows what it doesn't recognize. A menu is a quasi-modal moment, and
920
+ an app key firing behind a panel you can see would be worse than a keystroke
921
+ that does nothing.
922
+
923
+ ### What it looks like, and why it isn't a tab strip
924
+
925
+ The strip paints ` File Edit View ` — each caption with a space either
926
+ side, no separator column, and the menu that Enter would open highlighted
927
+ while the bar has focus. Move focus away and the highlight goes entirely.
928
+
929
+ Compare that with the tab strip earlier in this chapter, which keeps its
930
+ selected caption **bold** even unfocused and rules its segments apart with
931
+ `│`. The difference is on purpose. A tab strip has to say where you are
932
+ after focus has moved on, so its selection is permanent and needs a channel
933
+ that survives losing focus. A menu bar has nothing permanent to say: close
934
+ the menu and no item is selected, because you are not "in" File the way you
935
+ are "on" the Details tab. Two one-row caption strips that looked the same
936
+ would make you work out which control you were looking at; these two don't.
937
+
938
+ What the two *do* share is everything from "When the strip is too narrow"
939
+ above. A bar that outgrows its terminal scrolls to keep the highlighted menu
940
+ whole in view, cues the hidden captions the same way, and stays reachable by
941
+ arrow, by mnemonic and by click — a mnemonic jumping to a menu off the right
942
+ edge brings it on screen before its panel opens.
943
+
944
+ ### Mnemonics: one letter per level
945
+
946
+ Give an item a letter and it answers to it:
947
+
948
+ ```ruby
949
+ file = bar.add_item("File", mnemonic: "f")
950
+ file.add_item("Export", mnemonic: "e") { export }
951
+ file.add_item("Quit", mnemonic: "q") { quit }
952
+ bar.add_item("Edit", mnemonic: "e").add_item("Copy", mnemonic: "c") { copy }
953
+ ```
954
+
955
+ The letter is underlined in the caption where it occurs — `F̲ile` — on the
956
+ strip and in every open panel, whether or not the bar has focus. There is no
957
+ Alt key to reveal them with, so they are simply always visible.
958
+
959
+ Now press `f`, then `q`: File opens, Quit fires. That reads like a two-key
960
+ accelerator, but it is nothing so clever — it is two ordinary keystrokes, and
961
+ the second one means something different because the first one changed what
962
+ is on screen. That is the whole rule:
963
+
964
+ > A mnemonic is matched against **one** set of items: the top-level ones
965
+ > while no menu is open, and the deepest open menu's while one is. Nothing
966
+ > else is ever consulted.
967
+
968
+ Read the example again with that in mind and notice what *cannot* happen.
969
+ `Export` and `Edit` both bind `e`, and there is no conflict to resolve —
970
+ with File open, `Edit` is not one of the candidates, so `e` means Export. If
971
+ you close the menu first, `e` means Edit. The two are never in the same
972
+ lookup, so the framework never has to guess, and you never have to hunt for
973
+ a free letter across the whole tree. Only *siblings* compete, and two
974
+ siblings claiming one letter is a mistake Tuile refuses at `add_item` rather
975
+ than resolving at the keyboard.
976
+
977
+ The same rule says what a *wrong* letter does. With File open, `v` matches
978
+ nothing in File's menu — and nothing happens. It does not fall out to the
979
+ strip and open the View menu, because a mistyped letter tearing down the
980
+ menu you are reading would be a poor trade for a shortcut. You get the
981
+ terminal bell instead, and Left, Right and ESC are still there to move.
982
+
983
+ One cost to know about, because it is what a mnemonic *means*: while the bar
984
+ has focus, its letters win. A `mnemonic: "s"` eats the `s`-to-save described
985
+ above, and a `mnemonic: "q"` inside a popup eats the popup's own `q`-to-close.
986
+ That is the bubble working correctly — the focused component is asked first
987
+ — but it is worth a thought before binding a common letter.
988
+
989
+ **A click outside an open menu closes it.** The panels float above the UI
990
+ without blocking it (chapter 3's overlays are all like this), so the click
991
+ still reaches whatever it was aimed at — but on its way the screen dismisses
992
+ the cascade, so a menu can't linger over content it no longer belongs to.
993
+ Clicking *within* the menu is not outside it, whichever panel you land on:
994
+ the panels are chained to each other, so drilling into a submenu by mouse
995
+ leaves the levels above it standing. ESC and Tab also get you out.
996
+
997
+ ### One thing it deliberately doesn't do
998
+
999
+ **A resize closes an open menu.** Every panel is positioned against
1000
+ something — the strip segment it dropped from, or the parent row it cascaded
1001
+ out of — so after the terminal changes size those positions are all stale.
1002
+ Recomputing them level by level is possible; closing is unambiguous, and
1003
+ every GUI dismisses its menus on a window resize too.
1004
+
610
1005
  ## Overlays
611
1006
 
612
1007
  {Tuile::Component::Popup} is how you float something above the tiled UI.
@@ -626,6 +1021,22 @@ floats above the content without taking focus — the autocomplete-list case
626
1021
  from earlier, where the caller positions it against a field's caret and
627
1022
  drives it from app code.
628
1023
 
1024
+ **A left click outside a popup closes it**, modal or not — the same light
1025
+ dismissal a desktop dialog gives you. It's a per-popup switch,
1026
+ `close_on_outside_click`, on by default; a popup that must survive stray
1027
+ clicks turns it off, as a Notification does. The click still reaches
1028
+ whatever was beneath it, unless an open modal swallowed it — in which case
1029
+ the first click dismisses and a second one acts.
1030
+
1031
+ "Outside" means more than "outside this rectangle". An overlay opened by a
1032
+ component that lives inside another popup — a ComboBox on a dialog, whose
1033
+ dropdown drops past the dialog's own border — says so with `owner`, and a
1034
+ click landing in it then counts as landing inside the dialog too. Otherwise
1035
+ picking from the dropdown would dismiss the form under it. Overlays that
1036
+ *aren't* related that way stay independent: clicking one dismisses the
1037
+ other, as two dismissable windows should. You only need `owner` when you
1038
+ build a compound overlay of your own; the built-in ones already set it.
1039
+
629
1040
  Because the popup is just a transparent host, you get a bordered dialog by
630
1041
  wrapping a Window:
631
1042
 
@@ -639,6 +1050,55 @@ A nested TextField still swallows printable keys first, so typing `q` into
639
1050
  a field inside a popup doesn't dismiss it — the popup's own `q` handler sits
640
1051
  on the ancestor, and only sees keys the field declined.
641
1052
 
1053
+ ## Notifications
1054
+
1055
+ {Tuile::Component::Notification} is the one overlay you don't assemble at
1056
+ all. It's the TTY toast: a message in the top-right corner that shows up,
1057
+ holds for three seconds, and removes itself.
1058
+
1059
+ ```ruby
1060
+ Component::Notification.show("Saved")
1061
+ Component::Notification.show("Disk almost full", color: Color::RED)
1062
+ ```
1063
+
1064
+ That class method is the *only* way in — `new` is private. The reason is
1065
+ worth understanding, because it's the design in one line: there is never
1066
+ more than one notification box on screen. `show` looks for the live one in
1067
+ the popups stack and appends to it, so a burst of messages stacks as
1068
+ entries inside a single frame:
1069
+
1070
+ ```
1071
+ ┌──────────────┐
1072
+ │Job 1 finished│ ← goes in 3 s
1073
+ │Job 2 finished│ ← then this one
1074
+ │Job 3 finished│
1075
+ └──────────────┘
1076
+ ```
1077
+
1078
+ They then leave **one at a time**, oldest first, three seconds apart. This
1079
+ is the interesting half of the design. Five notifications raised in the
1080
+ same instant would, given five independent timers, appear and vanish
1081
+ together — a flash you have no chance of reading. Draining them one per
1082
+ tick means the burst takes fifteen seconds to clear and you read it in
1083
+ peace. A message arriving mid-cycle just waits its turn rather than
1084
+ restarting the clock, which is also what stops a steady trickle of
1085
+ notifications from keeping the box alive forever.
1086
+
1087
+ Everything else follows from "a toast must not interrupt": it's a non-modal
1088
+ popup, so it takes no focus, receives no keys (not even the `q` a normal
1089
+ popup would claim), and blocks no click outside its own box. You keep
1090
+ typing into whatever you were typing into, and the notification appears and
1091
+ leaves around you. A left-click on the box dismisses the whole thing early;
1092
+ a click anywhere else doesn't, because a toast is timed and an unrelated
1093
+ click isn't about it.
1094
+
1095
+ Two limits are worth knowing before you reach them. A long message wraps to
1096
+ at most three rows and is then ellipsized — the box is capped at 40 % of
1097
+ the screen — and at most five messages are held, after which the newest is
1098
+ dropped and reported to `Tuile.logger`. Both are deliberate: a notification
1099
+ is a glance, not a document, and an app with more to say than five short
1100
+ lines wants a LogWindow, which is next.
1101
+
642
1102
  ## Batteries-included windows
643
1103
 
644
1104
  The last three components are conveniences: common Window-plus-content
data/book/08-testing.md CHANGED
@@ -28,10 +28,13 @@ after { Screen.close }
28
28
  `Screen.fake` installs a {Tuile::FakeScreen} as the process singleton — a
29
29
  `Screen` subclass with the terminal amputated. It has a fixed 160×50
30
30
  viewport (so geometry is deterministic, independent of whoever's terminal
31
- runs the suite), it writes nothing to any TTY, its `check_locked` is a
32
- no-op so you can mutate the UI freely from the test thread without holding
33
- the UI lock, and its event queue is the synchronous {Tuile::FakeEventQueue}
34
- (more on that below). It also pins the color scheme to `:dark`, skipping
31
+ runs the suite), it writes nothing to any TTY, and its event queue is the
32
+ synchronous {Tuile::FakeEventQueue} (more on that below). You can mutate the
33
+ UI directly from your example — and note there is no lock *bypass* doing
34
+ that for you: the fake runs no loop, so `running?` is false and chapter 4's
35
+ rule falls back to "the thread that created the screen," which is yours. A
36
+ spec that mutates the UI from a **spawned** thread therefore raises, exactly
37
+ as an app would. It also pins the color scheme to `:dark`, skipping
35
38
  the OSC 11 probe from chapter 6 — a probe would otherwise write an escape
36
39
  query to the test runner's terminal and swallow its input.
37
40
 
@@ -44,6 +47,17 @@ from nothing. Skip the `after` and you get the classic singleton test
44
47
  smell: passes in isolation, fails in suite, order-dependent. The pair is
45
48
  not boilerplate you can trim.
46
49
 
50
+ One more line of setup earns its place if your app has a theme of its own. A
51
+ fresh `Screen.fake` starts from the built-in {Tuile::ThemeDef}, so a
52
+ component reading `theme[:my_token]` would `KeyError` in every example.
53
+ Rather than assigning `Screen.instance.theme_def` in every `before` block,
54
+ point the construction-time default at your definition once, in
55
+ `spec_helper`:
56
+
57
+ ```ruby
58
+ Tuile::ThemeDef.default = APP_THEME # every Screen.fake now carries it
59
+ ```
60
+
47
61
  ## Asserting what got painted
48
62
 
49
63
  Here's where the back buffer earns its keep. Recall from chapter 2 that