tuile 0.12.0 → 0.14.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -27
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +2342 -158
  5. data/README.md +151 -505
  6. data/TERMINOLOGY.md +15 -5
  7. data/book/01-first-app.md +22 -17
  8. data/book/02-repaint.md +18 -5
  9. data/book/03-layout.md +28 -20
  10. data/book/05-focus.md +137 -19
  11. data/book/06-theming.md +103 -2
  12. data/book/07-components.md +567 -27
  13. data/book/08-testing.md +34 -4
  14. data/book/09-styled-text.md +3 -3
  15. data/book/README.md +7 -5
  16. data/examples/file_commander.rb +23 -17
  17. data/examples/hello_world.rb +17 -5
  18. data/examples/sampler.rb +527 -108
  19. data/ideas/arrow-key-navigation.md +17 -1
  20. data/ideas/modal-backdrop.md +24 -0
  21. data/ideas/new-components.md +28 -27
  22. data/lib/tuile/ansi.rb +10 -0
  23. data/lib/tuile/buffer.rb +51 -3
  24. data/lib/tuile/color.rb +143 -0
  25. data/lib/tuile/color_depth.rb +80 -0
  26. data/lib/tuile/component/abstract_string_field.rb +36 -0
  27. data/lib/tuile/component/button.rb +3 -3
  28. data/lib/tuile/component/checkbox.rb +3 -3
  29. data/lib/tuile/component/combo_box.rb +12 -3
  30. data/lib/tuile/component/confirm_window.rb +442 -0
  31. data/lib/tuile/component/has_content.rb +22 -9
  32. data/lib/tuile/component/has_value.rb +1 -1
  33. data/lib/tuile/component/info_window.rb +64 -16
  34. data/lib/tuile/component/layout.rb +0 -10
  35. data/lib/tuile/component/list.rb +22 -0
  36. data/lib/tuile/component/list_dropdown.rb +102 -11
  37. data/lib/tuile/component/log_text_view.rb +71 -0
  38. data/lib/tuile/component/log_window.rb +13 -48
  39. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  40. data/lib/tuile/component/menu_bar.rb +582 -0
  41. data/lib/tuile/component/notification.rb +24 -39
  42. data/lib/tuile/component/overlay.rb +192 -0
  43. data/lib/tuile/component/picker_window.rb +0 -5
  44. data/lib/tuile/component/popup.rb +61 -123
  45. data/lib/tuile/component/progress_bar.rb +1 -1
  46. data/lib/tuile/component/select.rb +17 -7
  47. data/lib/tuile/component/slot.rb +54 -0
  48. data/lib/tuile/component/tab_sheet.rb +231 -0
  49. data/lib/tuile/component/tabs.rb +528 -0
  50. data/lib/tuile/component/text_area.rb +5 -4
  51. data/lib/tuile/component/text_field.rb +23 -6
  52. data/lib/tuile/component/text_view.rb +8 -5
  53. data/lib/tuile/component/window.rb +22 -46
  54. data/lib/tuile/component.rb +186 -31
  55. data/lib/tuile/event_queue.rb +45 -1
  56. data/lib/tuile/fake_screen.rb +40 -2
  57. data/lib/tuile/keys.rb +72 -0
  58. data/lib/tuile/screen.rb +212 -113
  59. data/lib/tuile/screen_pane.rb +125 -41
  60. data/lib/tuile/styled_string.rb +80 -7
  61. data/lib/tuile/terminal_background.rb +74 -16
  62. data/lib/tuile/version.rb +1 -1
  63. data/sig/tuile.rbs +2527 -358
  64. metadata +13 -3
  65. 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
@@ -530,16 +531,59 @@ gets ellipsized. It opens below the select, flips above near the bottom of
530
531
  the screen, slides left rather than running off the right edge, and grows a
531
532
  scrollbar when there are more options than it can show.
532
533
 
533
- For a discrete action rather than a selection, {Tuile::Component::Button}
534
- is a one-row `[ caption ]` that fires `on_click` on Enter, Space, or a
535
- left-click, highlighting its background while focused. It's a tab stop, so
536
- 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.
537
581
 
538
582
  ## Reporting progress
539
583
 
540
- Everything so far either shows text or captures input.
541
- {Tuile::Component::ProgressBar} does neither: it reports, and it is the
542
- 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
543
587
  `█` grows left to right over a `░` track, measured against a range you set:
544
588
 
545
589
  ```ruby
@@ -630,24 +674,403 @@ window.content = Component::List.new.tap { _1.lines = entries }
630
674
  window.scrollbar = true
631
675
  ```
632
676
 
677
+ ### Reserving a region: `Slot`
678
+
679
+ A `Window` has one content region. When *you* build a container with
680
+ several — a dialog with a message, a button row and maybe a header — give
681
+ each region a {Tuile::Component::Slot}: a component whose whole job is to
682
+ hold one child and size it to itself.
683
+
684
+ ```ruby
685
+ @message = Component::Slot.new
686
+ add(@message, Expand[1]) # the region, wired once at construction
687
+ @message.content = Component::Label.new("Delete this file?") # the occupant
688
+ ```
689
+
690
+ The reason to bother is an arithmetic problem you'd otherwise have to
691
+ solve. Children are ordered, and order decides paint order and Tab order —
692
+ so if you held the message and the buttons as direct children, "where does
693
+ the message get inserted?" would depend on whether the header happens to be
694
+ present right now. Inside a slot the answer is always index 0, because the
695
+ slot itself never leaves the tree. Add regions, reorder them, leave some
696
+ empty: none of it changes a swap.
697
+
698
+ Which leads to the one thing that surprises people: **an empty slot doesn't
699
+ collapse.** It keeps the rectangle its parent gave it and clears it, so a
700
+ dialog with no message shows the hole — exactly as it would with an *empty*
701
+ message. If you want the gap closed, that's the parent's arithmetic (give
702
+ the slot a zero extent), which is the same top-down rule as everything else
703
+ in chapter 3. Don't detach the slot to make it go away; that hands you back
704
+ the insert-index problem it exists to remove.
705
+
706
+ A slot is invisible to input: it can't take focus, clicks pass straight
707
+ through to the occupant, and when an occupant leaves, the focus repair is
708
+ handed up to your container rather than stranding focus on the slot.
709
+
710
+ ## Switching between views
711
+
712
+ When a screen has more content than fits and the parts are *alternatives*
713
+ rather than neighbours — a settings dialog's General / Network / Advanced,
714
+ a monitor's Requests / Errors / Config — you want one visible at a time and
715
+ a way to pick. That's {Tuile::Component::TabSheet}: a one-row strip of
716
+ captions across the top, and below it the pane belonging to whichever tab
717
+ is selected.
718
+
719
+ ```ruby
720
+ sheet = Component::TabSheet.new
721
+ sheet.add_tab("Details", details_form) # the first tab is selected
722
+ sheet.add_tab("Payment", payment_form)
723
+ sheet.on_tab_selected = ->(index, tab) { status.text = "on #{tab&.caption}" }
724
+ ```
725
+
726
+ The strip is a component in its own right, {Tuile::Component::Tabs}, and
727
+ you can use it alone when the thing being switched isn't a pane you want
728
+ the sheet to own — repointing a `TextView` at a different document, say, or
729
+ driving a swap somewhere else entirely on the screen. `TabSheet` is the
730
+ convenience of "strip plus the pane that goes with it"; `Tabs` is the
731
+ selector by itself.
732
+
733
+ ### A selection is not a value
734
+
735
+ `Tabs` is not a {Tuile::Component::HasValue} field, and the test that
736
+ tells you why is worth carrying to your own components: **would a form save
737
+ it?** A {Tuile::Component::RadioGroup}'s selection *is* the datum being
738
+ edited — it goes in the record — so it's a value. A tab's selection is
739
+ where the user happens to be looking. Nothing saves it, nothing validates
740
+ it, and a form iterating its fields should never find it. So the strip
741
+ speaks in its own words — `selected`, `selected_index`, `on_tab_selected`
742
+ — and stays out of the seam the value section earlier in this chapter set
743
+ up.
744
+
745
+ `on_tab_selected` reports that the selection *changed*, not that the user
746
+ pressed something: arrows, a click, an assignment from your own code, the
747
+ autoselect of the very first tab, and the re-selection that follows
748
+ removing the selected tab all reach it. When the last tab goes it fires
749
+ with `(nil, nil)`, which matters if you render from it — you have to be
750
+ told to render *nothing*, or the departed tab's content sits there with no
751
+ tab pointing at it.
752
+
753
+ ### The strip is one tab stop, and arrows switch immediately
754
+
755
+ Tab lands on the strip, and Tab again enters the pane — the browser's
756
+ order, and you get it for free: the strip is the sheet's first child, and
757
+ Tab collects the tab stops in tree order (chapter 5). Within the strip,
758
+ Left and Right switch tabs. Tab itself
759
+ never moves *between* tabs: it means "leave this widget" everywhere in
760
+ Tuile, and a strip is one widget.
761
+
762
+ Switching is immediate — there is no cursor to walk across the strip and no
763
+ Enter to confirm — and that is a design choice with a visible payoff.
764
+ Manual activation would need two states on one row, the tab you're on and
765
+ the tab you're pointing at, and therefore two ways of marking them. Making
766
+ the arrows *be* the selection leaves exactly one thing highlighted, which
767
+ is why the strip can spend both of its visual channels on saying where you
768
+ are: the selected caption is **bold always**, and it additionally sits on
769
+ the theme's `active_bg_color` while the strip has focus. Bold is the one
770
+ that survives focus moving away — the strip is the map of where you are in
771
+ the app, and a map shouldn't go blank when you look somewhere else.
772
+
773
+ Everything else bubbles. Enter, Space, Home, End, the vertical arrows and
774
+ every printable pass straight through to your ancestors, so a form's
775
+ default button and an app's own keys keep working while the strip has
776
+ focus. If you want a key elsewhere in the app to drive the strip — a
777
+ `Ctrl+PageDown` habit from your editor — bind it yourself and call
778
+ `select_next` / `select_previous`. Tuile ships no such binding, because
779
+ "one key, anywhere, meaning switch tab" is a statement about your app, and
780
+ with nested sheets it would be ambiguous about *which* sheet.
781
+
782
+ ### Tabs are handles, not items
783
+
784
+ Everywhere else in this chapter, a widget takes a collection: `items=`,
785
+ `lines=`. A strip doesn't. `add_tab` mints a {Tuile::Component::Tabs::Tab}
786
+ and hands it back, and you keep it:
787
+
788
+ ```ruby
789
+ payment = sheet.add_tab("Payment", payment_form)
790
+ payment.caption = "Payment ⚠" # repaints the strip
791
+ payment.remove # the handle raises from here on
792
+ ```
793
+
794
+ The difference isn't cosmetic. An item is an element of a collection
795
+ somebody else owns — assign the whole array and the widget renders what it
796
+ finds. A tab is an identity with its own state, and it is what a
797
+ `TabSheet` keys its pane mapping by. Handing the strip a fresh array would
798
+ destroy those identities and the mapping with them, so the operation
799
+ simply isn't offered: you add and remove tabs one at a time. Captions are
800
+ mutable through the handle, and a removed one raises on every mutation and
801
+ on anything it would have to ask the strip — better than quietly answering
802
+ about a tab that is no longer there. Its caption stays readable, so an
803
+ error message can still name it.
804
+
805
+ ### Hiding a component means detaching it
806
+
807
+ Here is the part with consequences beyond this widget. **Tuile has no
808
+ visibility flag.** There is no `visible?`, no `display`; the empty rect you
809
+ met in chapter 2 gates *painting* and nothing else — an "invisible" widget
810
+ with an empty rect is still in the Tab cycle, still a target of the focus
811
+ cascades, still answering for the cursor. So hiding, in Tuile, means taking
812
+ something out of the tree, and that is exactly what a `TabSheet` does: only
813
+ the selected tab's pane is a child of the sheet, and the rest are detached.
814
+
815
+ Three things fall out of that, and they're the reason it's the right
816
+ mechanism rather than a workaround for a missing feature:
817
+
818
+ - **A hidden pane is invisible to everything** — the Tab cycle, focus,
819
+ repaint, the cursor, tree walks. Not because anything checks a flag, but
820
+ because it isn't there. There is no gate to get wrong.
821
+ - **Its state survives, because state is ivars.** Scroll position, caret,
822
+ list cursor, typed text: all exactly as the user left them. You can go on
823
+ mutating a hidden pane too, with no special handling and no "am I
824
+ visible?" check — `invalidate` on a detached component is a silent no-op,
825
+ and the sheet assigns it a rect and invalidates it when it comes back.
826
+ - **The lifecycle hooks fire on every switch.** `on_detached` when a pane
827
+ goes away, `on_attached` when it returns — so a
828
+ {Tuile::Component::ProgressBar} in a hidden tab stops its ticker and
829
+ restarts it on return, with no bookkeeping from you.
830
+
831
+ The cost is the mirror image of that last point: a pane that must keep
832
+ something *alive* while hidden can't, because chapter 4's
833
+ `on_attached`/`on_detached` contract is exactly what detachment triggers. The
834
+ way out is to move the thing that must not stop: give the resource to the
835
+ model your pane renders rather than to the pane, and let the pane pick up
836
+ its current state on return. That is usually the better shape anyway — if
837
+ you're reluctant to let a hidden pane's poller or subscription die, it
838
+ probably wanted to outlive the view all along.
839
+
840
+ The `TabSheet` pane in `examples/sampler.rb` demonstrates the state part
841
+ directly: scroll the prose tab, switch away, come back, and the status line
842
+ under the sheet reports the row you left it on.
843
+
844
+ ### When the strip is too narrow
845
+
846
+ Give a strip the width its captions need and there is nothing here to think
847
+ about; with the three to five tabs this shape is actually for, that is the
848
+ normal case. When a layout can't give it that width — a narrow terminal, a
849
+ caption that grew a badge — the strip **scrolls** rather than putting some of
850
+ its tabs out of reach. It keeps the selected segment whole in view and moves
851
+ its window by the smallest amount that does so, so arrowing along an
852
+ overflowing strip walks the selection off one edge and the strip follows it,
853
+ a tab at a time.
854
+
855
+ Two things tell you there is more strip than you can see. The captions at the
856
+ edges are cut mid-word, which is the oldest overflow hint there is; and a `<`
857
+ or `>` is painted over the edge column itself. The cue is there because the
858
+ cut alone isn't reliable — scroll to just the right place and a segment
859
+ boundary lands exactly on the edge, leaving clean space that reads as "that's
860
+ all of them". The cues are ASCII, they are painted whether or not the strip
861
+ has focus (overflow is a fact about the captions and the rect, not about
862
+ where you are), and they are not buttons: a click on one lands on the
863
+ half-visible segment underneath, which selects that tab and pulls it into
864
+ view — the direction the cue was pointing anyway.
865
+
866
+ The one thing a strip cannot do is show a caption wider than the whole rect.
867
+ There it gives you the head and clips the tail, on the grounds that the start
868
+ of a word identifies it and the end usually doesn't.
869
+
870
+ ## Menus
871
+
872
+ A menu bar is the other way to say "the app can do these things", and it
873
+ answers a different question from tabs. Tabs are a *map*: they show where
874
+ you are, and switching one changes what you're looking at. A menu is a
875
+ *catalogue of verbs*: it shows what you can do, it closes again the moment
876
+ you pick, and it leaves the screen exactly as it was. If the caption names
877
+ a place, use tabs; if it names an action, use a menu.
878
+
879
+ {Tuile::Component::MenuBar} is one row of captions, and each of them drops
880
+ open a menu:
881
+
882
+ ```ruby
883
+ bar = Component::MenuBar.new
884
+
885
+ file = bar.add_item("File")
886
+ file.add_item("New") { new_document }
887
+ file.add_item("Open") { open_dialog }
888
+ recent = file.add_item("Open recent") # no block ⇒ a submenu
889
+ recent.add_item("notes.txt") { open("notes.txt") }
890
+
891
+ bar.add_item("About") { show_about } # a top-level leaf: a button
892
+ ```
893
+
894
+ Two things about that snippet do most of the work. First, `add_item` is the
895
+ *same* method on the bar and on an item, so nesting needs no new vocabulary
896
+ — and because an item can hold items, submenus go as deep as you build
897
+ them. Second, whether an item is a submenu or an action is not something
898
+ you declare: an item with children *is* a submenu, and its own block (if
899
+ you gave it one) is simply dead. That's why `recent` above takes no block
900
+ and `"New"` does. A top-level item with no children isn't a menu at all —
901
+ it's a button on the bar, which is exactly how you get a single "About" or
902
+ "Help" entry without inventing a one-item menu for it.
903
+
904
+ An item with neither children nor a block is legal, and does nothing. It
905
+ highlights, Enter closes the menu, and nothing happens. That is a deliberate
906
+ non-decision: a half-built menu is a programming error you'll see the moment
907
+ you run the app, and it isn't worth an exception that fires while you're
908
+ still assembling the thing.
909
+
910
+ ### The bar keeps focus the whole time
911
+
912
+ This is the part worth understanding, because it explains everything else.
913
+ The open menus are **overlays**, not children of the bar — they're mounted on
914
+ the screen pane, floating above whatever they cover, and they never take
915
+ focus. Focus stays on the strip from the moment you open a menu until it
916
+ closes, however deep you drill. So the bar receives every keystroke and
917
+ decides what to do with it; the panels are things it draws and drives.
918
+
919
+ That is the same arrangement {Tuile::Component::Select} uses for its
920
+ dropdown (chapter 5 has the key-dispatch ladder this rests on), and it
921
+ buys two properties. The whole widget is one tab stop — Tab moves *past*
922
+ the bar, never into a menu. And nothing about menus needed adding to the
923
+ framework's key handling: a menu is not a mode.
924
+
925
+ The keyboard map is the one every menu bar has had since Turbo Vision, and
926
+ it's worth learning once because Vaadin, the web's ARIA pattern and every
927
+ other TUI toolkit agree on it:
928
+
929
+ | While the bar has focus | |
930
+ |---|---|
931
+ | Left / Right | move along the strip |
932
+ | Enter, Space, Down | open the highlighted menu |
933
+ | a mnemonic letter | open that menu (see below) |
934
+ | anything else | bubbles to your app |
935
+ | **Inside an open menu** | |
936
+ | Up / Down (PgUp/PgDn, Ctrl+U/D) | move the highlight |
937
+ | Right, Enter, Space | open the submenu under the highlight |
938
+ | Enter, Space | activate a row that has no submenu |
939
+ | Left | back to the previous menu |
940
+ | Left at the first level, Right on a plain row | step to the neighbouring menu |
941
+ | a mnemonic letter | activate that row of *this* menu |
942
+ | ESC | close one level |
943
+
944
+ Stepping sideways *shows* the neighbour's menu; it never presses anything. So
945
+ arrowing onto a top-level button — an item with a listener and no menu — closes
946
+ whatever was open and highlights it, and it fires only when you press Enter or
947
+ Space. Otherwise walking the strip would trigger every button on it.
948
+
949
+ The last row of the first block matters for real apps: while the bar merely
950
+ has focus, every other key **bubbles past it**, so a form's `s`-to-save or
951
+ a layout's `1`/`2`/`3` pane jumps keep working. An *open* menu is different
952
+ — it swallows what it doesn't recognize. A menu is a quasi-modal moment, and
953
+ an app key firing behind a panel you can see would be worse than a keystroke
954
+ that does nothing.
955
+
956
+ ### What it looks like, and why it isn't a tab strip
957
+
958
+ The strip paints ` File Edit View ` — each caption with a space either
959
+ side, no separator column, and the menu that Enter would open highlighted
960
+ while the bar has focus. Move focus away and the highlight goes entirely.
961
+
962
+ Compare that with the tab strip earlier in this chapter, which keeps its
963
+ selected caption **bold** even unfocused and rules its segments apart with
964
+ `│`. The difference is on purpose. A tab strip has to say where you are
965
+ after focus has moved on, so its selection is permanent and needs a channel
966
+ that survives losing focus. A menu bar has nothing permanent to say: close
967
+ the menu and no item is selected, because you are not "in" File the way you
968
+ are "on" the Details tab. Two one-row caption strips that looked the same
969
+ would make you work out which control you were looking at; these two don't.
970
+
971
+ What the two *do* share is everything from "When the strip is too narrow"
972
+ above. A bar that outgrows its terminal scrolls to keep the highlighted menu
973
+ whole in view, cues the hidden captions the same way, and stays reachable by
974
+ arrow, by mnemonic and by click — a mnemonic jumping to a menu off the right
975
+ edge brings it on screen before its panel opens.
976
+
977
+ ### Mnemonics: one letter per level
978
+
979
+ Give an item a letter and it answers to it:
980
+
981
+ ```ruby
982
+ file = bar.add_item("File", mnemonic: "f")
983
+ file.add_item("Export", mnemonic: "e") { export }
984
+ file.add_item("Quit", mnemonic: "q") { quit }
985
+ bar.add_item("Edit", mnemonic: "e").add_item("Copy", mnemonic: "c") { copy }
986
+ ```
987
+
988
+ The letter is underlined in the caption where it occurs — `F̲ile` — on the
989
+ strip and in every open panel, whether or not the bar has focus. There is no
990
+ Alt key to reveal them with, so they are simply always visible.
991
+
992
+ Now press `f`, then `q`: File opens, Quit fires. That reads like a two-key
993
+ accelerator, but it is nothing so clever — it is two ordinary keystrokes, and
994
+ the second one means something different because the first one changed what
995
+ is on screen. That is the whole rule:
996
+
997
+ > A mnemonic is matched against **one** set of items: the top-level ones
998
+ > while no menu is open, and the deepest open menu's while one is. Nothing
999
+ > else is ever consulted.
1000
+
1001
+ Read the example again with that in mind and notice what *cannot* happen.
1002
+ `Export` and `Edit` both bind `e`, and there is no conflict to resolve —
1003
+ with File open, `Edit` is not one of the candidates, so `e` means Export. If
1004
+ you close the menu first, `e` means Edit. The two are never in the same
1005
+ lookup, so the framework never has to guess, and you never have to hunt for
1006
+ a free letter across the whole tree. Only *siblings* compete, and two
1007
+ siblings claiming one letter is a mistake Tuile refuses at `add_item` rather
1008
+ than resolving at the keyboard.
1009
+
1010
+ The same rule says what a *wrong* letter does. With File open, `v` matches
1011
+ nothing in File's menu — and nothing happens. It does not fall out to the
1012
+ strip and open the View menu, because a mistyped letter tearing down the
1013
+ menu you are reading would be a poor trade for a shortcut. You get the
1014
+ terminal bell instead, and Left, Right and ESC are still there to move.
1015
+
1016
+ One cost to know about, because it is what a mnemonic *means*: while the bar
1017
+ has focus, its letters win. A `mnemonic: "s"` eats the `s`-to-save described
1018
+ above, and a `mnemonic: "q"` inside a popup eats the popup's own `q`-to-close.
1019
+ That is the bubble working correctly — the focused component is asked first
1020
+ — but it is worth a thought before binding a common letter.
1021
+
1022
+ **A click outside an open menu closes it.** The panels float above the UI
1023
+ without blocking it (chapter 3's overlays are all like this), so the click
1024
+ still reaches whatever it was aimed at — but on its way the screen dismisses
1025
+ the cascade, so a menu can't linger over content it no longer belongs to.
1026
+ Clicking *within* the menu is not outside it, whichever panel you land on:
1027
+ the panels are chained to each other, so drilling into a submenu by mouse
1028
+ leaves the levels above it standing. ESC and Tab also get you out.
1029
+
1030
+ ### One thing it deliberately doesn't do
1031
+
1032
+ **A resize closes an open menu.** Every panel is positioned against
1033
+ something — the strip segment it dropped from, or the parent row it cascaded
1034
+ out of — so after the terminal changes size those positions are all stale.
1035
+ Recomputing them level by level is possible; closing is unambiguous, and
1036
+ every GUI dismisses its menus on a window resize too.
1037
+
633
1038
  ## Overlays
634
1039
 
635
1040
  {Tuile::Component::Popup} is how you float something above the tiled UI.
636
1041
  The popup itself paints nothing — it's a transparent host that wraps any
637
1042
  component as its content and manages the lifecycle (`open` / `close`,
638
1043
  ESC/`q` to dismiss). Crucially, and per chapter 3, **it does not size
639
- itself to its content**: its box is declared by `size` — a `Fraction`
1044
+ itself to its content**: its box is set by `declared_size` — a `Fraction`
640
1045
  (default `Fraction::HALF`, half the screen, re-resolved on every resize)
641
1046
  or an absolute `Size`. The content then fills that box, so use content
642
1047
  that can cope with overflow — a TextView or TextArea that scrolls, not a
643
1048
  bare Label that only truncates.
644
1049
 
645
- A popup is **modal by default**: centered, it grabs focus, eats keys, and
1050
+ A popup is **always modal**: centered, it grabs focus, eats keys, and
646
1051
  blocks clicks beneath it — that's what makes an open dialog trap Tab and
647
- input inside itself. Pass `modal: false` for a non-modal overlay that
648
- floats above the content without taking focus — the autocomplete-list case
649
- from earlier, where the caller positions it against a field's caret and
650
- drives it from app code.
1052
+ input inside itself. For a layer that floats *without* taking focus — the
1053
+ autocomplete-list case from earlier, where the caller positions it against a
1054
+ field's caret and drives it from app code — use its base class, `Overlay`,
1055
+ directly. An `Overlay` is a Popup minus the modality: same open/close
1056
+ lifecycle, same outside-click dismissal, but it sits at the rect you assign
1057
+ and never disturbs focus or key dispatch.
1058
+
1059
+ **A left click outside an overlay closes it**, modal or not — the same light
1060
+ dismissal a desktop dialog gives you. It's a per-overlay switch,
1061
+ `close_on_outside_click`, on by default; one that must survive stray
1062
+ clicks turns it off, as a Notification does. The click still reaches
1063
+ whatever was beneath it, unless an open modal swallowed it — in which case
1064
+ the first click dismisses and a second one acts.
1065
+
1066
+ "Outside" means more than "outside this rectangle". An overlay opened by a
1067
+ component that lives inside another popup — a ComboBox on a dialog, whose
1068
+ dropdown drops past the dialog's own border — says so with `owner`, and a
1069
+ click landing in it then counts as landing inside the dialog too. Otherwise
1070
+ picking from the dropdown would dismiss the form under it. Overlays that
1071
+ *aren't* related that way stay independent: clicking one dismisses the
1072
+ other, as two dismissable windows should. You only need `owner` when you
1073
+ build a compound overlay of your own; the built-in ones already set it.
651
1074
 
652
1075
  Because the popup is just a transparent host, you get a bordered dialog by
653
1076
  wrapping a Window:
@@ -662,6 +1085,116 @@ A nested TextField still swallows printable keys first, so typing `q` into
662
1085
  a field inside a popup doesn't dismiss it — the popup's own `q` handler sits
663
1086
  on the ancestor, and only sees keys the field declined.
664
1087
 
1088
+ ## The confirm dialog
1089
+
1090
+ That Window-in-a-Popup assembly is how you build any dialog. One dialog is
1091
+ so common it comes pre-assembled: {Tuile::Component::ConfirmWindow}, the
1092
+ "are you sure?" box — a caption, a short message, a row of buttons.
1093
+
1094
+ ```ruby
1095
+ Component::ConfirmWindow.confirm("Delete Report Q4?", "This cannot be undone.",
1096
+ confirm: "Delete") { delete! }
1097
+ ```
1098
+
1099
+ ```
1100
+ ┌Delete Report Q4?───────────┐
1101
+ │ This cannot be undone. │
1102
+ │ │
1103
+ │ [ Delete ] [ Cancel ] │
1104
+ └────────────────────────────┘
1105
+ ```
1106
+
1107
+ Three factories cover the shapes you'll actually write: `alert(caption,
1108
+ message)` is the one-button acknowledgement, `confirm` the two-button
1109
+ question — its labels are keywords, so it is also your OK/Cancel and
1110
+ Delete/Cancel — and `yes_no` the other canonical phrasing. That's
1111
+ deliberately the whole list: Windows' `MessageBoxButtons` enum grew six
1112
+ values by naming every label pair, and any set the factories don't cover is
1113
+ a few lines of the builder below.
1114
+
1115
+ ### Why a block, and not an answer
1116
+
1117
+ Everyone's first instinct here is the blocking call — `if confirm?("Delete?")`
1118
+ — because that's what Swing's `JOptionPane`, tkinter's `askyesno` and GTK's
1119
+ `dialog.run` all offer. Tuile can't, and it's worth understanding why: the
1120
+ whole UI runs on one thread (chapter 4), so a call that *waits* for the
1121
+ answer would have to nest a second event loop inside the first, re-entering
1122
+ raw mode under a key thread that's already reading stdin. The dialog
1123
+ therefore takes callbacks: the block is the action, and the dialog returns
1124
+ immediately.
1125
+
1126
+ ### One kind of way out
1127
+
1128
+ Every button closes the dialog. A button *with* a block then fires it; a
1129
+ button *without* one is a Cancel. And ESC, `q`, a click outside the box and
1130
+ that Cancel button are all the same event — `on_dismiss`, fired exactly
1131
+ once, and only when no action button was chosen. You never write a `case`
1132
+ over outcomes, and you never have to enumerate the ways out: there is the
1133
+ action you asked about, and there is "do nothing", however the user spells
1134
+ it.
1135
+
1136
+ There is deliberately no way to keep the dialog open after a press. A
1137
+ dialog that leads somewhere — "Copy files" showing a progress window —
1138
+ opens the next window *from its callback*, which is safe because the block
1139
+ fires after the dialog has already closed and focus has been repaired.
1140
+
1141
+ For any other button set, the component is its own builder — buttons are
1142
+ declared one at a time, each a caption and an optional block:
1143
+
1144
+ ```ruby
1145
+ dialog = Component::ConfirmWindow.new("Unsaved changes")
1146
+ dialog.message = "Save your changes before leaving?"
1147
+ dialog.button("Save") { save! }
1148
+ dialog.button("Discard") { discard! }
1149
+ dialog.button("Cancel") # no block: pressing it dismisses
1150
+ dialog.on_dismiss = -> { stay_put }
1151
+ dialog.open
1152
+ ```
1153
+
1154
+ ### Keys, and the underlined letters
1155
+
1156
+ Focus opens on the first button — which, since Enter presses the *focused*
1157
+ button, makes it the default. Left/Right and Tab walk the row; Enter or
1158
+ Space press.
1159
+
1160
+ Each button also answers to a **mnemonic**: a letter, underlined in its
1161
+ caption, that presses the button from anywhere in the dialog. By default
1162
+ it's the caption's first letter (Save gets `s`, Discard `d`), matched
1163
+ case-insensitively; pass `mnemonic:` to pick another letter — the case you
1164
+ give chooses which occurrence gets the underline — or `nil` for none. The
1165
+ underline isn't decoration: Tuile draws no status bar to advertise keys in,
1166
+ so the caption *is* the advertisement.
1167
+
1168
+ Three letters are never mnemonics. `q` is unconditionally the do-nothing
1169
+ route out — a dialog that forces a choice only thinks it does, since the
1170
+ user can always Ctrl+C, and pretending there's no escape route just trains
1171
+ them to reach for it. And `g`/`G` belong to the message: the body scrolls
1172
+ *without taking focus* — Up/Down, PgUp/PgDn, Ctrl+U/D, Home/End and the
1173
+ less-style `g`/`G` are handed to it while a button keeps focus, so a long
1174
+ message reads without any focus gymnastics. The body is also a tab stop:
1175
+ Shift+Tab reaches it, so overflowing prose is visibly reachable, not
1176
+ secretly scrollable.
1177
+
1178
+ ### The popup that sizes itself
1179
+
1180
+ Chapter 3 was firm that nothing in Tuile sizes itself to its content, and a
1181
+ plain Popup takes half the screen whatever it wraps — absurd around a
1182
+ one-line "Delete?". The confirm dialog is the sanctioned exception *shape*:
1183
+ it measures **content it owns** — its caption, its message, its buttons —
1184
+ and asks the screen for exactly that box, still capped at half the screen
1185
+ (a message longer than the cap wraps and scrolls). Assign a `Component` as
1186
+ the message and the measuring honestly gives up: injected content is not
1187
+ the dialog's to measure, so the popup takes the full half-screen box.
1188
+
1189
+ ### What it deliberately isn't
1190
+
1191
+ The message is prose — a `String` or {Tuile::StyledString}, which on a TTY
1192
+ already covers color, emphasis and iconography — not a content slot. A
1193
+ dialog collecting *input* is not a confirm dialog: the moment you want a
1194
+ form, a picker or a diff view in there, you've outgrown the sugar, and the
1195
+ general mechanism is one line away — `Popup.new(content: your_layout)`,
1196
+ exactly as in the previous section.
1197
+
665
1198
  ## Notifications
666
1199
 
667
1200
  {Tuile::Component::Notification} is the one overlay you don't assemble at
@@ -700,7 +1233,9 @@ Everything else follows from "a toast must not interrupt": it's a non-modal
700
1233
  popup, so it takes no focus, receives no keys (not even the `q` a normal
701
1234
  popup would claim), and blocks no click outside its own box. You keep
702
1235
  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.
1236
+ leaves around you. A left-click on the box dismisses the whole thing early;
1237
+ a click anywhere else doesn't, because a toast is timed and an unrelated
1238
+ click isn't about it.
704
1239
 
705
1240
  Two limits are worth knowing before you reach them. A long message wraps to
706
1241
  at most three rows and is then ellipsized — the box is capped at 40 % of
@@ -715,18 +1250,23 @@ The last three components are conveniences: common Window-plus-content
715
1250
  assemblies you'd otherwise build by hand. Each works tiled (add it to a
716
1251
  layout) *or* as a popup (via a class-level `open`).
717
1252
 
718
- - {Tuile::Component::InfoWindow} — a Window preloaded with a List of
719
- static lines. The read-only "here's some information" box;
720
- `InfoWindow.open(caption, lines)` pops it up.
1253
+ - {Tuile::Component::InfoWindow} — a Window with a read-only body, in one
1254
+ of two presentations: `message=` is *prose*, wrapped by a scrollable
1255
+ TextView; `lines=` is *rows*, a List keeping one item per row and
1256
+ truncating — the choice for columnar output, where a wrap would destroy
1257
+ the alignment. `InfoWindow.open(caption, body)` pops it up, picking the
1258
+ presentation from the body's type (an Array is rows, text is prose).
721
1259
  - {Tuile::Component::PickerWindow} — a menu of options each bound to a
722
1260
  single key, firing your block with the picked key. Popped up via `open`,
723
1261
  it closes itself after a pick; ESC/`q` cancels without firing.
724
- - {Tuile::Component::LogWindow} — a Window wrapping an auto-scrolling,
725
- scrollbar-equipped TextView, purpose-built for log output. Its `log`
726
- method is **thread-safe** — it marshals the append back onto the UI
727
- thread via the event queue (chapter 4), so background work can log
728
- freely. And it carries an `IO`-shaped adapter so you can point a stdlib
729
- `Logger` (or a `TTY::Logger`) straight at it:
1262
+ - {Tuile::Component::LogWindow} — a Window framing a
1263
+ {Tuile::Component::LogTextView}: an auto-scrolling, scrollbar-equipped
1264
+ TextView purpose-built for log output. The view is where the behavior
1265
+ lives — compose it bare into a layout when you don't want the frame.
1266
+ Its `log` method is **thread-safe** — it marshals the append back onto
1267
+ the UI thread via the event queue (chapter 4), so background work can
1268
+ log freely. And the view carries an `IO`-shaped adapter so you can point
1269
+ a stdlib `Logger` (or a `TTY::Logger`) straight at either of them:
730
1270
 
731
1271
  ```ruby
732
1272
  window = Component::LogWindow.new