tuile 0.10.0 → 0.11.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.
data/README.md CHANGED
@@ -49,6 +49,12 @@ gem "tuile", git: "https://github.com/mvysny/tuile.git"
49
49
 
50
50
  Tuile requires Ruby 3.3+.
51
51
 
52
+ One component — `Component::BigDecimalField` — additionally needs the
53
+ `bigdecimal` gem, which Tuile deliberately does *not* depend on (it has been a
54
+ bundled gem since Ruby 3.4, so Bundler no longer puts it on the load path for
55
+ free). Add `gem "bigdecimal"` to your Gemfile if you use that field; nothing
56
+ else in Tuile loads it.
57
+
52
58
  ## Documentation
53
59
 
54
60
  - **[The Tuile guide](book/README.md)** teaches Tuile cover to cover — the
data/book/03-layout.md CHANGED
@@ -125,6 +125,15 @@ complex TUIs — tmux, neovim's splits, k9s, lazygit, htop — are all
125
125
  them needs flex grow/shrink/wrap/basis or a constraint solve. The
126
126
  hardest real terminal UIs already live comfortably inside "simple."
127
127
 
128
+ Be precise about what that validates, though: it's TUI *app architecture*,
129
+ not TUI *framework feature lists*. Several terminal frameworks do ship a
130
+ full engine — Textual has CSS, Ink embeds Yoga (the flexbox engine React
131
+ Native uses), ratatui runs a real Cassowary solver. The reason isn't that
132
+ terminals need one; it's that those frameworks never hand you a rectangle,
133
+ so an engine is the only way their users can lay anything out. Tuile hands
134
+ you coordinates, which is what makes richer layout *optional* here —
135
+ available where it helps, declinable everywhere else.
136
+
128
137
  It's stronger than "simple happens to work," though. Importing a CSS-like
129
138
  system would be *actively worse* on a terminal, for three concrete
130
139
  reasons:
@@ -204,6 +213,146 @@ That's the whole "responsive" story: plain Ruby, recomputed on a
204
213
  discrete resize event. No breakpoint DSL, no media queries — just the
205
214
  arithmetic you'd write anyway.
206
215
 
216
+ ## Stacks without the arithmetic: `Vertical` and `Horizontal`
217
+
218
+ `Absolute` is the right tool for genuinely two-dimensional geometry, and
219
+ tedious for the most common shape in any app: a stack. So Tuile ships two
220
+ *box* layouts that do that arithmetic for you. You declare what extent each
221
+ child should get, and the box hands down rectangles through the very same
222
+ `rect=`:
223
+
224
+ ```ruby
225
+ form = Tuile::Component::Layout::Vertical.new(spacing: 1)
226
+ form.add(prompt, Tuile::Component::Layout::Fixed[3]) # 3 rows
227
+ form.add(field, Tuile::Component::Layout::Fixed[1]) # 1 row
228
+ form.add(log, Tuile::Component::Layout::Expand[1]) # …all that's left
229
+ ```
230
+
231
+ `Horizontal` is the same with the axes swapped — the constraint is a width,
232
+ and `Expand` claims the rest of the row:
233
+
234
+ ```ruby
235
+ split = Tuile::Component::Layout::Horizontal.new
236
+ split.add(sidebar, Tuile::Component::Layout::Fixed[30])
237
+ split.add(main, Tuile::Component::Layout::Expand[1])
238
+ ```
239
+
240
+ Inside a subclass the constraint names need no prefix, since they live on
241
+ `Layout`, an ancestor:
242
+
243
+ ```ruby
244
+ class LoginForm < Tuile::Component::Layout::Vertical
245
+ def initialize
246
+ super(spacing: 1, padding: Insets[top: 1])
247
+ add(@user = Tuile::Component::TextField.new, Fixed[1], cross: Fixed[30])
248
+ add(@log = Tuile::Component::TextView.new, Expand[1])
249
+ end
250
+ end
251
+ ```
252
+
253
+ ### The three constraints
254
+
255
+ - **`Fixed[n]`** — exactly `n` cells, clamped to what's still unassigned.
256
+ - **`Percent[n]`** — `n`% of the space *available*, measured after padding
257
+ and the gaps between children come off. So two `Percent[50]` children fit
258
+ exactly instead of overflowing by the gap between them.
259
+ - **`Expand[weight]`** — a share of whatever is left once the `Fixed` and
260
+ `Percent` children have taken theirs, split in proportion to the weights.
261
+
262
+ That's the entire vocabulary, and the omission is the point: **there is no
263
+ `Auto`.** Nothing asks a child how big it would like to be. This is the same
264
+ rule as the rest of the chapter, wearing a friendlier face.
265
+
266
+ Two more knobs, both on the box rather than on each child: `spacing:` (blank
267
+ cells between adjacent children) and `padding:` (an inset from the box's own
268
+ rect — `Insets[top: 1, left: 2]`, or a plain integer for all four edges).
269
+
270
+ ### The cross axis, and alignment
271
+
272
+ Each child also gets a `cross:` constraint — its width in a `Vertical`, its
273
+ height in a `Horizontal`. It defaults to `Percent[100]`, so children fill the
274
+ box across the axis, which is usually what you want. Narrow one when it isn't:
275
+
276
+ ```ruby
277
+ form.add(field, Fixed[1], cross: Fixed[30]) # 30 columns
278
+ form.add(title, Fixed[1], cross: Percent[50], align: :center)
279
+ ```
280
+
281
+ `align:` is `:start`, `:center` or `:end` — axis-agnostic on purpose, since
282
+ `:start` means the left edge in a `Vertical` and the top edge in a
283
+ `Horizontal`. It does something only when the child is narrower than the
284
+ space available.
285
+
286
+ Alignment might look like it contradicts the top-down rule — surely centering
287
+ needs to know how wide the child is? It doesn't. It needs *a* width, and the
288
+ `cross:` constraint is where that width came from. Nothing gets measured.
289
+ (`Expand` is main-axis only for a related reason: across the axis a child has
290
+ no siblings to compete with, so a weight would have nothing to mean. Passing
291
+ one as `cross:` raises.)
292
+
293
+ ### Packing, starving, and remainders
294
+
295
+ Three behaviours worth knowing, because they are what you get *instead of* a
296
+ solver:
297
+
298
+ **Children pack from the start edge.** With no `Expand` among them the slack
299
+ is simply left at the end — there's no invisible filler to add, the way
300
+ Swing's `BoxLayout` needs glue.
301
+
302
+ **Over-subscription starves rather than raising.** If the children ask for
303
+ more than there is, they're satisfied in declaration order and whoever is
304
+ left over gets an empty rect — which, as chapter 2 established, paints
305
+ nothing. A pane too short for its content degrades quietly instead of
306
+ throwing or spilling outside its rect.
307
+
308
+ **A remainder goes to the earliest `Expand` children, one cell each.** Five
309
+ equal `Expand`s in 12 rows get `3, 3, 2, 2, 2` — never `2, 2, 2, 2, 4`, which
310
+ is what "give the leftover to the last one" produces. On a character grid a
311
+ doubled pane is plainly visible, so spare cells are spread rather than dumped.
312
+ One wrinkle, since this chapter showed you the hand-written version first: the
313
+ two-pane `Absolute` example above gives the odd column to the *right* pane,
314
+ while two `Expand[1]` children give it to the *left*. Both are deterministic;
315
+ they're just different code.
316
+
317
+ ### Varying the gap: nest, don't configure
318
+
319
+ `spacing` belongs to the box rather than to individual children, deliberately.
320
+ A gap sits *between* two children, so "whose gap is it?" has no good answer —
321
+ and both possible conventions confuse readers.
322
+
323
+ When you want tighter grouping, nest a box. A `spacing: 0` stack inside a
324
+ `spacing: 1` stack keeps two rows flush while the rest of the form breathes:
325
+
326
+ ```ruby
327
+ pair = Tuile::Component::Layout::Vertical.new # spacing: 0
328
+ pair.add(bar, Fixed[1])
329
+ pair.add(caption, Fixed[1]) # flush under the bar
330
+
331
+ form = Tuile::Component::Layout::Vertical.new(spacing: 1)
332
+ form.add(prompt, Fixed[4])
333
+ form.add(pair, Fixed[2]) # blank row around the pair
334
+ ```
335
+
336
+ That *states* the grouping instead of faking it with a per-child gap — boxes
337
+ within boxes, which is how the rest of Tuile composes anyway.
338
+
339
+ ### When to stay with `Absolute`
340
+
341
+ The boxes are sugar, not a replacement, and they can't say everything. A **cap
342
+ on a proportion** is the case to recognise:
343
+
344
+ ```ruby
345
+ list_width = (rect.width / 3).clamp(20, 40) # a third, but never <20 or >40
346
+ group_width = [16, rect.width / 3].min # a third, but never more than 16
347
+ ```
348
+
349
+ Both of these are in `examples/sampler.rb`, and both keep a `rect=`
350
+ override. That's the intended division of labour rather than a gap to work
351
+ around: use a box for the stack, drop to `Absolute` for the region that
352
+ genuinely needs arithmetic — usually nesting one inside the other, so only the
353
+ awkward part carries any. The sampler does exactly that, and porting it to
354
+ these layouts took it from 59 hand-written rectangles down to 7.
355
+
207
356
  ## Geometry: `Point`, `Size`, `Rect`
208
357
 
209
358
  The values you compute with are three small frozen types
@@ -369,11 +518,7 @@ own code and set the size top-down. Keep measurement opt-in and
369
518
  caller-side; the moment the framework starts consulting children for
370
519
  sizes automatically, it's on the road back to the constraint solver.
371
520
 
372
- And if you find yourself building many dynamic, user-draggable splits by
373
- hand and wishing for a `Layout.vertical([Length(3), Fill(1), …])`
374
- convenience — that's a known, *deliberately deferred* addition. It would
375
- be pure sugar: a rect producer running a small greedy 1-D pass and
376
- feeding results to the very same `rect=` setter you already use, with no
377
- change to the foundation. Absolute-first is the base; a descriptive
378
- split layer is an optional convenience on top, added if and when the
379
- convenience pays for itself.
521
+ Note that the box layouts above are not an exception to any of this. They
522
+ compute rectangles *for* you, but they compute them from constraints you
523
+ supplied, and they hand them down through the same `rect=`. No child is ever
524
+ consulted.
data/book/05-focus.md CHANGED
@@ -159,6 +159,8 @@ The same mechanism gives you a form's default button, one form per popup:
159
159
  | `TextField` with an `on_enter` | consumes it | no double-submit |
160
160
  | `TextField` without one | declines | bubbles up → submit |
161
161
  | `Button` | consumes it | activates *itself*, not the default |
162
+ | `Checkbox` | consumes it (toggles) | the form never sees it |
163
+ | `Select` | consumes it (opens, then commits) | the form never sees it |
162
164
 
163
165
  Because bubbling stops at the scope root, two forms in two popups each get
164
166
  their own Enter — something a global registry structurally cannot do. This
@@ -175,7 +175,25 @@ qty.on_value_change = ->(n) { recompute(n) } # n is an Integer, or nil
175
175
  qty.value = 3
176
176
  ```
177
177
 
178
- Both the combo box and the integer field are built the same way, and it's
178
+ {Tuile::Component::FloatField} is the same field one type over — it also
179
+ accepts a single decimal point, and its value is a `Float`. That naming is
180
+ a small rule worth knowing, because it tells you what you're getting: a
181
+ typed field is named after the Ruby class of its value, so `IntegerField`
182
+ hands back an `Integer` and `FloatField` a `Float` — a binary double, which
183
+ makes it exactly the wrong field for money. It parses generously while you
184
+ type: a buffer of `1.` already reads as `1.0`, so reaching for the decimal
185
+ point doesn't blink the value to `nil` and back in the listener you wired.
186
+
187
+ Money gets {Tuile::Component::BigDecimalField}, the same field once more with
188
+ an exact decimal inside — type `0.1` and it is `0.1`, not the `0.1000…0055`
189
+ a binary double stores. It is strict about how that exactness is preserved:
190
+ assigning a `Float` raises rather than quietly converting, because by the time
191
+ `19.99` reaches the setter it is already not `19.99`. This is the one
192
+ component with a dependency Tuile itself doesn't carry — `bigdecimal` has
193
+ been a *bundled* gem since Ruby 3.4, so an app that uses this field names it
194
+ in its own `Gemfile`, and an app that doesn't never loads it.
195
+
196
+ The combo box and the two numeric fields are built the same way, and it's
179
197
  worth seeing why: each *wraps* a text field rather than *being* one. A
180
198
  subclass would inherit the text field's `String`-typed value and wear it
181
199
  on its face right next to the real typed one — two conflicting answers to
@@ -272,15 +290,22 @@ cb.toggle # unchecks it, firing the listener with false
272
290
 
273
291
  Two of its choices are worth understanding, because they're really
274
292
  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.
293
+ toggles it, just as Space does.** Space is the native gesture for flipping
294
+ a checkbox, and for a while Enter was deliberately left alone — a checkbox
295
+ has no action to confirm. What settled it is the checkbox group further
296
+ down this chapter: a checkable row inside a list flips on Enter, because
297
+ Enter is how a list chooses the row under its cursor. Had the standalone
298
+ widget stayed silent, the same `[ ] Verbose` would have responded to Enter
299
+ in a group and ignored it in a form, which is a distinction the person at
300
+ the keyboard has no way to see.
301
+
302
+ The consequence is worth stating plainly, because it's the general rule
303
+ hiding behind the specific choice: a focused checkbox *consumes* Enter, so
304
+ a form's submit button on an ancestor won't see it. That's not a
305
+ regression from some guarantee — the framework never kept Enter clear, and
306
+ chapter 5's table shows why it can't: a text area claims Enter for a
307
+ newline, a button claims it to activate itself. Whether Enter reaches your
308
+ form always depends on the widget that has focus.
284
309
 
285
310
  The second is about *where the widget actually is*. A form column will
286
311
  happily hand a checkbox forty columns for a caption that needs twenty-two,
@@ -412,6 +437,76 @@ one cell, and keeps the set of characters riding on that bet small enough
412
437
  to enumerate, so a new widget reaches for ASCII and offers the pretty
413
438
  glyph only where someone can opt in knowing their terminal.
414
439
 
440
+ A radio group spends a row per option, permanently. When the form has six
441
+ of these and a terminal has twenty-four rows, that arithmetic stops
442
+ working, and {Tuile::Component::Select} is the same single answer on *one*
443
+ row: the selected label plus a `▾`, with the options appearing only while
444
+ you're choosing between them.
445
+
446
+ ```ruby
447
+ level = Component::Select.new(items: %w[debug info warn error], value: "warn")
448
+ level.on_value_change = ->(l) { logger.level = l }
449
+ ```
450
+
451
+ Enter, Space or Down opens the dropdown, the arrows move the highlight,
452
+ Enter or Space commits, ESC closes it having changed nothing. `value` is
453
+ the selected item as always, `nil` while nothing is selected — and that
454
+ `nil` is a perfectly ordinary state here, which is why there's no
455
+ placeholder text: an optional enum field simply shows a blank face.
456
+
457
+ So when do you reach for which? The temptation is to decide by item count,
458
+ and that's the wrong axis. Ask instead **who wrote the labels**:
459
+
460
+ | The options are… | Widget | Why |
461
+ |---|---|---|
462
+ | a developer-authored enum, on one form row | `Select` | one row; borrows *n* transiently |
463
+ | the same enum, worth comparing side by side | `RadioGroup` | spends *n* rows permanently |
464
+ | supplied by the app, open-ended, labels you don't control | `ComboBox` | filtering *is* the navigation |
465
+ | an enum, several of which apply | `CheckboxGroup` | a frozen `Set` value |
466
+
467
+ A select is for a closed set you knew when you wrote the code — log level,
468
+ sort order, line endings, Yes/No/Ask. A combo box is for countries, users,
469
+ branches: data. A twelve-value enum is still a select, and a three-row
470
+ country list loaded from a database is still a combo box, because next
471
+ release it's two hundred rows and the widget you chose shouldn't have to
472
+ change. Count is a symptom; authorship is the criterion.
473
+
474
+ Which brings up the property that really separates the two, and it's not
475
+ the filtering. **A select claims no printable key but Space.** Every other
476
+ letter and digit bubbles straight past it, up the focus chain, to your
477
+ application — so a form's `s`-to-save, or a layout's `1`/`2`/`3` jumps
478
+ between panes, keep working while focus sits in a select. A combo box can
479
+ never offer that: its field must eat every printable, because every
480
+ printable is potentially part of the query. Add the fact that a select has
481
+ no caret, and the two together are the whole case for the component. A
482
+ caret is the strongest promise a terminal can make about what a widget
483
+ does, and spending it on "you may type free text here" over a four-value
484
+ enum is a lie the user then has to discover.
485
+
486
+ Space is the one exception, and it's a safe one precisely because Space was
487
+ never yours to begin with: every activatable widget in Tuile already claims
488
+ it — a button, a checkbox, a radio group. Home and End, by contrast, are
489
+ declined, so they stay available for you to bind app-wide.
490
+
491
+ You may be waiting for type-ahead — press `f` and jump to the first item
492
+ starting with `f`, the way desktop lists do. It isn't there, deliberately.
493
+ The single-key version is silently wrong: with Finland, Fiji and Jamaica in
494
+ the list, typing `fij` selects *Jamaica*, because each key is a fresh
495
+ one-character match. The fix everyone reaches for next is a small
496
+ accumulating buffer that clears after a second of idleness — and that
497
+ buffer *is* the combo box's query with the display removed. If you're
498
+ holding query state, showing it is strictly better than hiding it, and
499
+ showing it is a combo box. On a terminal it's worse still: the timer leans
500
+ on inter-keystroke gaps, and gaps are exactly what a laggy SSH link or a
501
+ paste destroys.
502
+
503
+ One small nicety worth noticing: the dropdown is never narrower than the
504
+ select itself, and grows past it when a label needs the room — so its edges
505
+ line up with the face you clicked, and the labels are never the thing that
506
+ gets ellipsized. It opens below the select, flips above near the bottom of
507
+ the screen, slides left rather than running off the right edge, and grows a
508
+ scrollbar when there are more options than it can show.
509
+
415
510
  For a discrete action rather than a selection, {Tuile::Component::Button}
416
511
  is a one-row `[ caption ]` that fires `on_click` on Enter, Space, or a
417
512
  left-click, highlighting its background while focused. It's a tab stop, so
data/book/README.md CHANGED
@@ -52,7 +52,9 @@ one, not to fill an outline.
52
52
  the design. Top-down, absolute, integer coordinates; a parent
53
53
  assigns its children's `rect` and components never negotiate a size.
54
54
  The C64 argument for *why simple layouting is enough* on a character
55
- grid, `Layout::Absolute` and the `rect=` override, `Fraction` for
55
+ grid, `Layout::Absolute` and the `rect=` override, the `Vertical` /
56
+ `Horizontal` box layouts and their three constraints (`Fixed` /
57
+ `Percent` / `Expand`) as sugar over that same rule, `Fraction` for
56
58
  sizing a popup against the screen, and resize as a discrete
57
59
  recompute. Geometry primitives (`Point` / `Size` / `Rect`) live here.
58
60
  4. **[The event loop and background work](04-event-loop.md).** The