Class: Tuile::Component::FormLayout
- Defined in:
- lib/tuile/component/form_layout.rb,
sig/tuile.rbs
Overview
A column of FormItems. Hand it a field and a caption; it builds the item and stacks it below the last one.
form = Component::FormLayout.new
form.add(username, caption: "Username", required: true)
form.add(notes, caption: "Notes", rows: 5)
form.add(logging) # a Checkbox paints its own "[x] Enable logging"
form.add(save) # a Button, so no caption row at all
Username ∙ ← the caption row, with the required marker
[______________] ← `rows:` content rows, 1 by default
Must not be blank ← the message row, which is also the gap
Notes
[ ]
You hand it fields, it holds items. #add wraps whatever you give it
and returns the FormItem it built, so the chrome is the item's from the
start — add(field, caption:) reads as though it set the field's
caption, and does not (D_caption_ownership). Name that field again
wherever this class takes one — #remove, #constrain, #field_for's
answer — and the item around it responds. Hide that item, never the field:
its caption and message go with it and the rows come back here.
Implementation details
Every row count is the caller's. Nothing here measures and nothing is
asked of a field: a captioned item is handed 1 + rows + 1 rows, a
captionless one rows + 1. The message row doubles as the gap, which is
why there is no spacing — and why rows: is a placement constraint in
this layout's per-child map, exactly as Fixed[n] is in a Layout::Box,
rather than a property of the item. See D_form_layout.
Overflow clips, and there is no scrolling. Items are laid from the top
edge; the one straddling the bottom takes the rows that are left — a
FormItem serves its content first — and everything past it gets an empty
rect rather than a stale one (D_empty_ancestor).
The caption is read at every pass and nothing announces a change to it,
so one that appears or disappears after the item is placed resizes it at
the next rect= rather than at once. Pass it to #add and the question
never arises.
Constant Summary collapse
- DEFAULT_PLACEMENT =
Placement for an item wired in through
add_childinstead of #add. { rows: 1 }.freeze
Instance Method Summary collapse
-
#add(field, caption: nil, required: false, rows: 1, at: nil) ⇒ Object
Wraps
fieldin a FormItem, adds it, and re-runs the layout. -
#constrain(field, rows) ⇒ void
Re-sizes an item's content rows and re-runs the layout.
-
#field_for(caption:) ⇒ Component?
The field under a caption — sugar, since the association is a FormItem in the tree and an ordinary walk answers the same question.
-
#initialize ⇒ FormLayout
constructor
A new instance of FormLayout.
-
#item_for(field) ⇒ FormItem
@param
field— a field, or an item of this form. -
#item_height(item) ⇒ Integer
@param
item. -
#placement(item) ⇒ ::Hash[Symbol, Object]
@param
item. -
#relayout ⇒ void
Stacks the items from the top edge, each #item_height tall, and clips at the bottom.
-
#remove(field) ⇒ void
Removes the item, forgets its placement, and closes the rows it left.
-
#validate_rows(rows) ⇒ void
@param
rows. -
#wrap(field, caption, required) ⇒ FormItem
@param
field.
Methods inherited from Layout
Constructor Details
#initialize ⇒ FormLayout
Returns a new instance of FormLayout.
50 51 52 53 54 |
# File 'lib/tuile/component/form_layout.rb', line 50 def initialize super # Identity-keyed: two == items are still two distinct slots. @placements = {}.compare_by_identity end |
Instance Method Details
#add(field, caption: nil, required: false, rows: 1, at: nil) ⇒ Object
Wraps field in a Tuile::Component::FormItem, adds it, and re-runs the layout.
item = form.add(notes, caption: "Notes", rows: 5)
item.required = true # the chrome is the item's, so tune it there
@param field — the field to wrap — or a ready-made Tuile::Component::FormItem, which is adopted as it stands.
@param caption — the caption row's text. Omit it for a widget that paints its own face, such as a Checkbox or a Button, and the item reserves no caption row.
@param required — paints Tuile::Component::FormItem.required_marker beside the caption.
@param rows — content rows for the field; >= 1.
@param at — position among the existing items; appends when nil. The index is part of the contract — it is paint and Tab order.
@return — the item, whether built here or handed in.
76 77 78 79 80 81 82 83 |
# File 'lib/tuile/component/form_layout.rb', line 76 def add(field, caption: nil, required: false, rows: 1, at: nil) validate_rows(rows) item = wrap(field, caption, required) add_child(item, at:) @placements[item] = { rows: } invalidate_layout item end |
#constrain(field, rows) ⇒ void
This method returns an undefined value.
Re-sizes an item's content rows and re-runs the layout.
form.constrain(notes, 8)
@param field — the field, or the item around it.
@param rows — content rows; >= 1.
94 95 96 97 98 99 100 101 |
# File 'lib/tuile/component/form_layout.rb', line 94 def constrain(field, rows) item = item_for(field) validate_rows(rows) return if placement(item)[:rows] == rows @placements[item] = { rows: } invalidate_layout end |
#field_for(caption:) ⇒ Component?
The field under a caption — sugar, since the association is a Tuile::Component::FormItem in the tree and an ordinary walk answers the same question.
form.field_for(caption: "Username").value = "admin"
@param caption — matched against Tuile::Component::FormItem#caption as plain text; with duplicates, the first item wins.
@return — the wrapped field, or nil when no item matches or the matching one holds nothing.
131 132 133 134 |
# File 'lib/tuile/component/form_layout.rb', line 131 def field_for(caption:) wanted = caption.to_s children.find { _1.caption.to_s == wanted }&.content end |
#item_for(field) ⇒ FormItem
@param field — a field, or an item of this form.
186 187 188 189 190 191 192 193 |
# File 'lib/tuile/component/form_layout.rb', line 186 def item_for(field) return field if children.any? { _1.equal?(field) } item = children.find { _1.content.equal?(field) } raise ArgumentError, "#{field} is in no item of #{self}" if item.nil? item end |
#item_height(item) ⇒ Integer
@param item
@return — the rows it is handed: a caption row when it carries a caption, its content rows, and the message row that doubles as the gap.
160 |
# File 'lib/tuile/component/form_layout.rb', line 160 def item_height(item) = (item.caption.empty? ? 0 : 1) + placement(item)[:rows] + 1 |
#placement(item) ⇒ ::Hash[Symbol, Object]
@param item
@return — the item's rows.
164 |
# File 'lib/tuile/component/form_layout.rb', line 164 def placement(item) = @placements[item] || DEFAULT_PLACEMENT |
#relayout ⇒ void
This method returns an undefined value.
Stacks the items from the top edge, each #item_height tall, and clips at the bottom. A hidden item gives up its content rows and the fused gap row below them, and everything under it moves up.
Deliberately no return if rect.empty? guard: that strands the items
at the coordinates they last had, and the next full repaint paints them
there (D_empty_ancestor).
146 147 148 149 150 151 152 153 154 155 |
# File 'lib/tuile/component/form_layout.rb', line 146 def relayout collapsed = Rect.new(0, 0, 0, 0) top = 0 bottom = rect.empty? ? 0 : rect.height children.each do |item| rows = item.visible? ? [item_height(item), bottom - top].min : 0 item.rect = rows.positive? ? Rect.new(0, top, rect.width, rows) : collapsed top += rows end end |
#remove(field) ⇒ void
This method returns an undefined value.
Removes the item, forgets its placement, and closes the rows it left.
The item keeps the field, so hand the item back to #add to put the
row back where it was — the Layout::Box idiom, with rows: on your
side because the placement is gone:
form.remove(notes) # out, siblings move up
form.add(item, rows: 5, at: 1) # back where it was
@param field — the field, or the item around it.
115 116 117 118 119 120 |
# File 'lib/tuile/component/form_layout.rb', line 115 def remove(field) item = item_for(field) super(item) @placements.delete(item) invalidate_layout end |
#validate_rows(rows) ⇒ void
This method returns an undefined value.
@param rows
198 199 200 201 202 203 |
# File 'lib/tuile/component/form_layout.rb', line 198 def validate_rows(rows) return if rows.is_a?(Integer) && rows.positive? raise ArgumentError, "rows expects a positive Integer, got #{rows.inspect} — " \ "hide an item with visible = false rather than starving it" end |
#wrap(field, caption, required) ⇒ FormItem
@param field
@param caption
@param required
172 173 174 175 176 177 178 179 180 181 |
# File 'lib/tuile/component/form_layout.rb', line 172 def wrap(field, caption, required) raise TypeError, "expected Component, got #{field.inspect}" unless field.is_a?(Component) return FormItem.new(field, caption:, required:) unless field.is_a?(FormItem) unless caption.nil? && !required raise ArgumentError, "#{field} is already a FormItem: it carries its own caption and marker" end field end |