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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -64
- data/DECISIONS.md +619 -14
- data/README.md +6 -0
- data/book/03-layout.md +153 -8
- data/book/05-focus.md +2 -0
- data/book/07-components.md +105 -10
- data/book/README.md +3 -1
- data/examples/sampler.rb +282 -132
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +17 -8
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/checkbox.rb +10 -9
- data/lib/tuile/component/combo_box.rb +8 -26
- data/lib/tuile/component/float_field.rb +161 -0
- data/lib/tuile/component/layout/box.rb +316 -0
- data/lib/tuile/component/layout/horizontal.rb +40 -0
- data/lib/tuile/component/layout/vertical.rb +41 -0
- data/lib/tuile/component/layout.rb +149 -1
- data/lib/tuile/component/list_dropdown.rb +69 -18
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/styled_string.rb +13 -3
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +882 -29
- metadata +8 -1
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# Arrow keys move focus between fields — as a *behavior*, not a layout class
|
|
2
|
+
|
|
3
|
+
**Status:** design sketch, 2026-08-12. Nothing built. Started from the
|
|
4
|
+
sampler's PasswordField pane ("Up/Down between the three fields would be
|
|
5
|
+
friendlier"), but that pane is a *demo* of the feature, not an argument for
|
|
6
|
+
it — the argument is a ten-field form. Open questions at the bottom are the
|
|
7
|
+
point of this file.
|
|
8
|
+
|
|
9
|
+
## What is being proposed
|
|
10
|
+
|
|
11
|
+
In a form, Up/Down moves focus between fields, in addition to Tab/Shift+Tab.
|
|
12
|
+
Tab keeps its current job (cycle every tab stop in the scope, wrapping);
|
|
13
|
+
arrows do *local* motion within one container and stop at its edges.
|
|
14
|
+
|
|
15
|
+
## Why it's plausible at all: Tuile already has the whole mechanism
|
|
16
|
+
|
|
17
|
+
Two findings from the survey, both load-bearing:
|
|
18
|
+
|
|
19
|
+
1. **`TextField` already declines Up/Down by design.** `text_field.rb:134-140`:
|
|
20
|
+
`on_key_up` / `on_key_down` are nil by default and the nil branch is
|
|
21
|
+
`return false`, with rdoc reading "when nil, UP falls through to the parent
|
|
22
|
+
(default behavior)". The clash we feared was already resolved in the
|
|
23
|
+
direction that enables this.
|
|
24
|
+
2. **Rung 3 of the key ladder is exactly the right hook.** `bubble_key` asks
|
|
25
|
+
the focused widget, then each ancestor. AGENTS.md already names this as the
|
|
26
|
+
sanctioned home for scope-wide keys ("a layout's one-key jumps to its
|
|
27
|
+
panes"). So this is a `handle_key` on a container — no dispatch phase, no
|
|
28
|
+
gate in `Screen#handle_key`, no framework change.
|
|
29
|
+
|
|
30
|
+
Worth stating explicitly for a future reader: the key ladder's "no gate, no
|
|
31
|
+
predicate, no mode flag" rule constrains `Screen#handle_key`, **not** a
|
|
32
|
+
component's own `handle_key`. Adding behavior at rung 3 is sanctioned; a
|
|
33
|
+
per-instance switch on a *component* is not the thing that rule forbids.
|
|
34
|
+
|
|
35
|
+
Everything that must keep the arrows already claims them and wins for free:
|
|
36
|
+
`TextArea`, `TextView`, `List`, the three numeric fields, `ComboBox`.
|
|
37
|
+
|
|
38
|
+
## Prior art
|
|
39
|
+
|
|
40
|
+
Splits by lineage, not by age:
|
|
41
|
+
|
|
42
|
+
- **FTXUI** — closest to Tuile architecturally, and does exactly this.
|
|
43
|
+
`Container::Vertical` is *defined* as "navigated vertically using up/down
|
|
44
|
+
arrow keys"; `Container::Horizontal` gets left/right. Dispatch is our shape:
|
|
45
|
+
active child asked first, container only sees what the child declined
|
|
46
|
+
(`container.cpp`, `OnEvent`). Arrows do **not** wrap (`MoveSelector`); Tab
|
|
47
|
+
wraps (`MoveSelectorWrap`).
|
|
48
|
+
- **Midnight Commander** — "to move between the widgets use the arrow keys or
|
|
49
|
+
the Tab key". The ncurses form-dialog lineage generally (`dialog`, newt)
|
|
50
|
+
behaves this way; only MC was verified.
|
|
51
|
+
- **Bubble Tea** — the canonical `examples/textinputs` cycles focus on
|
|
52
|
+
up/down/tab/shift+tab, but app-side; the framework has no focus model.
|
|
53
|
+
- **Textual, ratatui, Ink, Vaadin** — no. Textual is the explicit web/ARIA
|
|
54
|
+
position: Tab between widgets, arrows only *within* a composite widget.
|
|
55
|
+
|
|
56
|
+
## The shape: a behavior on a layout, not a `Layout::Form`
|
|
57
|
+
|
|
58
|
+
`Layout::Form < Layout::Vertical` was the first sketch and is **rejected**.
|
|
59
|
+
Reasons, in order of force:
|
|
60
|
+
|
|
61
|
+
- **Vaadin's FormGroup precedent.** It coupled `Binder` to layouting and was
|
|
62
|
+
abandoned for it. `Form` here would couple a layout algorithm to a key
|
|
63
|
+
behavior — a smaller version of the same mistake.
|
|
64
|
+
- **It breaks the moment the layout is insufficient.** A real form needs a
|
|
65
|
+
nested `Horizontal` row, or an `Absolute` for a capped-proportion split. Now
|
|
66
|
+
the behavior is attached to the *outer* class and the nested layouts are
|
|
67
|
+
arbitrary, so "does this container navigate?" stops being answerable from
|
|
68
|
+
the class. Policy carried by a class is inherited by every subclass and
|
|
69
|
+
unavailable to every non-subclass; policy carried by a setter is per
|
|
70
|
+
instance and never inherited.
|
|
71
|
+
- **COP says so.** `Layout::Vertical` is a *generic, domain-agnostic*
|
|
72
|
+
component, and the skill's rule for those is to externalize policy via
|
|
73
|
+
injected strategies — not to subclass per policy. A `Form` whose only
|
|
74
|
+
divergence is one `handle_key` is precisely the "shallow divergent
|
|
75
|
+
scaffolding — duplicate or inject, don't fold into a base" case.
|
|
76
|
+
|
|
77
|
+
So: **any layout can be given the behavior; none has it by default.** virtui
|
|
78
|
+
gets nothing and stays exactly as it is; a form opts in. The sampler's `form`
|
|
79
|
+
helper (`sampler.rb:832`) becomes the one place the demo opts in.
|
|
80
|
+
|
|
81
|
+
Concretely, the nesting story this buys — and it is the whole reason for the
|
|
82
|
+
shape: a layout without the behavior **declines** the arrow key, so it bubbles
|
|
83
|
+
to the next ancestor that *does* have it. An inner `Absolute` inside a
|
|
84
|
+
navigating `Vertical` is therefore one opaque slot: focus anywhere inside it,
|
|
85
|
+
Down moves to the next slot of the outer box. No inheritance, no surprise, and
|
|
86
|
+
the answer is the same at any nesting depth.
|
|
87
|
+
|
|
88
|
+
## Semantics that already look settled
|
|
89
|
+
|
|
90
|
+
Recorded here so the open questions below stay narrow.
|
|
91
|
+
|
|
92
|
+
- **Walk direct children, not `on_tree`.** A `Horizontal` row nested in a
|
|
93
|
+
navigating `Vertical`: flattened pre-order would make Down from the row's
|
|
94
|
+
left field jump to the row's *right* field, which is geometrically wrong.
|
|
95
|
+
Direct children makes Down go to the next row. (FTXUI indexes `children()`
|
|
96
|
+
for the same reason.) "Which direct child holds focus" is a parent-chain
|
|
97
|
+
walk from `screen.focused`, so depth doesn't matter.
|
|
98
|
+
- **Skip children with no focusable descendant** (a `Label`, a spacer).
|
|
99
|
+
- **Don't wrap; decline at the edge.** Two payoffs: nesting composes (an inner
|
|
100
|
+
box at its edge declines and the outer box moves to the next sibling group),
|
|
101
|
+
and wrapping stays Tab's distinguishing job. Same split FTXUI landed on.
|
|
102
|
+
- **Descend via the existing focus cascade** — set `screen.focused` to the
|
|
103
|
+
sibling and let `Layout#on_focus` (`layout.rb:203`) forward to its first tab
|
|
104
|
+
stop. See open question on backwards entry.
|
|
105
|
+
- **Mouse is untouched.** Popups are untouched — the bubble is already scoped
|
|
106
|
+
to the topmost modal popup.
|
|
107
|
+
|
|
108
|
+
## The honest argument against
|
|
109
|
+
|
|
110
|
+
A *partially* live feature is worse than an absent one: if arrows navigate in
|
|
111
|
+
80% of positions the user can't build a model. Inside a form the exception set
|
|
112
|
+
is mostly coherent — `TextArea` / `TextView` / `List` swallow arrows, and
|
|
113
|
+
they're the *tall* widgets, where a user already expects arrows to move
|
|
114
|
+
*inside* the box. That reads as "arrows move within a tall widget, between
|
|
115
|
+
short ones", which is learnable and is the story MC tells.
|
|
116
|
+
|
|
117
|
+
The three numeric fields break that story and are the real problem (see Q6).
|
|
118
|
+
|
|
119
|
+
## Open questions
|
|
120
|
+
|
|
121
|
+
**Q1 — What exactly is the knob?** Candidates, roughly in order of how much
|
|
122
|
+
API they add:
|
|
123
|
+
|
|
124
|
+
a. keyword + accessor on `Layout`: `navigation: :vertical` / `:horizontal` /
|
|
125
|
+
`:both` / `nil` (default `nil`).
|
|
126
|
+
b. a strategy *object*: `layout.navigation = Layout::ArrowNavigation.new(...)`,
|
|
127
|
+
leaving room for per-instance config and app subclassing.
|
|
128
|
+
c. a module the app mixes in: `Vertical.new.extend(Layout::ArrowNavigable)`.
|
|
129
|
+
Composable with any layout without touching `Layout`, but `extend` on a
|
|
130
|
+
singleton class is obscure and hard to document.
|
|
131
|
+
d. a general `Component#on_key` interceptor hook (the shape
|
|
132
|
+
`AbstractStringField#on_key` already has), with the framework shipping a
|
|
133
|
+
ready-made callable to assign. Most decoupled — touches `Layout` not at
|
|
134
|
+
all — but adds a general hook whose merits should be argued on their own,
|
|
135
|
+
not smuggled in under this feature.
|
|
136
|
+
|
|
137
|
+
(a) is the smallest thing that works; (d) is the most COP-pure. Not decided.
|
|
138
|
+
|
|
139
|
+
**Q2 — Where does the axis come from?** If the behavior is layout-agnostic it
|
|
140
|
+
can't be derived from the class. `Vertical` → up/down and `Horizontal` →
|
|
141
|
+
left/right are natural defaults, but `Absolute` has none. Does the knob always
|
|
142
|
+
carry an explicit axis, or default per class and require it on `Absolute`?
|
|
143
|
+
|
|
144
|
+
**Q3 — Should `Horizontal` / left-right navigation exist at all?**
|
|
145
|
+
`AbstractStringField` *always* consumes Left/Right for the caret, so a row of
|
|
146
|
+
text fields will never arrow-navigate while a row of Buttons/Checkboxes will.
|
|
147
|
+
The rule stays uniform (widget wins); the outcome looks selective. Ship both
|
|
148
|
+
axes, or vertical-only until someone asks?
|
|
149
|
+
|
|
150
|
+
**Q4 — Ordering inside an `Absolute`.** Declaration order is all that's
|
|
151
|
+
available and may not match visual order — the original worry that killed the
|
|
152
|
+
idea of putting this on every layout. Options: document "declaration order is
|
|
153
|
+
yours to get right"; or sort direct children geometrically per keypress (by
|
|
154
|
+
`rect.top`, then `rect.left`), which is cheap and actually correct, and would
|
|
155
|
+
make `Absolute` a first-class citizen here. Is geometric ordering worth it?
|
|
156
|
+
|
|
157
|
+
**Q5 — Backwards entry into a multi-widget sibling.** `Layout#on_focus` always
|
|
158
|
+
forwards to the *first* tab stop, so arrowing **Up** into a previous group
|
|
159
|
+
lands on its first widget rather than its last. FTXUI has the same wart. Fix
|
|
160
|
+
with a `last:` variant of the cascade, or accept it?
|
|
161
|
+
|
|
162
|
+
**Q6 — The numeric fields.** `IntegerField` / `FloatField` / `BigDecimalField`
|
|
163
|
+
consume Up/Down to step by ±1, and they are *one row tall* — so they break the
|
|
164
|
+
"arrows move within tall widgets" story silently, with nothing on screen
|
|
165
|
+
explaining why. This is the sharpest concrete collision. Options:
|
|
166
|
+
|
|
167
|
+
a. leave it; document the exception,
|
|
168
|
+
b. move stepping to `Ctrl+Up/Down` or `PgUp/PgDn` (breaking, but the fields
|
|
169
|
+
are young),
|
|
170
|
+
c. make stepping opt-in per field (`step = 1` / `nil`) — a knob, but on the
|
|
171
|
+
widget that actually has the ambiguity, and a numeric field in a form
|
|
172
|
+
usually doesn't want spinner behavior anyway.
|
|
173
|
+
|
|
174
|
+
**Q7 — `List` at its edges.** It clamps and returns true, so arrows can never
|
|
175
|
+
escape a focused list; only Tab does. Keep clamping (a list is a list, and MC
|
|
176
|
+
agrees), or have it decline at its edges so arrows escape? Note this is
|
|
177
|
+
exactly virtui's shape, so the answer matters more there than in a form.
|
|
178
|
+
|
|
179
|
+
**Q8 — `ComboBox` is asymmetric.** Closed, it eats Down to open the menu
|
|
180
|
+
(`combo_box.rb:181`) but declines Up. So Up would jump out of a closed combo
|
|
181
|
+
while Down opens it. Browsers eat both. Deliberate choice or accident to fix?
|
|
182
|
+
|
|
183
|
+
**Q9 — Naming.** "Form" is the wrong word for the behavior — it's *focus
|
|
184
|
+
navigation*. `navigation` / `arrow_nav` / `key_navigation` /
|
|
185
|
+
`focus_navigation`? Whatever it is, it must not imply validation or submit,
|
|
186
|
+
which Tuile has no notion of.
|
|
187
|
+
|
|
188
|
+
**Q10 — Does Enter participate?** `dialog(1)` moves to the next field on
|
|
189
|
+
Enter. Almost certainly out of scope — `Checkbox`, `Button` and `TextArea` all
|
|
190
|
+
claim Enter already, and book ch5 has the per-widget Enter table — but worth
|
|
191
|
+
rejecting explicitly rather than by omission.
|
|
192
|
+
|
|
193
|
+
**Q11 — Where does the code live?** Zeitwerk wants one top-level constant per
|
|
194
|
+
file. If Q1 lands on (b) or (c) it needs its own file under
|
|
195
|
+
`lib/tuile/component/layout/`; if (a), it's a few lines on `Layout` itself.
|
|
196
|
+
Also: any public signature change means `rake sig` in the same commit.
|
|
197
|
+
|
|
198
|
+
## Graduation
|
|
199
|
+
|
|
200
|
+
If built: the user-facing half goes to book ch5 (the key/Enter tables live
|
|
201
|
+
there), the invariants half to AGENTS.md's key-dispatch section, and the
|
|
202
|
+
choice-plus-rejected-roads half to `DECISIONS.md` as `D-arrow-navigation` —
|
|
203
|
+
which must record the `Layout::Form` rejection and the Vaadin FormGroup
|
|
204
|
+
precedent behind it, since that's the reasoning most likely to be
|
|
205
|
+
re-litigated. Then retire this file.
|
data/ideas/new-components.md
CHANGED
|
@@ -15,6 +15,10 @@ built 2026-07-31; `progress-bar` (`D-color-slots`, book ch7 "Reporting
|
|
|
15
15
|
progress") and `password-field` (`D-integer-field`'s taxonomy, book ch7
|
|
16
16
|
"Editing text"), both built 2026-08-02.
|
|
17
17
|
|
|
18
|
+
The **box layouts** that headed the gating list below are done too
|
|
19
|
+
(`D-box-layouts`, 2026-08-07) — `Layout::Vertical` / `::Horizontal`, plus the
|
|
20
|
+
sampler ported onto them.
|
|
21
|
+
|
|
18
22
|
## What Tuile already has
|
|
19
23
|
|
|
20
24
|
Seven of the 54 have a counterpart: Button, Text Field, Text Area,
|
|
@@ -34,19 +38,19 @@ That leaves ~46 gaps.
|
|
|
34
38
|
|
|
35
39
|
| Component | Builds on | Note |
|
|
36
40
|
|---|---|---|
|
|
37
|
-
| Box layouts (H/V) | `Layout` |
|
|
41
|
+
| ~~Box layouts (H/V)~~ | `Layout` | **built** 2026-08-07 (`D-box-layouts`, book ch3); `Vertical`/`Horizontal` over `Box`, additive sugar on top of `Absolute` — no foundation change |
|
|
38
42
|
| ~~Checkbox~~ | `HasValue` | **built** 2026-07-30 (`D-boolean-fields`); tri-state still deferred |
|
|
39
43
|
| ~~Radio Group~~ | `List` + `HasValue` | **built** 2026-07-31 (`D-radio-group`); composes a `List`, cursor roams and Space selects |
|
|
40
44
|
| ~~Checkbox Group~~ | `List` + `HasValue` | **built** 2026-07-30 (`D-checkbox-group`); composes a `List`, frozen `Set` value |
|
|
41
|
-
| Select | `
|
|
42
|
-
| Password Field | `TextField` |
|
|
43
|
-
| Number Field | `IntegerField` twin |
|
|
44
|
-
| Progress Bar | `draw_line` + `EventQueue#tick_fps` | ticker
|
|
45
|
+
| ~~Select~~ | `ListDropdown` + `HasValue` | **built** 2026-08-12 (`D-select`, book ch7); a *second driver* of `ListDropdown`, not "ComboBox − filter" — it paints its own one-row face and needs no read-only axis. Claims no printable but Space |
|
|
46
|
+
| ~~Password Field~~ | `TextField` | **built** 2026-08-02 (`D-integer-field`'s taxonomy — subclass, since a password's value *is* its text; mask default in `D-ambiguous-width`); a `display_text` seam, one mask glyph per character |
|
|
47
|
+
| ~~Number Field~~ | `IntegerField` twin | **built** 2026-08-07 as `FloatField` (`D-float-field`) and `BigDecimalField` (`D-bigdecimal-field`, on Tuile's first optional dep); each named for its Ruby value type, deliberate copies of `IntegerField` |
|
|
48
|
+
| ~~Progress Bar~~ | `draw_line` + `EventQueue#tick_fps` | **built** 2026-08-02 (`D-progress-bar`, book ch7); a `value` that stays out of `HasValue`, ticker synced from `attached? && indeterminate?` |
|
|
45
49
|
| Notification | `Popup` + `Ticker` | needs corner-anchored (non-centered) popup placement |
|
|
46
50
|
| Confirm Dialog | `Popup`+`Window`+`Button` | fold `PickerWindow` in |
|
|
47
51
|
| Details → Accordion | `HasContent` | Details is the atom, Accordion the group |
|
|
48
52
|
| Tabs → Tabsheet | `HasValue` (index) + `HasContent` | strip, then strip + content swap |
|
|
49
|
-
| Popover |
|
|
53
|
+
| Popover | `ListDropdown#anchor_to` (extracted 2026-08-12) | generalize the anchored non-modal overlay: `anchor_to` moves down to it and `ListDropdown` inherits it. Build it when the second *kind* of anchoring appears (a point; a right edge that flips) — not the second caller of the same kind. Gates the next two |
|
|
50
54
|
| Menu Bar | `ListDropdown::Menu` + Popover | |
|
|
51
55
|
| Context Menu | same | `:right` button already parses |
|
|
52
56
|
| Slider | `draw_line` | arrows/PgUp; 25.2 also has a two-thumb *range* variant |
|
|
@@ -87,8 +91,13 @@ That leaves ~46 gaps.
|
|
|
87
91
|
These are prerequisites, not components, and each deserves its own idea
|
|
88
92
|
file when its cluster comes up:
|
|
89
93
|
|
|
90
|
-
1.
|
|
91
|
-
|
|
94
|
+
1. ~~**Box layouts** (H/V)~~ — **done** 2026-08-07 (`D-box-layouts`). Turned
|
|
95
|
+
out *not* to be structural: a `Box` is an `Absolute` subclass with a `rect=`
|
|
96
|
+
override, so it unblocked the form-shaped cluster without touching the
|
|
97
|
+
foundation. A future Grid should reuse its `Fixed`/`Percent`/`Expand`
|
|
98
|
+
constraints per row and column rather than invent a second vocabulary.
|
|
99
|
+
2. **Field label + helper text seam** → Form Layout. Note this is what Form
|
|
100
|
+
Layout is actually blocked on — the layout half now exists.
|
|
92
101
|
3. **Validation seam** → Email Field, forms generally.
|
|
93
102
|
4. **Anchored Popover extraction** → Menu Bar, Context Menu, pickers,
|
|
94
103
|
Tooltip.
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The gem's one *optional* dependency, deliberately absent from the gemspec so
|
|
4
|
+
# that only an app naming this component pays for it. Zeitwerk loads this file
|
|
5
|
+
# on the first reference to {Tuile::Component::BigDecimalField} and not before.
|
|
6
|
+
begin
|
|
7
|
+
require "bigdecimal"
|
|
8
|
+
rescue LoadError
|
|
9
|
+
raise LoadError, "Tuile::Component::BigDecimalField needs the bigdecimal gem. Add `gem \"bigdecimal\"` " \
|
|
10
|
+
"to your Gemfile — since Ruby 3.4 it is a bundled gem, so Bundler no longer puts it " \
|
|
11
|
+
"on the load path for free."
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module Tuile
|
|
15
|
+
class Component
|
|
16
|
+
# A single-line field whose {#value} is a `BigDecimal` (or `nil` when
|
|
17
|
+
# empty) — the numeric field for money, where {FloatField}'s binary double
|
|
18
|
+
# would round. Give it a single-row {#rect}:
|
|
19
|
+
#
|
|
20
|
+
# price = Component::BigDecimalField.new
|
|
21
|
+
# price.on_value_change = ->(d) { total.value = d } # BigDecimal or nil
|
|
22
|
+
# price.value = BigDecimal("19.99") # field shows "19.99"
|
|
23
|
+
# price.value = 19.99 # ArgumentError: a Float can't be exact
|
|
24
|
+
#
|
|
25
|
+
# Only `0`–`9`, one leading `-` and one `.` can be typed; any other
|
|
26
|
+
# printable key is dropped without moving the caret. Up/Down step by one.
|
|
27
|
+
# Range checks (`min`/`max`) and a display scale (`19.9` → `19.90`) belong
|
|
28
|
+
# to a forms layer, not here — nothing rounds or pads what you typed.
|
|
29
|
+
#
|
|
30
|
+
# Requires the `bigdecimal` gem, which Tuile does *not* depend on: it is a
|
|
31
|
+
# bundled gem from Ruby 3.4 on, so a `Gemfile` naming it is what puts it on
|
|
32
|
+
# the load path. Referencing this class without it raises `LoadError`.
|
|
33
|
+
#
|
|
34
|
+
# == Implementation details
|
|
35
|
+
# {#value} is a *derived parse*: the buffer is the single source of truth,
|
|
36
|
+
# recomputed on read and left exactly as typed (`"19.90"` keeps its zero,
|
|
37
|
+
# which `BigDecimal#to_s` would not). It reads `nil` for a buffer that
|
|
38
|
+
# isn't a number (`""`, a lone `"-"`) but `1` / `0.5` for a half-typed
|
|
39
|
+
# `"1."` / `".5"`, so reaching for the decimal point doesn't blink the
|
|
40
|
+
# value to `nil` and back through {#on_value_change} — which fires per
|
|
41
|
+
# keystroke, but only on a real *value* change (`"1.0"`→`"1.00"` is silent,
|
|
42
|
+
# since the two compare equal).
|
|
43
|
+
#
|
|
44
|
+
# Both ends of that round-trip are written here rather than left to the
|
|
45
|
+
# library, because `bigdecimal` 3.1 (Ruby 3.3's default gem) and 4.x
|
|
46
|
+
# disagree about them: 3.1 rejects `BigDecimal("1.")` and `BigDecimal(0.1)`
|
|
47
|
+
# where 4.x accepts both. So the buffer is normalized before parsing, a
|
|
48
|
+
# `Float` is refused on both, and display goes through `to_s("F")` — plain
|
|
49
|
+
# notation, never `BigDecimal#to_s`'s `"0.1999e2"`.
|
|
50
|
+
#
|
|
51
|
+
# It *composes* a {TextField} (its single {HasContent} child) rather than
|
|
52
|
+
# subclassing one, so its face carries only the typed {HasValue} seam,
|
|
53
|
+
# never the widget's `String`-typed `text`.
|
|
54
|
+
#
|
|
55
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
56
|
+
class BigDecimalField < Component
|
|
57
|
+
include HasContent
|
|
58
|
+
include HasValue
|
|
59
|
+
|
|
60
|
+
# A buffer {#value} parses: an optional sign and digits with an optional
|
|
61
|
+
# fractional part (either side may be empty, but not both). No exponent —
|
|
62
|
+
# `to_s("F")` never writes one and no key types an `e`.
|
|
63
|
+
# @return [Regexp]
|
|
64
|
+
NUMERIC = /\A-?(?:\d+(?:\.\d*)?|\.\d+)\z/
|
|
65
|
+
private_constant :NUMERIC
|
|
66
|
+
|
|
67
|
+
def initialize
|
|
68
|
+
super()
|
|
69
|
+
@last_value = nil
|
|
70
|
+
field = TextField.new
|
|
71
|
+
field.on_change = ->(_text) { fire_if_changed }
|
|
72
|
+
field.on_key = method(:field_key)
|
|
73
|
+
self.content = field
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [::BigDecimal, nil] the parsed buffer; `nil` when empty or not a
|
|
77
|
+
# number (e.g. a lone `"-"`).
|
|
78
|
+
def value
|
|
79
|
+
text = content.text
|
|
80
|
+
text.match?(NUMERIC) ? BigDecimal(normalize(text)) : nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Writes `new_value` into the buffer in plain notation and parks the
|
|
84
|
+
# caret at its end; fires {#on_value_change} only if the value actually
|
|
85
|
+
# changed.
|
|
86
|
+
# @param new_value [::BigDecimal, Integer, String, nil] `nil` empties the
|
|
87
|
+
# field. A `Float` is refused, not converted — see the raise.
|
|
88
|
+
# @raise [ArgumentError] on a `Float` (its binary value is not the
|
|
89
|
+
# decimal you wrote, which is the whole reason to use this field), a
|
|
90
|
+
# non-numeric `String`, a NaN or an infinity.
|
|
91
|
+
# @raise [TypeError] on a value `BigDecimal()` won't take at all.
|
|
92
|
+
# @return [void]
|
|
93
|
+
def value=(new_value)
|
|
94
|
+
content.text = new_value.nil? ? "" : coerce(new_value).to_s("F")
|
|
95
|
+
content.caret = content.text.length
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# `nil`, not `""`: a numeric field with no parseable number is empty.
|
|
99
|
+
# @return [nil]
|
|
100
|
+
def empty_value = nil
|
|
101
|
+
|
|
102
|
+
# @return [Point, nil] the field's caret (the hardware cursor is delegated
|
|
103
|
+
# to the inner field).
|
|
104
|
+
def cursor_position = content.cursor_position
|
|
105
|
+
|
|
106
|
+
# Fired when ENTER is pressed in the field; see {TextField#on_enter}.
|
|
107
|
+
# @return [Proc, Method, nil] no-arg callable, or nil.
|
|
108
|
+
def on_enter = content.on_enter
|
|
109
|
+
|
|
110
|
+
# @param callback [Proc, Method, nil]
|
|
111
|
+
# @return [void]
|
|
112
|
+
def on_enter=(callback)
|
|
113
|
+
content.on_enter = callback
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
protected
|
|
117
|
+
|
|
118
|
+
# Places the wrapped field across the whole rect ({HasContent} hook).
|
|
119
|
+
# @param field [Component]
|
|
120
|
+
# @return [void]
|
|
121
|
+
def layout(field) = (field.rect = rect)
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
# Rewrites the half-typed shapes {NUMERIC} admits into ones every
|
|
126
|
+
# `bigdecimal` version parses: `".5"` → `"0.5"`, `"1."` → `"1"`.
|
|
127
|
+
# @param text [String] a buffer matching {NUMERIC}.
|
|
128
|
+
# @return [String]
|
|
129
|
+
def normalize(text)
|
|
130
|
+
text = text.sub(".", "0.") if text.start_with?(".", "-.")
|
|
131
|
+
text.chomp(".")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @param new_value [::BigDecimal, Integer, String]
|
|
135
|
+
# @return [::BigDecimal]
|
|
136
|
+
# @raise [ArgumentError] on a `Float` — `bigdecimal` 4.x would take it
|
|
137
|
+
# and 3.1 would not, and neither answer is the one a money field wants
|
|
138
|
+
# to give silently. Also on a NaN or an infinity: `to_s("F")` writes
|
|
139
|
+
# `"NaN"`, which no parse reads back, so writing one would silently
|
|
140
|
+
# turn the value `nil`.
|
|
141
|
+
def coerce(new_value)
|
|
142
|
+
if new_value.is_a?(Float)
|
|
143
|
+
raise ArgumentError, "a Float is not exact — pass BigDecimal(#{new_value.to_s.inspect}) or the String"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
big = BigDecimal(new_value)
|
|
147
|
+
raise ArgumentError, "value must be finite, got #{big}" unless big.finite?
|
|
148
|
+
|
|
149
|
+
big
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The field's key interceptor, consulted *before* the field acts on the
|
|
153
|
+
# key — which is what lets a rejected character be swallowed without the
|
|
154
|
+
# caret ever moving.
|
|
155
|
+
# @param key [String]
|
|
156
|
+
# @return [Boolean] true to consume the key.
|
|
157
|
+
def field_key(key)
|
|
158
|
+
case key
|
|
159
|
+
when Keys::UP_ARROW then step(1)
|
|
160
|
+
when Keys::DOWN_ARROW then step(-1)
|
|
161
|
+
else return Keys.printable?(key) && !accepts?(key)
|
|
162
|
+
end
|
|
163
|
+
true
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Nudges {#value} by `delta`, treating an empty/un-parseable field as
|
|
167
|
+
# zero.
|
|
168
|
+
# @param delta [Integer]
|
|
169
|
+
# @return [void]
|
|
170
|
+
def step(delta) = (self.value = (value || BigDecimal(0)) + delta)
|
|
171
|
+
|
|
172
|
+
# Whether `char` may be inserted. Deliberately shallow: it keeps the
|
|
173
|
+
# buffer *typeable* rather than always-valid — a transient `"-"` or
|
|
174
|
+
# `"1."` has to be reachable — and {#value} decides what parses.
|
|
175
|
+
# @param char [String] a single printable character.
|
|
176
|
+
# @return [Boolean]
|
|
177
|
+
def accepts?(char)
|
|
178
|
+
case char
|
|
179
|
+
when /\A[0-9]\z/ then true
|
|
180
|
+
when "-" then content.caret.zero? && !content.text.start_with?("-")
|
|
181
|
+
when "." then !content.text.include?(".")
|
|
182
|
+
else false
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Re-emits {#on_value_change} with the freshly-parsed {#value}, but only
|
|
187
|
+
# when it differs from the last one fired — so a buffer edit that leaves
|
|
188
|
+
# the value unchanged (`"1.0"`→`"1.00"`) stays silent.
|
|
189
|
+
# @return [void]
|
|
190
|
+
def fire_if_changed
|
|
191
|
+
v = value
|
|
192
|
+
return if v == @last_value
|
|
193
|
+
|
|
194
|
+
@last_value = v
|
|
195
|
+
on_value_change&.call(v)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
|
-
# A boolean input on one row. Space or a left click toggles it:
|
|
5
|
+
# A boolean input on one row. Space, Enter or a left click toggles it:
|
|
6
6
|
#
|
|
7
7
|
# [x] Enable syslog forwarding
|
|
8
8
|
# [ ] Enable syslog forwarding
|
|
@@ -18,11 +18,12 @@ module Tuile
|
|
|
18
18
|
# {#empty_value}, so a fresh checkbox is {HasValue#empty? empty} and
|
|
19
19
|
# {HasValue#clear} unchecks.
|
|
20
20
|
#
|
|
21
|
-
# Space
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
# {
|
|
21
|
+
# Space and Enter both toggle — same as a checkable row in a
|
|
22
|
+
# {Component::List} ({CheckboxGroup}, {RadioGroup}), so the gesture reads the
|
|
23
|
+
# same standalone and grouped. A focused checkbox therefore *consumes* Enter:
|
|
24
|
+
# a form's Enter-to-submit on an ancestor won't see it, exactly as with a
|
|
25
|
+
# focused {Button} or {TextArea}. Which widget lets Enter through is per
|
|
26
|
+
# widget, never a framework guarantee — book ch5's Enter table is the list.
|
|
26
27
|
#
|
|
27
28
|
# A tab stop, so Tab lands on it, and the widget highlights while on the focus
|
|
28
29
|
# chain. Assign a {#rect} (typically from the surrounding {Layout}) at least
|
|
@@ -97,12 +98,12 @@ module Tuile
|
|
|
97
98
|
# @return [Rect]
|
|
98
99
|
def extent = Rect.new(rect.left, rect.top, [caption.display_width + 4, rect.width].min, 1)
|
|
99
100
|
|
|
100
|
-
# Toggles on Space. Every other key
|
|
101
|
-
#
|
|
101
|
+
# Toggles on Space or Enter. Every other key is left unhandled so it bubbles
|
|
102
|
+
# to an ancestor.
|
|
102
103
|
# @param key [String]
|
|
103
104
|
# @return [Boolean]
|
|
104
105
|
def handle_key(key)
|
|
105
|
-
return false unless
|
|
106
|
+
return false unless [" ", Keys::ENTER].include?(key)
|
|
106
107
|
|
|
107
108
|
toggle
|
|
108
109
|
true
|
|
@@ -147,11 +147,12 @@ module Tuile
|
|
|
147
147
|
protected
|
|
148
148
|
|
|
149
149
|
# Field spans the row bar the last column, which the `▾` occupies
|
|
150
|
-
# ({HasContent} layout hook).
|
|
150
|
+
# ({HasContent} layout hook). One row, or none at all when the combo itself
|
|
151
|
+
# was given none — a starved parent must not hand out a rect it doesn't own.
|
|
151
152
|
# @param field [Component]
|
|
152
153
|
# @return [void]
|
|
153
154
|
def layout(field)
|
|
154
|
-
field.rect = Rect.new(rect.left, rect.top, [rect.width - 1, 0].max, 1)
|
|
155
|
+
field.rect = Rect.new(rect.left, rect.top, [rect.width - 1, 0].max, [rect.height, 1].min)
|
|
155
156
|
end
|
|
156
157
|
|
|
157
158
|
private
|
|
@@ -251,31 +252,12 @@ module Tuile
|
|
|
251
252
|
# @return [String] the plain-text label for `item`, or "" for nil.
|
|
252
253
|
def display_for(item) = item.nil? ? "" : @item_label.call(item).to_s
|
|
253
254
|
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
#
|
|
255
|
+
# Places the dropdown at the combo's own width, so both its edges line up
|
|
256
|
+
# with the field — at the cost of the scrollbar taking its column from the
|
|
257
|
+
# labels, which ellipsize a column earlier once the list scrolls. That is
|
|
258
|
+
# the trade a measuring driver ({Select}) makes the other way.
|
|
257
259
|
# @return [void]
|
|
258
|
-
def anchor
|
|
259
|
-
desired = [@filtered.size, MAX_VISIBLE_ROWS].min
|
|
260
|
-
below = screen.size.height - (rect.top + 1)
|
|
261
|
-
above = rect.top
|
|
262
|
-
if desired <= below
|
|
263
|
-
top = rect.top + 1
|
|
264
|
-
height = desired
|
|
265
|
-
elsif above >= below
|
|
266
|
-
height = [desired, above].min
|
|
267
|
-
top = rect.top - height
|
|
268
|
-
else
|
|
269
|
-
height = below
|
|
270
|
-
top = rect.top + 1
|
|
271
|
-
end
|
|
272
|
-
@overlay.size = Size.new(rect.width, height)
|
|
273
|
-
@overlay.rect = Rect.new(rect.left, top, rect.width, height)
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
# Most matches shown before the dropdown scrolls.
|
|
277
|
-
# @return [Integer]
|
|
278
|
-
MAX_VISIBLE_ROWS = 10
|
|
260
|
+
def anchor = @overlay.anchor_to(rect, rows: @filtered.size)
|
|
279
261
|
end
|
|
280
262
|
end
|
|
281
263
|
end
|