Class: Tuile::Component::FormLayout

Inherits:
Layout
  • Object
show all
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_child instead of #add.

Returns:

  • (Hash{Symbol => Object})
{ rows: 1 }.freeze

Instance Method Summary collapse

Methods inherited from Layout

#focusable?, #handle_focus

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.

Parameters:



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.

Parameters:

Returns:



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.

Parameters:

Returns:



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.

Parameters:

Returns:

  • (Integer)


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.

Parameters:

Returns:

  • (::Hash[Symbol, Object])


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.

Parameters:



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

Parameters:

  • rows (Integer)


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

Parameters:

Returns:



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