tuile 0.13.0 → 0.15.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 +150 -37
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +4266 -226
- data/README.md +44 -24
- data/TERMINOLOGY.md +22 -7
- data/book/03-layout.md +17 -10
- data/book/05-focus.md +67 -3
- data/book/06-theming.md +153 -7
- data/book/07-components.md +643 -67
- data/book/08-testing.md +94 -0
- data/book/09-styled-text.md +3 -3
- data/book/10-locale.md +216 -0
- data/book/README.md +14 -5
- data/examples/file_commander.rb +1 -1
- data/examples/sampler.rb +402 -62
- data/ideas/arrow-key-navigation.md +2 -2
- data/ideas/binder.md +177 -0
- data/ideas/composite-field.md +77 -0
- data/ideas/focus-accent.md +116 -0
- data/ideas/form-layout.md +151 -0
- data/ideas/hover/probe.rb +241 -0
- data/ideas/hover/probe_spec.rb +82 -0
- data/ideas/hover.md +909 -0
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +49 -29
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/abstract_string_field.rb +106 -58
- data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/checkbox_group.rb +36 -20
- data/lib/tuile/component/combo_box.rb +68 -33
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/date_field.rb +322 -0
- data/lib/tuile/component/float_field.rb +57 -82
- data/lib/tuile/component/has_bad_input.rb +88 -0
- data/lib/tuile/component/has_caption.rb +8 -0
- data/lib/tuile/component/has_content.rb +43 -11
- data/lib/tuile/component/has_placeholder.rb +62 -0
- data/lib/tuile/component/has_validation.rb +115 -0
- data/lib/tuile/component/has_value.rb +28 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +6 -38
- data/lib/tuile/component/layout/box.rb +87 -19
- data/lib/tuile/component/layout.rb +13 -13
- data/lib/tuile/component/list.rb +11 -6
- data/lib/tuile/component/list_dropdown.rb +22 -10
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +3 -3
- data/lib/tuile/component/menu_bar.rb +5 -5
- data/lib/tuile/component/notification.rb +16 -34
- data/lib/tuile/component/overlay.rb +209 -0
- data/lib/tuile/component/popup.rb +59 -187
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +39 -22
- data/lib/tuile/component/select.rb +26 -10
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +0 -11
- data/lib/tuile/component/tabs.rb +5 -5
- data/lib/tuile/component/text_area.rb +14 -8
- data/lib/tuile/component/text_field.rb +42 -15
- data/lib/tuile/component/text_view.rb +25 -8
- data/lib/tuile/component/time_field.rb +454 -0
- data/lib/tuile/component/window.rb +48 -59
- data/lib/tuile/component.rb +580 -54
- data/lib/tuile/event_queue.rb +21 -1
- data/lib/tuile/fake_screen.rb +37 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/keys.rb +7 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/screen.rb +251 -55
- data/lib/tuile/screen_pane.rb +50 -44
- data/lib/tuile/styled_string.rb +40 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +100 -10
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +88 -12
- data/lib/tuile.rb +1 -0
- data/sig/tuile.rbs +4545 -770
- metadata +25 -1
data/book/07-components.md
CHANGED
|
@@ -117,15 +117,13 @@ 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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
the list, so the caret stays in the field and typing keeps refilling the
|
|
128
|
-
suggestions.
|
|
120
|
+
Both inherit the same event hooks from the base. `on_change` fires whenever
|
|
121
|
+
the text changes; `on_escape` handles ESC (with a sensible default);
|
|
122
|
+
`on_enter`, `on_key_up` and `on_key_down` each claim one key. Notice what
|
|
123
|
+
they have in common: every one of them either *reports* something or takes a
|
|
124
|
+
**single named key** whose meaning the field itself has no use for. There is
|
|
125
|
+
deliberately no callback that intercepts keys in general — to change what
|
|
126
|
+
keys *do*, you subclass (see *Keeping input out of a field*, below).
|
|
129
127
|
|
|
130
128
|
```ruby
|
|
131
129
|
field = Component::TextField.new
|
|
@@ -138,6 +136,83 @@ left `nil`, let those keys *fall through* to the parent — that's how Enter
|
|
|
138
136
|
in a search field can trigger the surrounding window's action while the
|
|
139
137
|
field still handles ordinary typing.
|
|
140
138
|
|
|
139
|
+
Three editing keys are worth knowing because nothing on screen advertises
|
|
140
|
+
them. Ctrl+Left and Ctrl+Right jump by a word; **Ctrl+W** deletes the word
|
|
141
|
+
behind the caret — exactly what Ctrl+Left would have skipped over — and
|
|
142
|
+
**Ctrl+U** deletes everything before the caret, which with the caret at the
|
|
143
|
+
end is "clear this field". All three are readline's, so they already behave
|
|
144
|
+
the way they do in your shell and in every prompt you have typed into. A
|
|
145
|
+
TextArea has them too, and there Ctrl+U kills back to the start of the
|
|
146
|
+
caret's *row* — the same place Home goes.
|
|
147
|
+
|
|
148
|
+
What you cannot have is Shift+Backspace, and the reason is a useful window
|
|
149
|
+
into terminal keys in general. Backspace arrives as a single byte with
|
|
150
|
+
nowhere to carry a modifier, so a terminal sends Shift+Backspace as plain
|
|
151
|
+
Backspace; the shift only survives under newer opt-in protocols that not
|
|
152
|
+
every terminal speaks. That is why terminal software reaches for
|
|
153
|
+
Ctrl+*letter* as often as it does — those are among the few combinations the
|
|
154
|
+
wire can express at all.
|
|
155
|
+
|
|
156
|
+
### The hint in an empty field
|
|
157
|
+
|
|
158
|
+
A blank field tells the user nothing about what belongs in it. Usually that
|
|
159
|
+
is fine — a caption beside it says "Name" and there is nothing more to know.
|
|
160
|
+
But some fields want a *shape* rather than a label: a date field has to say
|
|
161
|
+
which of the several orderings it writes back, and no caption can carry that
|
|
162
|
+
without becoming a sentence.
|
|
163
|
+
|
|
164
|
+
That is what a placeholder is for. Give a field one and it paints the hint
|
|
165
|
+
into its own cells while it is empty:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
field = Component::TextField.new
|
|
169
|
+
field.placeholder = "dd.mm.yyyy"
|
|
170
|
+
field.text # => "" — the hint is not content
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The last line is the whole contract. A placeholder is paint and nothing
|
|
174
|
+
else: it never enters the value, never fires `on_value_change`, is never
|
|
175
|
+
touched by a paste, and never counts against `max_text_length`. That
|
|
176
|
+
asymmetry *is* the feature — a hint that lived in the buffer would be a
|
|
177
|
+
default value, and a form saving an untouched field would write
|
|
178
|
+
`"dd.mm.yyyy"` to your database.
|
|
179
|
+
|
|
180
|
+
It stays on screen while the field has focus, which is worth saying out
|
|
181
|
+
loud because browsers spent years doing the opposite. The reasoning is
|
|
182
|
+
simply that a format hint is wanted *most* while someone is typing into the
|
|
183
|
+
field — that is exactly the moment they need to know whether the month or
|
|
184
|
+
the day comes first.
|
|
185
|
+
|
|
186
|
+
The typed fields carry it too, and you set it on the field itself rather
|
|
187
|
+
than reaching for the text field inside:
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
port = Component::IntegerField.new
|
|
191
|
+
port.placeholder = "1-65535"
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Two smaller notes. A hint wider than its field is *ellipsized*, not cut, so
|
|
195
|
+
you see `dd.mm.y…` rather than `dd.mm.yyy` — the second reads as a complete
|
|
196
|
+
format that happens to be wrong, which is worse than obviously truncated.
|
|
197
|
+
And a field marked invalid keeps showing its placeholder: an empty required
|
|
198
|
+
field is the commonest invalid state there is, and the red well saying
|
|
199
|
+
*something is wrong* pairs naturally with a hint saying *what goes here*.
|
|
200
|
+
|
|
201
|
+
You will notice the ink is faint — deliberately so. A placeholder is a hint
|
|
202
|
+
the user is welcome to miss; nothing breaks if they never read it, so it is
|
|
203
|
+
tuned to sit just above invisible rather than to compete with the text they
|
|
204
|
+
type. It comes from the theme (`placeholder_color`), which is also why the
|
|
205
|
+
setter takes a plain `String` and refuses a `StyledString`: the colour is
|
|
206
|
+
the theme's to choose, and one baked in by hand would be stale the moment
|
|
207
|
+
the terminal flipped to light mode.
|
|
208
|
+
|
|
209
|
+
One thing to resist: a placeholder is not a caption in disguise. It is
|
|
210
|
+
tempting in a cramped form to drop the label and let the hint do that work,
|
|
211
|
+
but the hint vanishes the instant the user types a character — and a filled
|
|
212
|
+
form where every field has forgotten what it is called is a form nobody can
|
|
213
|
+
check before submitting. Chapter 5's rule still holds: the caption belongs
|
|
214
|
+
to the layout around the field.
|
|
215
|
+
|
|
141
216
|
## The value seam
|
|
142
217
|
|
|
143
218
|
Every input component — a text field today, a combo box or a date field
|
|
@@ -162,9 +237,11 @@ the input components show both. A combo box's value is kept *quite apart*
|
|
|
162
237
|
from the text — you type a query, but the value is the object you pick. An
|
|
163
238
|
{Tuile::Component::IntegerField}'s value is instead *derived from* the
|
|
164
239
|
text: it holds an `Integer` (or `nil`), parsed from the buffer on demand.
|
|
165
|
-
|
|
166
|
-
quietly refused without so much as nudging the caret
|
|
167
|
-
|
|
240
|
+
The buffer only ever holds digits and at most one leading `-`; anything
|
|
241
|
+
else is quietly refused without so much as nudging the caret — and that
|
|
242
|
+
holds for a *paste* too, which is less obvious than it sounds (see
|
|
243
|
+
*Keeping input out of a field*, below). Up/Down step the number by one (an
|
|
244
|
+
empty field counting as zero). Read `value` and you
|
|
168
245
|
get an `Integer`, or `nil` when the buffer is blank or only half a number
|
|
169
246
|
(a lone `-`). It reports changes as you type, but only when the number
|
|
170
247
|
*itself* changes — padding `7` out to `07` moves the text without moving
|
|
@@ -194,6 +271,327 @@ component with a dependency Tuile itself doesn't carry — `bigdecimal` has
|
|
|
194
271
|
been a *bundled* gem since Ruby 3.4, so an app that uses this field names it
|
|
195
272
|
in its own `Gemfile`, and an app that doesn't never loads it.
|
|
196
273
|
|
|
274
|
+
### A date, where the display is a choice
|
|
275
|
+
|
|
276
|
+
{Tuile::Component::DateField} follows the same naming rule — its value is a
|
|
277
|
+
stdlib `Date`, or `nil` — but it is the first field where *how the value looks*
|
|
278
|
+
is not settled by the type. `42` has one spelling; 4 September 2026 has a dozen.
|
|
279
|
+
So the field holds a list of strftime formats rather than one:
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
due = Component::DateField.new
|
|
283
|
+
due.formats = ["%Y-%m-%d", "%d.%m.%Y"] # accepts both
|
|
284
|
+
due.value = Date.new(2026, 9, 4) # shows "2026-09-04", the first one
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Parsing tries them in order and the first whole match wins, while the *first*
|
|
288
|
+
format is also the one the field writes back. That single asymmetry is the whole
|
|
289
|
+
design: the field is lenient about what it accepts and strict about what it
|
|
290
|
+
shows, with no mode flag and no ambiguity about which format is "the" format.
|
|
291
|
+
Adding a format is therefore a UX decision as much as a parsing one — every
|
|
292
|
+
format you accept is a class of complaint that stops happening.
|
|
293
|
+
|
|
294
|
+
Which is why the list is *yours* and Tuile's default is a single ISO format
|
|
295
|
+
rather than a generous handful. It is tempting to ship the generous one, and it
|
|
296
|
+
cannot be done safely: `%m/%d/%Y` and `%d/%m/%Y` both match `04/09/2026` and
|
|
297
|
+
disagree about what it means, and nothing in the framework can tell which
|
|
298
|
+
reading you intended. Guessing would hand a European who typed 4 September a
|
|
299
|
+
field holding April 9 — a wrong value that saves cleanly, which is strictly
|
|
300
|
+
worse than input the user can see is bad. The order of your list is the
|
|
301
|
+
disambiguation, so it has to be your call. An app that wants two-digit-year
|
|
302
|
+
input is out of luck for the same class of reason: `%y` is rejected outright,
|
|
303
|
+
because two characters cannot carry a century and Ruby's fixed 1969–2068 window
|
|
304
|
+
silently misreads everything outside it.
|
|
305
|
+
|
|
306
|
+
The payoff for the reader arrives when they leave the field. Type `4.9.2026`
|
|
307
|
+
into the field above and Tab away, and it rewrites itself as `2026-09-04` —
|
|
308
|
+
**the user sees that the field understood what they typed**, which is what makes
|
|
309
|
+
a multi-format list legible rather than mysterious. Enter does the same, since a
|
|
310
|
+
form whose Save button is reached by Enter never moves focus at all. This is a
|
|
311
|
+
deliberate divergence from `IntegerField`, which leaves a typed `007` alone: a
|
|
312
|
+
format list is a statement that input and display are separate vocabularies,
|
|
313
|
+
which the integer field never made.
|
|
314
|
+
|
|
315
|
+
Two smaller things this field is the first to need. Its empty well paints a hint
|
|
316
|
+
— `yyyy-mm-dd` — *derived from the primary format*, because a date field is
|
|
317
|
+
unguessable in a way an integer field is not, and because the bad-input message
|
|
318
|
+
below deliberately cannot name the formats. And its calendar is proleptic
|
|
319
|
+
Gregorian rather than Ruby's `Date::ITALY` default, which is why `1582-10-10` is
|
|
320
|
+
an ordinary date here and a `Date::Error` in plain Ruby; `calendar_start` is
|
|
321
|
+
there for an app that means the Julian one.
|
|
322
|
+
|
|
323
|
+
### A time of day, where precision is the knob
|
|
324
|
+
|
|
325
|
+
{Tuile::Component::TimeField} is the date field's twin, and it diverges in
|
|
326
|
+
exactly two places worth knowing about.
|
|
327
|
+
|
|
328
|
+
The first is the value. Ruby has no civil-time class — no `LocalTime` — so
|
|
329
|
+
there is nothing to point at the way `DateField` points at `Date`. Tuile does
|
|
330
|
+
not invent one: it owns *UI* value types (`Point`, `Rect`, `Color`, `Theme`)
|
|
331
|
+
and no domain ones, because a time of day lands in your model, your column and
|
|
332
|
+
your serializer, and a Tuile-owned class would be a type none of them speak. So
|
|
333
|
+
the value is a plain `Time` pinned to a fixed epoch date in UTC — the shape
|
|
334
|
+
Rails' `time` column already hands you:
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
alarm = Component::TimeField.new
|
|
338
|
+
alarm.set_to(7, 30) # shows "07:30"
|
|
339
|
+
alarm.value # => 2000-01-01 07:30:00 UTC
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
That epoch is the accepted cost, and it is stated rather than hidden: the value
|
|
343
|
+
is a real instant, so it is a perfectly good `Time` that is *wrong* anywhere an
|
|
344
|
+
instant was meant. Combine it with a date at your own boundary. An app that
|
|
345
|
+
forgets gets the year 2000 in its output — visible, which beats subtly wrong.
|
|
346
|
+
|
|
347
|
+
The second divergence is the interesting one. A date has a spelling; a time has
|
|
348
|
+
a spelling **and a precision**, and they are different questions. The spelling —
|
|
349
|
+
`13.45` or `1:45 PM` or `13:45` — is a convention, so it comes from
|
|
350
|
+
`Screen#locale` like the date formats do. The precision is the app's, and it
|
|
351
|
+
rides on `step`:
|
|
352
|
+
|
|
353
|
+
```ruby
|
|
354
|
+
alarm.step = 1 # shows "07:30:00"; Up walks a second
|
|
355
|
+
alarm.step = 60 # shows "07:30"; Up walks a minute — the default
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
One knob doing both jobs looks like a shortcut and is the opposite. Precision is
|
|
359
|
+
a property of the format the buffer is written in, and the buffer is the single
|
|
360
|
+
source of truth for the value — so a second knob for it could disagree with the
|
|
361
|
+
first. Vaadin's `TimePicker` and the HTML `<input type="time">` both tie
|
|
362
|
+
precision to `step` for the same reason. The cost, since it is real: you cannot
|
|
363
|
+
ask for seconds *and* a minute-long stride.
|
|
364
|
+
|
|
365
|
+
Up and Down walk that stride; PageUp and PageDown walk an hour whatever the
|
|
366
|
+
stride is; either pair on an empty field lands on *now*. That is the whole
|
|
367
|
+
picker. There is deliberately no dropdown of times the way Vaadin's `TimePicker`
|
|
368
|
+
has one — a list of times tells you nothing you did not already know (a
|
|
369
|
+
calendar is different: it answers what weekday the 17th is), and with your hands
|
|
370
|
+
already on the keys, typing `1345` beats scrolling to it. Tuile is
|
|
371
|
+
keyboard-first: the mouse gets what falls out of click routing for free and never
|
|
372
|
+
motivates a widget on its own. The ranking behind that is `D_mouse` in
|
|
373
|
+
`DECISIONS.md`.
|
|
374
|
+
|
|
375
|
+
What you get for it is that the two questions stay independent. Switching
|
|
376
|
+
precision never touches the spelling, so a Finnish user sees `13.45` and
|
|
377
|
+
`13.45.00` and never a stray colon — which is the failure mode a per-field
|
|
378
|
+
format setter would have, since overriding the format to add seconds would
|
|
379
|
+
throw the locale's separator away with it.
|
|
380
|
+
|
|
381
|
+
Two consequences that follow from the buffer being the truth. Stepping *adds*
|
|
382
|
+
rather than snapping to a grid, and wraps at midnight — `23:59` plus a minute is
|
|
383
|
+
`00:00`, because a clock has no day to carry into. And narrowing the precision
|
|
384
|
+
never silently truncates: `13:45:00` becomes `13:45`, since dropping a zero
|
|
385
|
+
discards nothing, but `13:45:30` stays exactly as typed and reads as bad input,
|
|
386
|
+
because the seconds are something you meant.
|
|
387
|
+
|
|
388
|
+
### Keeping input out of a field
|
|
389
|
+
|
|
390
|
+
Say you want a field that holds only hex digits. The tempting move is to
|
|
391
|
+
work at the keyboard: catch each keystroke, refuse the ones you don't
|
|
392
|
+
want. It seems to work, and it is wrong, for a reason worth
|
|
393
|
+
internalizing: **a paste is not a keystroke**. It arrives as one whole
|
|
394
|
+
`String` (chapter 5), nowhere near your key handling, so a filter written
|
|
395
|
+
there guards typing and waves the same characters through on Ctrl-V.
|
|
396
|
+
Tuile's own numeric fields shipped with exactly that bug — twice, in three
|
|
397
|
+
fields.
|
|
398
|
+
|
|
399
|
+
The lesson generalizes past this one API: filter at the altitude of the
|
|
400
|
+
thing you're constraining. You are constraining the *buffer*, so the seam
|
|
401
|
+
is `insert_text`, where *every* insertion goes — a typed character, a
|
|
402
|
+
pasted clipboard, the newline Enter puts in a text area:
|
|
403
|
+
|
|
404
|
+
```ruby
|
|
405
|
+
class HexField < Tuile::Component::TextField
|
|
406
|
+
protected
|
|
407
|
+
|
|
408
|
+
def insert_text(str)
|
|
409
|
+
super if @text.dup.insert(@caret, str).match?(/\A\h*\z/)
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Two things about that method are deliberate. It judges the **result**, not
|
|
415
|
+
the fragment being inserted — sieving a paste character by character would
|
|
416
|
+
turn a pasted `1,5` into `15`, a number the user never copied and can't see
|
|
417
|
+
is wrong, where testing the whole buffer simply drops it. And it filters
|
|
418
|
+
*user input* only: assigning `field.text =` still writes whatever you give
|
|
419
|
+
it, which is what lets a `FloatField` display the `1.0e-05` that
|
|
420
|
+
`value = 1e-5` produces.
|
|
421
|
+
|
|
422
|
+
This kind of filtering only works when the field's grammar is
|
|
423
|
+
**prefix-closed** — when every valid value can be reached through valid
|
|
424
|
+
intermediate states. An integer qualifies: `""`, `-`, `-1`. A *date* does
|
|
425
|
+
not. `2020-13-45` is well-formed at every single character and means
|
|
426
|
+
nothing, and month lengths and leap years are facts about the whole string,
|
|
427
|
+
so no filter over insertions can catch it. A field like that has to accept
|
|
428
|
+
the input and *report* it as bad instead — which is exactly what
|
|
429
|
+
{Tuile::Component::DateField} does: it filters nothing at all, typed or pasted.
|
|
430
|
+
Prevention where it can be total, reporting where it can't; a half-filter is
|
|
431
|
+
the one thing to avoid, because it reads like a guarantee.
|
|
432
|
+
|
|
433
|
+
### Reporting bad input
|
|
434
|
+
|
|
435
|
+
Even a total filter leaves a residue, because the buffers a value is typed
|
|
436
|
+
*through* have to be admitted: an integer field must let you type a lone
|
|
437
|
+
`-` on the way to `-1`. Read `value` at that moment and you get `nil` — and
|
|
438
|
+
`empty?` says `true`, on a field with a glyph in it. Nothing distinguishes
|
|
439
|
+
it from a field the user never touched, and a form that believes `empty?`
|
|
440
|
+
will save `nil` over a number they think they entered.
|
|
441
|
+
|
|
442
|
+
`on_value_change` cannot rescue you here, and it is worth seeing *why* it
|
|
443
|
+
structurally can't: it is a diff over **values**, and every input the value
|
|
444
|
+
can't represent collapses onto the same `nil`. The information was
|
|
445
|
+
destroyed by the parse before the diff ran. Typing `-` into a blank field
|
|
446
|
+
moves the buffer and not the value, so the listener stays silent — correctly.
|
|
447
|
+
|
|
448
|
+
So a field whose parse can fail carries a second, separate question, the
|
|
449
|
+
{Tuile::Component::HasBadInput} mixin:
|
|
450
|
+
|
|
451
|
+
```ruby
|
|
452
|
+
amount = Component::IntegerField.new
|
|
453
|
+
# ... the user types a lone "-"
|
|
454
|
+
amount.value # => nil
|
|
455
|
+
amount.empty? # => true — empty of *value*
|
|
456
|
+
amount.bad_input? # => true — but there is input it could not use
|
|
457
|
+
amount.bad_input_message # => "not a whole number"
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Ask it before you ask `empty?`, and ask it whatever the value says. A form's
|
|
461
|
+
Save handler is the canonical consumer, walking a mixed bag of fields —
|
|
462
|
+
only some kinds can answer at all, which is what the `respond_to?` is for:
|
|
463
|
+
|
|
464
|
+
```ruby
|
|
465
|
+
bad = fields.select { _1.respond_to?(:bad_input?) && _1.bad_input? }
|
|
466
|
+
if bad.any?
|
|
467
|
+
Component::ConfirmWindow.alert("Cannot save", bad.map(&:bad_input_message).join("\n"))
|
|
468
|
+
else
|
|
469
|
+
save!
|
|
470
|
+
end
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Two properties to hold onto. **An empty field is empty, not bad** — a blank
|
|
474
|
+
optional field has to save, so no field reports an empty buffer as bad
|
|
475
|
+
input. And **the answer is derived on read, never stored**, which matters
|
|
476
|
+
because the fact is *continuous*: every prefix of a valid date is bad input,
|
|
477
|
+
so typing `2026-05-01` walks nine bad states before one good one. That makes
|
|
478
|
+
it perfect for a gate you consult at the click and treacherous for anything
|
|
479
|
+
that reacts per keystroke — an error message wired straight to it would flash
|
|
480
|
+
red through the act of typing correctly. Ask at the moment you need the
|
|
481
|
+
answer, and you always get the current one.
|
|
482
|
+
|
|
483
|
+
Which fields carry it is decided by one question: *can my input be something
|
|
484
|
+
my value cannot represent?* The numeric fields say yes, and the date field says
|
|
485
|
+
yes so emphatically that it is the reason this mixin exists. A text field says no —
|
|
486
|
+
its value *is* its input, so nothing can fail. A checkbox or a select says no
|
|
487
|
+
for a different reason: there is nothing between the keystroke and the value
|
|
488
|
+
for a parse to fail in.
|
|
489
|
+
|
|
490
|
+
### Marking a field invalid
|
|
491
|
+
|
|
492
|
+
Bad input is the field's *own* verdict — it owns the format, so it knows. A
|
|
493
|
+
rule's verdict is a different animal: "at least 3 characters", "must be in the
|
|
494
|
+
past", "these two passwords differ". The field cannot compute any of those. It
|
|
495
|
+
cannot even see the sibling the last one compares against.
|
|
496
|
+
|
|
497
|
+
So Tuile splits the job by *who has the cells for it*. The **verdict** — this
|
|
498
|
+
field is invalid — fits the one row the field already owns, because it is a
|
|
499
|
+
restyle of cells the field paints anyway. The **message** does not: it needs
|
|
500
|
+
cells beside the field, which belong to whatever laid the field out. Hence
|
|
501
|
+
{Tuile::Component::HasValidation}, which every field has:
|
|
502
|
+
|
|
503
|
+
```ruby
|
|
504
|
+
login.on_click = lambda do
|
|
505
|
+
username.error_message = username.empty? ? "Username is required" : nil
|
|
506
|
+
password.error_message = password.empty? ? "Password is required" : nil
|
|
507
|
+
next if [username, password].any?(&:error_message)
|
|
508
|
+
|
|
509
|
+
authenticate(username.value, password.value)
|
|
510
|
+
end
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Assigning a message turns the field's **well** red — `Theme#error_bg_color`,
|
|
514
|
+
or `Theme#error_active_bg_color` while it has focus; assigning `nil` clears it.
|
|
515
|
+
Notice there is no layout in that handler — it works the same inside a form, a
|
|
516
|
+
`Layout::Vertical`, or a `Popup` — and no `invalid?` predicate: a non-nil
|
|
517
|
+
message *is* the verdict, so there is only ever one thing to ask, and no
|
|
518
|
+
ambiguity with `bad_input?` next door.
|
|
519
|
+
|
|
520
|
+
A background rather than red text, for a reason worth spelling out: red text
|
|
521
|
+
needs glyphs, and the commonest rule of all — *required* — fires on a field
|
|
522
|
+
that has none. The well is what shows a field's boundary in the first place, so
|
|
523
|
+
an invalid field gets a red one and loses nothing. It takes *two* colors
|
|
524
|
+
because a focused invalid field still has to look focused; a `Select` paints no
|
|
525
|
+
caret, so its well is the only focus indicator it has.
|
|
526
|
+
|
|
527
|
+
Bad input reddens the well too, so a field you cannot save from looks like one
|
|
528
|
+
whether the format or a rule is at fault. That does mean the well is *live*
|
|
529
|
+
where the verdict is discrete: a `FloatField` goes red at the half-typed `1.`
|
|
530
|
+
and back at `1.5`. Brief, and worth it — the well is telling you the field will
|
|
531
|
+
refuse to save in that state.
|
|
532
|
+
|
|
533
|
+
A `DateField` is where that stops being worth it, and the difference is
|
|
534
|
+
instructive. Its residue is not one buffer but *all of them*: `2`, `20`, `202`
|
|
535
|
+
are each bad input on the way to `2026-09-04`, so a live well would hold the
|
|
536
|
+
field red from the first keystroke to the last, saying "you are wrong" when the
|
|
537
|
+
truth is "you are not finished". So the date field **settles**: the well waits
|
|
538
|
+
for a commit gesture — you leave the field, or press Enter — reddens what did
|
|
539
|
+
not parse, and goes quiet again on your next edit. Only the *ink* waits.
|
|
540
|
+
`bad_input?` is still answered from the current buffer the instant you ask it,
|
|
541
|
+
which is what keeps the Save handler above correct with no change at all.
|
|
542
|
+
|
|
543
|
+
The one discipline the writer owes is visible in those `: nil` branches: **set
|
|
544
|
+
or clear on every pass.** Only assign the message where you validate, and a
|
|
545
|
+
field that has been fixed goes back to normal on its own. Forget the clear and
|
|
546
|
+
a corrected field stays red forever.
|
|
547
|
+
|
|
548
|
+
To show the text, subscribe — and put it in cells you own:
|
|
549
|
+
|
|
550
|
+
```ruby
|
|
551
|
+
error = Component::Label.new
|
|
552
|
+
username.on_error_message_change = ->(msg) { error.text = msg || StyledString::EMPTY }
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
That listener is not decoration. The field repaints *itself* when its verdict
|
|
556
|
+
changes, but it knows nothing about your label, so without the notice your
|
|
557
|
+
message would go stale.
|
|
558
|
+
|
|
559
|
+
Two things follow from the split, both worth knowing before they surprise you.
|
|
560
|
+
An **empty** invalid field has no glyphs to tint — which is exactly the
|
|
561
|
+
required-field case — so on a bare pane the message *is* the whole signal; put
|
|
562
|
+
it somewhere. And the ink is **inherited**, so a composed field like an
|
|
563
|
+
`IntegerField` needs no help: the `TextField` inside it picks the color up from
|
|
564
|
+
its parent, as do the rows of a `RadioGroup`'s list.
|
|
565
|
+
|
|
566
|
+
Where does the caption go, then? The same rule answers it, in the other
|
|
567
|
+
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. The
|
|
569
|
+
`Label` beside it is yours (or, one day, a form layout's), which is why every
|
|
570
|
+
form in this book builds its own captions.
|
|
571
|
+
|
|
572
|
+
The sibling seam, one level up, is **which keys the field acts on at all**:
|
|
573
|
+
override `handle_text_input_key` and call `super` for everything you don't
|
|
574
|
+
claim.
|
|
575
|
+
|
|
576
|
+
```ruby
|
|
577
|
+
class SubmitField < Tuile::Component::TextArea
|
|
578
|
+
protected
|
|
579
|
+
|
|
580
|
+
def handle_text_input_key(key)
|
|
581
|
+
return super unless key == Tuile::Keys::ENTER
|
|
582
|
+
|
|
583
|
+
submit(text) # Enter submits instead of inserting a newline
|
|
584
|
+
true
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
Both seams are overrides rather than callbacks on purpose, and the reason is
|
|
590
|
+
the same one that runs through this whole chapter: they *compose*. Two
|
|
591
|
+
behaviors can stack through `super`, where one callback slot can only be held
|
|
592
|
+
by one owner — and a key you decline still bubbles to an ancestor
|
|
593
|
+
(chapter 5), which a callback that returned `false` could not arrange.
|
|
594
|
+
|
|
197
595
|
The combo box and the two numeric fields are built the same way, and it's
|
|
198
596
|
worth seeing why: each *wraps* a text field rather than *being* one. A
|
|
199
597
|
subclass would inherit the text field's `String`-typed value and wear it
|
|
@@ -283,7 +681,9 @@ widget then decorates, a row is the whole rendering.) It's the value seam doing
|
|
|
283
681
|
`value` is the selected *item*, the object and not its label, so a combo
|
|
284
682
|
over `User`s hands back a `User`. The field's text is merely a transient
|
|
285
683
|
query: it reverts to the selection's label when you dismiss the dropdown,
|
|
286
|
-
and only a real commit fires `on_value_change`.
|
|
684
|
+
and only a real commit fires `on_value_change`. Being a text field, it takes
|
|
685
|
+
the editing keys above — Ctrl+U empties a query you want to start over in one
|
|
686
|
+
press, rather than holding Backspace down. The dropdown itself is a
|
|
287
687
|
borderless popup tinted apart from the content beneath it (chapter 6's
|
|
288
688
|
background inheritance again), floating below the field or flipping above
|
|
289
689
|
when it's near the bottom of the screen.
|
|
@@ -674,6 +1074,39 @@ window.content = Component::List.new.tap { _1.lines = entries }
|
|
|
674
1074
|
window.scrollbar = true
|
|
675
1075
|
```
|
|
676
1076
|
|
|
1077
|
+
### Reserving a region: `Slot`
|
|
1078
|
+
|
|
1079
|
+
A `Window` has one content region. When *you* build a container with
|
|
1080
|
+
several — a dialog with a message, a button row and maybe a header — give
|
|
1081
|
+
each region a {Tuile::Component::Slot}: a component whose whole job is to
|
|
1082
|
+
hold one child and size it to itself.
|
|
1083
|
+
|
|
1084
|
+
```ruby
|
|
1085
|
+
@message = Component::Slot.new
|
|
1086
|
+
add(@message, Expand[1]) # the region, wired once at construction
|
|
1087
|
+
@message.content = Component::Label.new("Delete this file?") # the occupant
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
The reason to bother is an arithmetic problem you'd otherwise have to
|
|
1091
|
+
solve. Children are ordered, and order decides paint order and Tab order —
|
|
1092
|
+
so if you held the message and the buttons as direct children, "where does
|
|
1093
|
+
the message get inserted?" would depend on whether the header happens to be
|
|
1094
|
+
present right now. Inside a slot the answer is always index 0, because the
|
|
1095
|
+
slot itself never leaves the tree. Add regions, reorder them, leave some
|
|
1096
|
+
empty: none of it changes a swap.
|
|
1097
|
+
|
|
1098
|
+
Which leads to the one thing that surprises people: **an empty slot doesn't
|
|
1099
|
+
collapse.** It keeps the rectangle its parent gave it and clears it, so a
|
|
1100
|
+
dialog with no message shows the hole — exactly as it would with an *empty*
|
|
1101
|
+
message. If you want the gap closed, that's the parent's arithmetic (give
|
|
1102
|
+
the slot a zero extent), which is the same top-down rule as everything else
|
|
1103
|
+
in chapter 3. Don't detach the slot to make it go away; that hands you back
|
|
1104
|
+
the insert-index problem it exists to remove.
|
|
1105
|
+
|
|
1106
|
+
A slot is invisible to input: it can't take focus, clicks pass straight
|
|
1107
|
+
through to the occupant, and when an occupant leaves, the focus repair is
|
|
1108
|
+
handed up to your container rather than stranding focus on the slot.
|
|
1109
|
+
|
|
677
1110
|
## Switching between views
|
|
678
1111
|
|
|
679
1112
|
When a screen has more content than fits and the parts are *alternatives*
|
|
@@ -769,44 +1202,70 @@ on anything it would have to ask the strip — better than quietly answering
|
|
|
769
1202
|
about a tab that is no longer there. Its caption stays readable, so an
|
|
770
1203
|
error message can still name it.
|
|
771
1204
|
|
|
772
|
-
###
|
|
773
|
-
|
|
774
|
-
Here is the part with consequences beyond this widget.
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
something
|
|
800
|
-
`
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
1205
|
+
### Two ways to hide something, and why a `TabSheet` picks the other one
|
|
1206
|
+
|
|
1207
|
+
Here is the part with consequences beyond this widget. Hiding a component
|
|
1208
|
+
is one line:
|
|
1209
|
+
|
|
1210
|
+
```ruby
|
|
1211
|
+
company_name.visible = false # gone
|
|
1212
|
+
company_name.visible = true # back, exactly as it was
|
|
1213
|
+
```
|
|
1214
|
+
|
|
1215
|
+
The flag means **gone**, in the sense Android's `GONE` means it: the
|
|
1216
|
+
component paints nothing, takes no space in a box layout, and cannot be
|
|
1217
|
+
reached by Tab, keys, the cursor, the mouse or `Testing.find`. Say it as
|
|
1218
|
+
*as if detached, but it stays in the tree* — because the one thing it keeps
|
|
1219
|
+
that a detached component loses is the whole point. It keeps its parent, its
|
|
1220
|
+
rect, its box constraints, its state, and anything it had running: **no
|
|
1221
|
+
lifecycle hook fires**. A pane holding a subscription goes on holding it
|
|
1222
|
+
while out of sight.
|
|
1223
|
+
|
|
1224
|
+
Two things it is worth being precise about. First, the flag is
|
|
1225
|
+
*ancestor-inclusive*: hide a panel and everything under it goes with it,
|
|
1226
|
+
whatever those components' own flags say — and their flags are remembered,
|
|
1227
|
+
so showing the panel brings back exactly the subtree that was showing
|
|
1228
|
+
before. Second, there is no "invisible but still occupying its space" state,
|
|
1229
|
+
because you already have one: a {Tuile::Component::Slot} with no content
|
|
1230
|
+
holds its rect open and paints nothing.
|
|
1231
|
+
|
|
1232
|
+
The other way to hide something is to take it out of the tree, and it is
|
|
1233
|
+
what a `TabSheet` does: only the selected tab's pane is a child of the
|
|
1234
|
+
sheet, the rest are detached. That is a deliberate choice rather than
|
|
1235
|
+
history, and the reason is the sentence above about hooks — inverted:
|
|
1236
|
+
|
|
1237
|
+
- **`on_detached` fires when a pane goes away, `on_attached` when it
|
|
1238
|
+
returns.** A {Tuile::Component::ProgressBar} in a background tab stops its
|
|
1239
|
+
ticker and restarts it on return, with no bookkeeping from you. Hiding
|
|
1240
|
+
would keep it ticking, unseen.
|
|
1241
|
+
- **State survives either way, because state is ivars.** Scroll position,
|
|
1242
|
+
caret, list cursor, typed text: all exactly as the user left them, hidden
|
|
1243
|
+
or detached. You can go on mutating either — `invalidate` is a silent
|
|
1244
|
+
no-op on a detached component, and a hidden one's repaint is dropped at
|
|
1245
|
+
the drain.
|
|
1246
|
+
|
|
1247
|
+
So the question to ask isn't "which is the real way to hide" but **should
|
|
1248
|
+
this thing keep running while nobody can see it?** If yes, hide it. If no —
|
|
1249
|
+
if you'd rather its ticker, its poller or its subscription stopped — detach
|
|
1250
|
+
it, and let the hooks do the work.
|
|
1251
|
+
|
|
1252
|
+
One rule at the seam, worth knowing before you meet it as a bug: focus
|
|
1253
|
+
never stays on something the user cannot see. Hide the subtree that holds
|
|
1254
|
+
focus and it moves to the hidden component's parent, which passes it on to
|
|
1255
|
+
the first reachable field the same way it would after a removal. It is not
|
|
1256
|
+
handed back when the component reappears.
|
|
1257
|
+
|
|
1258
|
+
An overlay is the exception: {Tuile::Component::Popup} and friends refuse
|
|
1259
|
+
`visible=` outright. An overlay is *closed*, not hidden — it already has
|
|
1260
|
+
`open` and `close` for exactly this, and a hidden-but-open modal would go on
|
|
1261
|
+
scoping every key and swallowing every click while painting nothing.
|
|
806
1262
|
|
|
807
1263
|
The `TabSheet` pane in `examples/sampler.rb` demonstrates the state part
|
|
808
1264
|
directly: scroll the prose tab, switch away, come back, and the status line
|
|
809
|
-
under the sheet reports the row you left it on.
|
|
1265
|
+
under the sheet reports the row you left it on. The Shell → Visibility pane
|
|
1266
|
+
demonstrates the flag: tick "Business customer" and two more fields appear
|
|
1267
|
+
in the form, with the rows closing up cleanly when they go and the text you
|
|
1268
|
+
typed into them still there when they come back.
|
|
810
1269
|
|
|
811
1270
|
### When the strip is too narrow
|
|
812
1271
|
|
|
@@ -1008,22 +1467,24 @@ every GUI dismisses its menus on a window resize too.
|
|
|
1008
1467
|
The popup itself paints nothing — it's a transparent host that wraps any
|
|
1009
1468
|
component as its content and manages the lifecycle (`open` / `close`,
|
|
1010
1469
|
ESC/`q` to dismiss). Crucially, and per chapter 3, **it does not size
|
|
1011
|
-
itself to its content**: its box is
|
|
1470
|
+
itself to its content**: its box is set by `declared_size` — a `Fraction`
|
|
1012
1471
|
(default `Fraction::HALF`, half the screen, re-resolved on every resize)
|
|
1013
1472
|
or an absolute `Size`. The content then fills that box, so use content
|
|
1014
1473
|
that can cope with overflow — a TextView or TextArea that scrolls, not a
|
|
1015
1474
|
bare Label that only truncates.
|
|
1016
1475
|
|
|
1017
|
-
A popup is **modal
|
|
1476
|
+
A popup is **always modal**: centered, it grabs focus, eats keys, and
|
|
1018
1477
|
blocks clicks beneath it — that's what makes an open dialog trap Tab and
|
|
1019
|
-
input inside itself.
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1478
|
+
input inside itself. For a layer that floats *without* taking focus — the
|
|
1479
|
+
autocomplete-list case from earlier, where the caller positions it against a
|
|
1480
|
+
field's caret and drives it from app code — use its base class, `Overlay`,
|
|
1481
|
+
directly. An `Overlay` is a Popup minus the modality: same open/close
|
|
1482
|
+
lifecycle, same outside-click dismissal, but it sits at the rect you assign
|
|
1483
|
+
and never disturbs focus or key dispatch.
|
|
1484
|
+
|
|
1485
|
+
**A left click outside an overlay closes it**, modal or not — the same light
|
|
1486
|
+
dismissal a desktop dialog gives you. It's a per-overlay switch,
|
|
1487
|
+
`close_on_outside_click`, on by default; one that must survive stray
|
|
1027
1488
|
clicks turns it off, as a Notification does. The click still reaches
|
|
1028
1489
|
whatever was beneath it, unless an open modal swallowed it — in which case
|
|
1029
1490
|
the first click dismisses and a second one acts.
|
|
@@ -1050,6 +1511,116 @@ A nested TextField still swallows printable keys first, so typing `q` into
|
|
|
1050
1511
|
a field inside a popup doesn't dismiss it — the popup's own `q` handler sits
|
|
1051
1512
|
on the ancestor, and only sees keys the field declined.
|
|
1052
1513
|
|
|
1514
|
+
## The confirm dialog
|
|
1515
|
+
|
|
1516
|
+
That Window-in-a-Popup assembly is how you build any dialog. One dialog is
|
|
1517
|
+
so common it comes pre-assembled: {Tuile::Component::ConfirmWindow}, the
|
|
1518
|
+
"are you sure?" box — a caption, a short message, a row of buttons.
|
|
1519
|
+
|
|
1520
|
+
```ruby
|
|
1521
|
+
Component::ConfirmWindow.confirm("Delete Report Q4?", "This cannot be undone.",
|
|
1522
|
+
confirm: "Delete") { delete! }
|
|
1523
|
+
```
|
|
1524
|
+
|
|
1525
|
+
```
|
|
1526
|
+
┌Delete Report Q4?───────────┐
|
|
1527
|
+
│ This cannot be undone. │
|
|
1528
|
+
│ │
|
|
1529
|
+
│ [ Delete ] [ Cancel ] │
|
|
1530
|
+
└────────────────────────────┘
|
|
1531
|
+
```
|
|
1532
|
+
|
|
1533
|
+
Three factories cover the shapes you'll actually write: `alert(caption,
|
|
1534
|
+
message)` is the one-button acknowledgement, `confirm` the two-button
|
|
1535
|
+
question — its labels are keywords, so it is also your OK/Cancel and
|
|
1536
|
+
Delete/Cancel — and `yes_no` the other canonical phrasing. That's
|
|
1537
|
+
deliberately the whole list: Windows' `MessageBoxButtons` enum grew six
|
|
1538
|
+
values by naming every label pair, and any set the factories don't cover is
|
|
1539
|
+
a few lines of the builder below.
|
|
1540
|
+
|
|
1541
|
+
### Why a block, and not an answer
|
|
1542
|
+
|
|
1543
|
+
Everyone's first instinct here is the blocking call — `if confirm?("Delete?")`
|
|
1544
|
+
— because that's what Swing's `JOptionPane`, tkinter's `askyesno` and GTK's
|
|
1545
|
+
`dialog.run` all offer. Tuile can't, and it's worth understanding why: the
|
|
1546
|
+
whole UI runs on one thread (chapter 4), so a call that *waits* for the
|
|
1547
|
+
answer would have to nest a second event loop inside the first, re-entering
|
|
1548
|
+
raw mode under a key thread that's already reading stdin. The dialog
|
|
1549
|
+
therefore takes callbacks: the block is the action, and the dialog returns
|
|
1550
|
+
immediately.
|
|
1551
|
+
|
|
1552
|
+
### One kind of way out
|
|
1553
|
+
|
|
1554
|
+
Every button closes the dialog. A button *with* a block then fires it; a
|
|
1555
|
+
button *without* one is a Cancel. And ESC, `q`, a click outside the box and
|
|
1556
|
+
that Cancel button are all the same event — `on_dismiss`, fired exactly
|
|
1557
|
+
once, and only when no action button was chosen. You never write a `case`
|
|
1558
|
+
over outcomes, and you never have to enumerate the ways out: there is the
|
|
1559
|
+
action you asked about, and there is "do nothing", however the user spells
|
|
1560
|
+
it.
|
|
1561
|
+
|
|
1562
|
+
There is deliberately no way to keep the dialog open after a press. A
|
|
1563
|
+
dialog that leads somewhere — "Copy files" showing a progress window —
|
|
1564
|
+
opens the next window *from its callback*, which is safe because the block
|
|
1565
|
+
fires after the dialog has already closed and focus has been repaired.
|
|
1566
|
+
|
|
1567
|
+
For any other button set, the component is its own builder — buttons are
|
|
1568
|
+
declared one at a time, each a caption and an optional block:
|
|
1569
|
+
|
|
1570
|
+
```ruby
|
|
1571
|
+
dialog = Component::ConfirmWindow.new("Unsaved changes")
|
|
1572
|
+
dialog.message = "Save your changes before leaving?"
|
|
1573
|
+
dialog.button("Save") { save! }
|
|
1574
|
+
dialog.button("Discard") { discard! }
|
|
1575
|
+
dialog.button("Cancel") # no block: pressing it dismisses
|
|
1576
|
+
dialog.on_dismiss = -> { stay_put }
|
|
1577
|
+
dialog.open
|
|
1578
|
+
```
|
|
1579
|
+
|
|
1580
|
+
### Keys, and the underlined letters
|
|
1581
|
+
|
|
1582
|
+
Focus opens on the first button — which, since Enter presses the *focused*
|
|
1583
|
+
button, makes it the default. Left/Right and Tab walk the row; Enter or
|
|
1584
|
+
Space press.
|
|
1585
|
+
|
|
1586
|
+
Each button also answers to a **mnemonic**: a letter, underlined in its
|
|
1587
|
+
caption, that presses the button from anywhere in the dialog. By default
|
|
1588
|
+
it's the caption's first letter (Save gets `s`, Discard `d`), matched
|
|
1589
|
+
case-insensitively; pass `mnemonic:` to pick another letter — the case you
|
|
1590
|
+
give chooses which occurrence gets the underline — or `nil` for none. The
|
|
1591
|
+
underline isn't decoration: Tuile draws no status bar to advertise keys in,
|
|
1592
|
+
so the caption *is* the advertisement.
|
|
1593
|
+
|
|
1594
|
+
Three letters are never mnemonics. `q` is unconditionally the do-nothing
|
|
1595
|
+
route out — a dialog that forces a choice only thinks it does, since the
|
|
1596
|
+
user can always Ctrl+C, and pretending there's no escape route just trains
|
|
1597
|
+
them to reach for it. And `g`/`G` belong to the message: the body scrolls
|
|
1598
|
+
*without taking focus* — Up/Down, PgUp/PgDn, Ctrl+U/D, Home/End and the
|
|
1599
|
+
less-style `g`/`G` are handed to it while a button keeps focus, so a long
|
|
1600
|
+
message reads without any focus gymnastics. The body is also a tab stop:
|
|
1601
|
+
Shift+Tab reaches it, so overflowing prose is visibly reachable, not
|
|
1602
|
+
secretly scrollable.
|
|
1603
|
+
|
|
1604
|
+
### The popup that sizes itself
|
|
1605
|
+
|
|
1606
|
+
Chapter 3 was firm that nothing in Tuile sizes itself to its content, and a
|
|
1607
|
+
plain Popup takes half the screen whatever it wraps — absurd around a
|
|
1608
|
+
one-line "Delete?". The confirm dialog is the sanctioned exception *shape*:
|
|
1609
|
+
it measures **content it owns** — its caption, its message, its buttons —
|
|
1610
|
+
and asks the screen for exactly that box, still capped at half the screen
|
|
1611
|
+
(a message longer than the cap wraps and scrolls). Assign a `Component` as
|
|
1612
|
+
the message and the measuring honestly gives up: injected content is not
|
|
1613
|
+
the dialog's to measure, so the popup takes the full half-screen box.
|
|
1614
|
+
|
|
1615
|
+
### What it deliberately isn't
|
|
1616
|
+
|
|
1617
|
+
The message is prose — a `String` or {Tuile::StyledString}, which on a TTY
|
|
1618
|
+
already covers color, emphasis and iconography — not a content slot. A
|
|
1619
|
+
dialog collecting *input* is not a confirm dialog: the moment you want a
|
|
1620
|
+
form, a picker or a diff view in there, you've outgrown the sugar, and the
|
|
1621
|
+
general mechanism is one line away — `Popup.new(content: your_layout)`,
|
|
1622
|
+
exactly as in the previous section.
|
|
1623
|
+
|
|
1053
1624
|
## Notifications
|
|
1054
1625
|
|
|
1055
1626
|
{Tuile::Component::Notification} is the one overlay you don't assemble at
|
|
@@ -1105,18 +1676,23 @@ The last three components are conveniences: common Window-plus-content
|
|
|
1105
1676
|
assemblies you'd otherwise build by hand. Each works tiled (add it to a
|
|
1106
1677
|
layout) *or* as a popup (via a class-level `open`).
|
|
1107
1678
|
|
|
1108
|
-
- {Tuile::Component::InfoWindow} — a Window
|
|
1109
|
-
|
|
1110
|
-
`
|
|
1679
|
+
- {Tuile::Component::InfoWindow} — a Window with a read-only body, in one
|
|
1680
|
+
of two presentations: `message=` is *prose*, wrapped by a scrollable
|
|
1681
|
+
TextView; `lines=` is *rows*, a List keeping one item per row and
|
|
1682
|
+
truncating — the choice for columnar output, where a wrap would destroy
|
|
1683
|
+
the alignment. `InfoWindow.open(caption, body)` pops it up, picking the
|
|
1684
|
+
presentation from the body's type (an Array is rows, text is prose).
|
|
1111
1685
|
- {Tuile::Component::PickerWindow} — a menu of options each bound to a
|
|
1112
1686
|
single key, firing your block with the picked key. Popped up via `open`,
|
|
1113
1687
|
it closes itself after a pick; ESC/`q` cancels without firing.
|
|
1114
|
-
- {Tuile::Component::LogWindow} — a Window
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1688
|
+
- {Tuile::Component::LogWindow} — a Window framing a
|
|
1689
|
+
{Tuile::Component::LogTextView}: an auto-scrolling, scrollbar-equipped
|
|
1690
|
+
TextView purpose-built for log output. The view is where the behavior
|
|
1691
|
+
lives — compose it bare into a layout when you don't want the frame.
|
|
1692
|
+
Its `log` method is **thread-safe** — it marshals the append back onto
|
|
1693
|
+
the UI thread via the event queue (chapter 4), so background work can
|
|
1694
|
+
log freely. And the view carries an `IO`-shaped adapter so you can point
|
|
1695
|
+
a stdlib `Logger` (or a `TTY::Logger`) straight at either of them:
|
|
1120
1696
|
|
|
1121
1697
|
```ruby
|
|
1122
1698
|
window = Component::LogWindow.new
|