tuile 0.15.0 → 0.17.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 +229 -80
- data/README.md +49 -24
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +17 -16
- data/book/05-focus.md +106 -34
- data/book/06-theming.md +108 -38
- data/book/07-components.md +249 -46
- data/book/08-testing.md +134 -32
- data/book/10-locale.md +3 -3
- data/book/README.md +11 -10
- data/examples/file_commander.rb +52 -32
- data/examples/hello_world.rb +18 -5
- data/examples/sampler.rb +576 -146
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +96 -97
- data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +27 -19
- data/lib/tuile/component/checkbox.rb +21 -19
- data/lib/tuile/component/checkbox_group.rb +17 -18
- data/lib/tuile/component/combo_box.rb +69 -64
- data/lib/tuile/component/confirm_window.rb +34 -27
- data/lib/tuile/component/date_field.rb +50 -18
- data/lib/tuile/component/date_time_field.rb +319 -0
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +99 -28
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +8 -15
- data/lib/tuile/component/has_placeholder.rb +1 -1
- data/lib/tuile/component/has_validation.rb +40 -14
- data/lib/tuile/component/has_value.rb +71 -17
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -60
- data/lib/tuile/component/layout.rb +127 -13
- data/lib/tuile/component/list.rb +233 -120
- data/lib/tuile/component/list_dropdown.rb +151 -91
- data/lib/tuile/component/menu_bar/cascade.rb +102 -32
- data/lib/tuile/component/menu_bar.rb +102 -82
- data/lib/tuile/component/notification.rb +76 -49
- data/lib/tuile/component/overlay.rb +217 -58
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +41 -17
- data/lib/tuile/component/popup.rb +15 -26
- data/lib/tuile/component/progress_bar.rb +17 -11
- data/lib/tuile/component/radio_group.rb +16 -17
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +26 -43
- data/lib/tuile/component/slot.rb +4 -5
- data/lib/tuile/component/tab_sheet.rb +27 -34
- data/lib/tuile/component/tabs.rb +49 -34
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +32 -28
- data/lib/tuile/component/text_field.rb +68 -50
- data/lib/tuile/component/text_view.rb +157 -89
- data/lib/tuile/component/time_field.rb +51 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +653 -323
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +18 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +120 -7
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +233 -0
- data/lib/tuile/mouse.rb +244 -0
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +510 -138
- data/lib/tuile/screen_pane.rb +185 -67
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +144 -14
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +316 -42
- data/lib/tuile/theme.rb +192 -53
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +6084 -1507
- metadata +19 -17
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -8562
- data/TERMINOLOGY.md +0 -85
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/binder.md +0 -177
- data/ideas/composite-field.md +0 -77
- data/ideas/focus-accent.md +0 -116
- data/ideas/form-layout.md +0 -151
- data/ideas/hover/probe.rb +0 -241
- data/ideas/hover/probe_spec.rb +0 -82
- data/ideas/hover.md +0 -909
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -144
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/book/07-components.md
CHANGED
|
@@ -117,9 +117,9 @@ protect the plaintext in memory: it's an ordinary Ruby string, and
|
|
|
117
117
|
anything stronger is a job for a type the whole application cooperates
|
|
118
118
|
with.
|
|
119
119
|
|
|
120
|
-
Both inherit the same event hooks from the base. `
|
|
121
|
-
the text changes; `on_escape`
|
|
122
|
-
`
|
|
120
|
+
Both inherit the same event hooks from the base. `on_value_change` fires
|
|
121
|
+
whenever the text changes; `on_escape` reacts to ESC; `on_enter`, `on_key_up` and
|
|
122
|
+
`on_key_down` each claim one key. Notice what
|
|
123
123
|
they have in common: every one of them either *reports* something or takes a
|
|
124
124
|
**single named key** whose meaning the field itself has no use for. There is
|
|
125
125
|
deliberately no callback that intercepts keys in general — to change what
|
|
@@ -127,14 +127,26 @@ keys *do*, you subclass (see *Keeping input out of a field*, below).
|
|
|
127
127
|
|
|
128
128
|
```ruby
|
|
129
129
|
field = Component::TextField.new
|
|
130
|
-
field.
|
|
131
|
-
field.on_enter
|
|
130
|
+
field.on_value_change { |e| filter_results(e.value) }
|
|
131
|
+
field.on_enter { submit } # empty (default) → Enter bubbles to the parent
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
-
Note that `on_enter` / `on_key_up` / `on_key_down` on a TextField,
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
Note that `on_enter` / `on_key_up` / `on_key_down` on a TextField, while
|
|
135
|
+
*empty*, let those keys *fall through* to the parent — that's how Enter in a
|
|
136
|
+
search field can trigger the surrounding window's action while the field still
|
|
137
|
+
handles ordinary typing. Register a listener and the field starts consuming the
|
|
138
|
+
key; remove it again and the key bubbles once more.
|
|
139
|
+
|
|
140
|
+
ESC is the one that does not start out falling through, because a field blurs
|
|
141
|
+
on it — cancelling text entry rather than quitting the app. That blur is
|
|
142
|
+
`escape_clears_focus`, a flag rather than a listener sitting in the slot, so an
|
|
143
|
+
`on_escape` listener runs *beside* it. Turn the flag off to give ESC another
|
|
144
|
+
meaning outright, and with the slot empty as well ESC bubbles like the rest:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
field.escape_clears_focus = false
|
|
148
|
+
field.on_escape { close_search_row } # ESC closes the row; focus stays put
|
|
149
|
+
```
|
|
138
150
|
|
|
139
151
|
Three editing keys are worth knowing because nothing on screen advertises
|
|
140
152
|
them. Ctrl+Left and Ctrl+Right jump by a word; **Ctrl+W** deletes the word
|
|
@@ -249,7 +261,7 @@ the value, and stays silent.
|
|
|
249
261
|
|
|
250
262
|
```ruby
|
|
251
263
|
qty = Component::IntegerField.new
|
|
252
|
-
qty.on_value_change
|
|
264
|
+
qty.on_value_change { |e| recompute(e.value) } # e.value is an Integer, or nil
|
|
253
265
|
qty.value = 3
|
|
254
266
|
```
|
|
255
267
|
|
|
@@ -370,7 +382,7 @@ calendar is different: it answers what weekday the 17th is), and with your hands
|
|
|
370
382
|
already on the keys, typing `1345` beats scrolling to it. Tuile is
|
|
371
383
|
keyboard-first: the mouse gets what falls out of click routing for free and never
|
|
372
384
|
motivates a widget on its own. The ranking behind that is `D_mouse` in
|
|
373
|
-
`
|
|
385
|
+
`design/decisions.md`.
|
|
374
386
|
|
|
375
387
|
What you get for it is that the two questions stay independent. Switching
|
|
376
388
|
precision never touches the spelling, so a Finnish user sees `13.45` and
|
|
@@ -501,7 +513,7 @@ cells beside the field, which belong to whatever laid the field out. Hence
|
|
|
501
513
|
{Tuile::Component::HasValidation}, which every field has:
|
|
502
514
|
|
|
503
515
|
```ruby
|
|
504
|
-
login.on_click
|
|
516
|
+
login.on_click do
|
|
505
517
|
username.error_message = username.empty? ? "Username is required" : nil
|
|
506
518
|
password.error_message = password.empty? ? "Password is required" : nil
|
|
507
519
|
next if [username, password].any?(&:error_message)
|
|
@@ -540,6 +552,24 @@ not parse, and goes quiet again on your next edit. Only the *ink* waits.
|
|
|
540
552
|
`bad_input?` is still answered from the current buffer the instant you ask it,
|
|
541
553
|
which is what keeps the Save handler above correct with no change at all.
|
|
542
554
|
|
|
555
|
+
There is a second half to this, and it bites harder, because some prefixes of a
|
|
556
|
+
date do not merely fail to parse — they parse *cleanly*. In a `dd.mm.yyyy`
|
|
557
|
+
field, `1.1.2` on the way to `1.1.2024` is the first of January in the year 2:
|
|
558
|
+
a perfectly good `Date`, one `bad_input?` will never flag, and one your
|
|
559
|
+
listener would be handed while the user is still typing, along with whatever
|
|
560
|
+
recalculation hangs off it. So the date and time fields settle their *notice*
|
|
561
|
+
on those same two gestures: `on_value_change` fires when you leave the field or
|
|
562
|
+
press Enter, not as you type. Reading `value` is again unaffected, so a Save on
|
|
563
|
+
a keyboard shortcut that never moves focus still sees the date on screen — and
|
|
564
|
+
a value nobody had to type, a `value=` or an Up/Down step or a `clear`, is
|
|
565
|
+
announced the moment it happens.
|
|
566
|
+
|
|
567
|
+
The ink and the notice settle together because one question decides both, and
|
|
568
|
+
it is the prefix-closed question from a few pages back. Every buffer an
|
|
569
|
+
`IntegerField` passes through really is the number it shows, so `4` on the way
|
|
570
|
+
to `42` is worth announcing and worth reddening. A date's are neither. That one
|
|
571
|
+
property of the grammar settles the filter, the ink and the notice alike.
|
|
572
|
+
|
|
543
573
|
The one discipline the writer owes is visible in those `: nil` branches: **set
|
|
544
574
|
or clear on every pass.** Only assign the message where you validate, and a
|
|
545
575
|
field that has been fixed goes back to normal on its own. Forget the clear and
|
|
@@ -549,7 +579,7 @@ To show the text, subscribe — and put it in cells you own:
|
|
|
549
579
|
|
|
550
580
|
```ruby
|
|
551
581
|
error = Component::Label.new
|
|
552
|
-
username.on_error_message_change
|
|
582
|
+
username.on_error_message_change { |e| error.text = e.error_message || StyledString::EMPTY }
|
|
553
583
|
```
|
|
554
584
|
|
|
555
585
|
That listener is not decoration. The field repaints *itself* when its verdict
|
|
@@ -565,19 +595,75 @@ its parent, as do the rows of a `RadioGroup`'s list.
|
|
|
565
595
|
|
|
566
596
|
Where does the caption go, then? The same rule answers it, in the other
|
|
567
597
|
direction: a field can tint the row it has, but it cannot *add* a row for a
|
|
568
|
-
label without displacing the value — so a field carries no caption at all.
|
|
569
|
-
|
|
570
|
-
|
|
598
|
+
label without displacing the value — so a field carries no caption at all. Those
|
|
599
|
+
cells belong to whatever surrounds the field, and
|
|
600
|
+
{Tuile::Component::FormItem} is that surround: one row of a form, around one
|
|
601
|
+
field.
|
|
602
|
+
|
|
603
|
+
```ruby
|
|
604
|
+
item = Component::FormItem.new(username, caption: "Username", required: true)
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
```
|
|
608
|
+
Username ∙ ← the caption, with the required marker
|
|
609
|
+
[________________] ← the field you wrapped
|
|
610
|
+
Must not be blank ← the message, when there is one
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
The item does the subscribing you just saw — to the verdict *and* to the field's
|
|
614
|
+
own report of input it cannot parse — so the label-and-listener pair is what you
|
|
615
|
+
write when the message belongs somewhere else entirely, one status row for a
|
|
616
|
+
whole form, say. Otherwise wrap a field and drop the item wherever a component
|
|
617
|
+
goes: a `Vertical` of items is already a form.
|
|
618
|
+
|
|
619
|
+
It is always three rows, and the last is the message row *and* the gap row. That
|
|
620
|
+
fusion is the point: a form that grew a row when a field went invalid would push
|
|
621
|
+
everything below it down while you are typing into it, and on a 24-row terminal
|
|
622
|
+
that walks the focused field off the bottom edge. The price is that a form with
|
|
623
|
+
several errors looks tight exactly where it is least happy.
|
|
624
|
+
|
|
625
|
+
Two habits follow. A widget that paints its own text — a `Checkbox`, a `Button` —
|
|
626
|
+
is wrapped *without* a caption and reserves no caption row, because the form owns
|
|
627
|
+
a column and the widget owns its face; move `[x] Enable logging` into the column
|
|
628
|
+
and the checkbox has nothing left to say. And hide the **item**, never the field:
|
|
629
|
+
`item.visible = false` takes the caption and the message with it, where hiding
|
|
630
|
+
the field alone strands its caption above a gap.
|
|
631
|
+
|
|
632
|
+
Stack items and you have a form. {Tuile::Component::FormLayout} is that column,
|
|
633
|
+
and it does the wrapping for you:
|
|
634
|
+
|
|
635
|
+
```ruby
|
|
636
|
+
form = Component::FormLayout.new
|
|
637
|
+
form.add(username, caption: "Username", required: true)
|
|
638
|
+
item = form.add(notes, caption: "Notes", rows: 5) # rows: is the *content* height
|
|
639
|
+
form.add(logging) # a Checkbox, so no caption row
|
|
640
|
+
form.add(save) # nor does a Button
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
Everything it holds is a `FormItem` — `add` returns the one it built, so
|
|
644
|
+
`item.required = true` later goes to the right receiver — and the arithmetic is
|
|
645
|
+
one line: a captioned item is `1 + rows + 1` rows tall, a captionless one
|
|
646
|
+
`rows + 1`. There is no `spacing`, because the message row already *is* the gap;
|
|
647
|
+
a looser form is a `Vertical` of several `FormLayout`s, and a tighter one isn't
|
|
648
|
+
available.
|
|
649
|
+
|
|
650
|
+
Two things it deliberately won't do. It **measures nothing** — `rows:` is yours
|
|
651
|
+
to declare, exactly as `Fixed[n]` is in a `Vertical`, because a field that could
|
|
652
|
+
ask for a height is the bottom-up channel chapter 3 doesn't have. And it
|
|
653
|
+
**doesn't scroll**: items are laid from the top, the one straddling the bottom
|
|
654
|
+
edge keeps the rows that are left, and anything past it is clipped away. A form
|
|
655
|
+
taller than its rect either wants splitting — across a `TabSheet`, say — or
|
|
656
|
+
wants wrapping in a `Scroller`, later in this chapter.
|
|
571
657
|
|
|
572
658
|
The sibling seam, one level up, is **which keys the field acts on at all**:
|
|
573
|
-
override `handle_text_input_key
|
|
659
|
+
override `handle_text_input_key?` and call `super` for everything you don't
|
|
574
660
|
claim.
|
|
575
661
|
|
|
576
662
|
```ruby
|
|
577
663
|
class SubmitField < Tuile::Component::TextArea
|
|
578
664
|
protected
|
|
579
665
|
|
|
580
|
-
def handle_text_input_key(key)
|
|
666
|
+
def handle_text_input_key?(key)
|
|
581
667
|
return super unless key == Tuile::Keys::ENTER
|
|
582
668
|
|
|
583
669
|
submit(text) # Enter submits instead of inserting a newline
|
|
@@ -613,8 +699,44 @@ Turning a field's value into a domain model — parsing, validation, the
|
|
|
613
699
|
box-holds-a-`String` ⟷ bean-holds-an-`Integer` conversion — is
|
|
614
700
|
deliberately *not* the field's job; it belongs to a forms/binder layer
|
|
615
701
|
that will one day sit above these components. So the seam is kept thin on
|
|
616
|
-
purpose: `on_value_change` carries just the new value
|
|
617
|
-
read-only or required flag yet. Room left for that
|
|
702
|
+
purpose: the event `on_value_change` carries just the new value and its
|
|
703
|
+
source, and there's no read-only or required flag yet. Room left for that
|
|
704
|
+
layer to grow into.
|
|
705
|
+
|
|
706
|
+
### Two fields, one value
|
|
707
|
+
|
|
708
|
+
{Tuile::Component::DateTimeField} is the first field made of *fields*: the date
|
|
709
|
+
field and the time field from earlier in this chapter, side by side on one row,
|
|
710
|
+
behind a single `DateTime`.
|
|
711
|
+
|
|
712
|
+
```ruby
|
|
713
|
+
starts = Component::DateTimeField.new
|
|
714
|
+
starts.value = DateTime.new(2026, 9, 14, 13, 45) # [2026-09-14] [13:45]
|
|
715
|
+
starts.date_field.formats = "%d.%m.%Y" # tune a half in place…
|
|
716
|
+
starts.time_field.step = 900 # …rather than through a forwarder
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
The halves are exposed read-only: a child you *tune* but never *supply* is
|
|
720
|
+
reached directly, so there is no second set of names to keep in step — and no
|
|
721
|
+
argument about whether `formats=` on the composite would mean the date's or the
|
|
722
|
+
time's.
|
|
723
|
+
|
|
724
|
+
The value is non-nil only when both halves parse, and a half going bad nils the
|
|
725
|
+
whole thing rather than holding the last good one: a field holds bad input **or**
|
|
726
|
+
a value, never both. What is genuinely new is the question of who goes red, and
|
|
727
|
+
the answer is one sentence — **the composite paints only the fault no half can
|
|
728
|
+
wear**. Garbage in the date half is attributable, so that half reddens itself on
|
|
729
|
+
the latch you saw a moment ago, and the composite paints nothing. A date with no
|
|
730
|
+
time is nobody else's fault, so the composite reddens *whole* — but only once you
|
|
731
|
+
leave it, so it judges you when you are done rather than while you are filling it
|
|
732
|
+
in. A rule's verdict is not attributable either, and reddens whole with no latch
|
|
733
|
+
at all.
|
|
734
|
+
|
|
735
|
+
One wrinkle follows from that. Pressing Enter over a half-filled field reports
|
|
736
|
+
`bad_input?` and its message but does not redden it; the ink waits for you to
|
|
737
|
+
leave. Latching on Enter would reopen exactly the window the rule closes — the
|
|
738
|
+
one where the field tells you that you are wrong when the truth is that you are
|
|
739
|
+
not finished.
|
|
618
740
|
|
|
619
741
|
## Choosing from a set
|
|
620
742
|
|
|
@@ -628,9 +750,9 @@ the text it drew for it.
|
|
|
628
750
|
```ruby
|
|
629
751
|
list = Component::List.new
|
|
630
752
|
list.items = User.all
|
|
631
|
-
list.renderer = ->(u) { "#{u.name} #{u.email}" }
|
|
753
|
+
list.renderer = ->(u, _w) { "#{u.name} #{u.email}" }
|
|
632
754
|
list.cursor = Component::List::Cursor.new
|
|
633
|
-
list.on_item_chosen
|
|
755
|
+
list.on_item_chosen { |e| open(e.item) }
|
|
634
756
|
```
|
|
635
757
|
|
|
636
758
|
That's the same bargain the value seam struck earlier in this chapter: the
|
|
@@ -647,6 +769,25 @@ renderer a pure function of its item. It may be called on any frame, so it
|
|
|
647
769
|
is the wrong place to reach for a database; do that work when you build
|
|
648
770
|
the items.
|
|
649
771
|
|
|
772
|
+
That second parameter is the width, in columns, that the row you return
|
|
773
|
+
will get — the list's own width, less a gutter either side and less the
|
|
774
|
+
scrollbar's column when one is showing. Most renderers ignore it and let
|
|
775
|
+
the list ellipsize what doesn't fit, which is why it is usually spelled
|
|
776
|
+
`_w`. Take it when the *shape* of a row depends on how much room there is:
|
|
777
|
+
a right-hand column that has to line up down the pane, or a path you want
|
|
778
|
+
cut from the *left* so its tail survives (`ellipsize(n, at: :start)` —
|
|
779
|
+
`…/shared/markdown/` tells you more than `lib/shared/mar…`). Note the
|
|
780
|
+
direction: the list divides up the space it was given and tells you your
|
|
781
|
+
share, and never asks how much you would like.
|
|
782
|
+
|
|
783
|
+
There is one place that can bite. The list re-runs the renderer on every
|
|
784
|
+
width change — that is what keeps the columns lined up after a resize, and
|
|
785
|
+
it costs only the rows on screen — but it re-runs it *per row*. So a
|
|
786
|
+
measurement over the whole collection, like "how wide is the widest
|
|
787
|
+
`+12/-3` in this snapshot", is computed once where you assign the items and
|
|
788
|
+
closed over. Compute it inside the renderer and you have written an
|
|
789
|
+
`items²` pass that fires on every drag of the terminal's edge.
|
|
790
|
+
|
|
650
791
|
What makes the list flexible beyond that is that its *cursor behavior is a
|
|
651
792
|
pluggable object* rather than a boolean. Assign one of three
|
|
652
793
|
{Tuile::Component::List::Cursor} variants to fit the interaction:
|
|
@@ -660,9 +801,9 @@ pluggable object* rather than a boolean. Assign one of three
|
|
|
660
801
|
lines. For a list where only some rows are selectable (headers
|
|
661
802
|
interspersed with items, say), it skips the rest.
|
|
662
803
|
|
|
663
|
-
Two
|
|
664
|
-
|
|
665
|
-
cursor's row — Enter or a left-click — and is the "open this" signal.
|
|
804
|
+
Two slots cover the events you care about, and both are handed an event
|
|
805
|
+
carrying `position` and `item`. `on_item_chosen` fires when the user commits to
|
|
806
|
+
the cursor's row — Enter or a left-click — and is the "open this" signal.
|
|
666
807
|
`on_cursor_changed` fires when the highlighted row *changes*, which is
|
|
667
808
|
exactly what you wire to keep a details pane in sync with the selection.
|
|
668
809
|
For a tailing list — a live log — set `auto_scroll`; it pins to the bottom
|
|
@@ -692,7 +833,7 @@ when it's near the bottom of the screen.
|
|
|
692
833
|
combo = Component::ComboBox.new
|
|
693
834
|
combo.items = User.all
|
|
694
835
|
combo.item_label = ->(u) { u.full_name }
|
|
695
|
-
combo.on_value_change
|
|
836
|
+
combo.on_value_change { |e| show(e.value) }
|
|
696
837
|
```
|
|
697
838
|
|
|
698
839
|
When the choice is simply yes-or-no, {Tuile::Component::Checkbox} is a
|
|
@@ -707,7 +848,7 @@ however you flip it.
|
|
|
707
848
|
|
|
708
849
|
```ruby
|
|
709
850
|
cb = Component::Checkbox.new("Enable syslog forwarding", value: true)
|
|
710
|
-
cb.on_value_change
|
|
851
|
+
cb.on_value_change { |e| config.syslog = e.value }
|
|
711
852
|
cb.toggle # unchecks it, firing the listener with false
|
|
712
853
|
```
|
|
713
854
|
|
|
@@ -756,7 +897,7 @@ is the `Set` of items you selected.
|
|
|
756
897
|
```ruby
|
|
757
898
|
levels = Component::CheckboxGroup.new(items: LogLevel.all)
|
|
758
899
|
levels.item_label = ->(l) { l.name }
|
|
759
|
-
levels.on_value_change
|
|
900
|
+
levels.on_value_change { |e| refilter(e.value) } # e.value is a Set of LogLevels
|
|
760
901
|
```
|
|
761
902
|
|
|
762
903
|
Notice what `value` holds: the *items*, exactly as the combo box does — a
|
|
@@ -814,7 +955,7 @@ the same widget with a single answer.
|
|
|
814
955
|
```ruby
|
|
815
956
|
sort = Component::RadioGroup.new(items: SORT_ORDERS)
|
|
816
957
|
sort.item_label = ->(order) { order.label }
|
|
817
|
-
sort.on_value_change
|
|
958
|
+
sort.on_value_change { |e| resort(e.value) }
|
|
818
959
|
```
|
|
819
960
|
|
|
820
961
|
Its `value` is the selected item — the object, not its label, as always —
|
|
@@ -869,7 +1010,7 @@ you're choosing between them.
|
|
|
869
1010
|
|
|
870
1011
|
```ruby
|
|
871
1012
|
level = Component::Select.new(items: %w[debug info warn error], value: "warn")
|
|
872
|
-
level.on_value_change
|
|
1013
|
+
level.on_value_change { |e| logger.level = e.value }
|
|
873
1014
|
```
|
|
874
1015
|
|
|
875
1016
|
Enter, Space or Down opens the dropdown, the arrows move the highlight,
|
|
@@ -938,14 +1079,14 @@ into a list. {Tuile::Component::Button} holds nothing. It runs a block:
|
|
|
938
1079
|
|
|
939
1080
|
```ruby
|
|
940
1081
|
save = Component::Button.new("Save") { form.submit }
|
|
941
|
-
save.on_click
|
|
1082
|
+
save.on_click { form.submit } # or register it afterwards
|
|
942
1083
|
```
|
|
943
1084
|
|
|
944
1085
|
It paints as `[ Save ]` on one row, highlights its background while it is on
|
|
945
1086
|
the focus chain, and is a tab stop, so Tab reaches it like any field. Enter,
|
|
946
|
-
Space and a left click all fire `on_click`, and
|
|
947
|
-
|
|
948
|
-
the event.
|
|
1087
|
+
Space and a left click all fire `on_click`, and its event carries nothing but
|
|
1088
|
+
its `source` — a button has nothing else to report, because that it was pressed
|
|
1089
|
+
*is* the event.
|
|
949
1090
|
|
|
950
1091
|
**Sizing it is your job**, exactly as chapter 3 promised: there is no channel
|
|
951
1092
|
for a component to advertise the width it would like, so the caller does the
|
|
@@ -967,7 +1108,7 @@ looks like in practice.)
|
|
|
967
1108
|
**A focused button consumes Enter**, and that matters the moment you have
|
|
968
1109
|
more than one. Enter on a focused `Save` activates *that* button — not some
|
|
969
1110
|
form-wide default, because Tuile has no notion of a default button at all.
|
|
970
|
-
The form's Enter-to-submit is a `handle_key
|
|
1111
|
+
The form's Enter-to-submit is a `handle_key?` on the ancestor that owns the
|
|
971
1112
|
form (chapter 5), and it only ever sees Enter when the focused widget
|
|
972
1113
|
declined it. So a dialog's two buttons are just two widgets, and which one
|
|
973
1114
|
Enter hits is simply which one has focus.
|
|
@@ -1107,6 +1248,48 @@ A slot is invisible to input: it can't take focus, clicks pass straight
|
|
|
1107
1248
|
through to the occupant, and when an occupant leaves, the focus repair is
|
|
1108
1249
|
handed up to your container rather than stranding focus on the slot.
|
|
1109
1250
|
|
|
1251
|
+
### Showing more than fits: `Scroller`
|
|
1252
|
+
|
|
1253
|
+
A slot sizes its occupant to itself. A {Tuile::Component::Scroller} does the
|
|
1254
|
+
opposite: it gives its content *more* rows than it has, and shows a window onto
|
|
1255
|
+
them.
|
|
1256
|
+
|
|
1257
|
+
```ruby
|
|
1258
|
+
form = Component::FormLayout.new
|
|
1259
|
+
# …nine fields, 27 rows of them…
|
|
1260
|
+
window.content = Component::Scroller.new(form, content_rows: 27)
|
|
1261
|
+
```
|
|
1262
|
+
|
|
1263
|
+
`content_rows` is the part that looks odd at first, and it is the same rule as
|
|
1264
|
+
everywhere else in this book: **nothing measures**, so the number is yours to
|
|
1265
|
+
supply, exactly as `rows:` and `Fixed[n]` are. The form is then handed a rect 27
|
|
1266
|
+
rows tall inside a viewport that may be 12 — the first rect in Tuile *meant* not
|
|
1267
|
+
to fit its parent, and, once you scroll, the first with a negative `top`.
|
|
1268
|
+
Nothing goes wrong, because every component is bounded by the rect it was given
|
|
1269
|
+
and its ancestors': the rows above and below the viewport are painted into
|
|
1270
|
+
nothing (chapter 2). Below the viewport height the number is ignored and the
|
|
1271
|
+
content simply fills the viewport.
|
|
1272
|
+
|
|
1273
|
+
What makes it usable is that it scrolls **on focus**. A field scrolled out of
|
|
1274
|
+
sight is not hidden — it keeps its rect, its keys and its place in the Tab cycle
|
|
1275
|
+
(chapter 5) — so Tab reaches the eighth field as readily as the first, and
|
|
1276
|
+
`Screen#focused=` then makes the same request your own code can make:
|
|
1277
|
+
|
|
1278
|
+
```ruby
|
|
1279
|
+
field.scroll_to_visible # "show me this"
|
|
1280
|
+
editor.scroll_to_visible(caret_row_rect) # or just this much of me
|
|
1281
|
+
```
|
|
1282
|
+
|
|
1283
|
+
It climbs the parent chain to the nearest scroller, which scrolls the minimum
|
|
1284
|
+
distance that makes the rect visible and passes the request on to whatever
|
|
1285
|
+
scrolls above it.
|
|
1286
|
+
|
|
1287
|
+
The wheel scrolls it too, and `scroll_half_page_up` / `scroll_half_page_down`
|
|
1288
|
+
are there for app code. What it deliberately does **not** do is claim keys: the
|
|
1289
|
+
arrows and PgUp/PgDn belong to the field you are typing in. Content with no tab
|
|
1290
|
+
stop anywhere in it — a long label, a read-only panel — therefore has no
|
|
1291
|
+
keyboard way to scroll; bind one yourself on the container around it.
|
|
1292
|
+
|
|
1110
1293
|
## Switching between views
|
|
1111
1294
|
|
|
1112
1295
|
When a screen has more content than fits and the parts are *alternatives*
|
|
@@ -1120,7 +1303,7 @@ is selected.
|
|
|
1120
1303
|
sheet = Component::TabSheet.new
|
|
1121
1304
|
sheet.add_tab("Details", details_form) # the first tab is selected
|
|
1122
1305
|
sheet.add_tab("Payment", payment_form)
|
|
1123
|
-
sheet.on_tab_selected
|
|
1306
|
+
sheet.on_tab_selected { |e| status.text = "on #{e.tab&.caption}" }
|
|
1124
1307
|
```
|
|
1125
1308
|
|
|
1126
1309
|
The strip is a component in its own right, {Tuile::Component::Tabs}, and
|
|
@@ -1234,7 +1417,7 @@ what a `TabSheet` does: only the selected tab's pane is a child of the
|
|
|
1234
1417
|
sheet, the rest are detached. That is a deliberate choice rather than
|
|
1235
1418
|
history, and the reason is the sentence above about hooks — inverted:
|
|
1236
1419
|
|
|
1237
|
-
- **`
|
|
1420
|
+
- **`handle_detached` fires when a pane goes away, `handle_attached` when it
|
|
1238
1421
|
returns.** A {Tuile::Component::ProgressBar} in a background tab stops its
|
|
1239
1422
|
ticker and restarts it on return, with no bookkeeping from you. Hiding
|
|
1240
1423
|
would keep it ticking, unseen.
|
|
@@ -1366,11 +1549,28 @@ other TUI toolkit agree on it:
|
|
|
1366
1549
|
| Left at the first level, Right on a plain row | step to the neighbouring menu |
|
|
1367
1550
|
| a mnemonic letter | activate that row of *this* menu |
|
|
1368
1551
|
| ESC | close one level |
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1552
|
+
| **In a menu you stepped to** | |
|
|
1553
|
+
| Down, Enter, Space | move onto its first row |
|
|
1554
|
+
| Up | move onto its last row |
|
|
1555
|
+
| Left, Right | keep walking the strip |
|
|
1556
|
+
| ESC, on an item with no menu | leave menu mode |
|
|
1557
|
+
|
|
1558
|
+
Stepping sideways only *shows* the neighbour's menu: it arrives with no row
|
|
1559
|
+
highlighted, so the next Right goes on walking the strip instead of drilling
|
|
1560
|
+
into whatever that menu happens to list first. Down, Enter or Space moves onto
|
|
1561
|
+
its first row, Up onto its last.
|
|
1562
|
+
|
|
1563
|
+
It never presses anything, either. So arrowing onto a top-level button — an item
|
|
1564
|
+
with a listener and no menu — closes whatever was open and highlights it, and it
|
|
1565
|
+
fires only when you press Enter or Space. Otherwise walking the strip would
|
|
1566
|
+
trigger every button on it.
|
|
1567
|
+
|
|
1568
|
+
A button has no menu to show, and the walk carries on across it. What the arrows
|
|
1569
|
+
follow is *menu mode*: the bar enters it when a menu opens and leaves it when the
|
|
1570
|
+
last panel goes — or when you press ESC at a button, where there is no panel left
|
|
1571
|
+
to close. That ESC is the one the strip keeps from your app, which matters
|
|
1572
|
+
because an unhandled one quits (chapter 5). Enter on a button fires it and enters
|
|
1573
|
+
no mode at all.
|
|
1374
1574
|
|
|
1375
1575
|
The last row of the first block matters for real apps: while the bar merely
|
|
1376
1576
|
has focus, every other key **bubbles past it**, so a form's `s`-to-save or
|
|
@@ -1479,8 +1679,9 @@ input inside itself. For a layer that floats *without* taking focus — the
|
|
|
1479
1679
|
autocomplete-list case from earlier, where the caller positions it against a
|
|
1480
1680
|
field's caret and drives it from app code — use its base class, `Overlay`,
|
|
1481
1681
|
directly. An `Overlay` is a Popup minus the modality: same open/close
|
|
1482
|
-
lifecycle, same outside-click dismissal, but it sits
|
|
1483
|
-
and never disturbs focus or key
|
|
1682
|
+
lifecycle, same outside-click dismissal, but it sits where you open it —
|
|
1683
|
+
`overlay.open(Overlay::At[rect])` — and never disturbs focus or key
|
|
1684
|
+
dispatch.
|
|
1484
1685
|
|
|
1485
1686
|
**A left click outside an overlay closes it**, modal or not — the same light
|
|
1486
1687
|
dismissal a desktop dialog gives you. It's a per-overlay switch,
|
|
@@ -1573,7 +1774,7 @@ dialog.message = "Save your changes before leaving?"
|
|
|
1573
1774
|
dialog.button("Save") { save! }
|
|
1574
1775
|
dialog.button("Discard") { discard! }
|
|
1575
1776
|
dialog.button("Cancel") # no block: pressing it dismisses
|
|
1576
|
-
dialog.on_dismiss
|
|
1777
|
+
dialog.on_dismiss { stay_put }
|
|
1577
1778
|
dialog.open
|
|
1578
1779
|
```
|
|
1579
1780
|
|
|
@@ -1684,7 +1885,9 @@ layout) *or* as a popup (via a class-level `open`).
|
|
|
1684
1885
|
presentation from the body's type (an Array is rows, text is prose).
|
|
1685
1886
|
- {Tuile::Component::PickerWindow} — a menu of options each bound to a
|
|
1686
1887
|
single key, firing your block with the picked key. Popped up via `open`,
|
|
1687
|
-
it closes itself after a pick; ESC/`q` cancels without firing.
|
|
1888
|
+
it closes itself after a pick; ESC/`q` cancels without firing. Captions
|
|
1889
|
+
paint in the terminal's own foreground; hand in a {Tuile::StyledString}
|
|
1890
|
+
(or the ANSI string `theme.fg` returns) to color one, per option.
|
|
1688
1891
|
- {Tuile::Component::LogWindow} — a Window framing a
|
|
1689
1892
|
{Tuile::Component::LogTextView}: an auto-scrolling, scrollbar-equipped
|
|
1690
1893
|
TextView purpose-built for log output. The view is where the behavior
|