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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +108 -0
- data/README.md +21 -12
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +5 -4
- data/book/05-focus.md +12 -9
- data/book/06-theming.md +55 -17
- data/book/07-components.md +188 -40
- data/book/08-testing.md +115 -15
- data/book/10-locale.md +1 -1
- data/book/README.md +5 -5
- data/examples/file_commander.rb +38 -27
- data/examples/hello_world.rb +1 -1
- data/examples/sampler.rb +225 -169
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +81 -80
- data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +19 -11
- data/lib/tuile/component/checkbox.rb +12 -10
- data/lib/tuile/component/checkbox_group.rb +11 -13
- data/lib/tuile/component/combo_box.rb +30 -40
- data/lib/tuile/component/confirm_window.rb +27 -22
- data/lib/tuile/component/date_field.rb +27 -20
- data/lib/tuile/component/date_time_field.rb +75 -31
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +98 -27
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +5 -12
- data/lib/tuile/component/has_validation.rb +39 -13
- data/lib/tuile/component/has_value.rb +70 -16
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -63
- data/lib/tuile/component/layout.rb +124 -10
- data/lib/tuile/component/list.rb +197 -94
- data/lib/tuile/component/list_dropdown.rb +148 -88
- data/lib/tuile/component/menu_bar/cascade.rb +97 -27
- data/lib/tuile/component/menu_bar.rb +84 -64
- data/lib/tuile/component/notification.rb +44 -31
- data/lib/tuile/component/overlay.rb +210 -52
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +15 -10
- data/lib/tuile/component/popup.rb +13 -24
- data/lib/tuile/component/progress_bar.rb +7 -7
- data/lib/tuile/component/radio_group.rb +10 -12
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +15 -31
- data/lib/tuile/component/slot.rb +1 -2
- data/lib/tuile/component/tab_sheet.rb +21 -28
- data/lib/tuile/component/tabs.rb +39 -24
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +21 -19
- data/lib/tuile/component/text_field.rb +55 -39
- data/lib/tuile/component/text_view.rb +143 -79
- data/lib/tuile/component/time_field.rb +26 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +481 -259
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +14 -0
- data/lib/tuile/fake_screen.rb +41 -10
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +51 -35
- data/lib/tuile/mouse.rb +96 -29
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +419 -84
- data/lib/tuile/screen_pane.rb +144 -31
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +139 -9
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +310 -36
- data/lib/tuile/theme.rb +170 -19
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +4951 -1158
- metadata +16 -2
- 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
|
-
#
|
|
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
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
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
|
|
82
|
-
#
|
|
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
|
-
# ==
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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).
|