tuile 0.9.0 → 0.10.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 +32 -0
- data/DECISIONS.md +1961 -0
- data/README.md +31 -24
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +82 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +363 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +19 -13
- data/examples/sampler.rb +435 -20
- data/ideas/new-components.md +109 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +52 -51
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +133 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +281 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- data/lib/tuile/component/layout.rb +3 -17
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +106 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- data/lib/tuile/styled_string.rb +112 -83
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2043 -614
- metadata +18 -7
data/book/07-components.md
CHANGED
|
@@ -56,14 +56,36 @@ decision.
|
|
|
56
56
|
## Editing text
|
|
57
57
|
|
|
58
58
|
When you need input back from the user, the two editable components share
|
|
59
|
-
a base — {Tuile::Component::
|
|
59
|
+
a base — {Tuile::Component::AbstractStringField} — and differ only in shape.
|
|
60
60
|
|
|
61
61
|
{Tuile::Component::TextField} is a single line with a real hardware caret.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
Its width bounds what you can *see*, not what it can hold: the text scrolls
|
|
63
|
+
horizontally, moving by the minimum needed to keep the caret in view. If you
|
|
64
|
+
want an actual limit, set `max_text_length` — a cap in characters, after
|
|
65
|
+
which typing quietly does nothing. Because it consumes every printable key
|
|
66
|
+
while focused (including the ones it ignores at the cap), it's also what
|
|
67
|
+
keeps a scope-wide key binding from firing while you type: as chapter 5
|
|
68
|
+
explains, an ancestor only hears the keys its descendants declined.
|
|
69
|
+
|
|
70
|
+
That cap counts *characters*, and the distinction matters more than it
|
|
71
|
+
looks. A field position is either an index into the text or a column on the
|
|
72
|
+
terminal, and the two coincide only while every glyph is one column wide —
|
|
73
|
+
a fullwidth CJK character is two columns, a combining mark zero. So `caret`
|
|
74
|
+
and `max_text_length` speak indices, while `rect`, `left_column` and a mouse
|
|
75
|
+
click speak columns, and the field converts between them rather than
|
|
76
|
+
assuming they're the same number. You don't need to think about this to use
|
|
77
|
+
a TextField; you do the moment you write a component that paints text.
|
|
78
|
+
|
|
79
|
+
There's a third unit hiding in there, and it's the one your *user* thinks
|
|
80
|
+
in: the glyph they see. A single visible character can be several characters
|
|
81
|
+
of storage — an `e` with a combining accent, a flag, an emoji family — and
|
|
82
|
+
editing a field one storage character at a time is how you get a Backspace
|
|
83
|
+
that strips the accent and leaves a bare `e`. So while `caret` counts
|
|
84
|
+
characters, it may only ever sit *between* glyphs, and the editing keys work
|
|
85
|
+
in glyphs too: one arrow press moves over one, one Backspace deletes one,
|
|
86
|
+
however many characters that turns out to be. Assign a caret into the middle
|
|
87
|
+
of a glyph and the field quietly moves it to that glyph's far edge — where
|
|
88
|
+
it was already being drawn anyway.
|
|
67
89
|
|
|
68
90
|
{Tuile::Component::TextArea} is the multi-line counterpart: a word-wrapping
|
|
69
91
|
editor that scrolls vertically to keep the caret's line visible, with
|
|
@@ -71,6 +93,29 @@ Enter inserting a newline as in any text editor. Like everything else,
|
|
|
71
93
|
it's sized by its parent — it does not grow to fit its content; text that
|
|
72
94
|
overflows the rect is reached by scrolling.
|
|
73
95
|
|
|
96
|
+
{Tuile::Component::PasswordField} is a text field that paints a mask —
|
|
97
|
+
one `*` per character — instead of its text. Everything else is the text
|
|
98
|
+
field's, unchanged: you edit it, click into it, and scroll it exactly the
|
|
99
|
+
same way, and `value` hands back the plaintext whenever you ask. Setting
|
|
100
|
+
`revealed = true` shows the real text; there's no in-field reveal button,
|
|
101
|
+
because a terminal row has nowhere to put one, so apps wire that to a
|
|
102
|
+
"show password" checkbox or a key of their own.
|
|
103
|
+
|
|
104
|
+
Two of its details are worth knowing, because both come straight from the
|
|
105
|
+
index-versus-column distinction above. The mask is one *single-column*
|
|
106
|
+
glyph per character, which is why the default is a plain `*` rather than a
|
|
107
|
+
prettier `•`: a bullet is one of those characters whose width depends on
|
|
108
|
+
how the terminal is configured, and a mask that's occasionally two columns
|
|
109
|
+
wide would put the caret in the wrong place. (You can still set
|
|
110
|
+
`mask_char` yourself if you know your terminal.) And because the mask
|
|
111
|
+
replaces each character with exactly one column, a masked CJK passphrase
|
|
112
|
+
takes *fewer* columns than the plaintext would — which is fine, and the
|
|
113
|
+
field's caret, scrolling and click handling all measure the mask rather
|
|
114
|
+
than the hidden text. The one thing it deliberately does *not* do is
|
|
115
|
+
protect the plaintext in memory: it's an ordinary Ruby string, and
|
|
116
|
+
anything stronger is a job for a type the whole application cooperates
|
|
117
|
+
with.
|
|
118
|
+
|
|
74
119
|
Both inherit the same event hooks from the base, and this is where the
|
|
75
120
|
design pays off: you customize an input by assigning callbacks, not by
|
|
76
121
|
subclassing. `on_change` fires whenever the text changes; `on_escape`
|
|
@@ -92,6 +137,68 @@ left `nil`, let those keys *fall through* to the parent — that's how Enter
|
|
|
92
137
|
in a search field can trigger the surrounding window's action while the
|
|
93
138
|
field still handles ordinary typing.
|
|
94
139
|
|
|
140
|
+
## The value seam
|
|
141
|
+
|
|
142
|
+
Every input component — a text field today, a combo box or a date field
|
|
143
|
+
tomorrow — answers the same handful of questions, so Tuile gives them a
|
|
144
|
+
shared vocabulary: the {Tuile::Component::HasValue} mixin. Read or set
|
|
145
|
+
`value`, ask `empty?`, `clear` it, and subscribe to `on_value_change`.
|
|
146
|
+
Write code against that seam and it doesn't care which kind of input each
|
|
147
|
+
field is.
|
|
148
|
+
|
|
149
|
+
The idea worth internalizing is that **a component's `value` is of its own
|
|
150
|
+
natural type, not a string**. A text field's value *is* its text (a
|
|
151
|
+
`String` — `value` and `text` are two names for the one buffer, `text`
|
|
152
|
+
reading better while you're editing prose). But a combo box's value is the
|
|
153
|
+
*object you picked*, not the text shown for it — pick a `User` and you get
|
|
154
|
+
the `User` back, even when two users render to the same name. That typing
|
|
155
|
+
is free in Ruby: a "value" is just whatever you stored, there's no generic
|
|
156
|
+
to declare, so Tuile leans into it rather than making everything a string
|
|
157
|
+
you map back by hand.
|
|
158
|
+
|
|
159
|
+
A typed value can relate to the text on screen in two different ways, and
|
|
160
|
+
the input components show both. A combo box's value is kept *quite apart*
|
|
161
|
+
from the text — you type a query, but the value is the object you pick. An
|
|
162
|
+
{Tuile::Component::IntegerField}'s value is instead *derived from* the
|
|
163
|
+
text: it holds an `Integer` (or `nil`), parsed from the buffer on demand.
|
|
164
|
+
You may type only digits and at most one leading `-`; anything else is
|
|
165
|
+
quietly refused without so much as nudging the caret, and Up/Down step the
|
|
166
|
+
number by one (an empty field counting as zero). Read `value` and you
|
|
167
|
+
get an `Integer`, or `nil` when the buffer is blank or only half a number
|
|
168
|
+
(a lone `-`). It reports changes as you type, but only when the number
|
|
169
|
+
*itself* changes — padding `7` out to `07` moves the text without moving
|
|
170
|
+
the value, and stays silent.
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
qty = Component::IntegerField.new
|
|
174
|
+
qty.on_value_change = ->(n) { recompute(n) } # n is an Integer, or nil
|
|
175
|
+
qty.value = 3
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Both the combo box and the integer field are built the same way, and it's
|
|
179
|
+
worth seeing why: each *wraps* a text field rather than *being* one. A
|
|
180
|
+
subclass would inherit the text field's `String`-typed value and wear it
|
|
181
|
+
on its face right next to the real typed one — two conflicting answers to
|
|
182
|
+
"what's your value?". Composing sidesteps that: the wrapper holds a text
|
|
183
|
+
field privately, does its own filtering and parsing, and exposes only the
|
|
184
|
+
value that makes sense for it. (This is the "configure a generic component
|
|
185
|
+
to make a domain one" idea from the architecture the whole library is
|
|
186
|
+
built on.)
|
|
187
|
+
|
|
188
|
+
The password field is the same rule read the other way. Its value *is* its
|
|
189
|
+
text — same type, same vocabulary — so there's no second seam to collide
|
|
190
|
+
with, and it simply *is* a text field, subclassed to paint differently.
|
|
191
|
+
That's the test when you build your own input: if what you hand back
|
|
192
|
+
differs in type from what the user types, wrap a field; if it's the same
|
|
193
|
+
thing shown another way, extend one.
|
|
194
|
+
|
|
195
|
+
Turning a field's value into a domain model — parsing, validation, the
|
|
196
|
+
box-holds-a-`String` ⟷ bean-holds-an-`Integer` conversion — is
|
|
197
|
+
deliberately *not* the field's job; it belongs to a forms/binder layer
|
|
198
|
+
that will one day sit above these components. So the seam is kept thin on
|
|
199
|
+
purpose: `on_value_change` carries just the new value, and there's no
|
|
200
|
+
read-only or required flag yet. Room left for that layer to grow into.
|
|
201
|
+
|
|
95
202
|
## Choosing from a set
|
|
96
203
|
|
|
97
204
|
{Tuile::Component::List} is the workhorse: a scrollable column of
|
|
@@ -126,11 +233,258 @@ list.cursor = Component::List::Cursor.new
|
|
|
126
233
|
list.on_item_chosen = ->(index, line) { open(entries[index]) }
|
|
127
234
|
```
|
|
128
235
|
|
|
236
|
+
When the set is long and the user roughly knows what they want, a plain
|
|
237
|
+
list makes them scroll for it. {Tuile::Component::ComboBox} is the answer:
|
|
238
|
+
a text field with a dropdown that filters as you type. Hand it `items` (of
|
|
239
|
+
any type) and, when their `to_s` isn't what you want shown, an
|
|
240
|
+
`item_label` strategy to render each one; type to narrow, arrow to move,
|
|
241
|
+
Enter or click to accept. It's the value seam doing real work — its
|
|
242
|
+
`value` is the selected *item*, the object and not its label, so a combo
|
|
243
|
+
over `User`s hands back a `User`. The field's text is merely a transient
|
|
244
|
+
query: it reverts to the selection's label when you dismiss the dropdown,
|
|
245
|
+
and only a real commit fires `on_value_change`. The dropdown itself is a
|
|
246
|
+
borderless popup tinted apart from the content beneath it (chapter 6's
|
|
247
|
+
background inheritance again), floating below the field or flipping above
|
|
248
|
+
when it's near the bottom of the screen.
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
combo = Component::ComboBox.new
|
|
252
|
+
combo.items = User.all
|
|
253
|
+
combo.item_label = ->(u) { u.full_name }
|
|
254
|
+
combo.on_value_change = ->(u) { show(u) }
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
When the choice is simply yes-or-no, {Tuile::Component::Checkbox} is a
|
|
258
|
+
single row — `[x] Enable syslog forwarding` — that Space or a left-click
|
|
259
|
+
flips. Its `value` is the value seam again, at its simplest: always `true`
|
|
260
|
+
or `false`, never `nil`. Unchecked is the *empty* value, so a fresh
|
|
261
|
+
checkbox reports `empty?` and `clear` unchecks it. Because `value` reads a
|
|
262
|
+
little colorlessly in application code, the same state answers to
|
|
263
|
+
`checked?`, `checked=` and `toggle` — four names, one piece of state, and
|
|
264
|
+
one write path, so your `on_value_change` listener fires exactly once
|
|
265
|
+
however you flip it.
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
cb = Component::Checkbox.new("Enable syslog forwarding", value: true)
|
|
269
|
+
cb.on_value_change = ->(on) { config.syslog = on }
|
|
270
|
+
cb.toggle # unchecks it, firing the listener with false
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Two of its choices are worth understanding, because they're really
|
|
274
|
+
statements about how Tuile widgets behave in general. The first: **Enter
|
|
275
|
+
does nothing.** A checkbox has no action to confirm — Space is the native
|
|
276
|
+
gesture for flipping one — so Enter is left unhandled, and by chapter 5's
|
|
277
|
+
rules it bubbles up to an ancestor. It's tempting to read that as a
|
|
278
|
+
guarantee, as though the framework kept Enter clear for a form's
|
|
279
|
+
submit button. It doesn't, and chapter 5's table shows why: a text area
|
|
280
|
+
claims Enter for a newline, a button claims it to activate itself. Whether
|
|
281
|
+
Enter reaches your form depends on the widget that has focus. A checkbox
|
|
282
|
+
declines it because it has nothing to do with it — which is a fact about
|
|
283
|
+
this widget, not a promise about all of them.
|
|
284
|
+
|
|
285
|
+
The second is about *where the widget actually is*. A form column will
|
|
286
|
+
happily hand a checkbox forty columns for a caption that needs twenty-two,
|
|
287
|
+
and the extra eighteen are blank. Both the focus highlight and the click
|
|
288
|
+
target stop at the end of the caption rather than filling the row — the
|
|
289
|
+
painted glyph is the affordance, so a click that visibly lands on nothing
|
|
290
|
+
must not toggle anything, and a full-width highlight band would read as a
|
|
291
|
+
selected *row*, which is the wrong signal for one field among ten.
|
|
292
|
+
(Clicking the blank tail still moves *focus* there; it's the field's row,
|
|
293
|
+
after all.) {Tuile::Component::Button} follows the identical rule, which is
|
|
294
|
+
why both expose that painted region as `extent`.
|
|
295
|
+
|
|
296
|
+
The glyphs are plain ASCII — `[x] ` and `[ ] `, three columns and a space
|
|
297
|
+
— rather than the prettier `☑`/`☐`. Not for the column-width reason you
|
|
298
|
+
might expect: those box characters genuinely measure one cell everywhere. They're simply missing from most monospace fonts, and missing
|
|
299
|
+
*asymmetrically* — `☐` is the worse-covered of the two, so the unchecked
|
|
300
|
+
state can degrade to tofu while the checked one renders, which reads as a
|
|
301
|
+
bug rather than a fallback.
|
|
302
|
+
|
|
303
|
+
When the user should pick *several* things from a handful,
|
|
304
|
+
{Tuile::Component::CheckboxGroup} stacks those rows into one widget: a
|
|
305
|
+
cursor moves with the arrows, Space toggles the row it sits on, and `value`
|
|
306
|
+
is the `Set` of items you selected.
|
|
307
|
+
|
|
308
|
+
```ruby
|
|
309
|
+
levels = Component::CheckboxGroup.new(items: LogLevel.all)
|
|
310
|
+
levels.item_label = ->(l) { l.name }
|
|
311
|
+
levels.on_value_change = ->(set) { refilter(set) } # a Set of LogLevels
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Notice what `value` holds: the *items*, exactly as the combo box does — a
|
|
315
|
+
group over `LogLevel`s hands back `LogLevel`s, so filtering is
|
|
316
|
+
`selected.include?(level)` and never a lookup from a label back to the
|
|
317
|
+
thing it named. A `Set` rather than an array, because the selection has no
|
|
318
|
+
inherent order — which brings a wrinkle worth knowing up front. The set
|
|
319
|
+
iterates in the order things were *toggled*, so if you need the order the
|
|
320
|
+
rows are shown in, ask for it: `items & value.to_a`. Treat the set as
|
|
321
|
+
unordered and you'll never be surprised.
|
|
322
|
+
|
|
323
|
+
The set is also **frozen**. That's deliberate, and it's the one thing that
|
|
324
|
+
can bite you if you don't expect it: `group.value << item` raises rather
|
|
325
|
+
than quietly working. It has to, because a listener that fires on change
|
|
326
|
+
can only notice a change if the value is *replaced* rather than edited in
|
|
327
|
+
place — mutate the set you were handed and the group would have no way to
|
|
328
|
+
tell anyone. So assign a new selection instead (an array is fine, it's
|
|
329
|
+
coerced), and let the widget's own toggling build the new sets for you.
|
|
330
|
+
|
|
331
|
+
Here the cursor and the selection are genuinely two different things — the
|
|
332
|
+
cursor says *where you are*, the checkmarks say *what you picked* — and
|
|
333
|
+
that shape is exactly what a list already provides. So a checkbox group
|
|
334
|
+
doesn't paint rows itself; it holds a {Tuile::Component::List} and gets the
|
|
335
|
+
cursor, the scrolling, the scrollbar and the per-row mouse handling for
|
|
336
|
+
free, in the same "wrap a generic component to make a domain one" way the
|
|
337
|
+
combo box wraps a text field. That inheritance goes further than
|
|
338
|
+
convenience: a click anywhere on a row toggles it, and Enter toggles the
|
|
339
|
+
cursor's row, because those are the list's own gestures for choosing an
|
|
340
|
+
item.
|
|
341
|
+
|
|
342
|
+
Which is worth pausing on, because it looks like a contradiction of what
|
|
343
|
+
you just read about the standalone checkbox, where a click on the blank
|
|
344
|
+
space past the caption pointedly does *not* toggle. Both are right, and the
|
|
345
|
+
difference is what the user is aiming at. A lone checkbox in a form column
|
|
346
|
+
is a small painted thing surrounded by emptiness — the glyph is the
|
|
347
|
+
target. A row in a list is a *row*: it highlights across its full width, so
|
|
348
|
+
its full width is what you can click. The rule didn't bend; the thing being
|
|
349
|
+
clicked changed.
|
|
350
|
+
|
|
351
|
+
One thing the group deliberately does *not* do is reconcile `items` against
|
|
352
|
+
`value`. Replacing the items changes only what's on screen — the selection
|
|
353
|
+
is left exactly as it was, even if some of it is now invisible, and no
|
|
354
|
+
change event fires. It sounds careless until you picture a form: a user
|
|
355
|
+
ticks three boxes, some code refreshes the item list, and a selection
|
|
356
|
+
silently narrows itself. The user saves without touching anything and has
|
|
357
|
+
just changed data they never edited. Keeping `value` authoritative means
|
|
358
|
+
that can't happen, and reconciling — when you actually want it — is a line
|
|
359
|
+
of your own: `group.value &= group.items.to_set`. The combo box makes the
|
|
360
|
+
identical promise for its single value.
|
|
361
|
+
|
|
362
|
+
When exactly one of a handful will do, {Tuile::Component::RadioGroup} is
|
|
363
|
+
the same widget with a single answer.
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
sort = Component::RadioGroup.new(items: SORT_ORDERS)
|
|
367
|
+
sort.item_label = ->(order) { order.label }
|
|
368
|
+
sort.on_value_change = ->(order) { resort(order) }
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Its `value` is the selected item — the object, not its label, as always —
|
|
372
|
+
and `nil` when nothing is selected, which is where a fresh group starts.
|
|
373
|
+
That `nil` is also the only way back out: Space on the row that's already
|
|
374
|
+
selected does nothing, because a radio group has no deselect gesture. If
|
|
375
|
+
"none of these" is a legitimate answer, give it a row of its own. Items
|
|
376
|
+
are chrome here too, with the same reasoning as above: replacing them
|
|
377
|
+
never touches `value`, and a selection that's no longer among the rows
|
|
378
|
+
simply shows nothing marked.
|
|
379
|
+
|
|
380
|
+
The interaction is worth dwelling on, because it deliberately breaks with
|
|
381
|
+
the desktop convention. In a graphical radio group the arrow keys move the
|
|
382
|
+
*selection*: press Down and you have chosen the next option. Tuile splits
|
|
383
|
+
the two. The arrows move a cursor, and you select with Space, Enter or a
|
|
384
|
+
click — the same gestures as the checkbox group above.
|
|
385
|
+
|
|
386
|
+
Two reasons, the second of which decides it. First, consistency: "a cursor
|
|
387
|
+
roams, Enter chooses" is how every list-shaped thing in Tuile behaves, and
|
|
388
|
+
two group widgets sitting one Tab apart in the same form must not answer
|
|
389
|
+
Down differently. Second, and more practically, selection-follows-arrows
|
|
390
|
+
fires your listener once per row you cross. Arrow from the first option to
|
|
391
|
+
the fifth, and a listener that re-sorts a table, refetches a page or
|
|
392
|
+
rewrites a config file does that work four times — three of them for
|
|
393
|
+
choices the user never made. Committing on a keystroke means it happens
|
|
394
|
+
once, when it was meant.
|
|
395
|
+
|
|
396
|
+
So the cursor is *chrome*: presentation state, like `items`, rather than
|
|
397
|
+
part of the value. Assigning `value` doesn't move it, and the two
|
|
398
|
+
indicators say two different things — the `(*)` marks what's selected and
|
|
399
|
+
is always visible, while the highlighted row marks where you are and fades
|
|
400
|
+
when focus leaves. The one thing that *does* move the cursor is `items=`,
|
|
401
|
+
which pulls it back into range when the row set shrinks beneath it.
|
|
402
|
+
|
|
403
|
+
The glyphs are `(*) ` and `( ) `, and this time the reason is the column
|
|
404
|
+
width the checkbox section set aside. A filled bullet — `(•)` — is the
|
|
405
|
+
nicer mark, but U+2022 is one of Unicode's East-Asian *ambiguous* width
|
|
406
|
+
characters: a terminal configured for CJK text draws it two cells wide, a
|
|
407
|
+
Western one draws it in a single cell, and a program cannot ask which it's
|
|
408
|
+
talking to. Guess wrong and every row's text sits one column off — not a
|
|
409
|
+
cosmetic blemish but a coordinate error, since Tuile computes every rect
|
|
410
|
+
and clip from the width it believes each character has. Tuile bets on
|
|
411
|
+
one cell, and keeps the set of characters riding on that bet small enough
|
|
412
|
+
to enumerate, so a new widget reaches for ASCII and offers the pretty
|
|
413
|
+
glyph only where someone can opt in knowing their terminal.
|
|
414
|
+
|
|
129
415
|
For a discrete action rather than a selection, {Tuile::Component::Button}
|
|
130
416
|
is a one-row `[ caption ]` that fires `on_click` on Enter, Space, or a
|
|
131
417
|
left-click, highlighting its background while focused. It's a tab stop, so
|
|
132
418
|
it joins the normal Tab cycle.
|
|
133
419
|
|
|
420
|
+
## Reporting progress
|
|
421
|
+
|
|
422
|
+
Everything so far either shows text or captures input.
|
|
423
|
+
{Tuile::Component::ProgressBar} does neither: it reports, and it is the
|
|
424
|
+
first component in this tour you never focus and never type into. A run of
|
|
425
|
+
`█` grows left to right over a `░` track, measured against a range you set:
|
|
426
|
+
|
|
427
|
+
```ruby
|
|
428
|
+
bar = Component::ProgressBar.new(range: 0..files.size)
|
|
429
|
+
bar.value = done
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
The first thing to notice is what it *doesn't* have — text. No percentage
|
|
433
|
+
sits on the bar, and there is no slot to put one there. That looks like an
|
|
434
|
+
omission until you try to write the alternative: centering a string over a
|
|
435
|
+
fill boundary means slicing it in two and restyling each half so it stays
|
|
436
|
+
legible against both, and the result can only ever be one centered line
|
|
437
|
+
clipped to the bar's width. A {Tuile::Component::Label} underneath is
|
|
438
|
+
strictly more capable and costs one line:
|
|
439
|
+
|
|
440
|
+
```ruby
|
|
441
|
+
label.text = "#{bar.percent}% — #{done}/#{files.size} files"
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
Now the app words it. "Scanning…", a filename, two lines, a count — none of
|
|
445
|
+
which a formatting knob on the bar could have produced. This is the
|
|
446
|
+
composition argument from chapter 1 in miniature, and the frameworks Tuile
|
|
447
|
+
takes after land in the same place: Vaadin's `ProgressBar` has no text API
|
|
448
|
+
either, and its own docs tell you to put a label beside it.
|
|
449
|
+
|
|
450
|
+
That leaves `fraction` and `percent` as real API rather than conveniences,
|
|
451
|
+
since they're what the label reads. Both scale the same way, and it's worth
|
|
452
|
+
knowing the rule: **the endpoints are exact.** A full bar means done and
|
|
453
|
+
`percent` returns 100 only at the maximum — 99.9 % floors to 99 and paints
|
|
454
|
+
one empty cell. The alternative, rounding, paints a *full* bar at 97.5 % on
|
|
455
|
+
a 20-cell rect, and a progress bar that says "finished" before it is has
|
|
456
|
+
told you the one lie it exists to avoid. At the other end the rule is
|
|
457
|
+
mirrored: anything above zero lights at least one cell, because a job that
|
|
458
|
+
has started and shows nothing reads as a job that has hung.
|
|
459
|
+
|
|
460
|
+
When you don't know the total, say so:
|
|
461
|
+
|
|
462
|
+
```ruby
|
|
463
|
+
bar.indeterminate = true
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
and the fill is replaced by a block sliding across the bar. It animates
|
|
467
|
+
itself — the bar starts a ticker when it's added to the tree and cancels it
|
|
468
|
+
when it's removed, so there is nothing to remember and nothing to leak.
|
|
469
|
+
That is the attach-hook idiom from chapter 4, and this is the first
|
|
470
|
+
component to use it. The cost is that an animating bar keeps the event loop
|
|
471
|
+
awake, so switch it off (or take the bar off screen) when the work ends.
|
|
472
|
+
|
|
473
|
+
One consequence of measuring against a range is worth calling out because
|
|
474
|
+
it looks like an edge case and isn't: `range = 0..0` is legal, and reads as
|
|
475
|
+
complete. An empty file list is a job with nothing outstanding, so
|
|
476
|
+
`bar.range = 0..files.size` needs no special case for the empty run — and
|
|
477
|
+
an app that reaches that state because it hasn't counted yet wanted
|
|
478
|
+
`indeterminate` anyway.
|
|
479
|
+
|
|
480
|
+
The bar takes its color from `bar_color`, which is `nil` by default — the
|
|
481
|
+
terminal's own foreground, the same choice chapter 6 makes for every
|
|
482
|
+
non-accent cell. Assign a {Tuile::Color} for a branded or threshold color
|
|
483
|
+
(green under 50 %, red over 90 %), or a `Theme.ref` to have it track the
|
|
484
|
+
light/dark scheme. Both glyphs take that one color: what distinguishes
|
|
485
|
+
filled from empty is the *density* of the character, not its hue, so the
|
|
486
|
+
bar still reads on a terminal with no color at all.
|
|
487
|
+
|
|
134
488
|
## Framing content
|
|
135
489
|
|
|
136
490
|
{Tuile::Component::Window} is the frame: a bordered box with a `caption`
|
|
@@ -186,9 +540,9 @@ window.content = Component::TextView.new.tap { _1.text = help_text }
|
|
|
186
540
|
Component::Popup.new(content: window).open
|
|
187
541
|
```
|
|
188
542
|
|
|
189
|
-
A nested TextField
|
|
190
|
-
|
|
191
|
-
|
|
543
|
+
A nested TextField still swallows printable keys first, so typing `q` into
|
|
544
|
+
a field inside a popup doesn't dismiss it — the popup's own `q` handler sits
|
|
545
|
+
on the ancestor, and only sees keys the field declined.
|
|
192
546
|
|
|
193
547
|
## Batteries-included windows
|
|
194
548
|
|
data/book/08-testing.md
CHANGED
|
@@ -97,14 +97,27 @@ list.handle_key(Keys::DOWN_ARROW) # exercises the cursor directly
|
|
|
97
97
|
list.handle_mouse(MouseEvent.new(:left, 5, 2))
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
-
**High: go through the
|
|
101
|
-
dispatch
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
that a focused text field swallows a key its
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
100
|
+
**High: go through the pane.** {Tuile::ScreenPane#handle_key} runs the
|
|
101
|
+
dispatch rung from chapter 5 that routing is actually about: delivery to
|
|
102
|
+
{Tuile::Screen#focused}, then the bubble up its ancestor chain to the scope
|
|
103
|
+
root. So when your test is about routing — that a layout's one-key pane jump
|
|
104
|
+
fires, that a focused text field swallows a key its ancestor would otherwise
|
|
105
|
+
claim, that an open modal keeps the content beneath it from seeing keys — you
|
|
106
|
+
drive the pane and let the real machinery run:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
screen.focused = list # focus as production does — or list.focus
|
|
110
|
+
assert screen.pane.handle_key("1") # the layout's ancestor binding fires
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The two rungs *above* the pane have their own doors, because `Screen`'s own
|
|
114
|
+
`handle_key` — the top of the ladder — is private: it belongs to the key
|
|
115
|
+
thread, not to app code. Tab cycling is {Tuile::Screen#focus_next} /
|
|
116
|
+
`focus_previous`, both already scoped to the topmost modal popup, which is
|
|
117
|
+
what "a popup traps Tab" means. A global shortcut is a block you registered,
|
|
118
|
+
so test the action it calls; the registry itself is a lookup table `Screen`
|
|
119
|
+
consults before handing the key to the pane, and `register_global_shortcut`
|
|
120
|
+
is worth a test only for what it *rejects* (printables, Tab, `EDITING_KEYS`).
|
|
108
121
|
|
|
109
122
|
Two more test-only hooks close the loop. After you mutate something, call
|
|
110
123
|
`Screen.instance.repaint` to flush the pending invalidations into the
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# 9. Styled text
|
|
2
|
+
|
|
3
|
+
Everything Tuile draws is, eventually, text with colors on it — a
|
|
4
|
+
highlighted list row, a red error label, a border in the active accent.
|
|
5
|
+
The value type that carries "text plus styling" through the whole
|
|
6
|
+
framework is {Tuile::StyledString}, and it's worth one chapter of *why*,
|
|
7
|
+
because the obvious representation — a plain `String` with ANSI escape
|
|
8
|
+
codes threaded through it — is the one Tuile deliberately does *not* use.
|
|
9
|
+
|
|
10
|
+
## Why not just a String with escape codes in it
|
|
11
|
+
|
|
12
|
+
A terminal styles text with SGR escape sequences: `"\e[31mred\e[0m"` is
|
|
13
|
+
the word "red" in red. It's tempting to treat a styled string as exactly
|
|
14
|
+
that — a normal `String` that happens to contain those bytes — and let
|
|
15
|
+
the terminal sort it out.
|
|
16
|
+
|
|
17
|
+
The trouble shows up the moment you do anything *structural* to the text.
|
|
18
|
+
Slice out columns 5 through 10: which colors are active at column 5? To
|
|
19
|
+
answer, you have to scan every escape sequence from the start of the
|
|
20
|
+
string, tracking the running SGR state, because the color at column 5 was
|
|
21
|
+
set by some `\e[...m` that might be twenty characters earlier. Word-wrap
|
|
22
|
+
it across a narrow viewport: every break point needs that same running
|
|
23
|
+
state re-established on the next line, or the color bleeds or resets
|
|
24
|
+
wrong. Concatenate two of them: whose reset wins? Every operation becomes
|
|
25
|
+
"parse the SGR state machine, figure out what's active here, splice
|
|
26
|
+
carefully." The escape codes and the text are tangled together, and the
|
|
27
|
+
tangle has to be re-untangled on every edit.
|
|
28
|
+
|
|
29
|
+
{Tuile::StyledString} untangles it once, structurally. A styled string is
|
|
30
|
+
a sequence of **spans**, each a maximal run of characters that share one
|
|
31
|
+
complete {Tuile::StyledString::Style} — foreground, background, bold,
|
|
32
|
+
italic, underline, strikethrough. The spans are non-overlapping and tile
|
|
33
|
+
the whole string: every character belongs to exactly one span, and that
|
|
34
|
+
span's `style` *is* the character's style. There are no overlay layers to
|
|
35
|
+
merge, no running state to reconstruct. "What's the style at column 5?"
|
|
36
|
+
is just "which span contains column 5?" — a lookup, not a replay.
|
|
37
|
+
|
|
38
|
+
That's the trade. You pay one extra type — you construct or parse a
|
|
39
|
+
{Tuile::StyledString} instead of building a raw `String` — and in return
|
|
40
|
+
slicing, wrapping, and concatenation become ordinary operations on a list
|
|
41
|
+
of spans, each of which already knows its own style. For a framework that
|
|
42
|
+
slices and wraps text constantly, on every repaint, that's the right side
|
|
43
|
+
of the trade.
|
|
44
|
+
|
|
45
|
+
## The algebra
|
|
46
|
+
|
|
47
|
+
Once text is spans, the operations you'd want on a string come back, but
|
|
48
|
+
style-aware. You concatenate with `+` (a plain `String` operand is parsed
|
|
49
|
+
first, so embedded escapes round-trip). You take substrings by *display
|
|
50
|
+
column* with `slice` — display column, not byte offset, because a
|
|
51
|
+
fullwidth CJK character is two columns wide and a combining mark is zero,
|
|
52
|
+
and the terminal cares about columns. You split on newlines with `lines`,
|
|
53
|
+
word-wrap to a width with `wrap`, and truncate-with-ellipsis with
|
|
54
|
+
`ellipsize`. Every one of them returns a fresh {Tuile::StyledString} with
|
|
55
|
+
the spans carried across the cut intact — the value is immutable and its
|
|
56
|
+
spans are frozen and shared, so these are cheap.
|
|
57
|
+
|
|
58
|
+
Two details are worth knowing because they're choices, not accidents.
|
|
59
|
+
Slicing **never splits a glyph**: if a two-column character straddles the
|
|
60
|
+
boundary of your slice, it's dropped rather than rendered as half a
|
|
61
|
+
character, which the terminal couldn't do anyway. "Glyph" there means a
|
|
62
|
+
*grapheme cluster*, not a character, and the distinction is not pedantic —
|
|
63
|
+
a letter plus its combining accent is two characters and one glyph, and a
|
|
64
|
+
slice that kept the letter but dropped the mark would hand you back a
|
|
65
|
+
visibly different word. Emoji make the same point louder: a thumbs-up plus
|
|
66
|
+
a skin-tone modifier is two characters, one glyph, and two columns. Tuile
|
|
67
|
+
measures clusters throughout and credits a combined emoji its real width
|
|
68
|
+
rather than adding up its pieces, so text with emoji in it lays out and
|
|
69
|
+
paints at the same size. And wrapping guarantees
|
|
70
|
+
no output line exceeds the target width *whenever every character fits in
|
|
71
|
+
that width* — a single glyph wider than the whole viewport still lands on
|
|
72
|
+
its own line at its natural width, because there's nowhere narrower to put
|
|
73
|
+
it. The exact signatures live in the rdoc; this is the shape of the
|
|
74
|
+
toolbox.
|
|
75
|
+
|
|
76
|
+
## Rendering and the minimal diff
|
|
77
|
+
|
|
78
|
+
Two spans, both red, sitting next to each other, should not each re-emit
|
|
79
|
+
`\e[31m` — the terminal is already red. {Tuile::StyledString#to_ansi}
|
|
80
|
+
renders the spans to escape codes by **diffing** each span's style against
|
|
81
|
+
the one before it, emitting only the codes that actually changed. A
|
|
82
|
+
transition back to the plain default style emits a single `\e[0m` rather
|
|
83
|
+
than laboriously turning each attribute off. The rendered run always
|
|
84
|
+
closes with `\e[0m` if it ended non-default, so styling never bleeds into
|
|
85
|
+
whatever the terminal prints next.
|
|
86
|
+
|
|
87
|
+
This isn't just tidiness. The same style-diffing logic
|
|
88
|
+
({Tuile::StyledString::Style#sgr_to}) is what the back buffer uses when it
|
|
89
|
+
flushes changed cells to the terminal (chapter 2) — cell-to-cell there,
|
|
90
|
+
span-to-span here, identical minimal sequences. Styled text and the
|
|
91
|
+
flicker-free repaint model are the same idea at two scales: never rewrite
|
|
92
|
+
what's already correct.
|
|
93
|
+
|
|
94
|
+
## The parser: strict by default, lenient on request
|
|
95
|
+
|
|
96
|
+
You can go the other way too — parse an ANSI-coded `String` *into* spans
|
|
97
|
+
with {Tuile::StyledString.parse}. Here Tuile makes a sharp choice that's
|
|
98
|
+
easy to get wrong, so it's worth stating plainly: **the parser is strict
|
|
99
|
+
by default.**
|
|
100
|
+
|
|
101
|
+
Strict means it recognizes exactly the SGR codes that map to a
|
|
102
|
+
{Tuile::StyledString::Style}'s attributes — the foreground and background
|
|
103
|
+
colors, bold, italic, underline, strikethrough — and *raises* on anything
|
|
104
|
+
else. An unmodeled attribute like blink or reverse video, an unknown SGR
|
|
105
|
+
code, a non-SGR escape like a cursor move or an OSC sequence: all of them
|
|
106
|
+
are a {Tuile::StyledString::ParseError}, not a shrug. The reason is a
|
|
107
|
+
contract worth protecting: `parse(to_ansi(x)) == x`. If parsing silently
|
|
108
|
+
dropped what it didn't understand, that round-trip would quietly lie, and
|
|
109
|
+
a styled string that survived a save/load cycle might come back subtly
|
|
110
|
+
different. Strict parsing keeps the round-trip honest — anything the model
|
|
111
|
+
can't represent is refused at the door, not swallowed.
|
|
112
|
+
|
|
113
|
+
But strictness is wrong for one common job: piping in colored output you
|
|
114
|
+
didn't produce and don't control — `git --color` through a pager, a build
|
|
115
|
+
tool's output, anything that sprinkles cursor moves and exotic attributes
|
|
116
|
+
you have no intention of modeling. For that, pass `lenient: true`. Now the
|
|
117
|
+
parser keeps the colors and attributes it understands and **discards
|
|
118
|
+
everything else** — unmodeled codes, malformed extended colors, cursor
|
|
119
|
+
moves, OSC and other string sequences, stray escapes — instead of
|
|
120
|
+
raising. It's lossy by definition (`parse(x, lenient: true)` does not
|
|
121
|
+
round-trip back to `x`), and that's the point: "give me the colors, throw
|
|
122
|
+
the rest away." Strict for text you own and must preserve exactly; lenient
|
|
123
|
+
for text you're borrowing and only want the colors from.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
{Tuile::StyledString} is the quiet primitive under everything visible.
|
|
128
|
+
You rarely construct one by hand for simple cases — a {Tuile::Component::Label}
|
|
129
|
+
takes a plain `String` and wraps it for you — but the moment you render
|
|
130
|
+
your own content with per-span colors (a log line, a syntax-highlighted
|
|
131
|
+
snippet, a diff), this is the type you're building, and the theming hook
|
|
132
|
+
from chapter 6 is where you rebuild it when the palette changes.
|
data/book/README.md
CHANGED
|
@@ -29,7 +29,9 @@ theming (including live OS light/dark flips). Chapters 7–8 close out
|
|
|
29
29
|
**narratively** — a tour of the shipped component toolbox framed around
|
|
30
30
|
when and why to reach for each, and how to test a Tuile app end to end.
|
|
31
31
|
Those two lean on the rdoc for the exact APIs; the guide keeps to the
|
|
32
|
-
walkthroughs and use-cases.
|
|
32
|
+
walkthroughs and use-cases. Chapter 9 is a **deep dive** on
|
|
33
|
+
`Tuile::StyledString`, the text primitive under everything the framework
|
|
34
|
+
draws — read it when you start rendering your own styled content.
|
|
33
35
|
|
|
34
36
|
The book grows organically — a chapter exists when a concept has earned
|
|
35
37
|
one, not to fill an outline.
|
|
@@ -59,21 +61,25 @@ one, not to fill an outline.
|
|
|
59
61
|
`submit`, and how terminal resize (`SIGWINCH`) is plumbed through the
|
|
60
62
|
same queue rather than handled off the signal.
|
|
61
63
|
5. **[Focus and the keyboard](05-focus.md).** The focus chain and
|
|
62
|
-
`focusable?`, and the order in which a keystroke is offered
|
|
63
|
-
tree — Tab, global shortcuts,
|
|
64
|
-
|
|
65
|
-
how `keyboard_hint`
|
|
64
|
+
`focusable?`, and the three-rung order in which a keystroke is offered
|
|
65
|
+
to the tree — Tab, global shortcuts, then `handle_key` delivered to
|
|
66
|
+
focus and bubbling up its ancestors. Why scope-wide keys (pane jumps, a
|
|
67
|
+
form's default button) belong on an ancestor, and how `keyboard_hint`
|
|
68
|
+
drives the status bar.
|
|
66
69
|
6. **[Theming](06-theming.md).** Semantic color tokens read at paint
|
|
67
|
-
time,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
time, opt-in component backgrounds that inherit down the tree
|
|
71
|
+
(`bg_color`), light/dark auto-detection at startup and live OS
|
|
72
|
+
appearance flips, pairing variants in a `ThemeDef`, app-specific custom
|
|
73
|
+
tokens, and rebuilding theme-derived content in `on_theme_changed`.
|
|
70
74
|
7. **[The component library](07-components.md).** A narrative tour of
|
|
71
75
|
the shipped toolbox — Window, List, the text inputs and views,
|
|
72
|
-
Popup, and the window conveniences — framed around
|
|
73
|
-
you reach for each. Signatures stay in the rdoc.
|
|
76
|
+
ProgressBar, Popup, and the window conveniences — framed around
|
|
77
|
+
*when and why* you reach for each. Signatures stay in the rdoc.
|
|
74
78
|
8. **[Testing a Tuile app](08-testing.md).** The testing approach:
|
|
75
79
|
`FakeScreen`, asserting against the painted buffer, driving
|
|
76
80
|
invalidation, and PTY-based end-to-end tests of runnable scripts.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
81
|
+
9. **[Styled text](09-styled-text.md).** A deep dive on
|
|
82
|
+
`Tuile::StyledString`, the span-based "text plus styling" value type
|
|
83
|
+
under everything Tuile draws: why spans instead of a `String` full of
|
|
84
|
+
escape codes, the style-aware algebra (slice/wrap/concat by display
|
|
85
|
+
column), minimal-diff rendering, and the strict-vs-lenient parser.
|