///|
/// Insets applied inside a layout canvas.
pub(all) struct Insets {
top : Double
right : Double
bottom : Double
left : Double
} derive(Eq, Debug, ToJson)
///|
pub fn Insets::new(
top : Double,
right : Double,
bottom : Double,
left : Double,
) -> Insets {
{
top: top.max(0.0),
right: right.max(0.0),
bottom: bottom.max(0.0),
left: left.max(0.0),
}
}
///|
pub fn Insets::uniform(value : Double) -> Insets {
Insets::new(value, value, value, value)
}
///|
pub fn Insets::horizontal(self : Insets) -> Double {
self.left + self.right
}
///|
pub fn Insets::vertical(self : Insets) -> Double {
self.top + self.bottom
}
///|
pub fn Insets::apply(self : Insets, bounds : Rect) -> Rect {
Rect::new(
bounds.x + self.left,
bounds.y + self.top,
(bounds.width - self.horizontal()).max(0.0),
(bounds.height - self.vertical()).max(0.0),
)
}
///|
pub(all) enum LayoutDirection {
Row
Column
} derive(Eq, Debug, ToJson)
///|
pub(all) enum LayoutAlign {
Start
Center
End
Stretch
} derive(Eq, Debug, ToJson)
///|
pub(all) enum LayoutJustify {
Start
Center
End
SpaceBetween
SpaceAround
SpaceEvenly
} derive(Eq, Debug, ToJson)
///|
/// A placed content panel with a stable index for downstream rendering.
pub(all) struct LayoutBox {
index : Int
row : Int
column : Int
frame : Rect
content : Rect
center : Vec2
} derive(Eq, Debug, ToJson)
///|
/// Grid and flow layout configuration for poster and packaging compositions.
pub(all) struct LayoutSpec {
bounds : Rect
columns : Int
rows : Int
gap_x : Double
gap_y : Double
padding : Insets
align : LayoutAlign
justify : LayoutJustify
} derive(Eq, Debug, ToJson)
///|
pub fn LayoutSpec::new(bounds : Rect, columns : Int, rows : Int) -> LayoutSpec {
{
bounds,
columns: columns.max(0),
rows: rows.max(0),
gap_x: 0.0,
gap_y: 0.0,
padding: Insets::uniform(0.0),
align: Stretch,
justify: Start,
}
}
///|
pub fn LayoutSpec::with_gap(
self : LayoutSpec,
gap_x : Double,
gap_y : Double,
) -> LayoutSpec {
{ ..self, gap_x: gap_x.max(0.0), gap_y: gap_y.max(0.0) }
}
///|
pub fn LayoutSpec::with_padding(
self : LayoutSpec,
padding : Insets,
) -> LayoutSpec {
{ ..self, padding, }
}
///|
pub fn LayoutSpec::aligned(
self : LayoutSpec,
align : LayoutAlign,
justify : LayoutJustify,
) -> LayoutSpec {
{ ..self, align, justify }
}
///|
fn offset_for_justify(
justify : LayoutJustify,
available : Double,
count : Int,
item : Double,
gap : Double,
) -> (Double, Double) {
if count <= 0 {
(0.0, 0.0)
} else {
let used = item * count.to_double() + gap * (count - 1).to_double()
let free = (available - used).max(0.0)
match justify {
Start => (0.0, gap)
Center => (free / 2.0, gap)
End => (free, gap)
SpaceBetween =>
(
0.0,
if count <= 1 {
0.0
} else {
gap + free / (count - 1).to_double()
},
)
SpaceAround => {
let unit = free / count.to_double()
(unit / 2.0, gap + unit)
}
SpaceEvenly => {
let unit = free / (count + 1).to_double()
(unit, gap + unit)
}
}
}
}
///|
fn align_offset(
align : LayoutAlign,
available : Double,
item : Double,
) -> Double {
let free = (available - item).max(0.0)
match align {
Start => 0.0
Center => free / 2.0
End => free
Stretch => 0.0
}
}
///|
fn cross_size(
align : LayoutAlign,
available : Double,
preferred : Double,
) -> Double {
match align {
Stretch => available.max(0.0)
_ => preferred.min(available.max(0.0)).max(0.0)
}
}
///|
/// Generates a regular grid of content boxes.
pub fn layout_grid(spec : LayoutSpec) -> Array[LayoutBox] {
let result = []
let area = spec.padding.apply(spec.bounds)
if spec.columns > 0 && spec.rows > 0 {
let total_gap_x = spec.gap_x * (spec.columns - 1).to_double()
let total_gap_y = spec.gap_y * (spec.rows - 1).to_double()
let cell_width = ((area.width - total_gap_x) / spec.columns.to_double()).max(
0.0,
)
let cell_height = ((area.height - total_gap_y) / spec.rows.to_double()).max(
0.0,
)
for row in 0.. Array[LayoutBox] {
let result = []
if count > 0 {
let main = match direction {
Row => bounds.width
Column => bounds.height
}
let cross = match direction {
Row => bounds.height
Column => bounds.width
}
let preferred_cross = item_size.min(cross).max(0.0)
let (start, step) = offset_for_justify(
justify,
main,
count,
item_size.min(main).max(0.0),
gap.max(0.0),
)
for index in 0..
Rect::new(
bounds.x + main_position,
bounds.y + cross_position,
item_size.min(main),
cross_size(align, cross, preferred_cross),
)
Column =>
Rect::new(
bounds.x + cross_position,
bounds.y + main_position,
cross_size(align, cross, preferred_cross),
item_size.min(main),
)
}
result.push({
index,
row: if direction is Row {
0
} else {
index
},
column: if direction is Row {
index
} else {
0
},
frame,
content: frame,
center: frame.center(),
})
}
}
result
}
///|
/// Splits a frame into proportional horizontal panels.
pub fn split_horizontal(
bounds : Rect,
weights : Array[Double],
gap : Double,
) -> Array[LayoutBox] {
split_weighted(bounds, weights, gap, Row)
}
///|
/// Splits a frame into proportional vertical panels.
pub fn split_vertical(
bounds : Rect,
weights : Array[Double],
gap : Double,
) -> Array[LayoutBox] {
split_weighted(bounds, weights, gap, Column)
}
///|
fn split_weighted(
bounds : Rect,
weights : Array[Double],
gap : Double,
direction : LayoutDirection,
) -> Array[LayoutBox] {
let result = []
let total_weight = weights.fold(init=0.0, fn(total, value) {
total + value.max(0.0)
})
if weights.length() > 0 && total_weight > 0.0 {
let total_gap = gap.max(0.0) * (weights.length() - 1).to_double()
let available = match direction {
Row => (bounds.width - total_gap).max(0.0)
Column => (bounds.height - total_gap).max(0.0)
}
let mut cursor = 0.0
for index, weight in weights {
let size = available * weight.max(0.0) / total_weight
let frame = match direction {
Row => Rect::new(bounds.x + cursor, bounds.y, size, bounds.height)
Column => Rect::new(bounds.x, bounds.y + cursor, bounds.width, size)
}
result.push({
index,
row: if direction is Row {
0
} else {
index
},
column: if direction is Row {
index
} else {
0
},
frame,
content: frame,
center: frame.center(),
})
cursor += size + gap.max(0.0)
}
}
result
}
///|
/// Fits a preferred aspect ratio inside every layout box.
pub fn fit_layout_boxes(
boxes : Array[LayoutBox],
aspect : Double,
) -> Array[LayoutBox] {
let result = []
if aspect <= 0.0 {
boxes.copy()
} else {
for box_ in boxes {
let current = box_.frame.width / box_.frame.height.max(0.000001)
let fitted = if current > aspect {
let width = box_.frame.height * aspect
Rect::new(
box_.frame.center().x - width / 2.0,
box_.frame.y,
width,
box_.frame.height,
)
} else {
let height = box_.frame.width / aspect
Rect::new(
box_.frame.x,
box_.frame.center().y - height / 2.0,
box_.frame.width,
height,
)
}
result.push({ ..box_, content: fitted, center: fitted.center() })
}
result
}
}
///|
/// Places points evenly along a sampled path.
pub fn layout_on_path(
path : Path,
count : Int,
subdivisions : Int,
closed : Bool,
) -> Array[LayoutBox] {
let result = []
let points = path.flatten(subdivisions~)
for index, sample in sample_polyline(points, count, closed~) {
let frame = Rect::new(sample.point.x, sample.point.y, 0.0, 0.0)
result.push({
index,
row: 0,
column: index,
frame,
content: frame,
center: sample.point,
})
}
result
}
///|
/// Generates a centered golden-angle layout for decorative accents.
pub fn golden_angle_layout(
center : Vec2,
count : Int,
radial_step : Double,
phase : Double,
) -> Array[LayoutBox] {
let result = []
if count > 0 {
let golden = @math.PI * (3.0 - 5.0.sqrt())
for index in 0.. Vec2 {
if spacing <= 0.0 {
point
} else {
Vec2::new(
(point.x / spacing).to_int().to_double() * spacing,
(point.y / spacing).to_int().to_double() * spacing,
)
}
}
///|
/// Returns boxes that overlap another box by more than the requested tolerance.
pub fn overlapping_layout_boxes(
boxes : Array[LayoutBox],
tolerance : Double,
) -> Array[(Int, Int)] {
let result = []
let threshold = tolerance.max(0.0)
if boxes.length() > 1 {
for i in 0..<(boxes.length() - 1) {
for j in (i + 1).. threshold && intersection.height > threshold {
result.push((boxes[i].index, boxes[j].index))
}
}
}
}
result
}
///|
/// Measures occupied area and bounds for a panel collection.
pub fn layout_metrics(boxes : Array[LayoutBox]) -> LayoutMetrics {
let rectangles = []
for box_ in boxes {
rectangles.push(box_.content)
}
let bounds = if rectangles.length() == 0 {
Rect::new(0.0, 0.0, 0.0, 0.0)
} else {
let mut result = rectangles[0]
for rectangle in rectangles[1:] {
result = result.union(rectangle)
}
result
}
let mut area = 0.0
for rectangle in rectangles {
area += rectangle.width * rectangle.height
}
{
element_count: boxes.length(),
occupied_area: area,
path_length: 0.0,
density: if bounds.width * bounds.height == 0.0 {
0.0
} else {
area / (bounds.width * bounds.height)
},
bounds,
}
}
///|
/// Creates an inset frame for a title, body and footer stack.
pub fn poster_stack(
bounds : Rect,
title_ratio : Double,
footer_ratio : Double,
gap : Double,
) -> Array[LayoutBox] {
let body_ratio = (1.0 - title_ratio.max(0.0) - footer_ratio.max(0.0)).max(0.0)
split_vertical(
bounds,
[title_ratio.max(0.0), body_ratio, footer_ratio.max(0.0)],
gap,
)
}
///|
/// Returns a safe area after applying a percentage bleed on every side.
pub fn bleed_safe_area(bounds : Rect, bleed : Double) -> Rect {
let amount_x = bounds.width * bleed.max(0.0).min(0.5)
let amount_y = bounds.height * bleed.max(0.0).min(0.5)
Rect::new(
bounds.x + amount_x,
bounds.y + amount_y,
(bounds.width - 2.0 * amount_x).max(0.0),
(bounds.height - 2.0 * amount_y).max(0.0),
)
}