tuile 0.16.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 +108 -0
- data/README.md +21 -12
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +5 -4
- data/book/05-focus.md +12 -9
- data/book/06-theming.md +55 -17
- data/book/07-components.md +188 -40
- data/book/08-testing.md +115 -15
- data/book/10-locale.md +1 -1
- data/book/README.md +5 -5
- data/examples/file_commander.rb +38 -27
- data/examples/hello_world.rb +1 -1
- data/examples/sampler.rb +225 -169
- 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 +81 -80
- data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +19 -11
- data/lib/tuile/component/checkbox.rb +12 -10
- data/lib/tuile/component/checkbox_group.rb +11 -13
- data/lib/tuile/component/combo_box.rb +30 -40
- data/lib/tuile/component/confirm_window.rb +27 -22
- data/lib/tuile/component/date_field.rb +27 -20
- data/lib/tuile/component/date_time_field.rb +75 -31
- 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 +98 -27
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +5 -12
- data/lib/tuile/component/has_validation.rb +39 -13
- data/lib/tuile/component/has_value.rb +70 -16
- 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 -63
- data/lib/tuile/component/layout.rb +124 -10
- data/lib/tuile/component/list.rb +197 -94
- data/lib/tuile/component/list_dropdown.rb +148 -88
- data/lib/tuile/component/menu_bar/cascade.rb +97 -27
- data/lib/tuile/component/menu_bar.rb +84 -64
- data/lib/tuile/component/notification.rb +44 -31
- data/lib/tuile/component/overlay.rb +210 -52
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +15 -10
- data/lib/tuile/component/popup.rb +13 -24
- data/lib/tuile/component/progress_bar.rb +7 -7
- data/lib/tuile/component/radio_group.rb +10 -12
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +15 -31
- data/lib/tuile/component/slot.rb +1 -2
- data/lib/tuile/component/tab_sheet.rb +21 -28
- data/lib/tuile/component/tabs.rb +39 -24
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +21 -19
- data/lib/tuile/component/text_field.rb +55 -39
- data/lib/tuile/component/text_view.rb +143 -79
- data/lib/tuile/component/time_field.rb +26 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +481 -259
- 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 +14 -0
- data/lib/tuile/fake_screen.rb +41 -10
- 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 +51 -35
- data/lib/tuile/mouse.rb +96 -29
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +419 -84
- data/lib/tuile/screen_pane.rb +144 -31
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +139 -9
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +310 -36
- data/lib/tuile/theme.rb +170 -19
- 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 +4951 -1158
- metadata +16 -2
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/book/05-focus.md
CHANGED
|
@@ -165,7 +165,7 @@ the tree — and neither needs machinery, because bubbling already has the
|
|
|
165
165
|
right shape. **Put the key on the ancestor that owns the region.**
|
|
166
166
|
|
|
167
167
|
```ruby
|
|
168
|
-
class AppLayout < Tuile::Component::Layout
|
|
168
|
+
class AppLayout < Tuile::Component::Layout
|
|
169
169
|
def handle_key?(key)
|
|
170
170
|
case key
|
|
171
171
|
when "1" then @files.focus; true
|
|
@@ -233,7 +233,7 @@ single `PasteEvent` — which never enters the ladder at all:
|
|
|
233
233
|
|
|
234
234
|
The default `handle_paste` returns `false` and the text is dropped.
|
|
235
235
|
{Tuile::Component::AbstractStringField} overrides it to insert at the caret
|
|
236
|
-
as **one** mutation — so `
|
|
236
|
+
as **one** mutation — so `on_value_change` fires once for the paste rather than
|
|
237
237
|
once per character, and a subclass that claims Enter needs no paste code of
|
|
238
238
|
its own:
|
|
239
239
|
|
|
@@ -245,7 +245,7 @@ class PromptTextArea < Tuile::Component::TextArea
|
|
|
245
245
|
return super unless key == Tuile::Keys::ENTER
|
|
246
246
|
|
|
247
247
|
submit(text) # a typed Enter, and only ever a typed Enter
|
|
248
|
-
self.
|
|
248
|
+
self.value = ""
|
|
249
249
|
true
|
|
250
250
|
end
|
|
251
251
|
end
|
|
@@ -260,7 +260,7 @@ def handle_paste(text)
|
|
|
260
260
|
return super if text.lines.size < 20
|
|
261
261
|
|
|
262
262
|
attach_as_file(text)
|
|
263
|
-
self.
|
|
263
|
+
self.value = "#{text.lines.size} lines attached"
|
|
264
264
|
true
|
|
265
265
|
end
|
|
266
266
|
```
|
|
@@ -346,8 +346,11 @@ one surface, and is why that app asks for `:hover`.
|
|
|
346
346
|
## Where the cursor comes in — and where it doesn't
|
|
347
347
|
|
|
348
348
|
A component signals cursor ownership through
|
|
349
|
-
{Tuile::Component#cursor_position} — return a `Point`
|
|
350
|
-
|
|
349
|
+
{Tuile::Component#cursor_position} — return a `Point` in your *own*
|
|
350
|
+
coordinates (the ones you paint in) and the terminal cursor is shown there;
|
|
351
|
+
return `nil` (the default) and there's no cursor. {Tuile::Screen} is what
|
|
352
|
+
converts the point to a screen position, so a caret is a column and a row and
|
|
353
|
+
nothing more. A
|
|
351
354
|
{Tuile::Component::TextField} being edited returns its caret position, so
|
|
352
355
|
the caret you see blinking is the focused component's answer to that one
|
|
353
356
|
question.
|
|
@@ -378,7 +381,7 @@ to tidy up what was typed, this is where:
|
|
|
378
381
|
|
|
379
382
|
```ruby
|
|
380
383
|
class TrimmedField < Tuile::Component::TextField
|
|
381
|
-
protected def handle_blur = (self.
|
|
384
|
+
protected def handle_blur = (self.value = text.strip)
|
|
382
385
|
end
|
|
383
386
|
```
|
|
384
387
|
|
|
@@ -438,10 +441,10 @@ is text you write next to the registration.
|
|
|
438
441
|
|
|
439
442
|
Some apps genuinely show different keys in different places — a window with
|
|
440
443
|
a search mode, or a pane whose commands only apply to it. For those,
|
|
441
|
-
{Tuile::Screen#on_focus_changed
|
|
444
|
+
{Tuile::Screen#on_focus_changed} is the notification:
|
|
442
445
|
|
|
443
446
|
```ruby
|
|
444
|
-
screen.on_focus_changed
|
|
447
|
+
screen.on_focus_changed { status.text = hint_for(screen.focused) }
|
|
445
448
|
```
|
|
446
449
|
|
|
447
450
|
It fires after every focus *change* — to and from `nil` included, and after
|
data/book/06-theming.md
CHANGED
|
@@ -90,17 +90,17 @@ prompt. You *can* name the panel's colour again on the field — but there is a
|
|
|
90
90
|
shorter way to say "I have no background of my own, use whatever is behind me":
|
|
91
91
|
|
|
92
92
|
```ruby
|
|
93
|
-
field.bg_color =
|
|
93
|
+
field.bg_color = ComponentBackground::INHERIT
|
|
94
94
|
```
|
|
95
95
|
|
|
96
96
|
That is CSS's `background: inherit`, and it is different from leaving
|
|
97
97
|
`bg_color` unset: unset means "ask *my* default first", which for a field is
|
|
98
|
-
its well. `
|
|
98
|
+
its well. `ComponentBackground::INHERIT` skips the well and goes straight to what surrounds it.
|
|
99
99
|
|
|
100
100
|
It is the same mechanism Tuile uses internally. A
|
|
101
101
|
{Tuile::Component::ComboBox} is one widget with one surface, built out of a
|
|
102
102
|
{Tuile::Component::TextField} plus a `▾` — so the ComboBox paints the well and
|
|
103
|
-
marks its inner field `
|
|
103
|
+
marks its inner field `ComponentBackground::INHERIT`. Exactly one well per widget, which is what
|
|
104
104
|
lets you tint the ComboBox and have the tint reach the cells the field draws.
|
|
105
105
|
|
|
106
106
|
Backgrounds can differ by state. An input is brighter while it holds focus,
|
|
@@ -238,12 +238,30 @@ background, and Tuile has it: the OSC 11 reply carries the RGB, and
|
|
|
238
238
|
{Tuile::Screen}`#background_color` hands it to you as a
|
|
239
239
|
{Tuile::Color}.
|
|
240
240
|
|
|
241
|
+
The place to use it is the theme itself. A token may be a Proc of the
|
|
242
|
+
background instead of a fixed {Tuile::Color}, and the screen calls it for
|
|
243
|
+
you:
|
|
244
|
+
|
|
241
245
|
```ruby
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
246
|
+
LIFT = ->(color, by) { Tuile::Color.rgb(*color.rgb.map { (_1 + by).clamp(0, 255) }) }
|
|
247
|
+
|
|
248
|
+
APP_THEME = Tuile::ThemeDef.new(
|
|
249
|
+
dark: Tuile::Theme::DARK.with(custom: {
|
|
250
|
+
pane_bg: ->(bg) { bg ? LIFT.call(bg, 10) : FALLBACK_TINT },
|
|
251
|
+
pane_frame: ->(_bg, t) { LIFT.call(t[:pane_bg], 20) }
|
|
252
|
+
}),
|
|
253
|
+
light: …
|
|
254
|
+
)
|
|
255
|
+
screen.theme_def = APP_THEME
|
|
256
|
+
sidebar.bg_color = Tuile::Theme.ref(:pane_bg)
|
|
245
257
|
```
|
|
246
258
|
|
|
259
|
+
The arithmetic is yours — Tuile ships no `lighten`, because how far to
|
|
260
|
+
step, in which direction, and whether to step at all is a design choice,
|
|
261
|
+
not a fact about the terminal. The second parameter, when a Proc asks for
|
|
262
|
+
it, reads the other tokens, so a hairline can be derived from the pane it
|
|
263
|
+
sits on, whatever order you declared them in.
|
|
264
|
+
|
|
247
265
|
That `FALLBACK_TINT` is not defensive padding — it's the branch you
|
|
248
266
|
should expect to hit. Plenty of terminals answer neither probe, and the
|
|
249
267
|
`COLORFGBG` fallback reports a palette *index* with no RGB behind it, so
|
|
@@ -256,12 +274,21 @@ more round trip than you might expect. The mode-2031 report says only
|
|
|
256
274
|
"the OS is light now" — it carries no RGB — so when the screen sees one,
|
|
257
275
|
it writes the OSC 11 query again, and the reply comes back through the
|
|
258
276
|
key thread as another event. The new color therefore lands a frame after
|
|
259
|
-
the new theme. When it does,
|
|
260
|
-
{Tuile::
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
know which
|
|
277
|
+
the new theme. When it does, the screen calls every derived token again,
|
|
278
|
+
producing a fresh, fully concrete {Tuile::Screen}`#theme`, and fires
|
|
279
|
+
{Tuile::Component}`#handle_theme_changed` across the tree once, exactly as a
|
|
280
|
+
theme swap does. A `Theme.ref` slot follows with no code of yours; content
|
|
281
|
+
you baked from the theme rebuilds in the same hook it always did, and you
|
|
282
|
+
don't need to know which half of the flip woke you.
|
|
283
|
+
|
|
284
|
+
Why a Proc in the theme, rather than a color that knows how to recompute
|
|
285
|
+
itself? Because {Tuile::Color} is a value: the back buffer decides whether a
|
|
286
|
+
cell changed by comparing colors, and a color that answered differently
|
|
287
|
+
depending on the terminal would compare equal to itself while painting
|
|
288
|
+
something new. Deriving once, when the inputs change, keeps every color
|
|
289
|
+
Tuile paints with a plain value — and the tree walks once per change,
|
|
290
|
+
instead of an app re-assigning its theme from inside the very hook the
|
|
291
|
+
walk is calling.
|
|
265
292
|
|
|
266
293
|
## Not every terminal can show what you computed
|
|
267
294
|
|
|
@@ -445,16 +472,27 @@ attached component whenever the theme changes. Your handler does exactly
|
|
|
445
472
|
one thing: **re-run the code that rendered the content**, so it rebuilds
|
|
446
473
|
the StyledString against the now-current theme.
|
|
447
474
|
|
|
448
|
-
There are two ways to consume it, matching how you built the component,
|
|
449
|
-
|
|
450
|
-
assembled stock components,
|
|
475
|
+
There are two ways to consume it, matching how you built the component, and
|
|
476
|
+
they are two different names: `on_` for the slot, `handle_` for the override. If
|
|
477
|
+
you assembled stock components, register on the `on_theme_changed` **listener
|
|
478
|
+
slot** — the reader *is* the registrar, and a slot holds as many listeners as
|
|
479
|
+
you give it:
|
|
480
|
+
|
|
481
|
+
```ruby
|
|
482
|
+
label.on_theme_changed { label.text = render_status_line }
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
There is no `on_theme_changed=`, deliberately: nothing you register can displace
|
|
486
|
+
what the widget — or another part of your app — already wired there. Hold what
|
|
487
|
+
`on_theme_changed` returns you if you mean to take it back off later:
|
|
451
488
|
|
|
452
489
|
```ruby
|
|
453
|
-
|
|
490
|
+
cb = label.on_theme_changed { … }
|
|
491
|
+
label.on_theme_changed.remove(cb)
|
|
454
492
|
```
|
|
455
493
|
|
|
456
494
|
If you subclassed, override `handle_theme_changed`, the **override point**
|
|
457
|
-
— and call `super`, so
|
|
495
|
+
— and call `super`, so registered listeners still fire:
|
|
458
496
|
|
|
459
497
|
```ruby
|
|
460
498
|
class StatusLabel < Tuile::Component::Label
|
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
|
|
|
@@ -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)
|
|
@@ -567,7 +579,7 @@ To show the text, subscribe — and put it in cells you own:
|
|
|
567
579
|
|
|
568
580
|
```ruby
|
|
569
581
|
error = Component::Label.new
|
|
570
|
-
username.on_error_message_change
|
|
582
|
+
username.on_error_message_change { |e| error.text = e.error_message || StyledString::EMPTY }
|
|
571
583
|
```
|
|
572
584
|
|
|
573
585
|
That listener is not decoration. The field repaints *itself* when its verdict
|
|
@@ -583,9 +595,65 @@ its parent, as do the rows of a `RadioGroup`'s list.
|
|
|
583
595
|
|
|
584
596
|
Where does the caption go, then? The same rule answers it, in the other
|
|
585
597
|
direction: a field can tint the row it has, but it cannot *add* a row for a
|
|
586
|
-
label without displacing the value — so a field carries no caption at all.
|
|
587
|
-
|
|
588
|
-
|
|
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.
|
|
589
657
|
|
|
590
658
|
The sibling seam, one level up, is **which keys the field acts on at all**:
|
|
591
659
|
override `handle_text_input_key?` and call `super` for everything you don't
|
|
@@ -631,8 +699,9 @@ Turning a field's value into a domain model — parsing, validation, the
|
|
|
631
699
|
box-holds-a-`String` ⟷ bean-holds-an-`Integer` conversion — is
|
|
632
700
|
deliberately *not* the field's job; it belongs to a forms/binder layer
|
|
633
701
|
that will one day sit above these components. So the seam is kept thin on
|
|
634
|
-
purpose: `on_value_change` carries just the new value
|
|
635
|
-
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.
|
|
636
705
|
|
|
637
706
|
### Two fields, one value
|
|
638
707
|
|
|
@@ -681,9 +750,9 @@ the text it drew for it.
|
|
|
681
750
|
```ruby
|
|
682
751
|
list = Component::List.new
|
|
683
752
|
list.items = User.all
|
|
684
|
-
list.renderer = ->(u) { "#{u.name} #{u.email}" }
|
|
753
|
+
list.renderer = ->(u, _w) { "#{u.name} #{u.email}" }
|
|
685
754
|
list.cursor = Component::List::Cursor.new
|
|
686
|
-
list.on_item_chosen
|
|
755
|
+
list.on_item_chosen { |e| open(e.item) }
|
|
687
756
|
```
|
|
688
757
|
|
|
689
758
|
That's the same bargain the value seam struck earlier in this chapter: the
|
|
@@ -700,6 +769,25 @@ renderer a pure function of its item. It may be called on any frame, so it
|
|
|
700
769
|
is the wrong place to reach for a database; do that work when you build
|
|
701
770
|
the items.
|
|
702
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
|
+
|
|
703
791
|
What makes the list flexible beyond that is that its *cursor behavior is a
|
|
704
792
|
pluggable object* rather than a boolean. Assign one of three
|
|
705
793
|
{Tuile::Component::List::Cursor} variants to fit the interaction:
|
|
@@ -713,9 +801,9 @@ pluggable object* rather than a boolean. Assign one of three
|
|
|
713
801
|
lines. For a list where only some rows are selectable (headers
|
|
714
802
|
interspersed with items, say), it skips the rest.
|
|
715
803
|
|
|
716
|
-
Two
|
|
717
|
-
|
|
718
|
-
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.
|
|
719
807
|
`on_cursor_changed` fires when the highlighted row *changes*, which is
|
|
720
808
|
exactly what you wire to keep a details pane in sync with the selection.
|
|
721
809
|
For a tailing list — a live log — set `auto_scroll`; it pins to the bottom
|
|
@@ -745,7 +833,7 @@ when it's near the bottom of the screen.
|
|
|
745
833
|
combo = Component::ComboBox.new
|
|
746
834
|
combo.items = User.all
|
|
747
835
|
combo.item_label = ->(u) { u.full_name }
|
|
748
|
-
combo.on_value_change
|
|
836
|
+
combo.on_value_change { |e| show(e.value) }
|
|
749
837
|
```
|
|
750
838
|
|
|
751
839
|
When the choice is simply yes-or-no, {Tuile::Component::Checkbox} is a
|
|
@@ -760,7 +848,7 @@ however you flip it.
|
|
|
760
848
|
|
|
761
849
|
```ruby
|
|
762
850
|
cb = Component::Checkbox.new("Enable syslog forwarding", value: true)
|
|
763
|
-
cb.on_value_change
|
|
851
|
+
cb.on_value_change { |e| config.syslog = e.value }
|
|
764
852
|
cb.toggle # unchecks it, firing the listener with false
|
|
765
853
|
```
|
|
766
854
|
|
|
@@ -809,7 +897,7 @@ is the `Set` of items you selected.
|
|
|
809
897
|
```ruby
|
|
810
898
|
levels = Component::CheckboxGroup.new(items: LogLevel.all)
|
|
811
899
|
levels.item_label = ->(l) { l.name }
|
|
812
|
-
levels.on_value_change
|
|
900
|
+
levels.on_value_change { |e| refilter(e.value) } # e.value is a Set of LogLevels
|
|
813
901
|
```
|
|
814
902
|
|
|
815
903
|
Notice what `value` holds: the *items*, exactly as the combo box does — a
|
|
@@ -867,7 +955,7 @@ the same widget with a single answer.
|
|
|
867
955
|
```ruby
|
|
868
956
|
sort = Component::RadioGroup.new(items: SORT_ORDERS)
|
|
869
957
|
sort.item_label = ->(order) { order.label }
|
|
870
|
-
sort.on_value_change
|
|
958
|
+
sort.on_value_change { |e| resort(e.value) }
|
|
871
959
|
```
|
|
872
960
|
|
|
873
961
|
Its `value` is the selected item — the object, not its label, as always —
|
|
@@ -922,7 +1010,7 @@ you're choosing between them.
|
|
|
922
1010
|
|
|
923
1011
|
```ruby
|
|
924
1012
|
level = Component::Select.new(items: %w[debug info warn error], value: "warn")
|
|
925
|
-
level.on_value_change
|
|
1013
|
+
level.on_value_change { |e| logger.level = e.value }
|
|
926
1014
|
```
|
|
927
1015
|
|
|
928
1016
|
Enter, Space or Down opens the dropdown, the arrows move the highlight,
|
|
@@ -991,14 +1079,14 @@ into a list. {Tuile::Component::Button} holds nothing. It runs a block:
|
|
|
991
1079
|
|
|
992
1080
|
```ruby
|
|
993
1081
|
save = Component::Button.new("Save") { form.submit }
|
|
994
|
-
save.on_click
|
|
1082
|
+
save.on_click { form.submit } # or register it afterwards
|
|
995
1083
|
```
|
|
996
1084
|
|
|
997
1085
|
It paints as `[ Save ]` on one row, highlights its background while it is on
|
|
998
1086
|
the focus chain, and is a tab stop, so Tab reaches it like any field. Enter,
|
|
999
|
-
Space and a left click all fire `on_click`, and
|
|
1000
|
-
|
|
1001
|
-
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.
|
|
1002
1090
|
|
|
1003
1091
|
**Sizing it is your job**, exactly as chapter 3 promised: there is no channel
|
|
1004
1092
|
for a component to advertise the width it would like, so the caller does the
|
|
@@ -1160,6 +1248,48 @@ A slot is invisible to input: it can't take focus, clicks pass straight
|
|
|
1160
1248
|
through to the occupant, and when an occupant leaves, the focus repair is
|
|
1161
1249
|
handed up to your container rather than stranding focus on the slot.
|
|
1162
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
|
+
|
|
1163
1293
|
## Switching between views
|
|
1164
1294
|
|
|
1165
1295
|
When a screen has more content than fits and the parts are *alternatives*
|
|
@@ -1173,7 +1303,7 @@ is selected.
|
|
|
1173
1303
|
sheet = Component::TabSheet.new
|
|
1174
1304
|
sheet.add_tab("Details", details_form) # the first tab is selected
|
|
1175
1305
|
sheet.add_tab("Payment", payment_form)
|
|
1176
|
-
sheet.on_tab_selected
|
|
1306
|
+
sheet.on_tab_selected { |e| status.text = "on #{e.tab&.caption}" }
|
|
1177
1307
|
```
|
|
1178
1308
|
|
|
1179
1309
|
The strip is a component in its own right, {Tuile::Component::Tabs}, and
|
|
@@ -1419,11 +1549,28 @@ other TUI toolkit agree on it:
|
|
|
1419
1549
|
| Left at the first level, Right on a plain row | step to the neighbouring menu |
|
|
1420
1550
|
| a mnemonic letter | activate that row of *this* menu |
|
|
1421
1551
|
| ESC | close one level |
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
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.
|
|
1427
1574
|
|
|
1428
1575
|
The last row of the first block matters for real apps: while the bar merely
|
|
1429
1576
|
has focus, every other key **bubbles past it**, so a form's `s`-to-save or
|
|
@@ -1532,8 +1679,9 @@ input inside itself. For a layer that floats *without* taking focus — the
|
|
|
1532
1679
|
autocomplete-list case from earlier, where the caller positions it against a
|
|
1533
1680
|
field's caret and drives it from app code — use its base class, `Overlay`,
|
|
1534
1681
|
directly. An `Overlay` is a Popup minus the modality: same open/close
|
|
1535
|
-
lifecycle, same outside-click dismissal, but it sits
|
|
1536
|
-
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.
|
|
1537
1685
|
|
|
1538
1686
|
**A left click outside an overlay closes it**, modal or not — the same light
|
|
1539
1687
|
dismissal a desktop dialog gives you. It's a per-overlay switch,
|
|
@@ -1626,7 +1774,7 @@ dialog.message = "Save your changes before leaving?"
|
|
|
1626
1774
|
dialog.button("Save") { save! }
|
|
1627
1775
|
dialog.button("Discard") { discard! }
|
|
1628
1776
|
dialog.button("Cancel") # no block: pressing it dismisses
|
|
1629
|
-
dialog.on_dismiss
|
|
1777
|
+
dialog.on_dismiss { stay_put }
|
|
1630
1778
|
dialog.open
|
|
1631
1779
|
```
|
|
1632
1780
|
|