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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/DECISIONS.md +1297 -13
- data/README.md +136 -490
- data/TERMINOLOGY.md +11 -2
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +11 -10
- data/book/05-focus.md +133 -18
- data/book/06-theming.md +5 -2
- data/book/07-components.md +402 -12
- data/book/08-testing.md +18 -4
- data/book/README.md +7 -5
- data/examples/file_commander.rb +22 -16
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +385 -66
- data/ideas/arrow-key-navigation.md +16 -0
- data/ideas/new-components.md +7 -6
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/combo_box.rb +3 -1
- data/lib/tuile/component/list.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +86 -3
- 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 +14 -11
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +75 -9
- data/lib/tuile/component/select.rb +3 -1
- data/lib/tuile/component/tab_sheet.rb +242 -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.rb +38 -13
- data/lib/tuile/event_queue.rb +25 -1
- data/lib/tuile/fake_screen.rb +14 -0
- data/lib/tuile/keys.rb +65 -0
- data/lib/tuile/screen.rb +94 -77
- data/lib/tuile/screen_pane.rb +109 -27
- data/lib/tuile/styled_string.rb +40 -0
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1473 -93
- metadata +6 -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,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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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,
|
|
70
|
-
|
|
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 —
|
|
78
|
-
|
|
79
|
-
*when and why* you reach for each.
|
|
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.
|
data/examples/file_commander.rb
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 -
|
|
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,
|
data/examples/hello_world.rb
CHANGED
|
@@ -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 =
|
|
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 =
|
|
34
|
+
screen.content = root
|
|
23
35
|
window.focus
|
|
24
36
|
begin
|
|
25
37
|
screen.run_event_loop
|