Class: Tuile::Component::FormItem

Inherits:
Component
  • Object
show all
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

Attributes included from HasContent

#content

Instance Method Summary collapse

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.

Parameters:

  • content (Component, nil) (defaults to: nil)
  • caption: (String, StyledString, nil) (defaults to: nil)
  • required: (Boolean) (defaults to: false)


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.

Returns:

  • (String)


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.

Returns:



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

Parameters:



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

Parameters:



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.on_error_message_change.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.on_error_message_change << 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.

Returns:

  • (Boolean)


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>

Returns:

  • (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, message_rect = row_rects
  @caption_label.rect = caption_rect
  @message_label.rect = message_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.
  message = StyledString.parse(content.respond_to?(:shown_message) ? content.shown_message : nil)
  @message_label.text = message.empty? ? StyledString::EMPTY : message.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

Parameters:

  • flag (Boolean)


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.

Returns:

  • (Boolean)


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.

Parameters:

  • _top (Integer)
  • rows (Integer)

Returns:



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.

Returns:



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
  message_rows = height - caption_rows < 2 ? 0 : 1
  content_rows = height - caption_rows - message_rows
  [row_rect(0, caption_rows),
   row_rect(caption_rows, content_rows),
   row_rect(caption_rows + content_rows, message_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.

Parameters:

  • rect (Rect) (defaults to: local_extent_rect)


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