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.
@@ -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.
@@ -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` | Tuile has only `Layout::Absolute`. Biggest structural win; unblocks half of this table |
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 | `ComboBox` − filter | ComboBox with a read-only field; near-free. Deferred once already in `D-combobox` (wants the parked read-only axis) |
42
- | Password Field | `TextField` | masked repaint only |
43
- | Number Field | `IntegerField` twin | same composed-field shape, `Float` |
44
- | Progress Bar | `draw_line` + `EventQueue#tick_fps` | ticker for the indeterminate mode |
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 | extract `ComboBox#anchor` + `ListDropdown` geometry | generalize the anchored non-modal overlay; gates the next two |
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. **Box layouts** (H/V) — everything form-shaped wants them.
91
- 2. **Field label + helper text seam** → Form Layout.
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 toggles. Enter is unhandled — unlike {Button} — simply because a
22
- # checkbox has no default action to confirm, so it bubbles to an ancestor;
23
- # treat that as this widget declining a key, not as a guarantee the framework
24
- # makes (a {TextArea} claims Enter for newline, and a checkable row in a
25
- # {Component::List} toggles on it).
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 — Enter included — is left unhandled so
101
- # it bubbles to an ancestor.
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 key == " "
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
- # Sizes and positions the dropdown against the field: full combo width,
255
- # `min(matches, 10)` rows, below the field — flipped above when it won't
256
- # fit beneath, clamped (with the list scrolling) when it fits neither.
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