Class: Tuile::Component::FormItem
- Inherits:
-
Component
- Object
- Component
- Tuile::Component::FormItem
- Includes:
- HasCaption, HasContent
- Defined in:
- lib/tuile/component/form_item.rb,
sig/tuile.rbs
Overview
One row of a form: a #caption above a field, and whatever the field has to say against itself below it.
item = Component::FormItem.new(username, caption: "Username", required: true)
username.error_message = "Must not be blank"
Username ∙ ← the caption, with the required marker
[________________] ← the content, whatever you wrapped
Must not be blank ← the message, here the verdict just written
Wrap a field and drop the item wherever a component goes — a Layout::Vertical stacking items is already a form:
column.add(Component::FormItem.new(notes, caption: "Notes"), Fixed[7])
The chrome is the item's, the face is the field's: a Checkbox or a Button paints its own text and takes no caption here, so the item is built without one and simply reserves no caption row.
Hide the item, never the field — item.visible = false takes the
caption and the message with it, while field.visible = false blanks the
field's rows and leaves its caption stranded above them.
Implementation details
The message row is also the gap row, which is why the item's pitch is
a flat three rows and nothing ever reflows: a form that grew a row when a
field went invalid would push the fields below it down while the user is
typing into one of them. See D_form_item.
It measures nothing. The rect it is handed is divided top-down —
caption, content, message — so there is no rows property here and no
question asked of the field. Rows are served content-first when there are
too few: the content never drops below one row, then the caption takes the
next row it can, then the message.
The message comes from two channels and the item orders neither. A
validator's verdict (HasValidation#error_message) and the field's own
report of input its value cannot represent (HasBadInput#bad_input?) both
land in this row; the item registers on both notices and paints
HasValidation#shown_message, which is where the precedence lives — bad
input wins, and a latched field says nothing until it settles
(D_bad_input). So the row can fill while error_message is still nil.
The message is claimed ink. It is painted in Theme#error_color
whatever colors the StyledString carried, so the row always reads as an
error — the matching half of the red well the field paints for itself
(D_has_validation).
Both that message and the required marker are StyledStrings this item authors, so they bake their colors and are rebuilt from #handle_theme_changed — and from #handle_attached, for a tree assembled before there was a Screen to read a theme from.
Class Attribute Summary collapse
-
.required_marker ⇒ String
The glyph marking a #required? item,
∙(U+2219 BULLET OPERATOR) by default:.
Attributes included from HasContent
Instance Method Summary collapse
-
#caption ⇒ StyledString
Read through this method, never
@caption— the ivar stays nil until the first non-empty set (#caption= short-circuits when unchanged). -
#caption=(new_caption) ⇒ void
Sets the caption, adding or dropping the caption row as it becomes non-empty or empty.
-
#content=(new_content) ⇒ void
Mounts the field, moving the message subscriptions onto it: the outgoing occupant is unsubscribed in the same call, which is the one choke point HasContent gives for it.
-
#focusable? ⇒ Boolean
@return — true, so clicking the caption forwards focus into the field through HasContent#handle_focus.
- #handle_attached ⇒ void
- #handle_focus ⇒ void
- #handle_theme_changed ⇒ void
-
#initialize(content = nil, caption: nil, required: false) ⇒ FormItem
constructor
@param
content— the field to wrap; assignable later through #content=. - #inspect_details ⇒ Array<String>
- #layout_chrome ⇒ void
-
#refresh_chrome ⇒ void
Rebuilds both chrome strings from the current caption, marker and verdict — the single writer, idempotent, so every input that can change one of them just calls this.
-
#relayout ⇒ void
The caption, the content and the message each take one row of three.
-
#required=(flag) ⇒ void
Paints FormItem.required_marker beside the caption.
-
#required? ⇒ Boolean
@return — whether the caption carries FormItem.required_marker.
-
#row_rect(top, rows) ⇒ Rect
@param
top. -
#row_rects ⇒ [Rect, Rect, Rect]
Divides #rect top-down.
-
#scroll_to_visible(rect = local_extent_rect) ⇒ void
Asks for the whole item, so a field scrolled into view brings its caption and message along — then for
rectitself, which wins when the item is taller than the viewport (a caret row over the caption).
Constructor Details
#initialize(content = nil, caption: nil, required: false) ⇒ FormItem
@param content — the field to wrap; assignable later through #content=.
@param caption — the text above it; omit it for a widget painting its own, such as a Checkbox or a Button.
@param required — whether to paint required_marker beside the caption.
96 97 98 99 100 101 102 103 104 105 106 107 |
# File 'lib/tuile/component/form_item.rb', line 96 def initialize(content = nil, caption: nil, required: false) super() @content = nil @required = false @caption_label = Label.new @message_label = Label.new add_child(@caption_label) # appended: HasContent forces the content to index 0 add_child(@message_label) self.caption = caption self.required = required self.content = content unless content.nil? end |
Class Attribute Details
.required_marker ⇒ String
The glyph marking a #required? item, ∙ (U+2219 BULLET OPERATOR)
by default:
Tuile::Component::FormItem.required_marker = "*"
An app-global, like VerticalScrollBar.handle_char — the marker is a house style, not a per-item decision.
The default is deliberately not one of •, ● or ·: those are
East-Asian Ambiguous and would measure two columns under the other
policy, enlarging the inventory that keeps D_ambiguous_width's bet
cheap to reverse. ∙ and ◦ measure one under both.
75 76 77 |
# File 'lib/tuile/component/form_item.rb', line 75 def required_marker @required_marker end |
Instance Method Details
#caption ⇒ StyledString
Read through this method, never @caption — the ivar stays nil until
the first non-empty set (#caption= short-circuits when unchanged).
@return — the caption; empty when never set.
8643 |
# File 'sig/tuile.rbs', line 8643
def caption: () -> StyledString
|
#caption=(new_caption) ⇒ void
This method returns an undefined value.
Sets the caption, adding or dropping the caption row as it becomes non-empty or empty.
@param new_caption
132 133 134 135 136 137 138 139 140 141 142 |
# File 'lib/tuile/component/form_item.rb', line 132 def caption=(new_caption) new_caption = StyledString.parse(new_caption) if required? && new_caption.empty? raise ArgumentError, "a required FormItem keeps its caption: the marker has nowhere else to go" end had_row = !caption.empty? super refresh_chrome invalidate_layout unless had_row == !caption.empty? end |
#content=(new_content) ⇒ void
This method returns an undefined value.
Mounts the field, moving the message subscriptions onto it: the outgoing occupant is unsubscribed in the same call, which is the one choke point HasContent gives for it.
A content component without HasValidation is fine — the message row then simply stays empty — and one that cannot hold bad input skips that slot.
@param new_content
153 154 155 156 157 158 159 160 161 162 163 164 |
# File 'lib/tuile/component/form_item.rb', line 153 def content=(new_content) return if content == new_content old = content super # Both channels paint this row, and either can move without the other. old..remove(method(:refresh_chrome)) if old.respond_to?(:on_error_message_change) old.on_bad_input_change.remove(method(:refresh_chrome)) if old.respond_to?(:on_bad_input_change) content. << method(:refresh_chrome) if content.respond_to?(:on_error_message_change) content.on_bad_input_change << method(:refresh_chrome) if content.respond_to?(:on_bad_input_change) refresh_chrome end |
#focusable? ⇒ Boolean
@return — true, so clicking the caption forwards focus into the field through HasContent#handle_focus. Never a Tuile::Component#tab_stop?: the field it wraps is the one stop.
169 |
# File 'lib/tuile/component/form_item.rb', line 169 def focusable? = true |
#handle_attached ⇒ void
This method returns an undefined value.
197 198 199 200 |
# File 'lib/tuile/component/form_item.rb', line 197 def handle_attached super refresh_chrome end |
#handle_focus ⇒ void
This method returns an undefined value.
8645 |
# File 'sig/tuile.rbs', line 8645
def handle_focus: () -> void
|
#handle_theme_changed ⇒ void
This method returns an undefined value.
191 192 193 194 |
# File 'lib/tuile/component/form_item.rb', line 191 def handle_theme_changed super refresh_chrome end |
#inspect_details ⇒ Array<String>
203 |
# File 'lib/tuile/component/form_item.rb', line 203 def inspect_details = required? ? super + ["required"] : super |
#layout_chrome ⇒ void
This method returns an undefined value.
224 225 226 227 228 |
# File 'lib/tuile/component/form_item.rb', line 224 def layout_chrome caption_rect, _content_rect, = row_rects @caption_label.rect = caption_rect @message_label.rect = end |
#refresh_chrome ⇒ void
This method returns an undefined value.
Rebuilds both chrome strings from the current caption, marker and verdict — the single writer, idempotent, so every input that can change one of them just calls this.
211 212 213 214 215 216 217 218 219 220 221 |
# File 'lib/tuile/component/form_item.rb', line 211 def refresh_chrome # The marker shares the message's red rather than earning a theme token # of its own — a required field is not yet invalid; see `D_form_item`. ink = Screen.instance? ? screen.theme.error_color : nil marker = StyledString.styled(" #{self.class.required_marker}", fg: ink) @caption_label.text = required? ? caption + marker : caption # `shown_message` orders the two channels, and hands back a plain # String for a field's own report — hence the parse. = StyledString.parse(content.respond_to?(:shown_message) ? content. : nil) @message_label.text = .empty? ? StyledString::EMPTY : .with_fg(ink) end |
#relayout ⇒ void
This method returns an undefined value.
The caption, the content and the message each take one row of three.
185 186 187 188 |
# File 'lib/tuile/component/form_item.rb', line 185 def relayout content&.rect = row_rects[1] layout_chrome end |
#required=(flag) ⇒ void
This method returns an undefined value.
Paints required_marker beside the caption. The field never learns it is required — nothing here validates, and this is not a rule.
@param flag
118 119 120 121 122 123 124 125 |
# File 'lib/tuile/component/form_item.rb', line 118 def required=(flag) flag = flag ? true : false return if @required == flag raise ArgumentError, "a required FormItem needs a caption for the marker" if flag && caption.empty? @required = flag refresh_chrome end |
#required? ⇒ Boolean
@return — whether the caption carries required_marker.
110 |
# File 'lib/tuile/component/form_item.rb', line 110 def required? = @required |
#row_rect(top, rows) ⇒ Rect
@param top
@param rows
@return — the full width, rows tall.
247 |
# File 'lib/tuile/component/form_item.rb', line 247 def row_rect(top, rows) = Rect.new(0, top, rect.width, rows) |
#row_rects ⇒ [Rect, Rect, Rect]
Divides Tuile::Component#rect top-down. A part with no row left gets a rect
of zero height — empty, so the Label in it paints nothing — rather
than a stale one (D_empty_ancestor).
@return — the caption, content and message rects.
234 235 236 237 238 239 240 241 242 |
# File 'lib/tuile/component/form_item.rb', line 234 def row_rects height = rect.height caption_rows = caption.empty? || height < 2 ? 0 : 1 = height - caption_rows < 2 ? 0 : 1 content_rows = height - caption_rows - [row_rect(0, caption_rows), row_rect(caption_rows, content_rows), row_rect(caption_rows + content_rows, )] end |
#scroll_to_visible(rect = local_extent_rect) ⇒ void
This method returns an undefined value.
Asks for the whole item, so a field scrolled into view brings its
caption and message along — then for rect itself, which wins when the
item is taller than the viewport (a caret row over the caption).
@param rect — in this component's coordinates.
176 177 178 179 |
# File 'lib/tuile/component/form_item.rb', line 176 def scroll_to_visible(rect = local_extent_rect) super(local_rect) super end |