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.

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)


60
61
62
63
64
65
66
# File 'lib/tuile/component/layout/box.rb', line 60

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:



72
73
74
# File 'lib/tuile/component/layout/box.rb', line 72

def padding
  @padding
end

#spacingInteger

@return — blank cells between adjacent children.

Returns:

  • (Integer)


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

def spacing
  @spacing
end

Instance Method Details

#add(child, main = Fixed[1], cross: Percent[100], align: :start) ⇒ 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])

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



112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/tuile/component/layout/box.rb', line 112

def add(child, main = Fixed[1], cross: Percent[100], align: :start)
  if child.is_a? Enumerable
    child.each { add(_1, main, cross:, align:) }
    return
  end

  validate_main(main)
  validate_cross(cross)
  validate_align(align)
  add_child(child)
  @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)


237
238
239
240
241
242
243
# File 'lib/tuile/component/layout/box.rb', line 237

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.



268
269
270
# File 'lib/tuile/component/layout/box.rb', line 268

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

#cross_extent(rect) ⇒ Integer

@param rect

@return — the extent along the cross axis.

Parameters:

Returns:

  • (Integer)


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

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


225
226
227
228
229
230
231
232
# File 'lib/tuile/component/layout/box.rb', line 225

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, indices, slack) ⇒ void

This method returns an undefined value.

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

@param sizes — mutated in place.

@param indices — child indices carrying an Expand.

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

Parameters:

  • sizes (::Array[Integer])
  • indices (::Array[Integer])
  • slack (Integer)


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

def distribute_expand(sizes, indices, slack)
  slack = 0 if slack.negative?
  weights = indices.map { placement(children[_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_rectRect

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

Returns:



163
164
165
166
# File 'lib/tuile/component/layout/box.rb', line 163

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)


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

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

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

@param inner#inner_rect.

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

Parameters:

Returns:

  • (::Array[Integer])


183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/tuile/component/layout/box.rb', line 183

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

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

  children.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, expanding, unassigned) unless expanding.empty?
  sizes
end

#percent_of(extent, constraint) ⇒ Integer

@param extent

@param constraint

Parameters:

  • extent (Integer)
  • constraint (Percent)

Returns:

  • (Integer)


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

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:



170
171
172
173
174
175
176
177
178
179
# File 'lib/tuile/component/layout/box.rb', line 170

def place_children(inner)
  sizes = main_sizes(inner)
  available = cross_extent(inner)
  offset = 0
  children.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])


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

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

#rect=(new_rect) ⇒ void

This method returns an undefined value.

@param new_rect

Parameters:



138
139
140
141
# File 'lib/tuile/component/layout/box.rb', line 138

def rect=(new_rect)
  super
  relayout
end

#relayoutvoid

This method returns an undefined value.

Recomputes and assigns every child's rect. Silent until this layout has a rect of its own — #add runs during construction, long before a parent assigns one.



149
150
151
152
153
154
155
156
157
158
159
# File 'lib/tuile/component/layout/box.rb', line 149

def relayout
  return if rect.empty?

  inner = inner_rect
  if inner.empty?
    children.each { _1.rect = Rect.new(rect.left, rect.top, 0, 0) }
  else
    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:



130
131
132
133
134
# File 'lib/tuile/component/layout/box.rb', line 130

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

#validate_align(align) ⇒ void

This method returns an undefined value.

@param align

Parameters:

  • align (Object)


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

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)


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

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)


286
287
288
289
290
# File 'lib/tuile/component/layout/box.rb', line 286

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)


275
276
277
278
279
280
281
# File 'lib/tuile/component/layout/box.rb', line 275

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