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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -27
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +2342 -158
- data/README.md +151 -505
- data/TERMINOLOGY.md +15 -5
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +28 -20
- data/book/05-focus.md +137 -19
- data/book/06-theming.md +103 -2
- data/book/07-components.md +567 -27
- data/book/08-testing.md +34 -4
- data/book/09-styled-text.md +3 -3
- data/book/README.md +7 -5
- data/examples/file_commander.rb +23 -17
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +527 -108
- data/ideas/arrow-key-navigation.md +17 -1
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +28 -27
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +12 -3
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/has_content.rb +22 -9
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/layout.rb +0 -10
- data/lib/tuile/component/list.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +102 -11
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +24 -39
- data/lib/tuile/component/overlay.rb +192 -0
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +61 -123
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +17 -7
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +231 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area.rb +5 -4
- data/lib/tuile/component/text_field.rb +23 -6
- data/lib/tuile/component/text_view.rb +8 -5
- data/lib/tuile/component/window.rb +22 -46
- data/lib/tuile/component.rb +186 -31
- data/lib/tuile/event_queue.rb +45 -1
- data/lib/tuile/fake_screen.rb +40 -2
- data/lib/tuile/keys.rb +72 -0
- data/lib/tuile/screen.rb +212 -113
- data/lib/tuile/screen_pane.rb +125 -41
- data/lib/tuile/styled_string.rb +80 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2527 -358
- metadata +13 -3
- data/mise.toml +0 -2
data/book/07-components.md
CHANGED
|
@@ -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`,
|
|
75
|
-
click speak columns, and the field converts between
|
|
76
|
-
assuming they're the same number. You don't need to think
|
|
77
|
-
a TextField; you do the moment you write a component that
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
|
541
|
-
{Tuile::Component::ProgressBar} does
|
|
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
|
|
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
|
|
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.
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
|
719
|
-
|
|
720
|
-
`
|
|
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
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|