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. (Absolute can skip this — there, siblings are independent.)
Main-axis resolution order, against
available = extent - padding - spacing * (children - 1):
- Fixed takes its cells, clamped to what is still unassigned.
- Percent takes its share of
available, likewise clamped. - 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. -
#handle_child_visibility_changed(_child) ⇒ void
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
removeplusadd(…, at:). -
#initialize(spacing: 0, padding: 0) ⇒ Box
constructor
@param
spacing— blank cells between adjacent children;>= 0. - #inner_rect ⇒ Rect
-
#main_extent(rect) ⇒ Integer
@param
rect. -
#main_sizes(inner, kids) ⇒ ::Array[Integer]
@param
inner— #inner_rect. -
#percent_of(extent, constraint) ⇒ Integer
@param
extent. -
#place_children(inner) ⇒ void
@param
inner— #inner_rect, known non-empty. -
#placement(child) ⇒ ::Hash[Symbol, Object]
@param
child. -
#rect=(new_rect) ⇒ void
@param
new_rect. -
#relayout ⇒ void
Recomputes and assigns every child's rect, giving each an empty one when this layout's own rect — 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.
@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.
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.
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.
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 (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
193 194 195 196 |
# File 'lib/tuile/component/layout/box.rb', line 193 def handle_child_visibility_changed(_child) super relayout end |
#inner_rect ⇒ Rect
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.
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.
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) = [] unassigned = available kids.each_with_index do |child, i| case (constraint = placement(child)[:main]) when Expand then << 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 (sizes, kids, , unassigned) unless .empty? sizes end |
#percent_of(extent, constraint) ⇒ Integer
@param extent
@param constraint
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.
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.
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
180 181 182 183 |
# File 'lib/tuile/component/layout/box.rb', line 180 def rect=(new_rect) super relayout end |
#relayout ⇒ void
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
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).
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
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
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
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
@return — cells.
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 |