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