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. (Absolute can skip this — there, siblings are independent.)

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

  1. Fixed takes its cells, clamped to what is still unassigned.
  2. Percent takes its share of available, likewise clamped.
  3. 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

#paddingInsets, 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

#spacingInteger

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

@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: }
  relayout
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)


308
309
310
311
312
313
314
# File 'lib/tuile/component/layout/box.rb', line 308

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.



339
340
341
# File 'lib/tuile/component/layout/box.rb', line 339

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.

@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
  relayout
end

#cross_extent(rect) ⇒ Integer

@param rect

@return — the extent along the cross axis.

Parameters:

Returns:

  • (Integer)


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

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])


296
297
298
299
300
301
302
303
# File 'lib/tuile/component/layout/box.rb', line 296

def cross_placement(child, available)
  spec = placement(child)
  size = case (constraint = spec[:cross])
         when Fixed then constraint.cells.clamp(0, available)
         else percent_of(available, constraint).clamp(0, available)
         end
  [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.



281
282
283
284
285
286
287
288
289
290
# File 'lib/tuile/component/layout/box.rb', line 281

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

#handle_child_visibility_changed(_child) ⇒ void

This method returns an undefined value.

Re-divides the space: 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 — which is what Tuile::Component#visible= buys over remove plus add(…, at:).

@param _child

Parameters:



193
194
195
196
# File 'lib/tuile/component/layout/box.rb', line 193

def handle_child_visibility_changed(_child)
  super
  relayout
end

#inner_rectRect

@return — #rect with #padding taken off each edge; may be empty.

Returns:



223
224
225
226
# File 'lib/tuile/component/layout/box.rb', line 223

def inner_rect
  Rect.new(rect.left + padding.left, rect.top + 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)


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

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])


253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
# File 'lib/tuile/component/layout/box.rb', line 253

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|
    case (constraint = placement(child)[:main])
    when Expand then expanding << i
    when Fixed then unassigned -= (sizes[i] = constraint.cells.clamp(0, unassigned))
    else unassigned -= (sizes[i] = percent_of(available, constraint).clamp(0, unassigned))
    end
  end

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

#percent_of(extent, constraint) ⇒ Integer

@param extent

@param constraint

Parameters:

  • extent (Integer)
  • constraint (Percent)

Returns:

  • (Integer)


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

def percent_of(extent, constraint) = (extent * constraint.percent / 100.0).round

#place_children(inner) ⇒ void

This method returns an undefined value.

@param inner#inner_rect, known non-empty.

Parameters:



230
231
232
233
234
235
236
237
238
239
240
# File 'lib/tuile/component/layout/box.rb', line 230

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])


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

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

#rect=(new_rect) ⇒ void

This method returns an undefined value.

@param new_rect

Parameters:



180
181
182
183
# File 'lib/tuile/component/layout/box.rb', line 180

def rect=(new_rect)
  super
  relayout
end

#relayoutvoid

This method returns an undefined value.

Recomputes and assigns every child's rect, giving each an empty one when this layout's own rect — or #inner_rect — is empty.

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). Construction is silent without one anyway — #add runs before a parent assigns a rect, so the children are already empty and invalidate no-ops while detached.



209
210
211
212
213
214
215
216
217
218
219
# File 'lib/tuile/component/layout/box.rb', line 209

def relayout
  inner = inner_rect
  collapsed = Rect.new(rect.left, rect.top, 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
  invalidate
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)
  relayout
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:



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

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

#validate_align(align) ⇒ void

This method returns an undefined value.

@param align

Parameters:

  • align (Object)


379
380
381
382
383
# File 'lib/tuile/component/layout/box.rb', line 379

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)


366
367
368
369
370
371
372
373
374
# File 'lib/tuile/component/layout/box.rb', line 366

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?(Fixed) || constraint.is_a?(Percent)

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

#validate_main(constraint) ⇒ void

This method returns an undefined value.

@param constraint

Parameters:

  • constraint (Object)


357
358
359
360
361
# File 'lib/tuile/component/layout/box.rb', line 357

def validate_main(constraint)
  return if [Fixed, Percent, Expand].any? { constraint.is_a?(_1) }

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

#validate_spacing(cells) ⇒ Integer

@param cells

@returncells.

Parameters:

  • cells (Integer)

Returns:

  • (Integer)


346
347
348
349
350
351
352
# File 'lib/tuile/component/layout/box.rb', line 346

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