feat: port el-ui vessels — rename crates→vessels, add El source + manifests
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "el-layout"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui responsive layout engine — responsive by default, zero breakpoints"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_layout"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
el-style = { path = "../el-style" }
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,21 @@
|
||||
// el-layout — Responsive layout engine for el-ui.
|
||||
//
|
||||
// Responsive by default. VStack and HStack wrap automatically.
|
||||
// Grid uses auto columns. You don't write breakpoints for basic layouts.
|
||||
|
||||
vessel "el-layout" {
|
||||
version "0.1.0"
|
||||
description "Stacks, grids, breakpoints, responsive values, safe-area insets"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
el-style "0.1"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/// Breakpoints — named viewport width thresholds.
|
||||
///
|
||||
/// These are provided for the rare cases where you need explicit breakpoint
|
||||
/// logic. For most cases, use the automatic layout in VStack/HStack/Grid.
|
||||
|
||||
/// Named breakpoint sizes (in dp/logical pixels).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Breakpoint {
|
||||
/// Default — any width (the base / mobile-first value).
|
||||
Base,
|
||||
/// Small — 640dp+ (large phones, landscape phones).
|
||||
Sm,
|
||||
/// Medium — 768dp+ (tablets, small laptops).
|
||||
Md,
|
||||
/// Large — 1024dp+ (laptops, most desktops).
|
||||
Lg,
|
||||
/// Extra large — 1280dp+ (large desktops, wide monitors).
|
||||
Xl,
|
||||
}
|
||||
|
||||
impl Breakpoint {
|
||||
/// The minimum width (dp) at which this breakpoint activates.
|
||||
pub fn min_width(&self) -> f32 {
|
||||
match self {
|
||||
Breakpoint::Base => 0.0,
|
||||
Breakpoint::Sm => 640.0,
|
||||
Breakpoint::Md => 768.0,
|
||||
Breakpoint::Lg => 1024.0,
|
||||
Breakpoint::Xl => 1280.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a container width into its active breakpoint.
|
||||
pub fn for_width(width: f32) -> Self {
|
||||
if width >= 1280.0 {
|
||||
Breakpoint::Xl
|
||||
} else if width >= 1024.0 {
|
||||
Breakpoint::Lg
|
||||
} else if width >= 768.0 {
|
||||
Breakpoint::Md
|
||||
} else if width >= 640.0 {
|
||||
Breakpoint::Sm
|
||||
} else {
|
||||
Breakpoint::Base
|
||||
}
|
||||
}
|
||||
|
||||
/// True if this breakpoint is at least as wide as `other`.
|
||||
pub fn at_least(&self, other: Breakpoint) -> bool {
|
||||
self >= &other
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn breakpoint_for_small_width() {
|
||||
assert_eq!(Breakpoint::for_width(320.0), Breakpoint::Base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakpoint_for_tablet_width() {
|
||||
assert_eq!(Breakpoint::for_width(800.0), Breakpoint::Md);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakpoint_for_desktop_width() {
|
||||
assert_eq!(Breakpoint::for_width(1440.0), Breakpoint::Xl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakpoint_ordering() {
|
||||
assert!(Breakpoint::Xl > Breakpoint::Base);
|
||||
assert!(Breakpoint::Lg > Breakpoint::Sm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_least() {
|
||||
assert!(Breakpoint::Lg.at_least(Breakpoint::Md));
|
||||
assert!(!Breakpoint::Sm.at_least(Breakpoint::Md));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_widths_ascending() {
|
||||
let bps = [
|
||||
Breakpoint::Base,
|
||||
Breakpoint::Sm,
|
||||
Breakpoint::Md,
|
||||
Breakpoint::Lg,
|
||||
Breakpoint::Xl,
|
||||
];
|
||||
for i in 1..bps.len() {
|
||||
assert!(bps[i].min_width() > bps[i - 1].min_width());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/// Layout constraints — what the parent is offering the child.
|
||||
///
|
||||
/// A parent passes a LayoutConstraints to each child during layout.
|
||||
/// The child must produce a size within these constraints.
|
||||
/// Constraints flow down; sizes flow back up.
|
||||
|
||||
/// Constraints passed from a parent to a child during layout.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct LayoutConstraints {
|
||||
/// Minimum width the child must be (dp).
|
||||
pub min_width: f32,
|
||||
/// Maximum width available to the child (dp). f32::INFINITY = unbounded.
|
||||
pub max_width: f32,
|
||||
/// Minimum height the child must be (dp).
|
||||
pub min_height: f32,
|
||||
/// Maximum height available to the child (dp). f32::INFINITY = unbounded.
|
||||
pub max_height: f32,
|
||||
}
|
||||
|
||||
impl LayoutConstraints {
|
||||
/// Unconstrained — the child can be any size.
|
||||
pub fn unbounded() -> Self {
|
||||
Self {
|
||||
min_width: 0.0,
|
||||
max_width: f32::INFINITY,
|
||||
min_height: 0.0,
|
||||
max_height: f32::INFINITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constrained to a specific width, unconstrained height.
|
||||
pub fn with_max_width(max_width: f32) -> Self {
|
||||
Self {
|
||||
min_width: 0.0,
|
||||
max_width,
|
||||
min_height: 0.0,
|
||||
max_height: f32::INFINITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constrained to an exact width and height.
|
||||
pub fn tight(width: f32, height: f32) -> Self {
|
||||
Self {
|
||||
min_width: width,
|
||||
max_width: width,
|
||||
min_height: height,
|
||||
max_height: height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return constraints loosened to allow any size up to the maximums.
|
||||
pub fn loosen(&self) -> Self {
|
||||
Self {
|
||||
min_width: 0.0,
|
||||
max_width: self.max_width,
|
||||
min_height: 0.0,
|
||||
max_height: self.max_height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deflate the constraints by padding amounts.
|
||||
/// Useful when a parent applies its own padding before offering space to a child.
|
||||
pub fn deflate(&self, horizontal: f32, vertical: f32) -> Self {
|
||||
Self {
|
||||
min_width: (self.min_width - horizontal).max(0.0),
|
||||
max_width: (self.max_width - horizontal).max(0.0),
|
||||
min_height: (self.min_height - vertical).max(0.0),
|
||||
max_height: (self.max_height - vertical).max(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the width dimension bounded?
|
||||
pub fn has_bounded_width(&self) -> bool {
|
||||
self.max_width.is_finite()
|
||||
}
|
||||
|
||||
/// Is the height dimension bounded?
|
||||
pub fn has_bounded_height(&self) -> bool {
|
||||
self.max_height.is_finite()
|
||||
}
|
||||
|
||||
/// Clamp a proposed size to fit within these constraints.
|
||||
pub fn clamp_size(&self, width: f32, height: f32) -> (f32, f32) {
|
||||
(
|
||||
width.clamp(self.min_width, self.max_width),
|
||||
height.clamp(self.min_height, self.max_height),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The size a child reports back to its parent after layout.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Size {
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
impl Size {
|
||||
pub fn new(width: f32, height: f32) -> Self {
|
||||
Self { width, height }
|
||||
}
|
||||
|
||||
pub fn zero() -> Self {
|
||||
Self { width: 0.0, height: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unbounded_has_no_max() {
|
||||
let c = LayoutConstraints::unbounded();
|
||||
assert!(c.max_width.is_infinite());
|
||||
assert!(c.max_height.is_infinite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tight_constraints() {
|
||||
let c = LayoutConstraints::tight(100.0, 50.0);
|
||||
assert_eq!(c.min_width, 100.0);
|
||||
assert_eq!(c.max_width, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deflate_reduces_available_space() {
|
||||
let c = LayoutConstraints::tight(200.0, 100.0);
|
||||
let deflated = c.deflate(16.0, 8.0);
|
||||
assert_eq!(deflated.max_width, 184.0);
|
||||
assert_eq!(deflated.max_height, 92.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deflate_does_not_go_negative() {
|
||||
let c = LayoutConstraints::tight(10.0, 10.0);
|
||||
let deflated = c.deflate(20.0, 20.0);
|
||||
assert_eq!(deflated.max_width, 0.0);
|
||||
assert_eq!(deflated.max_height, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_size() {
|
||||
let c = LayoutConstraints {
|
||||
min_width: 50.0,
|
||||
max_width: 200.0,
|
||||
min_height: 30.0,
|
||||
max_height: 100.0,
|
||||
};
|
||||
let (w, h) = c.clamp_size(250.0, 20.0);
|
||||
assert_eq!(w, 200.0);
|
||||
assert_eq!(h, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_width_detection() {
|
||||
let bounded = LayoutConstraints::with_max_width(400.0);
|
||||
let unbounded = LayoutConstraints::unbounded();
|
||||
assert!(bounded.has_bounded_width());
|
||||
assert!(!unbounded.has_bounded_width());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/// FlexLayout — the underlying flex engine for VStack, HStack.
|
||||
///
|
||||
/// This is the power behind the stacks. Most developers use VStack/HStack
|
||||
/// directly; FlexLayout is available when you need full control.
|
||||
|
||||
/// Direction of the flex axis.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FlexDirection {
|
||||
/// Children arranged top-to-bottom (VStack).
|
||||
Column,
|
||||
/// Children arranged left-to-right (HStack) — respects RTL automatically.
|
||||
Row,
|
||||
/// Like Column, but children wrap to new columns when they overflow.
|
||||
ColumnWrap,
|
||||
/// Like Row, but children wrap to new rows when they overflow.
|
||||
RowWrap,
|
||||
}
|
||||
|
||||
impl FlexDirection {
|
||||
pub fn is_horizontal(&self) -> bool {
|
||||
matches!(self, FlexDirection::Row | FlexDirection::RowWrap)
|
||||
}
|
||||
|
||||
pub fn is_vertical(&self) -> bool {
|
||||
matches!(self, FlexDirection::Column | FlexDirection::ColumnWrap)
|
||||
}
|
||||
|
||||
pub fn wraps(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
FlexDirection::ColumnWrap | FlexDirection::RowWrap
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Alignment along the cross axis.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CrossAxisAlignment {
|
||||
/// Stretch children to fill the cross axis.
|
||||
Stretch,
|
||||
/// Align children to the start of the cross axis.
|
||||
Start,
|
||||
/// Center children on the cross axis.
|
||||
Center,
|
||||
/// Align children to the end of the cross axis.
|
||||
End,
|
||||
/// Align text baselines (horizontal stacks only).
|
||||
Baseline,
|
||||
}
|
||||
|
||||
/// Alignment along the main axis.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MainAxisAlignment {
|
||||
/// Pack children toward the start.
|
||||
Start,
|
||||
/// Center children.
|
||||
Center,
|
||||
/// Pack children toward the end.
|
||||
End,
|
||||
/// Distribute space between children.
|
||||
SpaceBetween,
|
||||
/// Distribute space around children.
|
||||
SpaceAround,
|
||||
/// Distribute space evenly around children.
|
||||
SpaceEvenly,
|
||||
}
|
||||
|
||||
/// How much space the main axis should occupy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MainAxisSize {
|
||||
/// Shrink to minimum size needed.
|
||||
Min,
|
||||
/// Expand to fill all available space.
|
||||
Max,
|
||||
}
|
||||
|
||||
/// Logical horizontal alignment (RTL-aware).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HAlign {
|
||||
/// Toward the reading-start (left in LTR, right in RTL).
|
||||
Leading,
|
||||
Center,
|
||||
/// Toward the reading-end (right in LTR, left in RTL).
|
||||
Trailing,
|
||||
}
|
||||
|
||||
/// Vertical alignment.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VAlign {
|
||||
Top,
|
||||
Center,
|
||||
Bottom,
|
||||
Baseline,
|
||||
}
|
||||
|
||||
/// Full flex layout specification.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlexLayout {
|
||||
pub direction: FlexDirection,
|
||||
pub main_axis_alignment: MainAxisAlignment,
|
||||
pub cross_axis_alignment: CrossAxisAlignment,
|
||||
pub main_axis_size: MainAxisSize,
|
||||
/// Gap between children in dp.
|
||||
pub gap: u32,
|
||||
}
|
||||
|
||||
impl FlexLayout {
|
||||
/// VStack defaults (column, wrapping, leading-aligned).
|
||||
pub fn vstack(spacing: u32, wrap: bool) -> Self {
|
||||
Self {
|
||||
direction: if wrap {
|
||||
FlexDirection::ColumnWrap
|
||||
} else {
|
||||
FlexDirection::Column
|
||||
},
|
||||
main_axis_alignment: MainAxisAlignment::Start,
|
||||
cross_axis_alignment: CrossAxisAlignment::Stretch,
|
||||
main_axis_size: MainAxisSize::Max,
|
||||
gap: spacing,
|
||||
}
|
||||
}
|
||||
|
||||
/// HStack defaults (row, wrapping, center-aligned vertically).
|
||||
pub fn hstack(spacing: u32, wrap: bool) -> Self {
|
||||
Self {
|
||||
direction: if wrap {
|
||||
FlexDirection::RowWrap
|
||||
} else {
|
||||
FlexDirection::Row
|
||||
},
|
||||
main_axis_alignment: MainAxisAlignment::Start,
|
||||
cross_axis_alignment: CrossAxisAlignment::Center,
|
||||
main_axis_size: MainAxisSize::Max,
|
||||
gap: spacing,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn flex_direction_horizontal() {
|
||||
assert!(FlexDirection::Row.is_horizontal());
|
||||
assert!(FlexDirection::RowWrap.is_horizontal());
|
||||
assert!(!FlexDirection::Column.is_horizontal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flex_direction_vertical() {
|
||||
assert!(FlexDirection::Column.is_vertical());
|
||||
assert!(!FlexDirection::Row.is_vertical());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flex_direction_wraps() {
|
||||
assert!(FlexDirection::RowWrap.wraps());
|
||||
assert!(!FlexDirection::Row.wraps());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vstack_layout_defaults() {
|
||||
let layout = FlexLayout::vstack(8, true);
|
||||
assert!(layout.direction.is_vertical());
|
||||
assert!(layout.direction.wraps());
|
||||
assert_eq!(layout.gap, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hstack_layout_defaults() {
|
||||
let layout = FlexLayout::hstack(16, false);
|
||||
assert!(layout.direction.is_horizontal());
|
||||
assert!(!layout.direction.wraps());
|
||||
assert_eq!(layout.gap, 16);
|
||||
assert_eq!(layout.cross_axis_alignment, CrossAxisAlignment::Center);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/// GridLayout — a responsive column grid.
|
||||
///
|
||||
/// The auto column mode (GridColumns::Auto) automatically computes how many
|
||||
/// columns fit given a minimum column width. No breakpoints needed.
|
||||
/// Fixed column count (GridColumns::Fixed) puts exactly N columns in a row.
|
||||
|
||||
use el_style::modifier::{StyleModifier, StyleSet};
|
||||
|
||||
/// How to determine the number of grid columns.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum GridColumns {
|
||||
/// A fixed number of equally-wide columns.
|
||||
Fixed(u32),
|
||||
/// As many columns as fit with each column at least `min_width` dp wide.
|
||||
/// This is how you get responsive grids without breakpoints.
|
||||
Auto { min_width: f32 },
|
||||
}
|
||||
|
||||
impl GridColumns {
|
||||
/// Compute the actual column count given a container width.
|
||||
pub fn count_for_width(&self, container_width: f32) -> u32 {
|
||||
match self {
|
||||
GridColumns::Fixed(n) => *n,
|
||||
GridColumns::Auto { min_width } => {
|
||||
if container_width <= 0.0 || *min_width <= 0.0 {
|
||||
return 1;
|
||||
}
|
||||
let cols = (container_width / min_width).floor() as u32;
|
||||
cols.max(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the width of each column given container width and gap.
|
||||
pub fn column_width(&self, container_width: f32, gap: f32) -> f32 {
|
||||
let cols = self.count_for_width(container_width) as f32;
|
||||
let total_gap = gap * (cols - 1.0).max(0.0);
|
||||
((container_width - total_gap) / cols).max(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Row height specification.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum GridRows {
|
||||
/// All rows are the same height (dp).
|
||||
Fixed(f32),
|
||||
/// Rows take the height of their tallest item.
|
||||
Auto,
|
||||
}
|
||||
|
||||
/// A responsive grid layout.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GridLayout {
|
||||
pub columns: GridColumns,
|
||||
pub rows: GridRows,
|
||||
/// Horizontal gap between columns (dp).
|
||||
pub column_gap: u32,
|
||||
/// Vertical gap between rows (dp).
|
||||
pub row_gap: u32,
|
||||
pub style: StyleSet,
|
||||
}
|
||||
|
||||
impl Default for GridLayout {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
columns: GridColumns::Auto { min_width: 200.0 },
|
||||
rows: GridRows::Auto,
|
||||
column_gap: 16,
|
||||
row_gap: 16,
|
||||
style: StyleSet::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GridLayout {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Set a fixed column count.
|
||||
pub fn columns_fixed(mut self, n: u32) -> Self {
|
||||
self.columns = GridColumns::Fixed(n);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set auto columns with a minimum column width.
|
||||
pub fn columns_auto(mut self, min_width: f32) -> Self {
|
||||
self.columns = GridColumns::Auto { min_width };
|
||||
self
|
||||
}
|
||||
|
||||
/// Set gap (same for both axes).
|
||||
pub fn gap(mut self, dp: u32) -> Self {
|
||||
self.column_gap = dp;
|
||||
self.row_gap = dp;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set column and row gaps separately.
|
||||
pub fn gap_xy(mut self, column_gap: u32, row_gap: u32) -> Self {
|
||||
self.column_gap = column_gap;
|
||||
self.row_gap = row_gap;
|
||||
self
|
||||
}
|
||||
|
||||
/// How many columns are active at a given container width?
|
||||
pub fn active_columns(&self, container_width: f32) -> u32 {
|
||||
self.columns.count_for_width(container_width)
|
||||
}
|
||||
}
|
||||
|
||||
impl StyleModifier for GridLayout {
|
||||
fn style_mut(&mut self) -> &mut StyleSet {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fixed_columns_always_returns_n() {
|
||||
let cols = GridColumns::Fixed(3);
|
||||
assert_eq!(cols.count_for_width(100.0), 3);
|
||||
assert_eq!(cols.count_for_width(2000.0), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_columns_small_container() {
|
||||
let cols = GridColumns::Auto { min_width: 200.0 };
|
||||
assert_eq!(cols.count_for_width(300.0), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_columns_medium_container() {
|
||||
let cols = GridColumns::Auto { min_width: 200.0 };
|
||||
assert_eq!(cols.count_for_width(500.0), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_columns_wide_container() {
|
||||
let cols = GridColumns::Auto { min_width: 200.0 };
|
||||
assert_eq!(cols.count_for_width(1200.0), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_columns_minimum_one() {
|
||||
let cols = GridColumns::Auto { min_width: 500.0 };
|
||||
assert_eq!(cols.count_for_width(100.0), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_width_with_gap() {
|
||||
let cols = GridColumns::Fixed(3);
|
||||
// 300px wide, 3 cols, 16px gap between each = 2 gaps
|
||||
// (300 - 32) / 3 = 268/3 ≈ 89.33
|
||||
let w = cols.column_width(300.0, 16.0);
|
||||
assert!((w - (300.0 - 32.0) / 3.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_defaults() {
|
||||
let grid = GridLayout::new();
|
||||
assert_eq!(grid.column_gap, 16);
|
||||
assert_eq!(grid.row_gap, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_active_columns_auto() {
|
||||
let grid = GridLayout::new().columns_auto(200.0);
|
||||
assert_eq!(grid.active_columns(600.0), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_active_columns_fixed() {
|
||||
let grid = GridLayout::new().columns_fixed(4);
|
||||
assert_eq!(grid.active_columns(100.0), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_gap_xy() {
|
||||
let grid = GridLayout::new().gap_xy(8, 24);
|
||||
assert_eq!(grid.column_gap, 8);
|
||||
assert_eq!(grid.row_gap, 24);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! el-layout — Responsive layout engine for el-ui.
|
||||
//!
|
||||
//! **Responsive by default.** VStack and HStack wrap automatically.
|
||||
//! Grid uses auto columns. You don't write breakpoints for basic layouts.
|
||||
//!
|
||||
//! ## Layout primitives
|
||||
//!
|
||||
//! - [`VStack`] — vertical stack, wraps by default
|
||||
//! - [`HStack`] — horizontal stack, wraps by default, RTL-aware
|
||||
//! - [`ZStack`] — depth stack, children overlap
|
||||
//! - [`GridLayout`] — responsive grid, auto or fixed columns
|
||||
//! - [`ScrollView`] — scrollable container
|
||||
//!
|
||||
//! ## Responsive values
|
||||
//!
|
||||
//! For the rare case where you need a value to change at a specific breakpoint:
|
||||
//! ```
|
||||
//! use el_layout::prelude::*;
|
||||
//!
|
||||
//! // Most specific value that applies cascades down to less specific
|
||||
//! let cols: Responsive<u32> = Responsive::fixed(1).md(2).lg(3);
|
||||
//! assert_eq!(*cols.resolve(Breakpoint::Sm), 1);
|
||||
//! assert_eq!(*cols.resolve(Breakpoint::Md), 2);
|
||||
//! assert_eq!(*cols.resolve(Breakpoint::Lg), 3);
|
||||
//! assert_eq!(*cols.resolve(Breakpoint::Xl), 3); // cascades from lg
|
||||
//! ```
|
||||
|
||||
#![deny(warnings)]
|
||||
|
||||
pub mod breakpoint;
|
||||
pub mod constraints;
|
||||
pub mod flex;
|
||||
pub mod grid;
|
||||
pub mod platform;
|
||||
pub mod responsive;
|
||||
pub mod scroll;
|
||||
pub mod stack;
|
||||
|
||||
pub mod prelude {
|
||||
pub use crate::breakpoint::Breakpoint;
|
||||
pub use crate::constraints::{LayoutConstraints, Size};
|
||||
pub use crate::flex::{CrossAxisAlignment, FlexDirection, FlexLayout, HAlign, MainAxisAlignment, MainAxisSize, VAlign};
|
||||
pub use crate::grid::{GridColumns, GridLayout, GridRows};
|
||||
pub use crate::platform::{PlatformFamily, PlatformSizing, SafeAreaInsets};
|
||||
pub use crate::responsive::Responsive;
|
||||
pub use crate::scroll::{ScrollAxis, ScrollIndicator, ScrollView};
|
||||
pub use crate::stack::{HStack, Spacer, VStack, ZStack};
|
||||
}
|
||||
|
||||
pub use prelude::*;
|
||||
@@ -0,0 +1,318 @@
|
||||
// el-layout — Responsive layout engine for el-ui.
|
||||
//
|
||||
// Primitives:
|
||||
// VStack, HStack, ZStack — stack layouts (wrap by default)
|
||||
// GridLayout — responsive grid
|
||||
// ScrollView — scrollable container
|
||||
// Responsive<T> — value that changes by breakpoint
|
||||
|
||||
// ── Breakpoints ──────────────────────────────────────────────────────────────
|
||||
|
||||
let BP_XS: String = "xs" // < 640px
|
||||
let BP_SM: String = "sm" // >= 640px
|
||||
let BP_MD: String = "md" // >= 768px
|
||||
let BP_LG: String = "lg" // >= 1024px
|
||||
let BP_XL: String = "xl" // >= 1280px
|
||||
let BP_XXL: String = "xxl" // >= 1536px
|
||||
|
||||
let BP_SM_PX: Int = 640
|
||||
let BP_MD_PX: Int = 768
|
||||
let BP_LG_PX: Int = 1024
|
||||
let BP_XL_PX: Int = 1280
|
||||
let BP_XXL_PX: Int = 1536
|
||||
|
||||
fn breakpoint_for_width(width_px: Int) -> String {
|
||||
if width_px >= BP_XXL_PX { return BP_XXL }
|
||||
if width_px >= BP_XL_PX { return BP_XL }
|
||||
if width_px >= BP_LG_PX { return BP_LG }
|
||||
if width_px >= BP_MD_PX { return BP_MD }
|
||||
if width_px >= BP_SM_PX { return BP_SM }
|
||||
BP_XS
|
||||
}
|
||||
|
||||
// Cascade index — used by Responsive<T> to find the most-specific value <= bp.
|
||||
fn breakpoint_index(bp: String) -> Int {
|
||||
if str_eq(bp, "xs") { return 0 }
|
||||
if str_eq(bp, "sm") { return 1 }
|
||||
if str_eq(bp, "md") { return 2 }
|
||||
if str_eq(bp, "lg") { return 3 }
|
||||
if str_eq(bp, "xl") { return 4 }
|
||||
if str_eq(bp, "xxl") { return 5 }
|
||||
0
|
||||
}
|
||||
|
||||
// ── Responsive<T> ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Stored as a JSON object: { "xs": v0, "md": v1, "lg": v2 }
|
||||
// resolve(bp) walks down from bp until it finds a defined value.
|
||||
|
||||
fn responsive_fixed(value: String) -> String {
|
||||
"{\"xs\":" + value + "}"
|
||||
}
|
||||
|
||||
fn responsive_set(rv: String, bp: String, value: String) -> String {
|
||||
json_set(rv, bp, value)
|
||||
}
|
||||
|
||||
fn responsive_resolve(rv: String, bp: String) -> String {
|
||||
let order: String = "xxl,xl,lg,md,sm,xs"
|
||||
let target_idx: Int = breakpoint_index(bp)
|
||||
// Try each breakpoint <= target, most-specific first.
|
||||
let probe: String = bp
|
||||
while !str_eq(probe, "") {
|
||||
let v: String = json_get(rv, probe)
|
||||
if !str_eq(v, "") { return v }
|
||||
let idx: Int = breakpoint_index(probe)
|
||||
if idx == 0 { return "" }
|
||||
let probe = breakpoint_step_down(probe)
|
||||
}
|
||||
""
|
||||
}
|
||||
|
||||
fn breakpoint_step_down(bp: String) -> String {
|
||||
if str_eq(bp, "xxl") { return "xl" }
|
||||
if str_eq(bp, "xl") { return "lg" }
|
||||
if str_eq(bp, "lg") { return "md" }
|
||||
if str_eq(bp, "md") { return "sm" }
|
||||
if str_eq(bp, "sm") { return "xs" }
|
||||
""
|
||||
}
|
||||
|
||||
// ── Constraints / Size ───────────────────────────────────────────────────────
|
||||
|
||||
type Size {
|
||||
width: Int
|
||||
height: Int
|
||||
}
|
||||
|
||||
type LayoutConstraints {
|
||||
min_width: Int
|
||||
max_width: Int
|
||||
min_height: Int
|
||||
max_height: Int
|
||||
}
|
||||
|
||||
fn constraints_unbounded() -> LayoutConstraints {
|
||||
{ "min_width": 0, "max_width": 999999, "min_height": 0, "max_height": 999999 }
|
||||
}
|
||||
|
||||
fn constraints_tight(w: Int, h: Int) -> LayoutConstraints {
|
||||
{ "min_width": w, "max_width": w, "min_height": h, "max_height": h }
|
||||
}
|
||||
|
||||
// ── Flex axes ────────────────────────────────────────────────────────────────
|
||||
|
||||
let FLEX_ROW: String = "row"
|
||||
let FLEX_COLUMN: String = "column"
|
||||
|
||||
let MAIN_START: String = "start"
|
||||
let MAIN_END: String = "end"
|
||||
let MAIN_CENTER: String = "center"
|
||||
let MAIN_BETWEEN: String = "space-between"
|
||||
let MAIN_AROUND: String = "space-around"
|
||||
let MAIN_EVENLY: String = "space-evenly"
|
||||
|
||||
let CROSS_START: String = "start"
|
||||
let CROSS_END: String = "end"
|
||||
let CROSS_CENTER: String = "center"
|
||||
let CROSS_STRETCH: String = "stretch"
|
||||
let CROSS_BASELINE: String = "baseline"
|
||||
|
||||
type FlexLayout {
|
||||
direction: String
|
||||
main_alignment: String
|
||||
cross_alignment: String
|
||||
gap_px: Int
|
||||
wrap: Bool
|
||||
}
|
||||
|
||||
fn flex_default() -> FlexLayout {
|
||||
{ "direction": "row", "main_alignment": "start",
|
||||
"cross_alignment": "stretch", "gap_px": 8, "wrap": true }
|
||||
}
|
||||
|
||||
// ── Stacks ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// VStack / HStack / ZStack are component classes in the JS runtime; the
|
||||
// El side here exposes their layout descriptor — the bag of values the
|
||||
// el-ui-compiler emits into JSX/HTML attributes.
|
||||
|
||||
type StackLayout {
|
||||
direction: String // row | column | depth
|
||||
main_alignment: String
|
||||
cross_alignment: String
|
||||
gap_px: Int
|
||||
wrap: Bool
|
||||
spacing_token: String // semantic spacing token (md, lg, ...)
|
||||
}
|
||||
|
||||
fn vstack(spacing_token: String) -> StackLayout {
|
||||
{ "direction": "column", "main_alignment": "start",
|
||||
"cross_alignment": "stretch", "gap_px": 0, "wrap": true,
|
||||
"spacing_token": spacing_token }
|
||||
}
|
||||
|
||||
fn hstack(spacing_token: String) -> StackLayout {
|
||||
{ "direction": "row", "main_alignment": "start",
|
||||
"cross_alignment": "center", "gap_px": 0, "wrap": true,
|
||||
"spacing_token": spacing_token }
|
||||
}
|
||||
|
||||
fn zstack() -> StackLayout {
|
||||
{ "direction": "depth", "main_alignment": "center",
|
||||
"cross_alignment": "center", "gap_px": 0, "wrap": false,
|
||||
"spacing_token": "" }
|
||||
}
|
||||
|
||||
// ── Grid ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type GridLayout {
|
||||
columns: String // "auto" or "1fr 1fr 1fr" etc
|
||||
rows: String
|
||||
gap_px: Int
|
||||
auto_fit_min: Int // for `repeat(auto-fit, minmax(<min>, 1fr))`
|
||||
}
|
||||
|
||||
fn grid_auto(min_col_px: Int, gap: Int) -> GridLayout {
|
||||
{ "columns": "auto-fit", "rows": "auto", "gap_px": gap, "auto_fit_min": min_col_px }
|
||||
}
|
||||
|
||||
fn grid_fixed(num_cols: Int, gap: Int) -> GridLayout {
|
||||
let cols: String = repeat_str("1fr ", num_cols)
|
||||
{ "columns": cols, "rows": "auto", "gap_px": gap, "auto_fit_min": 0 }
|
||||
}
|
||||
|
||||
fn grid_to_css(g: GridLayout) -> String {
|
||||
let cols: String = g.columns
|
||||
if str_eq(cols, "auto-fit") {
|
||||
let cols = "repeat(auto-fit, minmax(" + int_to_str(g.auto_fit_min) + "px, 1fr))"
|
||||
}
|
||||
"display: grid; grid-template-columns: " + cols + "; gap: " + int_to_str(g.gap_px) + "px;"
|
||||
}
|
||||
|
||||
// ── ScrollView ──────────────────────────────────────────────────────────────
|
||||
|
||||
let SCROLL_X: String = "x"
|
||||
let SCROLL_Y: String = "y"
|
||||
let SCROLL_BOTH: String = "both"
|
||||
|
||||
type ScrollView {
|
||||
axis: String
|
||||
show_indicator: Bool
|
||||
bounce: Bool // iOS-style overscroll
|
||||
}
|
||||
|
||||
fn scroll_view_y() -> ScrollView {
|
||||
{ "axis": "y", "show_indicator": true, "bounce": true }
|
||||
}
|
||||
|
||||
// ── Platform sizing ──────────────────────────────────────────────────────────
|
||||
|
||||
let PLATFORM_PHONE: String = "phone"
|
||||
let PLATFORM_TABLET: String = "tablet"
|
||||
let PLATFORM_DESKTOP: String = "desktop"
|
||||
|
||||
type SafeAreaInsets {
|
||||
top: Int
|
||||
right: Int
|
||||
bottom: Int
|
||||
left: Int
|
||||
}
|
||||
|
||||
fn safe_area_zero() -> SafeAreaInsets {
|
||||
{ "top": 0, "right": 0, "bottom": 0, "left": 0 }
|
||||
}
|
||||
|
||||
fn platform_for_width(width: Int) -> String {
|
||||
if width < 768 { return "phone" }
|
||||
if width < 1024 { return "tablet" }
|
||||
"desktop"
|
||||
}
|
||||
|
||||
// ── HTML emit ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Server-side render: layout descriptor → HTML string.
|
||||
// Children is a pre-rendered HTML string passed in by the caller.
|
||||
// class_extra is an optional additional CSS class string (pass "" for none).
|
||||
|
||||
fn stack_direction_to_css(direction: String) -> String {
|
||||
if str_eq(direction, "column") { return "column" }
|
||||
if str_eq(direction, "depth") { return "unset" }
|
||||
"row"
|
||||
}
|
||||
|
||||
fn stack_align_to_css(align: String) -> String {
|
||||
if str_eq(align, "center") { return "center" }
|
||||
if str_eq(align, "end") { return "flex-end" }
|
||||
if str_eq(align, "baseline") { return "baseline" }
|
||||
if str_eq(align, "stretch") { return "stretch" }
|
||||
"flex-start"
|
||||
}
|
||||
|
||||
fn stack_justify_to_css(align: String) -> String {
|
||||
if str_eq(align, "center") { return "center" }
|
||||
if str_eq(align, "end") { return "flex-end" }
|
||||
if str_eq(align, "space-between") { return "space-between" }
|
||||
if str_eq(align, "space-around") { return "space-around" }
|
||||
if str_eq(align, "space-evenly") { return "space-evenly" }
|
||||
"flex-start"
|
||||
}
|
||||
|
||||
fn stack_to_html(layout: StackLayout, children: String, class_extra: String) -> String {
|
||||
let direction: String = stack_direction_to_css(layout.direction)
|
||||
let justify: String = stack_justify_to_css(layout.main_alignment)
|
||||
let align: String = stack_align_to_css(layout.cross_alignment)
|
||||
let gap: String = int_to_str(layout.gap_px) + "px"
|
||||
let wrap_val: String = if layout.wrap { "wrap" } else { "nowrap" }
|
||||
let style: String = "display:flex;flex-direction:" + direction
|
||||
+ ";justify-content:" + justify
|
||||
+ ";align-items:" + align
|
||||
+ ";gap:" + gap
|
||||
+ ";flex-wrap:" + wrap_val
|
||||
let base_class: String = "el-stack el-stack--" + layout.direction
|
||||
let cls: String = if str_eq(class_extra, "") { base_class } else { base_class + " " + class_extra }
|
||||
"<div class=\"" + cls + "\" style=\"" + style + "\">" + children + "</div>"
|
||||
}
|
||||
|
||||
fn vstack_to_html(spacing_px: Int, children: String, class_extra: String) -> String {
|
||||
let layout: StackLayout = { "direction": "column", "main_alignment": "start",
|
||||
"cross_alignment": "stretch", "gap_px": spacing_px, "wrap": true, "spacing_token": "" }
|
||||
stack_to_html(layout, children, class_extra)
|
||||
}
|
||||
|
||||
fn hstack_to_html(spacing_px: Int, children: String, class_extra: String) -> String {
|
||||
let layout: StackLayout = { "direction": "row", "main_alignment": "start",
|
||||
"cross_alignment": "center", "gap_px": spacing_px, "wrap": true, "spacing_token": "" }
|
||||
stack_to_html(layout, children, class_extra)
|
||||
}
|
||||
|
||||
fn zstack_to_html(children: String, class_extra: String) -> String {
|
||||
let base_class: String = "el-stack el-stack--depth"
|
||||
let cls: String = if str_eq(class_extra, "") { base_class } else { base_class + " " + class_extra }
|
||||
"<div class=\"" + cls + "\" style=\"display:grid;place-items:center\">" + children + "</div>"
|
||||
}
|
||||
|
||||
fn grid_to_html(layout: GridLayout, children: String, class_extra: String) -> String {
|
||||
let css: String = grid_to_css(layout)
|
||||
let base_class: String = "el-grid"
|
||||
let cls: String = if str_eq(class_extra, "") { base_class } else { base_class + " " + class_extra }
|
||||
"<div class=\"" + cls + "\" style=\"" + css + "\">" + children + "</div>"
|
||||
}
|
||||
|
||||
fn scroll_to_html(layout: ScrollView, children: String, class_extra: String) -> String {
|
||||
let overflow: String = if str_eq(layout.axis, "x") { "overflow-x:auto;overflow-y:hidden" }
|
||||
else if str_eq(layout.axis, "both") { "overflow:auto" }
|
||||
else { "overflow-x:hidden;overflow-y:auto" }
|
||||
let base_class: String = "el-scroll"
|
||||
let cls: String = if str_eq(class_extra, "") { base_class } else { base_class + " " + class_extra }
|
||||
"<div class=\"" + cls + "\" style=\"" + overflow + "\">" + children + "</div>"
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
||||
|
||||
let r: String = responsive_fixed("1")
|
||||
let r = responsive_set(r, "md", "2")
|
||||
let r = responsive_set(r, "lg", "3")
|
||||
println("[el-layout] cols at lg = " + responsive_resolve(r, "lg"))
|
||||
let v: String = vstack_to_html(16, "<p>hello</p>", "")
|
||||
println("[el-layout] vstack = " + v)
|
||||
@@ -0,0 +1,197 @@
|
||||
/// Platform-aware sizing constants.
|
||||
///
|
||||
/// Platform HIG minimum touch targets, safe area handling, and
|
||||
/// density-independent pixel conventions.
|
||||
|
||||
/// Which platform family the app is running on.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PlatformFamily {
|
||||
/// iOS / iPadOS
|
||||
Ios,
|
||||
/// Android
|
||||
Android,
|
||||
/// macOS
|
||||
Macos,
|
||||
/// Windows
|
||||
Windows,
|
||||
/// Linux desktop
|
||||
Linux,
|
||||
/// Web browser
|
||||
Web,
|
||||
}
|
||||
|
||||
/// Platform-specific sizing constants.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlatformSizing {
|
||||
/// Minimum touch target dimension in dp (height and width).
|
||||
pub min_touch_target: f32,
|
||||
/// Standard icon size in dp.
|
||||
pub icon_size: f32,
|
||||
/// Standard small icon size in dp.
|
||||
pub icon_size_sm: f32,
|
||||
/// Standard navigation bar height in dp.
|
||||
pub nav_bar_height: f32,
|
||||
/// Standard tab bar height in dp.
|
||||
pub tab_bar_height: f32,
|
||||
/// Standard status bar height in dp (approximate; actual is platform-provided).
|
||||
pub status_bar_height: f32,
|
||||
}
|
||||
|
||||
impl PlatformSizing {
|
||||
/// Sizing constants for a given platform.
|
||||
pub fn for_platform(platform: PlatformFamily) -> Self {
|
||||
match platform {
|
||||
PlatformFamily::Ios => Self {
|
||||
min_touch_target: 44.0, // Apple HIG
|
||||
icon_size: 24.0,
|
||||
icon_size_sm: 16.0,
|
||||
nav_bar_height: 44.0,
|
||||
tab_bar_height: 49.0,
|
||||
status_bar_height: 44.0, // approximate; varies with notch
|
||||
},
|
||||
PlatformFamily::Android => Self {
|
||||
min_touch_target: 48.0, // Material Design
|
||||
icon_size: 24.0,
|
||||
icon_size_sm: 18.0,
|
||||
nav_bar_height: 56.0,
|
||||
tab_bar_height: 56.0,
|
||||
status_bar_height: 24.0,
|
||||
},
|
||||
PlatformFamily::Macos => Self {
|
||||
min_touch_target: 44.0, // macOS HIG
|
||||
icon_size: 16.0,
|
||||
icon_size_sm: 12.0,
|
||||
nav_bar_height: 28.0,
|
||||
tab_bar_height: 36.0,
|
||||
status_bar_height: 0.0, // macOS status bar is system chrome
|
||||
},
|
||||
PlatformFamily::Windows => Self {
|
||||
min_touch_target: 44.0,
|
||||
icon_size: 16.0,
|
||||
icon_size_sm: 12.0,
|
||||
nav_bar_height: 40.0,
|
||||
tab_bar_height: 40.0,
|
||||
status_bar_height: 0.0,
|
||||
},
|
||||
PlatformFamily::Linux => Self {
|
||||
min_touch_target: 44.0,
|
||||
icon_size: 16.0,
|
||||
icon_size_sm: 12.0,
|
||||
nav_bar_height: 36.0,
|
||||
tab_bar_height: 36.0,
|
||||
status_bar_height: 0.0,
|
||||
},
|
||||
PlatformFamily::Web => Self {
|
||||
min_touch_target: 44.0, // WCAG 2.5.5 recommended
|
||||
icon_size: 20.0,
|
||||
icon_size_sm: 16.0,
|
||||
nav_bar_height: 64.0,
|
||||
tab_bar_height: 48.0,
|
||||
status_bar_height: 0.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the given width adequate for a touch target?
|
||||
pub fn is_touch_adequate(&self, width: f32, height: f32) -> bool {
|
||||
width >= self.min_touch_target && height >= self.min_touch_target
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe area insets — space reserved by system chrome.
|
||||
///
|
||||
/// These are provided at runtime by the platform. The values here
|
||||
/// are conservative defaults for simulation.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct SafeAreaInsets {
|
||||
pub top: f32,
|
||||
pub right: f32,
|
||||
pub bottom: f32,
|
||||
pub left: f32,
|
||||
}
|
||||
|
||||
impl SafeAreaInsets {
|
||||
/// No safe area insets (desktop platforms).
|
||||
pub fn none() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Typical iPhone safe area (notch at top, home indicator at bottom).
|
||||
pub fn iphone_notch() -> Self {
|
||||
Self {
|
||||
top: 44.0,
|
||||
right: 0.0,
|
||||
bottom: 34.0,
|
||||
left: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Dynamic island (iPhone 14 Pro+).
|
||||
pub fn iphone_dynamic_island() -> Self {
|
||||
Self {
|
||||
top: 59.0,
|
||||
right: 0.0,
|
||||
bottom: 34.0,
|
||||
left: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total horizontal safe area.
|
||||
pub fn horizontal(&self) -> f32 {
|
||||
self.left + self.right
|
||||
}
|
||||
|
||||
/// Total vertical safe area.
|
||||
pub fn vertical(&self) -> f32 {
|
||||
self.top + self.bottom
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ios_min_touch_target() {
|
||||
let sizing = PlatformSizing::for_platform(PlatformFamily::Ios);
|
||||
assert_eq!(sizing.min_touch_target, 44.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn android_min_touch_target() {
|
||||
let sizing = PlatformSizing::for_platform(PlatformFamily::Android);
|
||||
assert_eq!(sizing.min_touch_target, 48.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_adequate_check() {
|
||||
let sizing = PlatformSizing::for_platform(PlatformFamily::Ios);
|
||||
assert!(sizing.is_touch_adequate(44.0, 44.0));
|
||||
assert!(!sizing.is_touch_adequate(40.0, 44.0));
|
||||
assert!(!sizing.is_touch_adequate(44.0, 40.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_area_horizontal() {
|
||||
let sa = SafeAreaInsets {
|
||||
top: 44.0,
|
||||
right: 16.0,
|
||||
bottom: 34.0,
|
||||
left: 16.0,
|
||||
};
|
||||
assert_eq!(sa.horizontal(), 32.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_area_vertical() {
|
||||
let sa = SafeAreaInsets::iphone_notch();
|
||||
assert_eq!(sa.vertical(), 78.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_safe_area_is_zero() {
|
||||
let sa = SafeAreaInsets::none();
|
||||
assert_eq!(sa.horizontal(), 0.0);
|
||||
assert_eq!(sa.vertical(), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/// Responsive<T> — a value that varies by breakpoint.
|
||||
///
|
||||
/// The base value is always required (mobile-first). `sm`, `md`, `lg`, `xl`
|
||||
/// are all optional — if not set, the nearest smaller value is used.
|
||||
///
|
||||
/// You generally don't need Responsive<T> for layout — VStack and Grid handle
|
||||
/// reflow automatically. Use Responsive<T> when you need to vary a non-layout
|
||||
/// value (e.g. font size, column count, visibility) at specific breakpoints.
|
||||
|
||||
use crate::breakpoint::Breakpoint;
|
||||
|
||||
/// A value that varies by viewport breakpoint.
|
||||
///
|
||||
/// Follows mobile-first cascade: base < sm < md < lg < xl.
|
||||
/// If a breakpoint value is not set, it inherits from the next smaller one.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Responsive<T: Clone> {
|
||||
/// Mobile-first base value. Always required.
|
||||
pub base: T,
|
||||
/// 640dp+. If None, uses `base`.
|
||||
pub sm: Option<T>,
|
||||
/// 768dp+. If None, uses `sm` or `base`.
|
||||
pub md: Option<T>,
|
||||
/// 1024dp+. If None, uses `md`, `sm`, or `base`.
|
||||
pub lg: Option<T>,
|
||||
/// 1280dp+. If None, uses `lg`, `md`, `sm`, or `base`.
|
||||
pub xl: Option<T>,
|
||||
}
|
||||
|
||||
impl<T: Clone> Responsive<T> {
|
||||
/// Create a responsive value with only the base (same on all screen sizes).
|
||||
pub fn fixed(value: T) -> Self {
|
||||
Self {
|
||||
base: value,
|
||||
sm: None,
|
||||
md: None,
|
||||
lg: None,
|
||||
xl: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a fully-specified responsive value.
|
||||
pub fn new(
|
||||
base: T,
|
||||
sm: Option<T>,
|
||||
md: Option<T>,
|
||||
lg: Option<T>,
|
||||
xl: Option<T>,
|
||||
) -> Self {
|
||||
Self { base, sm, md, lg, xl }
|
||||
}
|
||||
|
||||
/// Resolve to the most-specific value that applies at the given breakpoint.
|
||||
///
|
||||
/// Cascades downward: xl → lg → md → sm → base.
|
||||
pub fn resolve(&self, breakpoint: Breakpoint) -> &T {
|
||||
match breakpoint {
|
||||
Breakpoint::Xl => {
|
||||
self.xl.as_ref()
|
||||
.or(self.lg.as_ref())
|
||||
.or(self.md.as_ref())
|
||||
.or(self.sm.as_ref())
|
||||
.unwrap_or(&self.base)
|
||||
}
|
||||
Breakpoint::Lg => {
|
||||
self.lg.as_ref()
|
||||
.or(self.md.as_ref())
|
||||
.or(self.sm.as_ref())
|
||||
.unwrap_or(&self.base)
|
||||
}
|
||||
Breakpoint::Md => {
|
||||
self.md.as_ref()
|
||||
.or(self.sm.as_ref())
|
||||
.unwrap_or(&self.base)
|
||||
}
|
||||
Breakpoint::Sm => {
|
||||
self.sm.as_ref().unwrap_or(&self.base)
|
||||
}
|
||||
Breakpoint::Base => &self.base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the sm value (builder pattern).
|
||||
pub fn sm(mut self, value: T) -> Self {
|
||||
self.sm = Some(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the md value.
|
||||
pub fn md(mut self, value: T) -> Self {
|
||||
self.md = Some(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the lg value.
|
||||
pub fn lg(mut self, value: T) -> Self {
|
||||
self.lg = Some(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the xl value.
|
||||
pub fn xl(mut self, value: T) -> Self {
|
||||
self.xl = Some(value);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fixed_always_returns_base() {
|
||||
let r = Responsive::fixed(42u32);
|
||||
assert_eq!(*r.resolve(Breakpoint::Base), 42);
|
||||
assert_eq!(*r.resolve(Breakpoint::Xl), 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_most_specific() {
|
||||
let r = Responsive::fixed(1u32).sm(2).md(3).lg(4).xl(5);
|
||||
assert_eq!(*r.resolve(Breakpoint::Base), 1);
|
||||
assert_eq!(*r.resolve(Breakpoint::Sm), 2);
|
||||
assert_eq!(*r.resolve(Breakpoint::Md), 3);
|
||||
assert_eq!(*r.resolve(Breakpoint::Lg), 4);
|
||||
assert_eq!(*r.resolve(Breakpoint::Xl), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascades_down_when_specific_missing() {
|
||||
let r = Responsive::fixed(1u32).md(3);
|
||||
// sm not set → falls back to base
|
||||
assert_eq!(*r.resolve(Breakpoint::Sm), 1);
|
||||
// lg not set → falls back to md
|
||||
assert_eq!(*r.resolve(Breakpoint::Lg), 3);
|
||||
// xl not set → falls back to md (lg not set either)
|
||||
assert_eq!(*r.resolve(Breakpoint::Xl), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_xl_to_lg_to_sm_to_base() {
|
||||
let r = Responsive::fixed("base").sm("sm");
|
||||
assert_eq!(*r.resolve(Breakpoint::Md), "sm");
|
||||
assert_eq!(*r.resolve(Breakpoint::Lg), "sm");
|
||||
assert_eq!(*r.resolve(Breakpoint::Xl), "sm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responsive_with_strings() {
|
||||
let r = Responsive::fixed("mobile").lg("desktop");
|
||||
assert_eq!(*r.resolve(Breakpoint::Base), "mobile");
|
||||
assert_eq!(*r.resolve(Breakpoint::Lg), "desktop");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/// ScrollView — scrollable container.
|
||||
///
|
||||
/// Wraps content that may exceed the available space. Scrolling axis can be
|
||||
/// vertical (default), horizontal, or both. On platforms with native scroll
|
||||
/// behaviors (iOS, Android), the backend translates this to a native scroll
|
||||
/// container — you get momentum, overscroll, pull-to-refresh for free.
|
||||
|
||||
use el_style::modifier::{StyleModifier, StyleSet};
|
||||
|
||||
/// Which axis (or axes) can scroll.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ScrollAxis {
|
||||
/// Vertical scrolling only (default). Most content views.
|
||||
Vertical,
|
||||
/// Horizontal scrolling only. Carousels, horizontal lists.
|
||||
Horizontal,
|
||||
/// Free scrolling in both directions. Maps, canvases.
|
||||
Both,
|
||||
}
|
||||
|
||||
/// Scroll indicator visibility.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ScrollIndicator {
|
||||
/// Show when scrolling, hide otherwise (platform default).
|
||||
Automatic,
|
||||
/// Always show.
|
||||
Always,
|
||||
/// Never show.
|
||||
Never,
|
||||
}
|
||||
|
||||
/// A scrollable container.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScrollView {
|
||||
pub axis: ScrollAxis,
|
||||
pub indicator: ScrollIndicator,
|
||||
/// Whether the user can zoom (pinch-to-zoom). Default: false.
|
||||
pub zoomable: bool,
|
||||
/// Minimum zoom scale (only relevant when zoomable = true).
|
||||
pub min_zoom: f32,
|
||||
/// Maximum zoom scale (only relevant when zoomable = true).
|
||||
pub max_zoom: f32,
|
||||
/// Whether to clip content to the scroll view bounds. Default: true.
|
||||
pub clips_to_bounds: bool,
|
||||
pub style: StyleSet,
|
||||
}
|
||||
|
||||
impl Default for ScrollView {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
axis: ScrollAxis::Vertical,
|
||||
indicator: ScrollIndicator::Automatic,
|
||||
zoomable: false,
|
||||
min_zoom: 1.0,
|
||||
max_zoom: 3.0,
|
||||
clips_to_bounds: true,
|
||||
style: StyleSet::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollView {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn horizontal() -> Self {
|
||||
Self {
|
||||
axis: ScrollAxis::Horizontal,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn both_axes() -> Self {
|
||||
Self {
|
||||
axis: ScrollAxis::Both,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn indicator(mut self, indicator: ScrollIndicator) -> Self {
|
||||
self.indicator = indicator;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn zoomable(mut self, min: f32, max: f32) -> Self {
|
||||
self.zoomable = true;
|
||||
self.min_zoom = min;
|
||||
self.max_zoom = max;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl StyleModifier for ScrollView {
|
||||
fn style_mut(&mut self) -> &mut StyleSet {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scroll_default_is_vertical() {
|
||||
let sv = ScrollView::new();
|
||||
assert_eq!(sv.axis, ScrollAxis::Vertical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_horizontal_constructor() {
|
||||
let sv = ScrollView::horizontal();
|
||||
assert_eq!(sv.axis, ScrollAxis::Horizontal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_both_axes() {
|
||||
let sv = ScrollView::both_axes();
|
||||
assert_eq!(sv.axis, ScrollAxis::Both);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_zoomable() {
|
||||
let sv = ScrollView::new().zoomable(0.5, 4.0);
|
||||
assert!(sv.zoomable);
|
||||
assert_eq!(sv.min_zoom, 0.5);
|
||||
assert_eq!(sv.max_zoom, 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_clips_by_default() {
|
||||
let sv = ScrollView::new();
|
||||
assert!(sv.clips_to_bounds);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_indicator_setting() {
|
||||
let sv = ScrollView::new().indicator(ScrollIndicator::Never);
|
||||
assert_eq!(sv.indicator, ScrollIndicator::Never);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/// VStack, HStack, ZStack — the primary layout building blocks.
|
||||
///
|
||||
/// These are the component API. FlexLayout is the engine underneath.
|
||||
/// VStack and HStack wrap automatically by default — that's what makes
|
||||
/// the layout responsive without writing breakpoints.
|
||||
|
||||
use crate::flex::{CrossAxisAlignment, FlexLayout, HAlign, VAlign};
|
||||
use el_style::modifier::{StyleModifier, StyleSet};
|
||||
|
||||
/// A vertical stack — children arranged from top to bottom.
|
||||
///
|
||||
/// Wraps automatically when height is constrained (mobile-first).
|
||||
/// The default gap is 8dp. Cross-axis fills the container width.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VStack {
|
||||
/// Space between children in dp (default: 8).
|
||||
pub spacing: u32,
|
||||
/// Horizontal alignment of children (default: Leading).
|
||||
pub alignment: HAlign,
|
||||
/// Whether to wrap to a new column when out of vertical space (default: true).
|
||||
pub wrap: bool,
|
||||
pub style: StyleSet,
|
||||
}
|
||||
|
||||
impl Default for VStack {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
spacing: 8,
|
||||
alignment: HAlign::Leading,
|
||||
wrap: true,
|
||||
style: StyleSet::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VStack {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn spacing(mut self, dp: u32) -> Self {
|
||||
self.spacing = dp;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn alignment(mut self, align: HAlign) -> Self {
|
||||
self.alignment = align;
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable or disable wrapping.
|
||||
pub fn wrap(mut self, wrap: bool) -> Self {
|
||||
self.wrap = wrap;
|
||||
self
|
||||
}
|
||||
|
||||
/// Convert to FlexLayout spec.
|
||||
pub fn to_flex(&self) -> FlexLayout {
|
||||
let mut flex = FlexLayout::vstack(self.spacing, self.wrap);
|
||||
flex.cross_axis_alignment = match self.alignment {
|
||||
HAlign::Leading => CrossAxisAlignment::Start,
|
||||
HAlign::Center => CrossAxisAlignment::Center,
|
||||
HAlign::Trailing => CrossAxisAlignment::End,
|
||||
};
|
||||
flex
|
||||
}
|
||||
}
|
||||
|
||||
impl StyleModifier for VStack {
|
||||
fn style_mut(&mut self) -> &mut StyleSet {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
/// A horizontal stack — children arranged from leading to trailing.
|
||||
///
|
||||
/// Respects reading direction (RTL reverses automatically).
|
||||
/// Wraps to new rows by default when width is limited (mobile-first).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HStack {
|
||||
/// Space between children in dp (default: 8).
|
||||
pub spacing: u32,
|
||||
/// Vertical alignment of children (default: Center).
|
||||
pub alignment: VAlign,
|
||||
/// Whether to wrap to a new row when out of horizontal space (default: true).
|
||||
pub wrap: bool,
|
||||
pub style: StyleSet,
|
||||
}
|
||||
|
||||
impl Default for HStack {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
spacing: 8,
|
||||
alignment: VAlign::Center,
|
||||
wrap: true,
|
||||
style: StyleSet::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HStack {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn spacing(mut self, dp: u32) -> Self {
|
||||
self.spacing = dp;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn alignment(mut self, align: VAlign) -> Self {
|
||||
self.alignment = align;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn wrap(mut self, wrap: bool) -> Self {
|
||||
self.wrap = wrap;
|
||||
self
|
||||
}
|
||||
|
||||
/// Convert to FlexLayout spec.
|
||||
pub fn to_flex(&self) -> FlexLayout {
|
||||
let mut flex = FlexLayout::hstack(self.spacing, self.wrap);
|
||||
flex.cross_axis_alignment = match self.alignment {
|
||||
VAlign::Top => CrossAxisAlignment::Start,
|
||||
VAlign::Center => CrossAxisAlignment::Center,
|
||||
VAlign::Bottom => CrossAxisAlignment::End,
|
||||
VAlign::Baseline => CrossAxisAlignment::Baseline,
|
||||
};
|
||||
flex
|
||||
}
|
||||
}
|
||||
|
||||
impl StyleModifier for HStack {
|
||||
fn style_mut(&mut self) -> &mut StyleSet {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
/// A depth stack — children layered on top of each other.
|
||||
///
|
||||
/// ZStack places all children at the same position, overlapping.
|
||||
/// Later children appear on top of earlier children.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ZStack {
|
||||
pub h_align: HAlign,
|
||||
pub v_align: VAlign,
|
||||
pub style: StyleSet,
|
||||
}
|
||||
|
||||
impl Default for ZStack {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
h_align: HAlign::Center,
|
||||
v_align: VAlign::Center,
|
||||
style: StyleSet::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ZStack {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn h_align(mut self, align: HAlign) -> Self {
|
||||
self.h_align = align;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn v_align(mut self, align: VAlign) -> Self {
|
||||
self.v_align = align;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl StyleModifier for ZStack {
|
||||
fn style_mut(&mut self) -> &mut StyleSet {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
/// A spacer that expands to fill available space in a stack.
|
||||
///
|
||||
/// In HStack: expands horizontally. In VStack: expands vertically.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Spacer {
|
||||
/// Minimum size in dp (0 = truly flexible).
|
||||
pub min_size: u32,
|
||||
}
|
||||
|
||||
impl Spacer {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn min(mut self, dp: u32) -> Self {
|
||||
self.min_size = dp;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::flex::CrossAxisAlignment;
|
||||
use el_style::modifier::StyleModifier;
|
||||
use el_style::color::Color;
|
||||
|
||||
#[test]
|
||||
fn vstack_default_spacing() {
|
||||
let v = VStack::new();
|
||||
assert_eq!(v.spacing, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vstack_default_wraps() {
|
||||
let v = VStack::new();
|
||||
assert!(v.wrap);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vstack_to_flex_direction() {
|
||||
let v = VStack::new();
|
||||
let flex = v.to_flex();
|
||||
assert!(flex.direction.is_vertical());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vstack_center_alignment() {
|
||||
let v = VStack::new().alignment(HAlign::Center);
|
||||
let flex = v.to_flex();
|
||||
assert_eq!(flex.cross_axis_alignment, CrossAxisAlignment::Center);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hstack_default_alignment() {
|
||||
let h = HStack::new();
|
||||
assert_eq!(h.alignment, VAlign::Center);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hstack_default_wraps() {
|
||||
let h = HStack::new();
|
||||
assert!(h.wrap);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hstack_to_flex_direction() {
|
||||
let h = HStack::new();
|
||||
let flex = h.to_flex();
|
||||
assert!(flex.direction.is_horizontal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hstack_no_wrap() {
|
||||
let h = HStack::new().wrap(false);
|
||||
let flex = h.to_flex();
|
||||
assert!(!flex.direction.wraps());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zstack_defaults() {
|
||||
let z = ZStack::new();
|
||||
assert_eq!(z.h_align, HAlign::Center);
|
||||
assert_eq!(z.v_align, VAlign::Center);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vstack_style_modifier() {
|
||||
let v = VStack::new().background(Color::Surface);
|
||||
assert_eq!(v.style.background, Some(Color::Surface));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacer_min_size() {
|
||||
let s = Spacer::new().min(16);
|
||||
assert_eq!(s.min_size, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacer_default_zero() {
|
||||
let s = Spacer::new();
|
||||
assert_eq!(s.min_size, 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user