tuile 0.12.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/DECISIONS.md +1297 -13
  4. data/README.md +136 -490
  5. data/TERMINOLOGY.md +11 -2
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +18 -5
  8. data/book/03-layout.md +11 -10
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +5 -2
  11. data/book/07-components.md +402 -12
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +22 -16
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +385 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +7 -6
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/component/abstract_string_field.rb +36 -0
  21. data/lib/tuile/component/combo_box.rb +3 -1
  22. data/lib/tuile/component/list.rb +22 -0
  23. data/lib/tuile/component/list_dropdown.rb +86 -3
  24. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  25. data/lib/tuile/component/menu_bar.rb +582 -0
  26. data/lib/tuile/component/notification.rb +14 -11
  27. data/lib/tuile/component/picker_window.rb +0 -5
  28. data/lib/tuile/component/popup.rb +75 -9
  29. data/lib/tuile/component/select.rb +3 -1
  30. data/lib/tuile/component/tab_sheet.rb +242 -0
  31. data/lib/tuile/component/tabs.rb +528 -0
  32. data/lib/tuile/component/text_area.rb +5 -4
  33. data/lib/tuile/component/text_field.rb +23 -6
  34. data/lib/tuile/component/text_view.rb +8 -5
  35. data/lib/tuile/component.rb +38 -13
  36. data/lib/tuile/event_queue.rb +25 -1
  37. data/lib/tuile/fake_screen.rb +14 -0
  38. data/lib/tuile/keys.rb +65 -0
  39. data/lib/tuile/screen.rb +94 -77
  40. data/lib/tuile/screen_pane.rb +109 -27
  41. data/lib/tuile/styled_string.rb +40 -0
  42. data/lib/tuile/version.rb +1 -1
  43. data/sig/tuile.rbs +1473 -93
  44. metadata +6 -3
  45. 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,6 +674,334 @@ window.content = Component::List.new.tap { _1.lines = entries }
630
674
  window.scrollbar = true
631
675
  ```
632
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
+
633
1005
  ## Overlays
634
1006
 
635
1007
  {Tuile::Component::Popup} is how you float something above the tiled UI.
@@ -649,6 +1021,22 @@ floats above the content without taking focus — the autocomplete-list case
649
1021
  from earlier, where the caller positions it against a field's caret and
650
1022
  drives it from app code.
651
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
+
652
1040
  Because the popup is just a transparent host, you get a bordered dialog by
653
1041
  wrapping a Window:
654
1042
 
@@ -700,7 +1088,9 @@ Everything else follows from "a toast must not interrupt": it's a non-modal
700
1088
  popup, so it takes no focus, receives no keys (not even the `q` a normal
701
1089
  popup would claim), and blocks no click outside its own box. You keep
702
1090
  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.
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.
704
1094
 
705
1095
  Two limits are worth knowing before you reach them. A long message wraps to
706
1096
  at most three rows and is then ellipsized — the box is capped at 40 % of
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
data/book/README.md CHANGED
@@ -66,17 +66,19 @@ one, not to fill an outline.
66
66
  `focusable?`, and the three-rung order in which a keystroke is offered
67
67
  to the tree — Tab, global shortcuts, then `handle_key` delivered to
68
68
  focus and bubbling up its ancestors. Why scope-wide keys (pane jumps, a
69
- form's default button) belong on an ancestor, and how `keyboard_hint`
70
- drives the status bar.
69
+ form's default button) belong on an ancestor, why a paste rides its own
70
+ path rather than the ladder, and how to write a status line over
71
+ `on_focus_changed` — Tuile draws none for you.
71
72
  6. **[Theming](06-theming.md).** Semantic color tokens read at paint
72
73
  time, opt-in component backgrounds that inherit down the tree
73
74
  (`bg_color`), light/dark auto-detection at startup and live OS
74
75
  appearance flips, pairing variants in a `ThemeDef`, app-specific custom
75
76
  tokens, and rebuilding theme-derived content in `on_theme_changed`.
76
77
  7. **[The component library](07-components.md).** A narrative tour of
77
- the shipped toolbox — Window, List, the text inputs and views,
78
- ProgressBar, Popup, and the window conveniences — framed around
79
- *when and why* you reach for each. Signatures stay in the rdoc.
78
+ the shipped toolbox — the text inputs and views, the value fields, the
79
+ selectors, Button, ProgressBar, Window, TabSheet, MenuBar, Popup and the
80
+ window conveniences — framed around *when and why* you reach for each.
81
+ Signatures stay in the rdoc.
80
82
  8. **[Testing a Tuile app](08-testing.md).** The testing approach:
81
83
  `FakeScreen`, asserting against the painted buffer, driving
82
84
  invalidation, and PTY-based end-to-end tests of runnable scripts.
@@ -4,7 +4,9 @@
4
4
  # Tuile two-pane file commander. Two windows side by side, each showing a
5
5
  # directory listing. Tab switches active pane; arrows / jk move the cursor;
6
6
  # Enter descends into a directory (no-op on a regular file); Backspace
7
- # ascends to the parent. The header label shows the active pane's cwd.
7
+ # ascends to the parent. The header label shows the active pane's cwd, and a
8
+ # static status line spells out the keys — Tuile draws no status bar and
9
+ # reserves no row, so both are ordinary children of the layout.
8
10
  # Unreadable directories surface an InfoWindow. Layout follows the
9
11
  # terminal on resize (WINCH) — the framework dispatches a TTYSizeEvent and
10
12
  # the layout's `rect=` rebuilds the geometry.
@@ -107,18 +109,6 @@ module FileCommanderExample
107
109
  end
108
110
  end
109
111
 
110
- # A pane window that advertises navigation shortcuts in the status bar.
111
- # The active window's `keyboard_hint` is rendered by {Tuile::Screen}
112
- # alongside the global `q` quit hint, so all the user-facing controls
113
- # land in one place.
114
- class PaneWindow < Tuile::Component::Window
115
- def keyboard_hint
116
- "Tab #{screen.theme.hint("Switch")} " \
117
- "Enter #{screen.theme.hint("Open")} " \
118
- "Bksp #{screen.theme.hint("Up")}"
119
- end
120
- end
121
-
122
112
  # Top-level layout. Header label on the first row, two side-by-side
123
113
  # windows below. `rect=` re-runs on the initial mount and on every WINCH,
124
114
  # so the split tracks the terminal size automatically.
@@ -128,19 +118,34 @@ module FileCommanderExample
128
118
  @header = Tuile::Component::Label.new
129
119
  add(@header)
130
120
 
131
- @left_window = PaneWindow.new
121
+ @left_window = Tuile::Component::Window.new
132
122
  @left_list = DirList.new(left_dir)
133
123
  @left_list.on_cwd_changed = method(:refresh_header)
134
124
  @left_window.content = @left_list
135
125
  @left_window.scrollbar = true
136
126
  add(@left_window)
137
127
 
138
- @right_window = PaneWindow.new
128
+ @right_window = Tuile::Component::Window.new
139
129
  @right_list = DirList.new(right_dir)
140
130
  @right_list.on_cwd_changed = method(:refresh_header)
141
131
  @right_window.content = @right_list
142
132
  @right_window.scrollbar = true
143
133
  add(@right_window)
134
+
135
+ # The status line. Every key here works in both panes, so the row never
136
+ # changes and nothing needs to watch focus — a status line is only worth
137
+ # wiring to Tuile::Screen#on_focus_changed= when its text actually varies
138
+ # with the focused component. `theme.hint` bakes its colors in, so the
139
+ # one thing this label does watch is a light/dark flip.
140
+ @status = Tuile::Component::Label.new
141
+ render_status = lambda do
142
+ t = screen.theme
143
+ @status.text = "q #{t.hint("quit")} Tab #{t.hint("Switch")} " \
144
+ "Enter #{t.hint("Open")} Bksp #{t.hint("Up")}"
145
+ end
146
+ render_status.call
147
+ @status.on_theme_changed = render_status
148
+ add(@status)
144
149
  end
145
150
 
146
151
  attr_reader :left_window
@@ -150,8 +155,9 @@ module FileCommanderExample
150
155
  return if rect.empty?
151
156
 
152
157
  @header.rect = Tuile::Rect.new(rect.left, rect.top, rect.width, 1)
158
+ @status.rect = Tuile::Rect.new(rect.left, rect.top + rect.height - 1, rect.width, 1)
153
159
  body_top = rect.top + 1
154
- body_height = [rect.height - 1, 0].max
160
+ body_height = [rect.height - 2, 0].max
155
161
  half = rect.width / 2
156
162
  @left_window.rect = Tuile::Rect.new(rect.left, body_top, half, body_height)
157
163
  @right_window.rect = Tuile::Rect.new(rect.left + half, body_top,
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Tuile hello-world. A Window wrapping a Label.
4
+ # Tuile hello-world. A Window wrapping a Label, over a status line the app
5
+ # owns — Tuile draws no chrome of its own and reserves no row.
5
6
  #
6
7
  # Run from the gem root:
7
8
  # bundle exec ruby -Ilib examples/hello_world.rb
@@ -14,12 +15,23 @@ require "tuile"
14
15
  # Tuile::Screen.instance during invalidate/repaint hooks.
15
16
  screen = Tuile::Screen.new
16
17
 
17
- label = Tuile::Component::Label.new("Hello, world!")
18
-
19
18
  window = Tuile::Component::Window.new("Tuile")
20
- window.content = label
19
+ window.content = Tuile::Component::Label.new("Hello, world!")
20
+
21
+ # The status line. `theme.hint` styles the *description* half of a "key what"
22
+ # pair, and bakes the color in — so the label rebuilds itself from
23
+ # `on_theme_changed` to follow a light/dark flip.
24
+ status = Tuile::Component::Label.new
25
+ render_status = -> { status.text = "q #{screen.theme.hint("quit")}" }
26
+ render_status.call
27
+ status.on_theme_changed = render_status
28
+
29
+ # One row for the status line, everything else to the window.
30
+ root = Tuile::Component::Layout::Vertical.new
31
+ root.add(window, Tuile::Component::Layout::Expand[1])
32
+ root.add(status, Tuile::Component::Layout::Fixed[1])
21
33
 
22
- screen.content = window
34
+ screen.content = root
23
35
  window.focus
24
36
  begin
25
37
  screen.run_event_loop