feat: port el-ui vessels — rename crates→vessels, add El source + manifests

This commit is contained in:
Will Anderson
2026-05-05 04:19:22 -05:00
parent b580a63540
commit faee6fdb25
145 changed files with 4050 additions and 12 deletions
+16
View File
@@ -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]
+21
View File
@@ -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/"
}
+98
View File
@@ -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());
}
}
}
+162
View File
@@ -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());
}
}
+178
View File
@@ -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);
}
}
+187
View File
@@ -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);
}
}
+50
View File
@@ -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::*;
+318
View File
@@ -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)
+197
View File
@@ -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);
}
}
+154
View File
@@ -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");
}
}
+141
View File
@@ -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);
}
}
+286
View File
@@ -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);
}
}