///|
/// A breakpoint maps a canvas width to a named production layout profile.
pub(all) struct BreakpointSpec {
name : String
min_width : Double
max_width : Double?
columns : Int
gutter : Double
padding : Insets
} derive(Eq, Debug, ToJson)
///|
pub(all) struct ResponsiveCanvas {
name : String
bounds : Rect
breakpoints : Array[BreakpointSpec]
active : BreakpointSpec?
boxes : Array[LayoutBox]
} derive(Debug)
///|
pub(all) enum AnchorEdge {
AnchorLeft
AnchorTop
AnchorRight
AnchorBottom
AnchorCenterX
AnchorCenterY
AnchorWidth
AnchorHeight
} derive(Eq, Debug, ToJson)
///|
pub(all) struct LayoutAnchor {
source : Int
source_edge : AnchorEdge
target : Int?
target_edge : AnchorEdge
constant : Double
priority : Int
} derive(Eq, Debug, ToJson)
///|
pub(all) struct ResponsiveItem {
id : Int
preferred_width : Double
preferred_height : Double
min_width : Double
min_height : Double
max_width : Double
max_height : Double
aspect : Double?
grow : Double
shrink : Double
frame : Rect
} derive(Eq, Debug, ToJson)
///|
pub(all) struct ResponsiveLayoutReport {
item_count : Int
occupied_area : Double
overflow_area : Double
gap_error : Double
aspect_error : Double
overlap_count : Int
score : Double
} derive(Eq, Debug, ToJson)
///|
pub fn BreakpointSpec::new(
name : String,
min_width : Double,
columns : Int,
) -> BreakpointSpec {
{
name,
min_width: min_width.max(0.0),
max_width: None,
columns: columns.max(1),
gutter: 0.0,
padding: Insets::uniform(0.0),
}
}
///|
pub fn BreakpointSpec::until(
self : BreakpointSpec,
max_width : Double,
) -> BreakpointSpec {
{ ..self, max_width: Some(max_width.max(self.min_width)) }
}
///|
pub fn BreakpointSpec::with_gutter(
self : BreakpointSpec,
gutter : Double,
) -> BreakpointSpec {
{ ..self, gutter: gutter.max(0.0) }
}
///|
pub fn BreakpointSpec::with_padding(
self : BreakpointSpec,
padding : Insets,
) -> BreakpointSpec {
{ ..self, padding, }
}
///|
pub fn breakpoint_matches(spec : BreakpointSpec, width : Double) -> Bool {
let range_ok = match spec.max_width {
Some(maximum) => width <= maximum
None => true
}
width >= spec.min_width && range_ok
}
///|
pub fn choose_breakpoint(
specs : Array[BreakpointSpec],
width : Double,
) -> BreakpointSpec? {
let mut selected : BreakpointSpec? = None
for spec in specs {
if breakpoint_matches(spec, width) {
match selected {
None => selected = Some(spec)
Some(previous) =>
if spec.min_width >= previous.min_width {
selected = Some(spec)
}
}
}
}
selected
}
///|
pub fn normalize_breakpoints(
specs : Array[BreakpointSpec],
) -> Array[BreakpointSpec] {
let result = specs.copy()
for index in 1.. 0 && result[position - 1].min_width > value.min_width {
result[position] = result[position - 1]
position -= 1
}
result[position] = value
}
result
}
///|
pub fn breakpoint_gaps(specs : Array[BreakpointSpec]) -> Array[String] {
let errors = []
let ordered = normalize_breakpoints(specs)
if ordered.length() > 1 {
for index in 0..<(ordered.length() - 1) {
match ordered[index].max_width {
Some(maximum) =>
if maximum < ordered[index + 1].min_width {
errors.push(
"gap between " +
ordered[index].name +
" and " +
ordered[index + 1].name,
)
}
None =>
if index + 1 < ordered.length() {
errors.push(
"unbounded breakpoint before " + ordered[index + 1].name,
)
}
}
}
}
errors
}
///|
pub fn ResponsiveCanvas::new(name : String, bounds : Rect) -> ResponsiveCanvas {
{ name, bounds, breakpoints: [], active: None, boxes: [] }
}
///|
pub fn ResponsiveCanvas::add_breakpoint(
self : ResponsiveCanvas,
spec : BreakpointSpec,
) -> Unit {
self.breakpoints.push(spec)
}
///|
pub fn ResponsiveCanvas::select(
self : ResponsiveCanvas,
width : Double,
) -> ResponsiveCanvas {
{ ..self, active: choose_breakpoint(self.breakpoints, width) }
}
///|
pub fn ResponsiveCanvas::current_columns(self : ResponsiveCanvas) -> Int {
match self.active {
Some(spec) => spec.columns
None => 1
}
}
///|
pub fn ResponsiveCanvas::current_area(self : ResponsiveCanvas) -> Rect {
match self.active {
Some(spec) => spec.padding.apply(self.bounds)
None => self.bounds
}
}
///|
pub fn ResponsiveCanvas::with_boxes(
self : ResponsiveCanvas,
boxes : Array[LayoutBox],
) -> ResponsiveCanvas {
{ ..self, boxes, }
}
///|
pub fn LayoutAnchor::new(
source : Int,
source_edge : AnchorEdge,
target : Int?,
target_edge : AnchorEdge,
constant : Double,
priority : Int,
) -> LayoutAnchor {
{
source,
source_edge,
target,
target_edge,
constant,
priority: priority.max(0),
}
}
///|
pub fn anchor_value(edge : AnchorEdge, frame : Rect) -> Double {
match edge {
AnchorLeft => frame.x
AnchorTop => frame.y
AnchorRight => frame.right()
AnchorBottom => frame.bottom()
AnchorCenterX => frame.center().x
AnchorCenterY => frame.center().y
AnchorWidth => frame.width
AnchorHeight => frame.height
}
}
///|
pub fn set_anchor_value(
edge : AnchorEdge,
frame : Rect,
value : Double,
) -> Rect {
match edge {
AnchorLeft => Rect::new(value, frame.y, frame.width, frame.height)
AnchorTop => Rect::new(frame.x, value, frame.width, frame.height)
AnchorRight =>
Rect::new(frame.x, frame.y, (value - frame.x).max(0.0), frame.height)
AnchorBottom =>
Rect::new(frame.x, frame.y, frame.width, (value - frame.y).max(0.0))
AnchorCenterX =>
Rect::new(value - frame.width / 2.0, frame.y, frame.width, frame.height)
AnchorCenterY =>
Rect::new(frame.x, value - frame.height / 2.0, frame.width, frame.height)
AnchorWidth => Rect::new(frame.x, frame.y, value.max(0.0), frame.height)
AnchorHeight => Rect::new(frame.x, frame.y, frame.width, value.max(0.0))
}
}
///|
pub fn apply_layout_anchor(frames : Array[Rect], anchor : LayoutAnchor) -> Unit {
if anchor.source >= 0 && anchor.source < frames.length() {
let base = match anchor.target {
Some(target) =>
if target >= 0 && target < frames.length() {
anchor_value(anchor.target_edge, frames[target])
} else {
0.0
}
None => 0.0
}
frames[anchor.source] = set_anchor_value(
anchor.source_edge,
frames[anchor.source],
base + anchor.constant,
)
}
}
///|
pub fn apply_layout_anchors(
frames : Array[Rect],
anchors : Array[LayoutAnchor],
passes : Int,
) -> Array[Rect] {
let result = frames.copy()
let ordered = anchors.copy()
for index in 1.. 0 && ordered[position - 1].priority < value.priority {
ordered[position] = ordered[position - 1]
position -= 1
}
ordered[position] = value
}
for _ in 0.. ResponsiveItem {
{
id,
preferred_width: width.max(0.0),
preferred_height: height.max(0.0),
min_width: 0.0,
min_height: 0.0,
max_width: 1000000.0,
max_height: 1000000.0,
aspect: None,
grow: 0.0,
shrink: 1.0,
frame: Rect::new(0.0, 0.0, width, height),
}
}
///|
pub fn ResponsiveItem::min_size(
self : ResponsiveItem,
width : Double,
height : Double,
) -> ResponsiveItem {
{ ..self, min_width: width.max(0.0), min_height: height.max(0.0) }
}
///|
pub fn ResponsiveItem::max_size(
self : ResponsiveItem,
width : Double,
height : Double,
) -> ResponsiveItem {
{
..self,
max_width: width.max(self.min_width),
max_height: height.max(self.min_height),
}
}
///|
pub fn ResponsiveItem::ratio(
self : ResponsiveItem,
aspect : Double,
) -> ResponsiveItem {
{ ..self, aspect: if aspect > 0.0 { Some(aspect) } else { None } }
}
///|
pub fn ResponsiveItem::flex(
self : ResponsiveItem,
grow : Double,
shrink : Double,
) -> ResponsiveItem {
{ ..self, grow: grow.max(0.0), shrink: shrink.max(0.0) }
}
///|
pub fn responsive_item_size(
item : ResponsiveItem,
available : Double,
) -> Double {
item.preferred_width
.min(available.max(0.0))
.max(item.min_width)
.min(item.max_width)
}
///|
pub fn responsive_row(
bounds : Rect,
items : Array[ResponsiveItem],
gap : Double,
align : LayoutAlign,
) -> Array[ResponsiveItem] {
let result = items.copy()
let spacing = gap.max(0.0)
let mut preferred = 0.0
let mut flex_total = 0.0
for item in result {
preferred += item.preferred_width
flex_total += item.grow
}
let available = (bounds.width -
spacing * (result.length() - 1).max(0).to_double()).max(0.0)
let extra = (available - preferred).max(0.0)
let mut cursor = bounds.x
for index, item in result {
let flex_share = if flex_total > 0.0 {
extra * item.grow / flex_total
} else {
0.0
}
let width = (item.preferred_width + flex_share)
.max(item.min_width)
.min(item.max_width)
.min(available)
let height = item.preferred_height
.min(bounds.height)
.max(item.min_height)
.min(item.max_height)
let cross_offset = match align {
Start | Stretch => 0.0
Center => (bounds.height - height).max(0.0) / 2.0
End => (bounds.height - height).max(0.0)
}
let y = bounds.y + cross_offset
let actual_height = if align is Stretch { bounds.height } else { height }
result[index] = {
..item,
frame: Rect::new(cursor, y, width, actual_height),
}
cursor += width + spacing
}
result
}
///|
pub fn responsive_column(
bounds : Rect,
items : Array[ResponsiveItem],
gap : Double,
align : LayoutAlign,
) -> Array[ResponsiveItem] {
let result = items.copy()
let spacing = gap.max(0.0)
let mut preferred = 0.0
let mut flex_total = 0.0
for item in result {
preferred += item.preferred_height
flex_total += item.grow
}
let available = (bounds.height -
spacing * (result.length() - 1).max(0).to_double()).max(0.0)
let extra = (available - preferred).max(0.0)
let mut cursor = bounds.y
for index, item in result {
let flex_share = if flex_total > 0.0 {
extra * item.grow / flex_total
} else {
0.0
}
let height = (item.preferred_height + flex_share)
.max(item.min_height)
.min(item.max_height)
.min(available)
let width = item.preferred_width
.min(bounds.width)
.max(item.min_width)
.min(item.max_width)
let cross_offset = match align {
Start | Stretch => 0.0
Center => (bounds.width - width).max(0.0) / 2.0
End => (bounds.width - width).max(0.0)
}
let x = bounds.x + cross_offset
let actual_width = if align is Stretch { bounds.width } else { width }
result[index] = {
..item,
frame: Rect::new(x, cursor, actual_width, height),
}
cursor += height + spacing
}
result
}
///|
pub fn responsive_grid(
bounds : Rect,
items : Array[ResponsiveItem],
columns : Int,
gap_x : Double,
gap_y : Double,
) -> Array[ResponsiveItem] {
let result = items.copy()
let column_count = columns.max(1)
let width = (bounds.width - gap_x.max(0.0) * (column_count - 1).to_double()).max(
0.0,
) /
column_count.to_double()
let row_count = if result.length() == 0 {
0
} else {
(result.length() + column_count - 1) / column_count
}
let row_height = if row_count == 0 {
0.0
} else {
(bounds.height - gap_y.max(0.0) * (row_count - 1).to_double()).max(0.0) /
row_count.to_double()
}
for index, item in result {
let row = index / column_count
let column = index % column_count
let frame_width = responsive_item_size(item, width)
let frame_height = match item.aspect {
Some(aspect) => (frame_width / aspect).min(row_height)
None => item.preferred_height.min(row_height)
}
let frame = Rect::new(
bounds.x +
column.to_double() * (width + gap_x.max(0.0)) +
(width - frame_width) / 2.0,
bounds.y +
row.to_double() * (row_height + gap_y.max(0.0)) +
(row_height - frame_height).max(0.0) / 2.0,
frame_width,
frame_height,
)
result[index] = { ..item, frame, }
}
result
}
///|
pub fn responsive_masonry(
bounds : Rect,
items : Array[ResponsiveItem],
columns : Int,
gap : Double,
) -> Array[ResponsiveItem] {
let result = items.copy()
let count = columns.max(1)
let column_width = (bounds.width - gap.max(0.0) * (count - 1).to_double()).max(
0.0,
) /
count.to_double()
let heights = []
for _ in 0.. width / aspect
None => item.preferred_height
}
let x = bounds.x +
selected.to_double() * (column_width + gap.max(0.0)) +
(column_width - width).max(0.0) / 2.0
let frame = Rect::new(
x,
heights[selected],
width,
height.max(item.min_height).min(item.max_height),
)
result[index] = { ..item, frame, }
heights[selected] = frame.bottom() + gap.max(0.0)
}
result
}
///|
pub fn responsive_pack(
bounds : Rect,
items : Array[ResponsiveItem],
gap : Double,
) -> Array[ResponsiveItem] {
let result = items.copy()
let mut x = bounds.x
let mut y = bounds.y
let mut line_height = 0.0
for index, item in result {
let width = responsive_item_size(item, bounds.width)
let height = item.preferred_height.min(bounds.height)
if x > bounds.x && x + width > bounds.right() {
x = bounds.x
y += line_height + gap.max(0.0)
line_height = 0.0
}
if y + height > bounds.bottom() {
result[index] = {
..item,
frame: Rect::new(x, bounds.bottom(), width, 0.0),
}
} else {
result[index] = { ..item, frame: Rect::new(x, y, width, height) }
}
x += width + gap.max(0.0)
line_height = line_height.max(height)
}
result
}
///|
pub fn responsive_apply_aspect(
items : Array[ResponsiveItem],
) -> Array[ResponsiveItem] {
let result = items.copy()
for index, item in result {
match item.aspect {
Some(aspect) => {
let height = (item.frame.width / aspect)
.min(item.max_height)
.max(item.min_height)
result[index] = {
..item,
frame: Rect::new(item.frame.x, item.frame.y, item.frame.width, height),
}
}
None => ()
}
}
result
}
///|
pub fn responsive_resolve_anchors(
items : Array[ResponsiveItem],
anchors : Array[LayoutAnchor],
passes : Int,
) -> Array[ResponsiveItem] {
let frames = []
for item in items {
frames.push(item.frame)
}
let resolved = apply_layout_anchors(frames, anchors, passes)
let result = items.copy()
for index, item in result {
result[index] = { ..item, frame: resolved[index] }
}
result
}
///|
pub fn responsive_overflow(
items : Array[ResponsiveItem],
bounds : Rect,
) -> Double {
let mut total = 0.0
for item in items {
let clipped = item.frame.intersect(bounds)
total += item.frame.width * item.frame.height -
clipped.width * clipped.height
}
total.max(0.0)
}
///|
pub fn responsive_overlap_count(
items : Array[ResponsiveItem],
tolerance : Double,
) -> Int {
let mut count = 0
if items.length() > 1 {
for i in 0..<(items.length() - 1) {
for j in (i + 1).. tolerance.max(0.0) &&
intersection.height > tolerance.max(0.0) {
count += 1
}
}
}
}
count
}
///|
pub fn responsive_report(
items : Array[ResponsiveItem],
bounds : Rect,
expected_gap : Double,
) -> ResponsiveLayoutReport {
let mut occupied = 0.0
let mut aspect_error = 0.0
for item in items {
occupied += item.frame.width * item.frame.height
match item.aspect {
Some(aspect) =>
aspect_error += (item.frame.width / item.frame.height.max(0.0001) -
aspect).abs()
None => ()
}
}
let mut gap_error = 0.0
if items.length() > 1 {
for i in 0..<(items.length() - 1) {
for j in (i + 1).. String {
"item_count,occupied_area,overflow_area,gap_error,aspect_error,overlap_count,score\n" +
report.item_count.to_string() +
"," +
report.occupied_area.to_string() +
"," +
report.overflow_area.to_string() +
"," +
report.gap_error.to_string() +
"," +
report.aspect_error.to_string() +
"," +
report.overlap_count.to_string() +
"," +
report.score.to_string() +
"\n"
}
///|
pub fn responsive_layout_variants(
bounds : Rect,
items : Array[ResponsiveItem],
specs : Array[BreakpointSpec],
) -> Array[(String, Array[ResponsiveItem], ResponsiveLayoutReport)] {
let result = []
for spec in specs {
let area = spec.padding.apply(bounds)
let laid_out = responsive_grid(
area,
items,
spec.columns,
spec.gutter,
spec.gutter,
)
result.push(
(spec.name, laid_out, responsive_report(laid_out, area, spec.gutter)),
)
}
result
}
///|
pub fn responsive_layout_signature(items : Array[ResponsiveItem]) -> String {
let builder = StringBuilder()
for item in items {
builder.write_string(
item.id.to_string() +
":" +
item.frame.x.to_string() +
"," +
item.frame.y.to_string() +
"," +
item.frame.width.to_string() +
"," +
item.frame.height.to_string() +
";",
)
}
builder.to_string()
}
///|
pub fn responsive_safe_area(
bounds : Rect,
margin : Double,
aspect : Double?,
) -> Rect {
let inset = bounds.expand(-margin.max(0.0))
match aspect {
None => inset
Some(ratio) => responsive_fit_rect(inset, ratio)
}
}
///|
fn responsive_fit_rect(bounds : Rect, aspect : Double) -> Rect {
if aspect <= 0.0 {
bounds
} else {
let current = bounds.width / bounds.height.max(0.0001)
if current > aspect {
let width = bounds.height * aspect
Rect::new(bounds.center().x - width / 2.0, bounds.y, width, bounds.height)
} else {
let height = bounds.width / aspect
Rect::new(
bounds.x,
bounds.center().y - height / 2.0,
bounds.width,
height,
)
}
}
}
///|
pub fn responsive_center_item(
item : ResponsiveItem,
bounds : Rect,
) -> ResponsiveItem {
let frame = Rect::new(
bounds.center().x - item.frame.width / 2.0,
bounds.center().y - item.frame.height / 2.0,
item.frame.width,
item.frame.height,
)
{ ..item, frame, }
}
///|
pub fn responsive_translate(
items : Array[ResponsiveItem],
offset : Vec2,
) -> Array[ResponsiveItem] {
let result = items.copy()
for index, item in result {
result[index] = {
..item,
frame: Rect::new(
item.frame.x + offset.x,
item.frame.y + offset.y,
item.frame.width,
item.frame.height,
),
}
}
result
}
///|
pub fn responsive_scale(
items : Array[ResponsiveItem],
center : Vec2,
scale : Double,
) -> Array[ResponsiveItem] {
let result = items.copy()
let factor = scale.max(0.0)
for index, item in result {
let position = Vec2::new(item.frame.x, item.frame.y)
.sub(center)
.scale(factor)
.add(center)
result[index] = {
..item,
frame: Rect::new(
position.x,
position.y,
item.frame.width * factor,
item.frame.height * factor,
),
}
}
result
}