feat: rename crates/ → vessels/ + add El ports per sub-vessel

Belated rename commit for foundation/el-ui — was missed in the
workspace-wide crates→vessels pass earlier today. Same structural
intent as the rename in the other repos: 'crates' is the Rust word,
'vessel' is El's, and the directory rename is the marker that this
slot holds an El buildable unit even if its current contents are
still Rust pending port.

Plus the El ports themselves — manifest.el + src/main.el per sub-
vessel (el-aop, el-auth, el-config, el-i18n, el-identity, el-layout,
el-platform, el-publish, el-secrets, el-services, el-style, el-ui-
compiler). The ui-compiler is a stub: elc only emits C right now;
generating browser-target JS/Wasm is the biggest open language gap
and gets its own project. Until then, el-ui-compiler emits a JS
module that throws elc.backend_missing so callers fail loudly.
Cross-repo path dependencies in Cargo.toml updated to vessels/.
This commit is contained in:
Will Anderson
2026-04-30 18:18:39 -05:00
parent f09803c317
commit f4abfe6fdc
138 changed files with 5445 additions and 900 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::*;
+237
View File
@@ -0,0 +1,237 @@
// 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"
}
// 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"))
+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);
}
}