Class: Tuile::Component::Layout::Box
- Inherits:
-
Layout
- Object
- Layout
- Tuile::Component::Layout::Box
- 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):
- 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. - 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
Constant Summary collapse
- DEFAULT_PLACEMENT =
Constraints for a child wired in through
add_childinstead of #add. { main: Fixed[1], cross: Percent[100], align: :start }.freeze
- ALIGNMENTS =
Where a child narrower than the cross extent sits within it.
%i[start center end].freeze
Instance Attribute Summary collapse
-
#padding ⇒ Insets, Integer
@return — inset from this layout's own rect.
-
#spacing ⇒ Integer
@return — blank cells between adjacent children.
Instance Method Summary collapse
-
#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.
-
#align_offset(align, slack) ⇒ Integer
@param
align— one of ALIGNMENTS. -
#build_rect(inner, main_offset, main_size, cross_offset, cross_size) ⇒ Object
@param
inner— #inner_rect, the origin both offsets are relative to. -
#constrain(child, main = nil, cross: nil, align: nil) ⇒ Object
Re-constrains a child already in the layout and re-runs the pass.
-
#cross_extent(rect) ⇒ Integer
@param
rect. -
#cross_placement(child, available) ⇒ [Integer, Integer]
@param
child. -
#distribute_expand(sizes, kids, indices, slack) ⇒ Object
Splits
slackbetween the Expand children by weight, writing the results intosizes. -
#initialize(spacing: 0, padding: 0) ⇒ Box
constructor
@param
spacing— blank cells between adjacent children;>= 0. -
#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.
-
#main_extent(rect) ⇒ Integer
@param
rect. -
#main_sizes(inner, kids) ⇒ ::Array[Integer]
@param
inner— #inner_rect. -
#place_children(inner) ⇒ void
@param
inner— #inner_rect, known non-empty. -
#placement(child) ⇒ ::Hash[Symbol, Object]
@param
child. -
#relayout ⇒ void
Re-divides the space, giving each child an empty rect when this layout's own — or #inner_rect — is empty.
-
#remove(child) ⇒ void
Removes the child, forgets its constraints, and closes the gap it left by re-running the layout.
-
#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.
-
#validate_align(align) ⇒ void
@param
align. -
#validate_cross(constraint) ⇒ void
@param
constraint. -
#validate_main(constraint) ⇒ void
@param
constraint. -
#validate_spacing(cells) ⇒ Integer
@param
cells.
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.
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.
79 80 81 |
# File 'lib/tuile/component/layout/box.rb', line 79 def padding @padding end |
#spacing ⇒ Integer
@return — blank cells between adjacent children.
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.
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.
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.
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 (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.
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.
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.
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) = [] unassigned = available kids.each_with_index do |child, i| cells = placement(child)[:main].resolve(available) if cells.nil? << i else unassigned -= (sizes[i] = cells.clamp(0, unassigned)) end end (sizes, kids, , unassigned) unless .empty? sizes end |
#place_children(inner) ⇒ void
This method returns an undefined value.
@param inner — #inner_rect, known non-empty.
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.
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
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).
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
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
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
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.
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 |