Class: Tuile::Component::Layout::Box

Inherits:
Layout
  • Object
show all
Defined in:
lib/tuile/component/layout/box.rb,
sig/tuile.rbs

Overview

Abstract base of the one-dimensional box layouts. Children are stacked along a main axis in the order they were added, each getting the extent its constraint asks for; across the cross axis they are sized one at a time, since nothing competes with them there. Vertical and Horizontal pick which axis is which.

class LoginForm < Tuile::Component::Layout::Vertical
def initialize
  super(spacing: 1, padding: Insets[top: 1])
  add(@prompt = Tuile::Component::Label.new, Fixed[4])
  add(@user = Tuile::Component::TextField.new, Fixed[1], cross: Fixed[30])
  add(@log = Tuile::Component::TextView.new, Expand[1])
end
end

The constraint names need no prefix inside a subclass — Ruby finds them on Layout, an ancestor. Component classes are not on that chain and still do.

Children pack from the start edge, so with no Expand among them the slack is simply left at the end: there is no filler component to add. Nest boxes to vary the gap — a Vertical.new(spacing: 0) inside a Vertical.new(spacing: 1) groups two rows tightly within a looser stack.

Hiding a pane is #remove, not Fixed[0] (see Fixed for why an empty rect is not hiding). #add's at: is what makes it reversible — keep the index and the constraints on your side:

remove(@sidebar)                    # hide: siblings reclaim the space
add(@sidebar, Expand[1], at: 0)     # show: back where it was

Implementation details

Every child-list mutation re-runs the whole pass, because in a box the children move: removing one shifts everything after it, and adding one shrinks every Expand share.

Main-axis resolution order, against available = extent - padding - spacing * (children - 1):

  1. In declaration order, each Fixed, Percent or Clamp takes the cells it resolves to — a Percent its share of available, a Clamp that share bounded — clamped to what is still unassigned.
  2. Expand children split the residue by weight; the integer remainder goes to the earliest of them, one cell each.

So over-subscription starves in declaration order rather than raising: a child with nothing left gets an empty rect and paints nothing. Padding wider than the layout does the same to every child.

Direct Known Subclasses

Horizontal, Vertical

Constant Summary collapse

DEFAULT_PLACEMENT =

Constraints for a child wired in through add_child instead of #add.

Returns:

  • (Hash{Symbol => Object})
{ main: Fixed[1], cross: Percent[100], align: :start }.freeze
ALIGNMENTS =

Where a child narrower than the cross extent sits within it.

Returns:

  • (Array<Symbol>)
%i[start center end].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(spacing: 0, padding: 0) ⇒ Box

@param spacing — blank cells between adjacent children; >= 0.

@param padding — inset from this layout's own rect; an Integer is coerced to a uniform Insets.

Parameters:

  • spacing: (Integer) (defaults to: 0)
  • padding: (Insets, Integer) (defaults to: 0)


67
68
69
70
71
72
73
# File 'lib/tuile/component/layout/box.rb', line 67

def initialize(spacing: 0, padding: 0)
  super()
  @spacing = validate_spacing(spacing)
  @padding = Insets.coerce(padding)
  # Identity-keyed: two == children are still two distinct slots.
  @placements = {}.compare_by_identity
end

Instance Attribute Details

#padding ⇒ Insets, Integer

@return — inset from this layout's own rect.

Returns:



79
80
81
# File 'lib/tuile/component/layout/box.rb', line 79

def padding
  @padding
end

#spacing ⇒ Integer

@return — blank cells between adjacent children.

Returns:

  • (Integer)


76
77
78
# File 'lib/tuile/component/layout/box.rb', line 76

def spacing
  @spacing
end

Instance Method Details

#add(child, main = Fixed[1], cross: Percent[100], align: :start, at: nil) ⇒ Object

Adds a child — or every element of an Enumerable, all with the same constraints — and re-runs the layout.

add(field, Fixed[1], cross: Fixed[30], align: :center)
add([ok, cancel], Fixed[1])
add(sidebar, Expand[1], at: 0)   # back where it was, after a #remove

@param child

@param main — extent along the main axis.

@param cross — extent across it; not an Expand.

@param align — one of ALIGNMENTS — where a child narrower than the cross extent sits. Vertical / Horizontal say which edge :start is.

@param at — position among the existing children; appends when nil. An Enumerable is inserted in order from there. This is what makes hiding-by-#remove reversible — see the class doc.



123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/tuile/component/layout/box.rb', line 123

def add(child, main = Fixed[1], cross: Percent[100], align: :start, at: nil)
  if child.is_a? Enumerable
    child.each_with_index { |c, i| add(c, main, cross:, align:, at: at && at + i) }
    return
  end

  validate_main(main)
  validate_cross(cross)
  validate_align(align)
  add_child(child, at:)
  @placements[child] = { main:, cross:, align: }
  invalidate_layout
end

#align_offset(align, slack) ⇒ Integer

@param align — one of ALIGNMENTS.

@param slack — unused cells across the axis.

Parameters:

  • align (Symbol)
  • slack (Integer)

Returns:

  • (Integer)


288
289
290
291
292
293
294
# File 'lib/tuile/component/layout/box.rb', line 288

def align_offset(align, slack)
  case align
  when :center then slack / 2
  when :end then slack
  else 0
  end
end

#build_rect(inner, main_offset, main_size, cross_offset, cross_size) ⇒ Object

@param inner — #inner_rect, the origin both offsets are relative to.

@param main_offset — cells along the main axis.

@param main_size — extent along the main axis.

@param cross_offset — cells along the cross axis.

@param cross_size — extent along the cross axis.

@return — absolute screen rect for one child.



314
315
316
# File 'lib/tuile/component/layout/box.rb', line 314

def build_rect(inner, main_offset, main_size, cross_offset, cross_size)
  raise NotImplementedError, "#{self.class} must implement build_rect"
end

#constrain(child, main = nil, cross: nil, align: nil) ⇒ Object

Re-constrains a child already in the layout and re-runs the pass. A nil argument keeps what that axis already had, so one can move alone:

box.constrain(sidebar, Fixed[0])   # collapse it; cross: and align: stand

Fixed[0] collapses — see Fixed for why that is not the same as hiding, and the class doc for what is.

@param child — a child of this layout.

@param main — extent along the main axis.

@param cross — extent across it; not an Expand.

@param align — one of ALIGNMENTS.



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/tuile/component/layout/box.rb', line 152

def constrain(child, main = nil, cross: nil, align: nil)
  raise ArgumentError, "#{child} is not a child of #{self}" unless children.any? { _1.equal?(child) }

  validate_main(main) unless main.nil?
  validate_cross(cross) unless cross.nil?
  validate_align(align) unless align.nil?

  current = placement(child)
  updated = { main: main || current[:main], cross: cross || current[:cross],
              align: align || current[:align] }
  return if current == updated

  @placements[child] = updated
  invalidate_layout
end

#cross_extent(rect) ⇒ Integer

@param rect

@return — the extent along the cross axis.

Parameters:

Returns:

  • (Integer)


306
# File 'lib/tuile/component/layout/box.rb', line 306

def cross_extent(rect) = raise(NotImplementedError, "#{self.class} must implement cross_extent")

#cross_placement(child, available) ⇒ [Integer, Integer]

@param child

@param available — cross extent of #inner_rect.

@return — offset from inner's start edge, and extent, along the cross axis.

Parameters:

Returns:

  • ([Integer, Integer])


279
280
281
282
283
# File 'lib/tuile/component/layout/box.rb', line 279

def cross_placement(child, available)
  spec = placement(child)
  size = spec[:cross].resolve(available).clamp(0, available)
  [align_offset(spec[:align], available - size), size]
end

#distribute_expand(sizes, kids, indices, slack) ⇒ Object

Splits slack between the Expand children by weight, writing the results into sizes.

@param sizes — mutated in place.

@param kids — #shown_children, which indices index.

@param indices — child indices carrying an Expand.

@param slack — cells left over; a negative value yields zeroes.



264
265
266
267
268
269
270
271
272
273
# File 'lib/tuile/component/layout/box.rb', line 264

def distribute_expand(sizes, kids, indices, slack)
  slack = 0 if slack.negative?
  weights = indices.map { placement(kids[_1])[:main].weight }
  total = weights.sum
  shares = weights.map { slack * _1 / total }
  # Under one cell is lost per floor, so the remainder can't outrun the
  # share count — the earliest Expand children each take one.
  (slack - shares.sum).times { |i| shares[i] += 1 }
  indices.each_with_index { |child_index, i| sizes[child_index] = shares[i] }
end

#inner_rect ⇒ Rect

@return — Tuile::Component#local_rect with #padding taken off each edge — this layout's own coordinates, which are its children's too; may be empty.

Returns:



205
206
207
208
# File 'lib/tuile/component/layout/box.rb', line 205

def inner_rect
  Rect.new(padding.left, padding.top,
           rect.width - padding.horizontal, rect.height - padding.vertical)
end

#main_extent(rect) ⇒ Integer

@param rect

@return — the extent along the main axis.

Parameters:

Returns:

  • (Integer)


302
# File 'lib/tuile/component/layout/box.rb', line 302

def main_extent(rect) = raise(NotImplementedError, "#{self.class} must implement main_extent")

#main_sizes(inner, kids) ⇒ ::Array[Integer]

@param inner — #inner_rect.

@param kids — #shown_children.

@return — main-axis extent per shown child, in order.

Parameters:

Returns:

  • (::Array[Integer])


235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/tuile/component/layout/box.rb', line 235

def main_sizes(inner, kids)
  count = kids.size
  return [] if count.zero?

  available = [main_extent(inner) - (spacing * (count - 1)), 0].max
  sizes = Array.new(count, 0)
  expanding = []
  unassigned = available

  kids.each_with_index do |child, i|
    cells = placement(child)[:main].resolve(available)
    if cells.nil?
      expanding << i
    else
      unassigned -= (sizes[i] = cells.clamp(0, unassigned))
    end
  end

  distribute_expand(sizes, kids, expanding, unassigned) unless expanding.empty?
  sizes
end

#place_children(inner) ⇒ void

This method returns an undefined value.

@param inner — #inner_rect, known non-empty.

Parameters:



212
213
214
215
216
217
218
219
220
221
222
# File 'lib/tuile/component/layout/box.rb', line 212

def place_children(inner)
  kids = shown_children
  sizes = main_sizes(inner, kids)
  available = cross_extent(inner)
  offset = 0
  kids.each_with_index do |child, i|
    cross_offset, cross_size = cross_placement(child, available)
    child.rect = build_rect(inner, offset, sizes[i], cross_offset, cross_size)
    offset += sizes[i] + spacing
  end
end

#placement(child) ⇒ ::Hash[Symbol, Object]

@param child

@return — the child's main/cross/align.

Parameters:

Returns:

  • (::Hash[Symbol, Object])


298
# File 'lib/tuile/component/layout/box.rb', line 298

def placement(child) = @placements[child] || DEFAULT_PLACEMENT

#relayout ⇒ void

This method returns an undefined value.

Re-divides the space, giving each child an empty rect when this layout's own — or #inner_rect — is empty. A child that went hidden gives its slot and the #spacing around it to its siblings, and one that came back takes them again with the constraints it was added with; that is what Tuile::Component#visible= buys over remove plus add(…, at:).

Deliberately no return if rect.empty? guard: that strands the children at the coordinates they last had, and the next full repaint paints them there (D_empty_ancestor).



191
192
193
194
195
196
197
198
199
200
# File 'lib/tuile/component/layout/box.rb', line 191

def relayout
  inner = inner_rect
  collapsed = Rect.new(0, 0, 0, 0)
  if rect.empty? || inner.empty?
    children.each { _1.rect = collapsed }
  else
    children.each { _1.rect = collapsed unless _1.visible? }
    place_children(inner)
  end
end

#remove(child) ⇒ void

This method returns an undefined value.

Removes the child, forgets its constraints, and closes the gap it left by re-running the layout.

@param child

Parameters:



172
173
174
175
176
# File 'lib/tuile/component/layout/box.rb', line 172

def remove(child)
  super
  @placements.delete(child)
  invalidate_layout
end

#shown_children ⇒ ::Array[Component]

The children this pass divides space between — a hidden one is out of the #spacing count as well as the arithmetic, so hiding a middle child closes the row up rather than leaving a double gap. Contrast a Fixed[0] child, still a member of the sequence and still paying for its gap (D_visibility).

Returns:



230
# File 'lib/tuile/component/layout/box.rb', line 230

def shown_children = children.select(&:visible?)

#validate_align(align) ⇒ void

This method returns an undefined value.

@param align

Parameters:

  • align (Object)


354
355
356
357
358
# File 'lib/tuile/component/layout/box.rb', line 354

def validate_align(align)
  return if ALIGNMENTS.include?(align)

  raise ArgumentError, "expected one of #{ALIGNMENTS.inspect}, got #{align.inspect}"
end

#validate_cross(constraint) ⇒ void

This method returns an undefined value.

@param constraint

Parameters:

  • constraint (Object)


341
342
343
344
345
346
347
348
349
# File 'lib/tuile/component/layout/box.rb', line 341

def validate_cross(constraint)
  if constraint.is_a? Expand
    raise ArgumentError, "Expand is main-axis only — a child has no siblings competing " \
                         "across the axis; use Fixed or Percent for cross:"
  end
  return if constraint.is_a?(Constraint)

  raise ArgumentError, "expected Fixed, Percent or Clamp for cross:, got #{constraint.inspect}"
end

#validate_main(constraint) ⇒ void

This method returns an undefined value.

@param constraint

Parameters:

  • constraint (Object)


332
333
334
335
336
# File 'lib/tuile/component/layout/box.rb', line 332

def validate_main(constraint)
  return if constraint.is_a?(Constraint)

  raise ArgumentError, "expected Fixed, Percent, Expand or Clamp, got #{constraint.inspect}"
end

#validate_spacing(cells) ⇒ Integer

@param cells

@return — cells.

Parameters:

  • cells (Integer)

Returns:

  • (Integer)


321
322
323
324
325
326
327
# File 'lib/tuile/component/layout/box.rb', line 321

def validate_spacing(cells)
  unless cells.is_a?(Integer) && !cells.negative?
    raise ArgumentError, "spacing expects a non-negative Integer, got #{cells.inspect}"
  end

  cells
end