tuile 0.9.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -36
  3. data/DECISIONS.md +2566 -0
  4. data/README.md +37 -24
  5. data/book/03-layout.md +153 -8
  6. data/book/04-event-loop.md +86 -0
  7. data/book/05-focus.md +84 -51
  8. data/book/06-theming.md +53 -1
  9. data/book/07-components.md +458 -9
  10. data/book/08-testing.md +21 -8
  11. data/book/09-styled-text.md +132 -0
  12. data/book/README.md +22 -14
  13. data/examples/sampler.rb +632 -67
  14. data/ideas/arrow-key-navigation.md +205 -0
  15. data/ideas/new-components.md +118 -0
  16. data/ideas/per-component-buffers.md +55 -0
  17. data/lib/tuile/buffer.rb +52 -51
  18. data/lib/tuile/color.rb +4 -10
  19. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  20. data/lib/tuile/component/big_decimal_field.rb +199 -0
  21. data/lib/tuile/component/button.rb +25 -21
  22. data/lib/tuile/component/checkbox.rb +134 -0
  23. data/lib/tuile/component/checkbox_group.rb +188 -0
  24. data/lib/tuile/component/combo_box.rb +263 -0
  25. data/lib/tuile/component/float_field.rb +161 -0
  26. data/lib/tuile/component/has_caption.rb +44 -0
  27. data/lib/tuile/component/has_content.rb +5 -5
  28. data/lib/tuile/component/has_value.rb +64 -0
  29. data/lib/tuile/component/integer_field.rb +135 -0
  30. data/lib/tuile/component/label.rb +13 -10
  31. data/lib/tuile/component/layout/box.rb +316 -0
  32. data/lib/tuile/component/layout/horizontal.rb +40 -0
  33. data/lib/tuile/component/layout/vertical.rb +41 -0
  34. data/lib/tuile/component/layout.rb +149 -15
  35. data/lib/tuile/component/list.rb +8 -7
  36. data/lib/tuile/component/list_dropdown.rb +157 -0
  37. data/lib/tuile/component/password_field.rb +105 -0
  38. data/lib/tuile/component/popup.rb +10 -14
  39. data/lib/tuile/component/progress_bar.rb +278 -0
  40. data/lib/tuile/component/radio_group.rb +188 -0
  41. data/lib/tuile/component/select.rb +251 -0
  42. data/lib/tuile/component/text_area.rb +189 -65
  43. data/lib/tuile/component/text_field.rb +170 -32
  44. data/lib/tuile/component/text_view.rb +57 -114
  45. data/lib/tuile/component/window.rb +32 -47
  46. data/lib/tuile/component.rb +250 -87
  47. data/lib/tuile/event_queue.rb +14 -17
  48. data/lib/tuile/fake_event_queue.rb +11 -2
  49. data/lib/tuile/fake_screen.rb +4 -5
  50. data/lib/tuile/fraction.rb +6 -10
  51. data/lib/tuile/screen.rb +202 -104
  52. data/lib/tuile/screen_pane.rb +51 -41
  53. data/lib/tuile/styled_string.rb +125 -86
  54. data/lib/tuile/theme.rb +78 -41
  55. data/lib/tuile/version.rb +1 -1
  56. data/lib/tuile.rb +4 -0
  57. data/sig/tuile.rbs +2962 -680
  58. metadata +25 -7
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # Multi-select from a set of typed items, one checkable row each. Arrows move
6
+ # a cursor; Space, Enter or a left click toggles the row under it:
7
+ #
8
+ # [x] Errors
9
+ # [ ] Warnings <- cursor row, highlighted across the full width
10
+ # [x] Info
11
+ # ^ the composed {List}'s one-column gutter
12
+ #
13
+ # cg = Component::CheckboxGroup.new(items: %w[Errors Warnings Info])
14
+ # cg.value = %w[Errors Info] # any Enumerable, stored as a Set
15
+ # cg.on_value_change = ->(set) { filter(set) } # once per toggle
16
+ # cg.value # => #<Set: {"Errors", "Info"}>
17
+ # cg.item_label = ->(level) { level.name } # default :to_s
18
+ #
19
+ # {#value} is a **frozen `Set` of the selected items themselves** — of
20
+ # whatever type {#items} holds, never their labels. Frozen so `cg.value <<
21
+ # item` fails loudly rather than mutating the selection behind
22
+ # {HasValue#on_value_change}'s back; assign a new set or an `Array` instead.
23
+ # Treat it as *unordered*: it iterates in toggle order, so use
24
+ # `cg.items & cg.value.to_a` when you need {#items} order.
25
+ #
26
+ # Composes rather than subclasses, like {ComboBox}: a {List} is its single
27
+ # {HasContent} child, which is where the cursor, scrolling, the scrollbar and
28
+ # per-row mouse hit-testing come from. `content` is that list, so an app can
29
+ # tune it (`scrollbar_visibility`, `show_cursor_when_inactive`, …). Rows
30
+ # beyond {#rect}'s height scroll; the inner list is the tab stop, not the
31
+ # group.
32
+ #
33
+ # == +items+ is chrome; +value+ is authoritative
34
+ # {#items=} changes only what is *presented*. It never touches {#value} and
35
+ # never fires {HasValue#on_value_change}, and a selected item absent from
36
+ # {#items} renders no checked row while surviving intact — so a form saved
37
+ # without the user editing anything changes nothing silently. Keeping the two
38
+ # in sync is the app's job: `cg.value &= cg.items.to_set` reconciles them.
39
+ # Same contract as {ComboBox#value}, one item at a time.
40
+ #
41
+ # There is no select-all — neither a key nor a header row. An app that wants
42
+ # one writes `cg.value = cg.items` behind its own affordance.
43
+ #
44
+ # == Implementation details
45
+ # Items need stable `#hash`/`#eql?`, since the selection is a `Set`: an item
46
+ # mutated after being selected becomes unfindable. Two `==`-equal items also
47
+ # share one selection — their rows check and uncheck together — whereas two
48
+ # *distinct* items that merely render the same label toggle independently,
49
+ # because a row resolves to an item by index.
50
+ #
51
+ # Rows repeat {Checkbox}'s `[x] `/`[ ] ` glyph convention rather than
52
+ # importing a constant from it.
53
+ #
54
+ # UI-thread-confined, like every component (see {Screen}).
55
+ class CheckboxGroup < Component
56
+ include HasContent
57
+ include HasValue
58
+
59
+ # @return [Set]
60
+ EMPTY_SELECTION = Set.new.freeze
61
+ private_constant :EMPTY_SELECTION
62
+
63
+ # @param items [Array] the items to present, one row each; also settable
64
+ # via {#items=}.
65
+ # @param value [Enumerable, nil] the initial selection. Seeds the backing
66
+ # ivar directly, so no listener fires and assignment order doesn't
67
+ # matter to a form helper.
68
+ def initialize(items: [], value: nil)
69
+ super()
70
+ @items = items.to_a
71
+ @item_label = :to_s.to_proc
72
+ @value = coerce(value)
73
+ @on_value_change = nil
74
+
75
+ list = List.new
76
+ # A List has no cursor at all by default (Cursor::None, position -1).
77
+ list.cursor = List::Cursor.new
78
+ list.on_item_chosen = ->(index, _line) { toggle_at(index) }
79
+ self.content = list
80
+ rebuild_rows
81
+ end
82
+
83
+ # @return [Array] the presented items.
84
+ attr_reader :items
85
+
86
+ # @return [Proc, Method] item -> row label (a `String`, {StyledString}, or
87
+ # anything with `#to_s`); `:to_s` by default.
88
+ attr_reader :item_label
89
+
90
+ # Replaces the presented rows, leaving {#value} untouched.
91
+ # @param new_items [Array]
92
+ # @raise [TypeError] unless `new_items` is an `Array`.
93
+ # @return [void]
94
+ def items=(new_items)
95
+ raise TypeError, "expected Array, got #{new_items.inspect}" unless new_items.is_a?(Array)
96
+
97
+ @items = new_items
98
+ rebuild_rows
99
+ end
100
+
101
+ # @param proc [Proc, Method] item -> row label.
102
+ # @return [void]
103
+ def item_label=(proc)
104
+ @item_label = proc
105
+ rebuild_rows
106
+ end
107
+
108
+ # @return [Set] the frozen empty set — {HasValue#empty?} means nothing is
109
+ # selected.
110
+ def empty_value = EMPTY_SELECTION
111
+
112
+ # Replaces the selection, firing {HasValue#on_value_change} when it really
113
+ # changed. Stores a frozen `Set` *copy*, so a set the caller goes on
114
+ # mutating can't reach in.
115
+ # @param new_value [Enumerable, nil] `nil` selects nothing.
116
+ # @raise [TypeError] unless `new_value` is an `Enumerable` or `nil`.
117
+ # @return [void]
118
+ def value=(new_value)
119
+ selected = coerce(new_value)
120
+ # HasValue#value= no-ops on an unchanged value; this guard is what also
121
+ # skips the row rebuild.
122
+ return if value == selected
123
+
124
+ super(selected)
125
+ rebuild_rows
126
+ end
127
+
128
+ # Toggles the cursor row on Space. Nothing else is claimed: the composed
129
+ # {List} — being the focused component — has already had its chance at the
130
+ # key (its arrows, Home/End, PgUp/PgDn, ^U/^D and Enter), and whatever
131
+ # neither of us wants bubbles on to an ancestor.
132
+ # @param key [String]
133
+ # @return [Boolean]
134
+ def handle_key(key)
135
+ return false unless key == " "
136
+
137
+ toggle_at(content.cursor.position)
138
+ true
139
+ end
140
+
141
+ protected
142
+
143
+ # Places the composed list across the whole rect ({HasContent} hook).
144
+ # @param list [Component]
145
+ # @return [void]
146
+ def layout(list) = (list.rect = rect)
147
+
148
+ private
149
+
150
+ # Flips membership of the item on row `index`; an index outside {#items} is
151
+ # ignored.
152
+ # @param index [Integer]
153
+ # @return [void]
154
+ def toggle_at(index)
155
+ return unless index.between?(0, @items.size - 1)
156
+
157
+ item = @items[index]
158
+ self.value = value.include?(item) ? value - [item] : value + [item]
159
+ end
160
+
161
+ # Re-renders every row from the current items, labels and selection.
162
+ # @return [void]
163
+ def rebuild_rows
164
+ content.lines = @items.map do |item|
165
+ StyledString.plain(value.include?(item) ? "[x] " : "[ ] ") + label_for(item)
166
+ end
167
+ end
168
+
169
+ # @param new_value [Enumerable, nil]
170
+ # @return [Set] a frozen copy; `nil` becomes {#empty_value}.
171
+ # @raise [TypeError] on anything else.
172
+ def coerce(new_value)
173
+ return empty_value if new_value.nil?
174
+ raise TypeError, "expected Enumerable, got #{new_value.inspect}" unless new_value.is_a?(Enumerable)
175
+
176
+ Set.new(new_value).freeze
177
+ end
178
+
179
+ # @param item [Object]
180
+ # @return [StyledString, String] whichever {StyledString#+} accepts on the
181
+ # right — so a styled label keeps its spans and a plain one is parsed.
182
+ def label_for(item)
183
+ label = @item_label.call(item)
184
+ label.is_a?(StyledString) ? label : label.to_s
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A text field with a filtering dropdown: type to narrow the candidates,
6
+ # arrow to move the highlight, Enter (or click) to accept. Its {#value} is
7
+ # the *selected item* — of whatever type the items are — not the display
8
+ # string, so a combo over domain objects hands back the object:
9
+ #
10
+ # combo = Component::ComboBox.new
11
+ # combo.items = User.all # Array of any type
12
+ # combo.item_label = ->(u) { u.full_name } # item -> shown text; default :to_s
13
+ # combo.on_value_change = ->(u) { open(u) } # fires on commit, with the item
14
+ # combo.value = some_user # selects it; field shows its label
15
+ #
16
+ # It's the assembly you'd otherwise wire by hand — a {TextField} plus a
17
+ # non-modal {Popup} over a {List} — promoted to one component. Give it a
18
+ # single-row {#rect}; it paints the field across that row with a `▾` in the
19
+ # last column and floats the dropdown above or below.
20
+ #
21
+ # == The two values
22
+ # {#value} (the committed selection) and the field's typed text (a transient
23
+ # *query*) are deliberately distinct. Keystrokes move the query and refilter
24
+ # the list; only Enter/click commits, and only a commit changes {#value} and
25
+ # fires {#on_value_change}. An uncommitted query reverts to the current
26
+ # value's label when the dropdown is dismissed (ESC) or the combo loses
27
+ # focus. Selecting by list index (not by matching the label back) is what
28
+ # lets two items share a label and still resolve to the right object.
29
+ #
30
+ # The dropdown is a {ListDropdown}, tinted to read as a floating panel; see
31
+ # it for the theming knob.
32
+ #
33
+ # UI-thread-confined, like every component (see {Screen}).
34
+ class ComboBox < Component
35
+ include HasContent
36
+ include HasValue
37
+
38
+ # @param items [Array] the candidate items (any type); also settable via
39
+ # {#items=}.
40
+ def initialize(items: [])
41
+ super()
42
+ @value = nil
43
+ @on_value_change = nil
44
+ @items = items.to_a
45
+ @item_label = :to_s.to_proc
46
+ @filtered = []
47
+ @suppressing_filter = false
48
+
49
+ field = TextField.new
50
+ field.on_change = ->(_text) { refill unless @suppressing_filter }
51
+ field.on_key = method(:field_key)
52
+ self.content = field
53
+
54
+ @overlay = ListDropdown.new
55
+ @overlay.on_item_chosen = ->(index, _line) { commit(index) }
56
+ end
57
+
58
+ # @return [Array] the candidate items.
59
+ attr_reader :items
60
+
61
+ # @return [Proc, Method] item -> shown label (a `String` or
62
+ # {StyledString}); the field shows its `#to_s`, the list its styled form.
63
+ attr_reader :item_label
64
+
65
+ # @param new_items [Array]
66
+ # @return [void]
67
+ def items=(new_items)
68
+ raise TypeError, "expected Array, got #{new_items.inspect}" unless new_items.is_a?(Array)
69
+
70
+ @items = new_items
71
+ refill if @overlay.open?
72
+ invalidate
73
+ end
74
+
75
+ # @param proc [Proc, Method] item -> shown label.
76
+ # @return [void]
77
+ def item_label=(proc)
78
+ @item_label = proc
79
+ sync_field(display_for(value)) # re-render the current selection
80
+ invalidate
81
+ end
82
+
83
+ # Selects `new_value` programmatically: updates the field to its label
84
+ # *without* opening the dropdown, then fires {#on_value_change}. `nil`
85
+ # clears the selection (blank field). The value need not be in {#items}.
86
+ # @param new_value [Object]
87
+ # @return [void]
88
+ def value=(new_value)
89
+ return if value == new_value
90
+
91
+ sync_field(display_for(new_value))
92
+ super
93
+ end
94
+
95
+ # @return [Point, nil] the field's caret position (the combo delegates the
96
+ # hardware cursor to its field).
97
+ def cursor_position = content.cursor_position
98
+
99
+ # @return [String]
100
+ def keyboard_hint = "↑↓ #{screen.theme.hint("select")} ⏎ #{screen.theme.hint("accept")}"
101
+
102
+ # Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
103
+ # field via {#layout}.
104
+ # @param new_rect [Rect]
105
+ # @return [void]
106
+ def rect=(new_rect)
107
+ super
108
+ anchor if @overlay.open?
109
+ end
110
+
111
+ # Closes the dropdown and reverts an uncommitted query when the combo
112
+ # leaves the focus chain — so tabbing away doesn't strand an open menu or
113
+ # a half-typed filter. Safe against re-entrancy: focus never sits inside
114
+ # the (non-focusable) {ListDropdown}, so closing the overlay repairs no
115
+ # focus.
116
+ # @param flag [Boolean]
117
+ # @return [void]
118
+ def active=(flag)
119
+ was = active?
120
+ super
121
+ return unless was && !active?
122
+
123
+ close_menu
124
+ revert_query
125
+ end
126
+
127
+ # @param event [MouseEvent]
128
+ # @return [void]
129
+ def handle_mouse(event)
130
+ if content.rect.contains?(event.point)
131
+ content.handle_mouse(event)
132
+ elsif event.button == :left && rect.contains?(event.point) # the ▾ cell
133
+ content.focus
134
+ @overlay.open? ? close_menu : open_menu
135
+ end
136
+ end
137
+
138
+ # @return [void]
139
+ def repaint
140
+ super
141
+ return if rect.empty?
142
+
143
+ well = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
144
+ draw_char(rect.left + rect.width - 1, rect.top, "▾", StyledString::Style::DEFAULT.with(bg: well))
145
+ end
146
+
147
+ protected
148
+
149
+ # Field spans the row bar the last column, which the `▾` occupies
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.
152
+ # @param field [Component]
153
+ # @return [void]
154
+ def layout(field)
155
+ field.rect = Rect.new(rect.left, rect.top, [rect.width - 1, 0].max, [rect.height, 1].min)
156
+ end
157
+
158
+ private
159
+
160
+ # The field's key interceptor: while the dropdown is open forwards movement
161
+ # to it ({ListDropdown#move}), commits on Enter ({ListDropdown#choose}),
162
+ # and dismisses on ESC (reverting the query); opens it on Down or Enter
163
+ # when closed. Everything else (printable keys, editing) falls through to
164
+ # the field, whose {TextField#on_change} refilters.
165
+ # @param key [String]
166
+ # @return [Boolean] true if consumed.
167
+ def field_key(key)
168
+ if @overlay.open?
169
+ if @overlay.move(key)
170
+ true
171
+ elsif key == Keys::ENTER
172
+ @overlay.choose
173
+ true
174
+ elsif key == Keys::ESC
175
+ close_menu
176
+ revert_query
177
+ true
178
+ else
179
+ false
180
+ end
181
+ elsif [Keys::DOWN_ARROW, Keys::ENTER].include?(key)
182
+ open_menu
183
+ true
184
+ else
185
+ false
186
+ end
187
+ end
188
+
189
+ # Recomputes the matches for the current query, opening the dropdown when
190
+ # there are any (and preselecting the current value's row) or closing it
191
+ # when there are none.
192
+ # @return [void]
193
+ def refill
194
+ @filtered = matching(content.text)
195
+ if @filtered.empty?
196
+ close_menu
197
+ else
198
+ @overlay.lines = @filtered.map { |item| @item_label.call(item) }
199
+ @overlay.cursor = List::Cursor.new(position: @filtered.index(value) || 0)
200
+ @overlay.open unless @overlay.open?
201
+ anchor
202
+ end
203
+ end
204
+
205
+ # Items whose label contains `query` (case-insensitive). A query still
206
+ # equal to the current value's label — the resting state, or a fresh
207
+ # open — is treated as "show everything", so Down opens the full list.
208
+ # @param query [String]
209
+ # @return [Array]
210
+ def matching(query)
211
+ return @items if query.empty? || query == display_for(value)
212
+
213
+ needle = query.downcase
214
+ @items.select { |item| @item_label.call(item).to_s.downcase.include?(needle) }
215
+ end
216
+
217
+ # Commits the item at the menu's `index`: closes the dropdown and adopts
218
+ # it as {#value} (which repaints the field with its label).
219
+ # @param index [Integer]
220
+ # @return [void]
221
+ def commit(index)
222
+ item = @filtered[index]
223
+ close_menu
224
+ self.value = item
225
+ end
226
+
227
+ # @return [void]
228
+ def open_menu = refill
229
+
230
+ # @return [void]
231
+ def close_menu = (@overlay.close if @overlay.open?)
232
+
233
+ # @return [void]
234
+ def revert_query = sync_field(display_for(value))
235
+
236
+ # Sets the field's text without triggering a refilter — for programmatic
237
+ # value changes and query reverts, which must not spring the dropdown.
238
+ # Parks the caret at the end: `text=` only *clamps* the caret, so a
239
+ # shorter query replaced by a longer label would otherwise strand it
240
+ # mid-word (commit "Go", then pick "Kotlin" → caret after "Ko").
241
+ # @param text [String]
242
+ # @return [void]
243
+ def sync_field(text)
244
+ @suppressing_filter = true
245
+ content.text = text
246
+ content.caret = content.text.length
247
+ ensure
248
+ @suppressing_filter = false
249
+ end
250
+
251
+ # @param item [Object]
252
+ # @return [String] the plain-text label for `item`, or "" for nil.
253
+ def display_for(item) = item.nil? ? "" : @item_label.call(item).to_s
254
+
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.
259
+ # @return [void]
260
+ def anchor = @overlay.anchor_to(rect, rows: @filtered.size)
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A single-line field whose {#value} is a `Float` (or `nil` when empty) —
6
+ # the {IntegerField} twin, one Ruby type over. Give it a single-row {#rect}:
7
+ #
8
+ # field = Component::FloatField.new
9
+ # field.on_value_change = ->(x) { puts x.inspect } # Float or nil, per change
10
+ # field.value = 19.99 # field shows "19.99"
11
+ # field.clear # empties it; value => nil
12
+ #
13
+ # Only `0`–`9`, one leading `-` and one `.` can be typed; any other
14
+ # printable key is dropped without moving the caret. Up/Down step by `1.0`
15
+ # (an empty field counting as `0.0`). A `Float` is a binary double, so this
16
+ # is the wrong field for money — hold that as `Integer` cents in an
17
+ # {IntegerField} — and range checks (`min`/`max`) belong to a forms layer,
18
+ # not here.
19
+ #
20
+ # == Implementation details
21
+ # {#value} is a *derived parse*: the buffer is the single source of truth,
22
+ # recomputed on read and left exactly as typed (`"007"` keeps its zeros).
23
+ # It reads `nil` for a buffer that isn't a number (`""`, a lone `"-"`) but
24
+ # `1.0` / `0.5` for a half-typed `"1."` / `".5"`, so reaching for the
25
+ # decimal point doesn't blink the value to `nil` and back through
26
+ # {#on_value_change} — which fires per keystroke, but only on a real *value*
27
+ # change (`"7"`→`"07"` is silent). The parse also accepts the exponent
28
+ # `Float#to_s` writes for extreme magnitudes, so `value = 1e-5` round-trips
29
+ # through the `"1.0e-05"` it displays, though no key types an `e`.
30
+ #
31
+ # It *composes* a {TextField} (its single {HasContent} child) rather than
32
+ # subclassing one, so its face carries only the typed {HasValue} seam, never
33
+ # the widget's `String`-typed `text`.
34
+ #
35
+ # UI-thread-confined, like every component (see {Screen}).
36
+ class FloatField < Component
37
+ include HasContent
38
+ include HasValue
39
+
40
+ # A buffer {#value} parses: an optional sign, digits with an optional
41
+ # fractional part (either side may be empty, but not both), and the
42
+ # exponent {#value=} can write.
43
+ # @return [Regexp]
44
+ NUMERIC = /\A-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?\z/
45
+ private_constant :NUMERIC
46
+
47
+ def initialize
48
+ super()
49
+ @last_value = nil
50
+ field = TextField.new
51
+ field.on_change = ->(_text) { fire_if_changed }
52
+ field.on_key = method(:field_key)
53
+ self.content = field
54
+ end
55
+
56
+ # @return [Float, nil] the parsed buffer; `nil` when empty or not a
57
+ # number (e.g. a lone `"-"`).
58
+ def value
59
+ text = content.text
60
+ text.match?(NUMERIC) ? text.to_f : nil
61
+ end
62
+
63
+ # Writes `new_value` into the buffer and parks the caret at its end; fires
64
+ # {#on_value_change} only if the value actually changed.
65
+ # @param new_value [Numeric, nil] `nil` empties the field; anything else
66
+ # is coerced with `Float()`, so an `Integer` `3` shows as `"3.0"`.
67
+ # @raise [ArgumentError] on a non-numeric `String`, a NaN or an infinity.
68
+ # @raise [TypeError] on a value `Float()` won't take at all (an `Array`).
69
+ # @return [void]
70
+ def value=(new_value)
71
+ content.text = new_value.nil? ? "" : coerce(new_value).to_s
72
+ content.caret = content.text.length
73
+ end
74
+
75
+ # `nil`, not `""`: a numeric field with no parseable number is empty.
76
+ # @return [nil]
77
+ def empty_value = nil
78
+
79
+ # @return [Point, nil] the field's caret (the hardware cursor is delegated
80
+ # to the inner field).
81
+ def cursor_position = content.cursor_position
82
+
83
+ # Fired when ENTER is pressed in the field; see {TextField#on_enter}.
84
+ # @return [Proc, Method, nil] no-arg callable, or nil.
85
+ def on_enter = content.on_enter
86
+
87
+ # @param callback [Proc, Method, nil]
88
+ # @return [void]
89
+ def on_enter=(callback)
90
+ content.on_enter = callback
91
+ end
92
+
93
+ protected
94
+
95
+ # Places the wrapped field across the whole rect ({HasContent} hook).
96
+ # @param field [Component]
97
+ # @return [void]
98
+ def layout(field) = (field.rect = rect)
99
+
100
+ private
101
+
102
+ # @param new_value [Numeric]
103
+ # @return [Float]
104
+ # @raise [ArgumentError] on a NaN or an infinity — `Float::NAN.to_s` is
105
+ # `"NaN"`, which no parse reads back, so writing one would silently
106
+ # turn the value `nil`.
107
+ def coerce(new_value)
108
+ float = Float(new_value)
109
+ raise ArgumentError, "value must be finite, got #{float}" unless float.finite?
110
+
111
+ float
112
+ end
113
+
114
+ # The field's key interceptor, consulted *before* the field acts on the
115
+ # key — which is what lets a rejected character be swallowed without the
116
+ # caret ever moving.
117
+ # @param key [String]
118
+ # @return [Boolean] true to consume the key.
119
+ def field_key(key)
120
+ case key
121
+ when Keys::UP_ARROW then step(1.0)
122
+ when Keys::DOWN_ARROW then step(-1.0)
123
+ else return Keys.printable?(key) && !accepts?(key)
124
+ end
125
+ true
126
+ end
127
+
128
+ # Nudges {#value} by `delta`, treating an empty/un-parseable field as
129
+ # `0.0`.
130
+ # @param delta [Float]
131
+ # @return [void]
132
+ def step(delta) = (self.value = (value || 0.0) + delta)
133
+
134
+ # Whether `char` may be inserted. Deliberately shallow: it keeps the
135
+ # buffer *typeable* rather than always-valid — a transient `"-"` or
136
+ # `"1."` has to be reachable — and {#value} decides what parses.
137
+ # @param char [String] a single printable character.
138
+ # @return [Boolean]
139
+ def accepts?(char)
140
+ case char
141
+ when /\A[0-9]\z/ then true
142
+ when "-" then content.caret.zero? && !content.text.start_with?("-")
143
+ when "." then !content.text.include?(".")
144
+ else false
145
+ end
146
+ end
147
+
148
+ # Re-emits {#on_value_change} with the freshly-parsed {#value}, but only
149
+ # when it differs from the last one fired — so a buffer edit that leaves
150
+ # the value unchanged (`"7"`→`"07"`) stays silent.
151
+ # @return [void]
152
+ def fire_if_changed
153
+ v = value
154
+ return if v == @last_value
155
+
156
+ @last_value = v
157
+ on_value_change&.call(v)
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # The chrome text a component *wears* — a {Window}'s border title, a
6
+ # {Button}'s label — as opposed to the value it *holds*.
7
+ #
8
+ # button.caption = "Submit"
9
+ # window.caption = StyledString.styled("Settings", fg: Color::RED)
10
+ #
11
+ # Tuile's naming split, which decides what a new component gets:
12
+ # **caption** is chrome, authored by the app; **text** is the value the
13
+ # user edits (aliased to {HasValue#value} on {AbstractStringField}). A
14
+ # component may carry both, hence two mixins.
15
+ #
16
+ # Includers own the *rendering* — clipping, width arithmetic, decoration
17
+ # such as {Window}'s `[key]-` shortcut prefix; this holds only the text.
18
+ #
19
+ # == Implementation details
20
+ # Being a mixin is what lets tree-walking code find "the {Button} captioned
21
+ # Submit" via `is_a?(HasCaption)` plus a caption compare, rather than a
22
+ # hardcoded list of classes that happen to respond to `caption`. Don't
23
+ # collapse it back into per-class accessors.
24
+ module HasCaption
25
+ # Read through *this* method, never `@caption` — the ivar stays nil until
26
+ # the first non-empty set ({#caption=} short-circuits when unchanged).
27
+ # @return [StyledString] the caption; empty when never set.
28
+ def caption = @caption || StyledString::EMPTY
29
+
30
+ # Sets the caption and invalidates the component. No-op when unchanged. A
31
+ # `String` is parsed via {StyledString.parse} (embedded ANSI is honored);
32
+ # a {StyledString} is used as-is; `nil` clears it.
33
+ # @param new_caption [String, StyledString, nil]
34
+ # @return [void]
35
+ def caption=(new_caption)
36
+ new_caption = StyledString.parse(new_caption)
37
+ return if caption == new_caption
38
+
39
+ @caption = new_caption
40
+ invalidate
41
+ end
42
+ end
43
+ end
44
+ end
@@ -15,9 +15,6 @@ module Tuile
15
15
  content.handle_mouse(event) if !content.nil? && content.rect.contains?(event.point)
16
16
  end
17
17
 
18
- # @return [Array<Component>]
19
- def children = content.nil? ? [] : [content]
20
-
21
18
  # Sets the new content of this component. Updates `@content` itself;
22
19
  # including classes may still override to add behaviour (e.g. a
23
20
  # special-cased Array input) but should call `super` to perform the
@@ -34,10 +31,13 @@ module Tuile
34
31
  end
35
32
 
36
33
  old = self.content
37
- old&.parent = nil
34
+ # Detached without notifying, and notified at the very end: the focus
35
+ # repair in on_child_removed cascades into whatever occupies the slot
36
+ # *now*, so it has to see the new content (window_spec pins it).
37
+ detach_child(old) unless old.nil?
38
38
  @content = content
39
39
  unless content.nil?
40
- content.parent = self
40
+ add_child(content, at: 0) # content paints beneath a Window's footer
41
41
  content.invalidate
42
42
  layout(content)
43
43
  end