tuile 0.16.0 → 0.17.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +21 -12
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +5 -4
  7. data/book/05-focus.md +12 -9
  8. data/book/06-theming.md +55 -17
  9. data/book/07-components.md +188 -40
  10. data/book/08-testing.md +115 -15
  11. data/book/10-locale.md +1 -1
  12. data/book/README.md +5 -5
  13. data/examples/file_commander.rb +38 -27
  14. data/examples/hello_world.rb +1 -1
  15. data/examples/sampler.rb +225 -169
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +81 -80
  21. data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +19 -11
  24. data/lib/tuile/component/checkbox.rb +12 -10
  25. data/lib/tuile/component/checkbox_group.rb +11 -13
  26. data/lib/tuile/component/combo_box.rb +30 -40
  27. data/lib/tuile/component/confirm_window.rb +27 -22
  28. data/lib/tuile/component/date_field.rb +27 -20
  29. data/lib/tuile/component/date_time_field.rb +75 -31
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +98 -27
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +5 -12
  37. data/lib/tuile/component/has_validation.rb +39 -13
  38. data/lib/tuile/component/has_value.rb +70 -16
  39. data/lib/tuile/component/integer_field.rb +7 -6
  40. data/lib/tuile/component/label.rb +8 -15
  41. data/lib/tuile/component/layout/absolute.rb +86 -0
  42. data/lib/tuile/component/layout/box.rb +38 -63
  43. data/lib/tuile/component/layout.rb +124 -10
  44. data/lib/tuile/component/list.rb +197 -94
  45. data/lib/tuile/component/list_dropdown.rb +148 -88
  46. data/lib/tuile/component/menu_bar/cascade.rb +97 -27
  47. data/lib/tuile/component/menu_bar.rb +84 -64
  48. data/lib/tuile/component/notification.rb +44 -31
  49. data/lib/tuile/component/overlay.rb +210 -52
  50. data/lib/tuile/component/password_field.rb +1 -8
  51. data/lib/tuile/component/picker_window.rb +15 -10
  52. data/lib/tuile/component/popup.rb +13 -24
  53. data/lib/tuile/component/progress_bar.rb +7 -7
  54. data/lib/tuile/component/radio_group.rb +10 -12
  55. data/lib/tuile/component/scroller.rb +266 -0
  56. data/lib/tuile/component/select.rb +15 -31
  57. data/lib/tuile/component/slot.rb +1 -2
  58. data/lib/tuile/component/tab_sheet.rb +21 -28
  59. data/lib/tuile/component/tabs.rb +39 -24
  60. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  61. data/lib/tuile/component/text_area.rb +21 -19
  62. data/lib/tuile/component/text_field.rb +55 -39
  63. data/lib/tuile/component/text_view.rb +143 -79
  64. data/lib/tuile/component/time_field.rb +26 -21
  65. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  66. data/lib/tuile/component/window.rb +27 -26
  67. data/lib/tuile/component.rb +481 -259
  68. data/lib/tuile/component_background.rb +177 -0
  69. data/lib/tuile/component_util.rb +43 -0
  70. data/lib/tuile/event.rb +29 -0
  71. data/lib/tuile/event_queue.rb +14 -0
  72. data/lib/tuile/fake_screen.rb +41 -10
  73. data/lib/tuile/keys.rb +15 -6
  74. data/lib/tuile/layout_pass.rb +180 -0
  75. data/lib/tuile/listeners.rb +219 -0
  76. data/lib/tuile/mouse/router.rb +51 -35
  77. data/lib/tuile/mouse.rb +96 -29
  78. data/lib/tuile/point.rb +6 -0
  79. data/lib/tuile/rect.rb +33 -0
  80. data/lib/tuile/screen.rb +419 -84
  81. data/lib/tuile/screen_pane.rb +144 -31
  82. data/lib/tuile/strict_layout.rb +127 -0
  83. data/lib/tuile/styled_string.rb +139 -9
  84. data/lib/tuile/testing/gestures.rb +35 -0
  85. data/lib/tuile/testing.rb +310 -36
  86. data/lib/tuile/theme.rb +170 -19
  87. data/lib/tuile/theme_def.rb +4 -0
  88. data/lib/tuile/version.rb +1 -1
  89. data/lib/tuile.rb +53 -0
  90. data/sig/tuile.rbs +4951 -1158
  91. metadata +16 -2
  92. data/lib/tuile/vertical_scroll_bar.rb +0 -122
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # One row of a form: a {#caption} above a field, and whatever the field has
6
+ # to say against itself below it.
7
+ #
8
+ # item = Component::FormItem.new(username, caption: "Username", required: true)
9
+ # username.error_message = "Must not be blank"
10
+ #
11
+ # Username ∙ ← the caption, with the required marker
12
+ # [________________] ← the content, whatever you wrapped
13
+ # Must not be blank ← the message, here the verdict just written
14
+ #
15
+ # Wrap a field and drop the item wherever a component goes — a
16
+ # {Layout::Vertical} stacking items is already a form:
17
+ #
18
+ # column.add(Component::FormItem.new(notes, caption: "Notes"), Fixed[7])
19
+ #
20
+ # The chrome is the item's, the face is the field's: a {Checkbox} or a
21
+ # {Button} paints its own text and takes no caption here, so the item is
22
+ # built without one and simply reserves no caption row.
23
+ #
24
+ # **Hide the item, never the field** — `item.visible = false` takes the
25
+ # caption and the message with it, while `field.visible = false` blanks the
26
+ # field's rows and leaves its caption stranded above them.
27
+ #
28
+ # == Implementation details
29
+ # **The message row is also the gap row**, which is why the item's pitch is
30
+ # a flat three rows and nothing ever reflows: a form that grew a row when a
31
+ # field went invalid would push the fields below it down *while the user is
32
+ # typing into one of them*. See `D_form_item`.
33
+ #
34
+ # **It measures nothing.** The rect it is handed is divided top-down —
35
+ # caption, content, message — so there is no `rows` property here and no
36
+ # question asked of the field. Rows are served content-first when there are
37
+ # too few: the content never drops below one row, then the caption takes the
38
+ # next row it can, then the message.
39
+ #
40
+ # **The message comes from two channels and the item orders neither.** A
41
+ # validator's verdict ({HasValidation#error_message}) and the field's own
42
+ # report of input its value cannot represent ({HasBadInput#bad_input?}) both
43
+ # land in this row; the item registers on both notices and paints
44
+ # {HasValidation#shown_message}, which is where the precedence lives — bad
45
+ # input wins, and a latched field says nothing until it settles
46
+ # (`D_bad_input`). So the row can fill while `error_message` is still `nil`.
47
+ #
48
+ # **The message is claimed ink.** It is painted in {Theme#error_color}
49
+ # whatever colors the {StyledString} carried, so the row always reads as an
50
+ # error — the matching half of the red well the field paints for itself
51
+ # (`D_has_validation`).
52
+ #
53
+ # Both that message and the required marker are {StyledString}s this item
54
+ # authors, so they bake their colors and are rebuilt from
55
+ # {Component#handle_theme_changed} — and from {Component#handle_attached},
56
+ # for a tree assembled before there was a {Screen} to read a theme from.
57
+ class FormItem < Component
58
+ include Component::HasContent
59
+ include Component::HasCaption
60
+
61
+ class << self
62
+ # The glyph marking a {#required?} item, `∙` (U+2219 BULLET OPERATOR)
63
+ # by default:
64
+ #
65
+ # Tuile::Component::FormItem.required_marker = "*"
66
+ #
67
+ # An app-global, like {VerticalScrollBar.handle_char} — the marker is a
68
+ # house style, not a per-item decision.
69
+ #
70
+ # The default is deliberately not one of `•`, `●` or `·`: those are
71
+ # East-Asian *Ambiguous* and would measure two columns under the other
72
+ # policy, enlarging the inventory that keeps `D_ambiguous_width`'s bet
73
+ # cheap to reverse. `∙` and `◦` measure one under both.
74
+ # @return [String]
75
+ attr_reader :required_marker
76
+
77
+ # @param glyph [String] one grapheme cluster, one column wide.
78
+ # @return [String] frozen.
79
+ # @raise [TypeError] when `glyph` is not a String.
80
+ # @raise [ArgumentError] when it is not exactly one cluster one column wide.
81
+ def required_marker=(glyph)
82
+ @required_marker = StyledString.validate_glyph(glyph, :required_marker)
83
+ end
84
+ end
85
+
86
+ self.required_marker = "∙"
87
+
88
+ # @param content [Component, nil] the field to wrap; assignable later
89
+ # through {#content=}.
90
+ # @param caption [String, StyledString, nil] the text above it; omit it
91
+ # for a widget painting its own, such as a {Checkbox} or a {Button}.
92
+ # @param required [Boolean] whether to paint {.required_marker} beside
93
+ # the caption.
94
+ # @raise [ArgumentError] when `required` is true and there is no caption
95
+ # for the marker to sit beside.
96
+ def initialize(content = nil, caption: nil, required: false)
97
+ super()
98
+ @content = nil
99
+ @required = false
100
+ @caption_label = Label.new
101
+ @message_label = Label.new
102
+ add_child(@caption_label) # appended: HasContent forces the content to index 0
103
+ add_child(@message_label)
104
+ self.caption = caption
105
+ self.required = required
106
+ self.content = content unless content.nil?
107
+ end
108
+
109
+ # @return [Boolean] whether the caption carries {.required_marker}.
110
+ def required? = @required
111
+
112
+ # Paints {.required_marker} beside the caption. The field never learns it
113
+ # is required — nothing here validates, and this is not a rule.
114
+ # @param flag [Boolean]
115
+ # @return [void]
116
+ # @raise [ArgumentError] when true and {#caption} is empty — the marker
117
+ # rides the caption, so without one it has nowhere to go.
118
+ def required=(flag)
119
+ flag = flag ? true : false
120
+ return if @required == flag
121
+ raise ArgumentError, "a required FormItem needs a caption for the marker" if flag && caption.empty?
122
+
123
+ @required = flag
124
+ refresh_chrome
125
+ end
126
+
127
+ # Sets the caption, adding or dropping the caption row as it becomes
128
+ # non-empty or empty.
129
+ # @param new_caption [String, StyledString, nil]
130
+ # @return [void]
131
+ # @raise [ArgumentError] when clearing the caption of a {#required?} item.
132
+ def caption=(new_caption)
133
+ new_caption = StyledString.parse(new_caption)
134
+ if required? && new_caption.empty?
135
+ raise ArgumentError, "a required FormItem keeps its caption: the marker has nowhere else to go"
136
+ end
137
+
138
+ had_row = !caption.empty?
139
+ super
140
+ refresh_chrome
141
+ invalidate_layout unless had_row == !caption.empty?
142
+ end
143
+
144
+ # Mounts the field, moving the message subscriptions onto it: the outgoing
145
+ # occupant is unsubscribed in the same call, which is the one choke point
146
+ # {HasContent} gives for it.
147
+ #
148
+ # A content component without {HasValidation} is fine — the message row
149
+ # then simply stays empty — and one that cannot hold bad input skips that
150
+ # slot.
151
+ # @param new_content [Component, nil]
152
+ # @return [void]
153
+ def content=(new_content)
154
+ return if content == new_content
155
+
156
+ old = content
157
+ super
158
+ # Both channels paint this row, and either can move without the other.
159
+ old.on_error_message_change.remove(method(:refresh_chrome)) if old.respond_to?(:on_error_message_change)
160
+ old.on_bad_input_change.remove(method(:refresh_chrome)) if old.respond_to?(:on_bad_input_change)
161
+ content.on_error_message_change << method(:refresh_chrome) if content.respond_to?(:on_error_message_change)
162
+ content.on_bad_input_change << method(:refresh_chrome) if content.respond_to?(:on_bad_input_change)
163
+ refresh_chrome
164
+ end
165
+
166
+ # @return [Boolean] true, so clicking the caption forwards focus into the
167
+ # field through {HasContent#handle_focus}. Never a {Component#tab_stop?}:
168
+ # the field it wraps is the one stop.
169
+ def focusable? = true
170
+
171
+ # Asks for the whole item, so a field scrolled into view brings its
172
+ # caption and message along — then for `rect` itself, which wins when the
173
+ # item is taller than the viewport (a caret row over the caption).
174
+ # @param rect [Rect] in this component's coordinates.
175
+ # @return [void]
176
+ def scroll_to_visible(rect = local_extent_rect)
177
+ super(local_rect)
178
+ super
179
+ end
180
+
181
+ protected
182
+
183
+ # The caption, the content and the message each take one row of three.
184
+ # @return [void]
185
+ def relayout
186
+ content&.rect = row_rects[1]
187
+ layout_chrome
188
+ end
189
+
190
+ # @return [void]
191
+ def handle_theme_changed
192
+ super
193
+ refresh_chrome
194
+ end
195
+
196
+ # @return [void]
197
+ def handle_attached
198
+ super
199
+ refresh_chrome
200
+ end
201
+
202
+ # @return [Array<String>]
203
+ def inspect_details = required? ? super + ["required"] : super
204
+
205
+ private
206
+
207
+ # Rebuilds both chrome strings from the current caption, marker and
208
+ # verdict — the single writer, idempotent, so every input that can change
209
+ # one of them just calls this.
210
+ # @return [void]
211
+ def refresh_chrome
212
+ # The marker shares the message's red rather than earning a theme token
213
+ # of its own — a required field is not yet invalid; see `D_form_item`.
214
+ ink = Screen.instance? ? screen.theme.error_color : nil
215
+ marker = StyledString.styled(" #{self.class.required_marker}", fg: ink)
216
+ @caption_label.text = required? ? caption + marker : caption
217
+ # `shown_message` orders the two channels, and hands back a plain
218
+ # String for a field's own report — hence the parse.
219
+ message = StyledString.parse(content.respond_to?(:shown_message) ? content.shown_message : nil)
220
+ @message_label.text = message.empty? ? StyledString::EMPTY : message.with_fg(ink)
221
+ end
222
+
223
+ # @return [void]
224
+ def layout_chrome
225
+ caption_rect, _content_rect, message_rect = row_rects
226
+ @caption_label.rect = caption_rect
227
+ @message_label.rect = message_rect
228
+ end
229
+
230
+ # Divides {Component#rect} top-down. A part with no row left gets a rect
231
+ # of zero height — empty, so the {Label} in it paints nothing — rather
232
+ # than a stale one (`D_empty_ancestor`).
233
+ # @return [Array(Rect, Rect, Rect)] the caption, content and message rects.
234
+ def row_rects
235
+ height = rect.height
236
+ caption_rows = caption.empty? || height < 2 ? 0 : 1
237
+ message_rows = height - caption_rows < 2 ? 0 : 1
238
+ content_rows = height - caption_rows - message_rows
239
+ [row_rect(0, caption_rows),
240
+ row_rect(caption_rows, content_rows),
241
+ row_rect(caption_rows + content_rows, message_rows)]
242
+ end
243
+
244
+ # @param top [Integer]
245
+ # @param rows [Integer]
246
+ # @return [Rect] the full width, `rows` tall.
247
+ def row_rect(top, rows) = Rect.new(0, top, rect.width, rows)
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A column of {FormItem}s. Hand it a field and a caption; it builds the item
6
+ # and stacks it below the last one.
7
+ #
8
+ # form = Component::FormLayout.new
9
+ # form.add(username, caption: "Username", required: true)
10
+ # form.add(notes, caption: "Notes", rows: 5)
11
+ # form.add(logging) # a Checkbox paints its own "[x] Enable logging"
12
+ # form.add(save) # a Button, so no caption row at all
13
+ #
14
+ # Username ∙ ← the caption row, with the required marker
15
+ # [______________] ← `rows:` content rows, 1 by default
16
+ # Must not be blank ← the message row, which is also the gap
17
+ # Notes
18
+ # [ ]
19
+ #
20
+ # **You hand it fields, it holds items.** {#add} wraps whatever you give it
21
+ # and returns the {FormItem} it built, so the chrome is the item's from the
22
+ # start — `add(field, caption:)` reads as though it set the *field*'s
23
+ # caption, and does not (`D_caption_ownership`). Name that field again
24
+ # wherever this class takes one — {#remove}, {#constrain}, {#field_for}'s
25
+ # answer — and the item around it responds. Hide that item, never the field:
26
+ # its caption and message go with it and the rows come back here.
27
+ #
28
+ # == Implementation details
29
+ # **Every row count is the caller's.** Nothing here measures and nothing is
30
+ # asked of a field: a captioned item is handed `1 + rows + 1` rows, a
31
+ # captionless one `rows + 1`. The message row doubles as the gap, which is
32
+ # why there is no `spacing` — and why `rows:` is a placement constraint in
33
+ # this layout's per-child map, exactly as `Fixed[n]` is in a {Layout::Box},
34
+ # rather than a property of the item. See `D_form_layout`.
35
+ #
36
+ # **Overflow clips, and there is no scrolling.** Items are laid from the top
37
+ # edge; the one straddling the bottom takes the rows that are left — a
38
+ # {FormItem} serves its content first — and everything past it gets an empty
39
+ # rect rather than a stale one (`D_empty_ancestor`).
40
+ #
41
+ # **The caption is read at every pass and nothing announces a change to it**,
42
+ # so one that appears or disappears after the item is placed resizes it at
43
+ # the next `rect=` rather than at once. Pass it to {#add} and the question
44
+ # never arises.
45
+ class FormLayout < Layout
46
+ # Placement for an item wired in through `add_child` instead of {#add}.
47
+ # @return [Hash{Symbol => Object}]
48
+ DEFAULT_PLACEMENT = { rows: 1 }.freeze
49
+
50
+ def initialize
51
+ super
52
+ # Identity-keyed: two == items are still two distinct slots.
53
+ @placements = {}.compare_by_identity
54
+ end
55
+
56
+ # Wraps `field` in a {FormItem}, adds it, and re-runs the layout.
57
+ #
58
+ # item = form.add(notes, caption: "Notes", rows: 5)
59
+ # item.required = true # the chrome is the item's, so tune it there
60
+ #
61
+ # @param field [Component] the field to wrap — or a ready-made {FormItem},
62
+ # which is adopted as it stands.
63
+ # @param caption [String, StyledString, nil] the caption row's text. Omit
64
+ # it for a widget that paints its own face, such as a {Checkbox} or a
65
+ # {Button}, and the item reserves no caption row.
66
+ # @param required [Boolean] paints {FormItem.required_marker} beside the
67
+ # caption.
68
+ # @param rows [Integer] content rows for the field; `>= 1`.
69
+ # @param at [Integer, nil] position among the existing items; appends when
70
+ # nil. The index is part of the contract — it is paint and Tab order.
71
+ # @raise [TypeError] unless `field` is a {Component}.
72
+ # @raise [ArgumentError] on a `rows` below 1, on `caption:` or `required:`
73
+ # passed alongside a ready-made item, or from {FormItem} when `required:`
74
+ # has no caption to sit beside.
75
+ # @return [FormItem] the item, whether built here or handed in.
76
+ def add(field, caption: nil, required: false, rows: 1, at: nil)
77
+ validate_rows(rows)
78
+ item = wrap(field, caption, required)
79
+ add_child(item, at:)
80
+ @placements[item] = { rows: }
81
+ invalidate_layout
82
+ item
83
+ end
84
+
85
+ # Re-sizes an item's content rows and re-runs the layout.
86
+ #
87
+ # form.constrain(notes, 8)
88
+ #
89
+ # @param field [Component] the field, or the item around it.
90
+ # @param rows [Integer] content rows; `>= 1`.
91
+ # @raise [ArgumentError] when `field` is in no item of this form, or on a
92
+ # `rows` below 1.
93
+ # @return [void]
94
+ def constrain(field, rows)
95
+ item = item_for(field)
96
+ validate_rows(rows)
97
+ return if placement(item)[:rows] == rows
98
+
99
+ @placements[item] = { rows: }
100
+ invalidate_layout
101
+ end
102
+
103
+ # Removes the item, forgets its placement, and closes the rows it left.
104
+ #
105
+ # The item keeps the field, so hand the *item* back to {#add} to put the
106
+ # row back where it was — the {Layout::Box} idiom, with `rows:` on your
107
+ # side because the placement is gone:
108
+ #
109
+ # form.remove(notes) # out, siblings move up
110
+ # form.add(item, rows: 5, at: 1) # back where it was
111
+ #
112
+ # @param field [Component] the field, or the item around it.
113
+ # @raise [ArgumentError] when `field` is in no item of this form.
114
+ # @return [void]
115
+ def remove(field)
116
+ item = item_for(field)
117
+ super(item)
118
+ @placements.delete(item)
119
+ invalidate_layout
120
+ end
121
+
122
+ # The field under a caption — sugar, since the association is a {FormItem}
123
+ # in the tree and an ordinary walk answers the same question.
124
+ #
125
+ # form.field_for(caption: "Username").value = "admin"
126
+ #
127
+ # @param caption [String, StyledString] matched against {FormItem#caption}
128
+ # as plain text; with duplicates, the first item wins.
129
+ # @return [Component, nil] the wrapped field, or nil when no item matches
130
+ # or the matching one holds nothing.
131
+ def field_for(caption:)
132
+ wanted = caption.to_s
133
+ children.find { _1.caption.to_s == wanted }&.content
134
+ end
135
+
136
+ private
137
+
138
+ # Stacks the items from the top edge, each {#item_height} tall, and clips
139
+ # at the bottom. A hidden item gives up its content rows *and* the fused
140
+ # gap row below them, and everything under it moves up.
141
+ #
142
+ # Deliberately *no* `return if rect.empty?` guard: that strands the items
143
+ # at the coordinates they last had, and the next full repaint paints them
144
+ # there (`D_empty_ancestor`).
145
+ # @return [void]
146
+ def relayout
147
+ collapsed = Rect.new(0, 0, 0, 0)
148
+ top = 0
149
+ bottom = rect.empty? ? 0 : rect.height
150
+ children.each do |item|
151
+ rows = item.visible? ? [item_height(item), bottom - top].min : 0
152
+ item.rect = rows.positive? ? Rect.new(0, top, rect.width, rows) : collapsed
153
+ top += rows
154
+ end
155
+ end
156
+
157
+ # @param item [FormItem]
158
+ # @return [Integer] the rows it is handed: a caption row when it carries a
159
+ # caption, its content rows, and the message row that doubles as the gap.
160
+ def item_height(item) = (item.caption.empty? ? 0 : 1) + placement(item)[:rows] + 1
161
+
162
+ # @param item [FormItem]
163
+ # @return [Hash{Symbol => Object}] the item's `rows`.
164
+ def placement(item) = @placements[item] || DEFAULT_PLACEMENT
165
+
166
+ # @param field [Component]
167
+ # @param caption [String, StyledString, nil]
168
+ # @param required [Boolean]
169
+ # @raise [TypeError] unless `field` is a {Component}.
170
+ # @raise [ArgumentError] when a ready-made item is handed chrome arguments.
171
+ # @return [FormItem]
172
+ def wrap(field, caption, required)
173
+ raise TypeError, "expected Component, got #{field.inspect}" unless field.is_a?(Component)
174
+ return FormItem.new(field, caption:, required:) unless field.is_a?(FormItem)
175
+
176
+ unless caption.nil? && !required
177
+ raise ArgumentError, "#{field} is already a FormItem: it carries its own caption and marker"
178
+ end
179
+
180
+ field
181
+ end
182
+
183
+ # @param field [Component] a field, or an item of this form.
184
+ # @raise [ArgumentError] when neither.
185
+ # @return [FormItem]
186
+ def item_for(field)
187
+ return field if children.any? { _1.equal?(field) }
188
+
189
+ item = children.find { _1.content.equal?(field) }
190
+ raise ArgumentError, "#{field} is in no item of #{self}" if item.nil?
191
+
192
+ item
193
+ end
194
+
195
+ # @param rows [Integer]
196
+ # @raise [ArgumentError] unless `rows` is a positive Integer.
197
+ # @return [void]
198
+ def validate_rows(rows)
199
+ return if rows.is_a?(Integer) && rows.positive?
200
+
201
+ raise ArgumentError, "rows expects a positive Integer, got #{rows.inspect} — " \
202
+ "hide an item with visible = false rather than starving it"
203
+ end
204
+ end
205
+ end
206
+ end
@@ -23,6 +23,19 @@ module Tuile
23
23
  # formatting of the value, so a no-match is not a failed conversion and it
24
24
  # reverts the query instead.
25
25
  #
26
+ # == A pull and a push, answering differently on purpose
27
+ # {#bad_input?} is derived on read and always current, which is what a save
28
+ # gate asked at a click wants. A consumer with *cells* — the form item
29
+ # painting the message beside the field — cannot be asked at a click, so it
30
+ # registers on {#on_bad_input_change} and paints {HasValidation#shown_message}:
31
+ #
32
+ # field.on_bad_input_change { |e| message_label.caption = e.message.to_s }
33
+ #
34
+ # The push carries the *showable* report, {#bad_input_message} gated by
35
+ # {#bad_input_settled?} and diffed, because the underlying fact is
36
+ # continuous — every prefix of a valid date is bad input — and a display
37
+ # following it raw would flash through the act of typing correctly.
38
+ #
26
39
  # == Implementation details
27
40
  # An includer overrides {#bad_input_message} and nothing else. Two rules
28
41
  # bind that override, and `design/decisions.md` `D_bad_input` has the why:
@@ -32,23 +45,43 @@ module Tuile
32
45
  # save.
33
46
  # - **One frozen constant per field kind, no interpolation** — `"not a
34
47
  # valid date"`, never `"'xyz' is not a valid date"`. It is read per call,
35
- # and a future error ink would read it per paint.
48
+ # the error ink reads it per paint, and the push diffs it per edit.
49
+ #
50
+ # The status is never stored: `@last_bad_input` is the diff guard the push
51
+ # needs and nothing reads it back, as {AbstractWrappingField}'s `@last_value`
52
+ # is for the value notice. Which fields *can* answer is a class fact worth
53
+ # caching; what they answer is not.
36
54
  #
37
- # Ask at the moment you need the answer and you always get the current one:
38
- # it is derived on read, never stored. Which fields *can* answer is a class
39
- # fact worth caching; what they answer is not. There is deliberately no
40
- # change notice, because the fact is *continuous* — every prefix of a valid
41
- # date is bad input — so anything reacting per keystroke flashes through the
42
- # act of typing correctly, while a save gate consulted at a click sees one
43
- # settled state. The **ink** is the one consumer that cannot be asked at a
44
- # click, so it has {#bad_input_settled?}: a field whose whole grammar is
45
- # prefix-bad latches the well on its commit gesture instead of painting it
46
- # per keystroke.
55
+ # An includer wrapping an editor is wired by this module — every buffer edit
56
+ # funnels through `handle_editor_change`, and the sole writer rides it. One
57
+ # that latches {#bad_input_settled?} or relays a child's report calls
58
+ # `sync_bad_input` from wherever *that* moves ({DateField}, {DateTimeField}).
47
59
  module HasBadInput
48
60
  # Pinned rather than relied on: {#error_ink?} calls `super`, so
49
61
  # {HasValidation} must be below this module in the ancestor chain whatever
50
62
  # order an includer writes its `include` lines in.
51
63
  include HasValidation
64
+ extend Listeners::Declare
65
+
66
+ # What {#on_bad_input_change} fires.
67
+ #
68
+ # @!attribute [r] source
69
+ # @return [Component] the field whose report changed.
70
+ # @!attribute [r] message
71
+ # @return [String, nil] the showable report, `nil` when there is none to
72
+ # show — the input converts, or the latch has not settled yet.
73
+ BadInputChangeEvent = Data.define(:source, :message) { include Tuile::Event }
74
+
75
+ # @!method on_bad_input_change
76
+ # Fired with a {BadInputChangeEvent} whenever the **showable** report
77
+ # changes — see the class doc for what that is and why it is not
78
+ # {#bad_input?}.
79
+ #
80
+ # **Empty means nobody outside the field is showing the report**, which
81
+ # is the common case: the red well needs no notice, since it reads the
82
+ # pull on every paint.
83
+ # @return [Listeners]
84
+ listener :on_bad_input_change
52
85
 
53
86
  # Why the current input cannot be turned into a {HasValue#value} — the
54
87
  # single override point.
@@ -61,28 +94,66 @@ module Tuile
61
94
  # represent.
62
95
  def bad_input? = !bad_input_message.nil?
63
96
 
64
- protected
65
-
66
- # Widens {HasValidation#error_ink?}: bad input paints the invalid well
67
- # too, with no verdict written — once {#bad_input_settled?} says the
68
- # report may be shown.
69
- # @return [Boolean]
70
- def error_ink? = (bad_input? && bad_input_settled?) || super
71
-
72
- # Whether bad input may paint the well *yet*. `true` here, so the well is
73
- # as continuous as the report: a {FloatField} reddens at the half-typed
74
- # `"1."`, which is a fair warning while the residue is one or two
75
- # transient buffers. Override it to *latch* where the grammar makes
76
- # **every** prefix bad input, or the well is red for the whole time the
97
+ # Whether the report may be *shown* yet. `true` here, so the well and the
98
+ # notice are as continuous as the report: a {FloatField} reddens at the
99
+ # half-typed `"1."`, which is a fair warning while the residue is one or
100
+ # two transient buffers. Override it to *latch* where the grammar makes
101
+ # **every** prefix bad input, or the field is red for the whole time the
77
102
  # user types a correct value:
78
103
  #
79
104
  # def bad_input_settled? = @settled # set on commit, cleared on an edit
80
105
  #
81
- # It gates the **ink only**: {#bad_input?} is a pull, and a save gate
82
- # asking at a click must get the answer settled or not (`design/decisions.md`
83
- # `D_bad_input`).
106
+ # It gates what is shown — the ink and the push — and never {#bad_input?},
107
+ # the pull a save gate asks at the click (`design/decisions.md`
108
+ # `D_bad_input`). Public because its readers are the app and a composite
109
+ # relaying a child's report, the same reason {#bad_input_message} is.
84
110
  # @return [Boolean]
85
111
  def bad_input_settled? = true
112
+
113
+ # The field's own report when it is showable, else whatever
114
+ # {HasValidation#shown_message} has — bad input outranks a verdict.
115
+ # @return [StyledString, String, nil]
116
+ def shown_message = (bad_input_message if bad_input_settled?) || super
117
+
118
+ protected
119
+
120
+ # Widens {HasValidation#error_ink?}: bad input paints the invalid well
121
+ # too, with no verdict written — once it is showable, and unless a child
122
+ # wears it instead.
123
+ # @return [Boolean]
124
+ def error_ink? = (bad_input? && bad_input_settled? && wears_bad_input_ink?) || super
125
+
126
+ # Whether *this* component paints the well for its own report. `true`
127
+ # here; a composite relaying a child's report answers `false` while that
128
+ # child is the one holding it, so the fault reddens where it happened
129
+ # rather than across the whole widget ({DateTimeField}).
130
+ # @return [Boolean]
131
+ def wears_bad_input_ink? = true
132
+
133
+ # Rides {AbstractWrappingField}'s edit funnel, so every includer wrapping
134
+ # an editor announces its report with no wiring of its own.
135
+ #
136
+ # `super` is guarded because an includer need not wrap an editor —
137
+ # {DateTimeField} is a {Layout::Horizontal} and never calls this.
138
+ # @return [void]
139
+ def handle_editor_change
140
+ super if defined?(super)
141
+ sync_bad_input
142
+ end
143
+
144
+ private
145
+
146
+ # Fires {#on_bad_input_change} when the showable report has really
147
+ # changed — the sole writer of `@last_bad_input`, called from wherever an
148
+ # input of that expression moves.
149
+ # @return [void]
150
+ def sync_bad_input
151
+ showable = bad_input_settled? ? bad_input_message : nil
152
+ return if showable == @last_bad_input
153
+
154
+ @last_bad_input = showable
155
+ on_bad_input_change.fire(BadInputChangeEvent.new(source: self, message: showable))
156
+ end
86
157
  end
87
158
  end
88
159
  end
@@ -16,11 +16,20 @@ module Tuile
16
16
  # Includers own the *rendering* — clipping, width arithmetic, decoration
17
17
  # such as {Window}'s `[key]-` shortcut prefix; this holds only the text.
18
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.
19
+ # == What this mixin is for
20
+ # **Nomenclature, plus one shared value rule**: the coercion, the
21
+ # no-op-when-unchanged short-circuit, the {Component#invalidate} and the
22
+ # {Component#inspect} line, held in one place so its includers cannot drift
23
+ # on them. `is_a?(HasCaption)` is a marker saying *this component wears
24
+ # chrome text*, and nothing in `lib/` consults it.
25
+ #
26
+ # **It is not a lookup seam, and must not become one.** {Tuile::Testing}
27
+ # used to filter by `caption:` on exactly this `is_a?`, which made a
28
+ # component's mixin membership answerable by what a test locator found
29
+ # convenient — a library is never shaped to suit its tests. Structural
30
+ # handles do that job ({Component#id}, the class, a subtree), and a spec
31
+ # that really wants the text says so per-class in a block. See
32
+ # `D_component_lookup`.
24
33
  module HasCaption
25
34
  # Read through *this* method, never `@caption` — the ivar stays nil until
26
35
  # the first non-empty set ({#caption=} short-circuits when unchanged).