diff --git a/Cargo.lock b/Cargo.lock index a4ff299..edb80c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,6 +135,26 @@ dependencies = [ "uuid", ] +[[package]] +name = "el-config" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror", + "toml", +] + +[[package]] +name = "el-i18n" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror", + "toml", +] + [[package]] name = "el-identity" version = "0.1.0" @@ -149,6 +169,14 @@ dependencies = [ "uuid", ] +[[package]] +name = "el-layout" +version = "0.1.0" +dependencies = [ + "el-style", + "thiserror", +] + [[package]] name = "el-platform" version = "0.1.0" @@ -163,6 +191,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "el-secrets" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "el-services" version = "0.1.0" @@ -170,6 +207,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "el-style" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "el-ui-compiler" version = "0.1.0" @@ -364,6 +410,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profile-card" +version = "0.1.0" +dependencies = [ + "el-config", + "el-i18n", + "el-layout", + "el-secrets", + "el-style", +] + [[package]] name = "quote" version = "1.0.45" @@ -434,6 +491,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -488,6 +554,47 @@ dependencies = [ "syn", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "typenum" version = "1.20.0" @@ -680,6 +787,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index b6142d8..6241e2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,5 +7,11 @@ members = [ "crates/el-auth", "crates/el-publish", "crates/el-identity", + "crates/el-style", + "crates/el-layout", + "crates/el-i18n", + "crates/el-config", + "crates/el-secrets", + "examples/profile-card", ] resolver = "2" diff --git a/crates/el-config/Cargo.toml b/crates/el-config/Cargo.toml new file mode 100644 index 0000000..b7e40b0 --- /dev/null +++ b/crates/el-config/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "el-config" +version = "0.1.0" +edition = "2021" +description = "el-ui configuration system — layered, typed, environment-aware" +license = "MIT" + +[lib] +name = "el_config" +path = "src/lib.rs" + +[dependencies] +thiserror = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" + +[dev-dependencies] diff --git a/crates/el-config/src/config.rs b/crates/el-config/src/config.rs new file mode 100644 index 0000000..07e06e7 --- /dev/null +++ b/crates/el-config/src/config.rs @@ -0,0 +1,337 @@ +/// Config — layered configuration with typed access. +/// +/// Sources are stacked in priority order. The first source to provide a value +/// for a key wins. Resolution order: +/// 1. Environment variables (highest) +/// 2. .env file (dev only) +/// 3. el.toml [env.] section +/// 4. el.toml [config] section (base) +/// 5. Defaults defined in code (lowest) + +use std::collections::HashMap; +use crate::error::ConfigError; +use crate::source::{ConfigSource, FromConfigStr, MapSource}; +use crate::env::Environment; + +/// The main configuration object. +/// +/// Holds a stack of sources and resolves keys through them in order. +pub struct Config { + /// Sources in descending priority order (index 0 = highest priority). + sources: Vec>, + /// Environment in effect. + pub environment: Environment, +} + +impl Config { + /// Create an empty config with no sources. + pub fn new(env: Environment) -> Self { + Self { + sources: Vec::new(), + environment: env, + } + } + + /// Build with the default source stack for an application: + /// env vars > defaults map. + pub fn default_stack() -> Self { + use crate::source::EnvVarSource; + let env = Environment::current(); + let mut config = Self::new(env); + config.push_source(Box::new(EnvVarSource::new())); + config + } + + /// Add a source at the lowest priority (end of the stack). + pub fn push_source(&mut self, source: Box) { + self.sources.push(source); + } + + /// Add a source at the highest priority (beginning of the stack). + pub fn prepend_source(&mut self, source: Box) { + self.sources.insert(0, source); + } + + /// Add defaults as the lowest-priority source. + pub fn set_defaults(&mut self, defaults: HashMap) { + let src = MapSource::from_map("defaults", defaults); + self.sources.push(Box::new(src)); + } + + /// Get a raw string value for a key. + pub fn get_raw(&self, key: &str) -> Option { + for source in &self.sources { + if let Some(val) = source.get_raw(key) { + return Some(val); + } + } + None + } + + /// Get a typed value for a key. + /// + /// Returns an error if the key is not found or can't be parsed. + pub fn get(&self, key: &str) -> Result { + let raw = self.get_raw(key).ok_or_else(|| ConfigError::NotFound { + key: key.to_string(), + })?; + + T::from_config_str(&raw).map_err(|e| { + // Inject the key into TypeMismatch errors + match e { + ConfigError::TypeMismatch { expected, got, .. } => { + ConfigError::TypeMismatch { + key: key.to_string(), + expected, + got, + } + } + other => other, + } + }) + } + + /// Get a typed value with a fallback default. + pub fn get_or(&self, key: &str, default: T) -> T { + self.get(key).unwrap_or(default) + } + + /// Get an optional typed value. Returns None if not set (not an error). + pub fn get_opt(&self, key: &str) -> Result, ConfigError> { + match self.get_raw(key) { + None => Ok(None), + Some(raw) => T::from_config_str(&raw) + .map(Some) + .map_err(|e| match e { + ConfigError::TypeMismatch { expected, got, .. } => { + ConfigError::TypeMismatch { + key: key.to_string(), + expected, + got, + } + } + other => other, + }), + } + } + + /// All key→value pairs from all sources (merged, highest-priority wins). + pub fn all(&self) -> HashMap { + let mut result = HashMap::new(); + // Iterate in reverse order (lowest priority first) so higher-priority + // sources overwrite lower-priority ones. + for source in self.sources.iter().rev() { + for (k, v) in source.all() { + result.insert(k, v); + } + } + result + } +} + +/// Load config from an `el.toml` string. +/// +/// Reads `[config]` as the base, then overlays `[env.]`. +pub fn load_from_toml(toml_str: &str, env: &Environment) -> Result { + let value: toml::Value = toml::from_str(toml_str) + .map_err(|e| ConfigError::ParseError(e.to_string()))?; + + let mut map = HashMap::new(); + + // Load base [config] section + if let Some(config_section) = value.get("config") { + if let Some(table) = config_section.as_table() { + flatten_toml_table(table, "", &mut map); + } + } + + // Overlay [env.] section + let env_key = env.name(); + if let Some(env_sections) = value.get("env") { + if let Some(env_table) = env_sections.get(env_key) { + if let Some(table) = env_table.as_table() { + flatten_toml_table(table, "", &mut map); + } + } + } + + Ok(MapSource::from_map("el.toml", map)) +} + +fn flatten_toml_table( + table: &toml::value::Table, + prefix: &str, + out: &mut HashMap, +) { + for (key, value) in table { + let full_key = if prefix.is_empty() { + key.clone() + } else { + format!("{}.{}", prefix, key) + }; + + match value { + toml::Value::String(s) => { + out.insert(full_key, s.clone()); + } + toml::Value::Integer(i) => { + out.insert(full_key, i.to_string()); + } + toml::Value::Float(f) => { + out.insert(full_key, f.to_string()); + } + toml::Value::Boolean(b) => { + out.insert(full_key, b.to_string()); + } + toml::Value::Table(t) => { + flatten_toml_table(t, &full_key, out); + } + _ => {} // Arrays, datetimes: skip for now + } + } +} + +/// Macro for typed config access on a global/injected Config. +/// +/// ```ignore +/// let name = config!(cfg, "app.name", String); +/// let port = config!(cfg, "server.port", u32, 8080); +/// ``` +#[macro_export] +macro_rules! config { + ($cfg:expr, $key:expr, $type:ty) => { + $cfg.get::<$type>($key) + }; + ($cfg:expr, $key:expr, $type:ty, $default:expr) => { + $cfg.get_or::<$type>($key, $default) + }; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::source::MapSource; + + fn make_config(pairs: &[(&str, &str)]) -> Config { + let mut src = MapSource::new("test"); + for (k, v) in pairs { + src.insert(*k, *v); + } + let mut cfg = Config::new(Environment::Development); + cfg.push_source(Box::new(src)); + cfg + } + + #[test] + fn get_string() { + let cfg = make_config(&[("app.name", "TestApp")]); + assert_eq!(cfg.get::("app.name").unwrap(), "TestApp"); + } + + #[test] + fn get_u32() { + let cfg = make_config(&[("server.port", "8080")]); + assert_eq!(cfg.get::("server.port").unwrap(), 8080u32); + } + + #[test] + fn get_bool() { + let cfg = make_config(&[("feature.enabled", "true")]); + assert_eq!(cfg.get::("feature.enabled").unwrap(), true); + } + + #[test] + fn get_missing_returns_error() { + let cfg = Config::new(Environment::Development); + assert!(cfg.get::("missing.key").is_err()); + } + + #[test] + fn get_or_default() { + let cfg = Config::new(Environment::Development); + assert_eq!(cfg.get_or("timeout", 30u32), 30u32); + } + + #[test] + fn get_or_prefers_source() { + let cfg = make_config(&[("timeout", "60")]); + assert_eq!(cfg.get_or("timeout", 30u32), 60u32); + } + + #[test] + fn higher_priority_source_wins() { + let mut cfg = Config::new(Environment::Development); + let mut low = MapSource::new("low"); + low.insert("key", "low-value"); + let mut high = MapSource::new("high"); + high.insert("key", "high-value"); + cfg.push_source(Box::new(high)); + cfg.push_source(Box::new(low)); + // First source (index 0) is highest priority + assert_eq!(cfg.get::("key").unwrap(), "high-value"); + } + + #[test] + fn get_opt_missing_is_none() { + let cfg = Config::new(Environment::Development); + assert_eq!(cfg.get_opt::("missing").unwrap(), None); + } + + #[test] + fn get_opt_present_is_some() { + let cfg = make_config(&[("key", "value")]); + assert_eq!( + cfg.get_opt::("key").unwrap(), + Some("value".to_string()) + ); + } + + #[test] + fn load_from_toml_base() { + let toml = r#" +[config] +app.name = "MyApp" +app.version = "1.0.0" +"#; + let src = load_from_toml(toml, &Environment::Development).unwrap(); + assert_eq!(src.get_raw("app.name"), Some("MyApp".to_string())); + } + + #[test] + fn load_from_toml_env_overlay() { + let toml = r#" +[config] +api.base_url = "https://api.example.com" + +[env.development] +api.base_url = "http://localhost:8080" +"#; + let src = load_from_toml(toml, &Environment::Development).unwrap(); + assert_eq!( + src.get_raw("api.base_url"), + Some("http://localhost:8080".to_string()) + ); + } + + #[test] + fn load_from_toml_env_does_not_override_in_prod() { + let toml = r#" +[config] +api.base_url = "https://api.example.com" + +[env.development] +api.base_url = "http://localhost:8080" +"#; + let src = load_from_toml(toml, &Environment::Production).unwrap(); + assert_eq!( + src.get_raw("api.base_url"), + Some("https://api.example.com".to_string()) + ); + } + + #[test] + fn load_from_toml_invalid() { + let result = load_from_toml("not valid toml %%%", &Environment::Development); + assert!(result.is_err()); + } +} diff --git a/crates/el-config/src/env.rs b/crates/el-config/src/env.rs new file mode 100644 index 0000000..dab5601 --- /dev/null +++ b/crates/el-config/src/env.rs @@ -0,0 +1,114 @@ +/// Environment detection — which deployment context are we in? + +/// The current deployment environment. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Environment { + /// Local developer machine. Verbose errors, hot reload, relaxed auth. + Development, + /// Pre-production environment. Production build, test data. + Staging, + /// Live production. Minimal logging, strict auth, performance mode. + Production, +} + +impl Environment { + /// Detect from the `EL_ENV` environment variable (or `APP_ENV`, `RUST_ENV`). + /// + /// Falls back to Development if unset or unrecognized. + pub fn current() -> Self { + let val = std::env::var("EL_ENV") + .or_else(|_| std::env::var("APP_ENV")) + .or_else(|_| std::env::var("RUST_ENV")) + .unwrap_or_default(); + + Self::from_str(&val) + } + + /// Parse from a string. + pub fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "production" | "prod" => Environment::Production, + "staging" | "stage" => Environment::Staging, + _ => Environment::Development, + } + } + + /// The canonical name for this environment. + pub fn name(&self) -> &'static str { + match self { + Environment::Development => "development", + Environment::Staging => "staging", + Environment::Production => "production", + } + } + + /// Whether this is a production environment. + pub fn is_production(&self) -> bool { + matches!(self, Environment::Production) + } + + /// Whether this is a development environment. + pub fn is_development(&self) -> bool { + matches!(self, Environment::Development) + } + + /// Whether debug features should be enabled. + pub fn debug_enabled(&self) -> bool { + !self.is_production() + } +} + +impl std::fmt::Display for Environment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_production() { + assert_eq!(Environment::from_str("production"), Environment::Production); + assert_eq!(Environment::from_str("prod"), Environment::Production); + assert_eq!(Environment::from_str("PRODUCTION"), Environment::Production); + } + + #[test] + fn parse_staging() { + assert_eq!(Environment::from_str("staging"), Environment::Staging); + assert_eq!(Environment::from_str("stage"), Environment::Staging); + } + + #[test] + fn parse_development_fallback() { + assert_eq!(Environment::from_str("dev"), Environment::Development); + assert_eq!(Environment::from_str(""), Environment::Development); + assert_eq!(Environment::from_str("unknown"), Environment::Development); + } + + #[test] + fn production_is_production() { + assert!(Environment::Production.is_production()); + assert!(!Environment::Development.is_production()); + } + + #[test] + fn development_debug_enabled() { + assert!(Environment::Development.debug_enabled()); + assert!(!Environment::Production.debug_enabled()); + } + + #[test] + fn environment_name() { + assert_eq!(Environment::Production.name(), "production"); + assert_eq!(Environment::Staging.name(), "staging"); + assert_eq!(Environment::Development.name(), "development"); + } + + #[test] + fn display() { + assert_eq!(format!("{}", Environment::Production), "production"); + } +} diff --git a/crates/el-config/src/error.rs b/crates/el-config/src/error.rs new file mode 100644 index 0000000..ed18a23 --- /dev/null +++ b/crates/el-config/src/error.rs @@ -0,0 +1,20 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("config key '{key}' not found")] + NotFound { key: String }, + + #[error("config key '{key}': expected {expected}, got '{got}'")] + TypeMismatch { + key: String, + expected: String, + got: String, + }, + + #[error("config parse error: {0}")] + ParseError(String), + + #[error("config source error '{source_name}': {message}")] + SourceError { source_name: String, message: String }, +} diff --git a/crates/el-config/src/lib.rs b/crates/el-config/src/lib.rs new file mode 100644 index 0000000..1f09241 --- /dev/null +++ b/crates/el-config/src/lib.rs @@ -0,0 +1,42 @@ +//! el-config — Layered, typed configuration for el-ui applications. +//! +//! ## Resolution order (highest priority wins) +//! +//! 1. Environment variables (`EL_APP_NAME=...`) +//! 2. `.env` file (development only) +//! 3. `el.toml` `[env.]` section +//! 4. `el.toml` `[config]` base section +//! 5. Defaults defined in code +//! +//! ## Quick start +//! +//! ``` +//! use el_config::prelude::*; +//! +//! let mut cfg = Config::new(Environment::Development); +//! let mut defaults = std::collections::HashMap::new(); +//! defaults.insert("app.name".to_string(), "MyApp".to_string()); +//! defaults.insert("server.port".to_string(), "8080".to_string()); +//! cfg.set_defaults(defaults); +//! +//! let name = cfg.get::("app.name").unwrap(); +//! let port = cfg.get::("server.port").unwrap(); +//! assert_eq!(name, "MyApp"); +//! assert_eq!(port, 8080); +//! ``` + +#![deny(warnings)] + +pub mod config; +pub mod env; +pub mod error; +pub mod source; + +pub mod prelude { + pub use crate::config::{load_from_toml, Config}; + pub use crate::env::Environment; + pub use crate::error::ConfigError; + pub use crate::source::{ConfigSource, EnvVarSource, FromConfigStr, MapSource}; +} + +pub use prelude::*; diff --git a/crates/el-config/src/source.rs b/crates/el-config/src/source.rs new file mode 100644 index 0000000..6d7cb43 --- /dev/null +++ b/crates/el-config/src/source.rs @@ -0,0 +1,248 @@ +/// ConfigSource trait and implementations. +/// +/// Each source provides key→value pairs. Sources are stacked in priority order; +/// the Config struct resolves by asking each source in turn. + +use std::collections::HashMap; +use crate::error::ConfigError; + +/// A source of configuration values. +pub trait ConfigSource: Send + Sync { + /// The name of this source (for debugging/error messages). + fn name(&self) -> &str; + + /// Get a raw string value for a key. + /// Returns None if this source doesn't have the key. + fn get_raw(&self, key: &str) -> Option; + + /// All key→value pairs from this source. + fn all(&self) -> HashMap; +} + +/// Reads from environment variables. +/// +/// Keys are mapped: `app.name` → `EL_APP_NAME` (uppercased, dots → underscores). +pub struct EnvVarSource { + /// Optional prefix. Default: "EL". + prefix: String, +} + +impl EnvVarSource { + pub fn new() -> Self { + Self { prefix: "EL".to_string() } + } + + pub fn with_prefix(prefix: impl Into) -> Self { + Self { prefix: prefix.into() } + } + + fn env_key(&self, key: &str) -> String { + let normalized = key.replace('.', "_").replace('-', "_").to_uppercase(); + format!("{}_{}", self.prefix, normalized) + } +} + +impl Default for EnvVarSource { + fn default() -> Self { + Self::new() + } +} + +impl ConfigSource for EnvVarSource { + fn name(&self) -> &str { + "environment" + } + + fn get_raw(&self, key: &str) -> Option { + std::env::var(self.env_key(key)).ok() + } + + fn all(&self) -> HashMap { + let prefix = format!("{}_", self.prefix); + std::env::vars() + .filter(|(k, _)| k.starts_with(&prefix)) + .map(|(k, v)| { + let stripped = k.strip_prefix(&prefix).unwrap_or(&k); + let config_key = stripped.to_lowercase().replace('_', "."); + (config_key, v) + }) + .collect() + } +} + +/// Holds an in-memory map of config values. +/// +/// Used for defaults defined in code, or for config loaded from a parsed +/// TOML/JSON file section. +pub struct MapSource { + name: String, + values: HashMap, +} + +impl MapSource { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + values: HashMap::new(), + } + } + + pub fn insert(&mut self, key: impl Into, value: impl Into) { + self.values.insert(key.into(), value.into()); + } + + pub fn from_map(name: impl Into, map: HashMap) -> Self { + Self { + name: name.into(), + values: map, + } + } +} + +impl ConfigSource for MapSource { + fn name(&self) -> &str { + &self.name + } + + fn get_raw(&self, key: &str) -> Option { + self.values.get(key).cloned() + } + + fn all(&self) -> HashMap { + self.values.clone() + } +} + +/// Typed config value extractor. +pub trait FromConfigStr: Sized { + fn from_config_str(s: &str) -> Result; +} + +impl FromConfigStr for String { + fn from_config_str(s: &str) -> Result { + Ok(s.to_string()) + } +} + +impl FromConfigStr for u32 { + fn from_config_str(s: &str) -> Result { + s.parse().map_err(|_| ConfigError::TypeMismatch { + key: String::new(), + expected: "u32".to_string(), + got: s.to_string(), + }) + } +} + +impl FromConfigStr for u64 { + fn from_config_str(s: &str) -> Result { + s.parse().map_err(|_| ConfigError::TypeMismatch { + key: String::new(), + expected: "u64".to_string(), + got: s.to_string(), + }) + } +} + +impl FromConfigStr for i32 { + fn from_config_str(s: &str) -> Result { + s.parse().map_err(|_| ConfigError::TypeMismatch { + key: String::new(), + expected: "i32".to_string(), + got: s.to_string(), + }) + } +} + +impl FromConfigStr for i64 { + fn from_config_str(s: &str) -> Result { + s.parse().map_err(|_| ConfigError::TypeMismatch { + key: String::new(), + expected: "i64".to_string(), + got: s.to_string(), + }) + } +} + +impl FromConfigStr for f32 { + fn from_config_str(s: &str) -> Result { + s.parse().map_err(|_| ConfigError::TypeMismatch { + key: String::new(), + expected: "f32".to_string(), + got: s.to_string(), + }) + } +} + +impl FromConfigStr for f64 { + fn from_config_str(s: &str) -> Result { + s.parse().map_err(|_| ConfigError::TypeMismatch { + key: String::new(), + expected: "f64".to_string(), + got: s.to_string(), + }) + } +} + +impl FromConfigStr for bool { + fn from_config_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "true" | "1" | "yes" | "on" => Ok(true), + "false" | "0" | "no" | "off" => Ok(false), + _ => Err(ConfigError::TypeMismatch { + key: String::new(), + expected: "bool".to_string(), + got: s.to_string(), + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn map_source_get() { + let mut src = MapSource::new("test"); + src.insert("app.name", "TestApp"); + assert_eq!(src.get_raw("app.name"), Some("TestApp".to_string())); + } + + #[test] + fn map_source_missing() { + let src = MapSource::new("test"); + assert_eq!(src.get_raw("no.key"), None); + } + + #[test] + fn bool_from_config_str() { + assert_eq!(bool::from_config_str("true").unwrap(), true); + assert_eq!(bool::from_config_str("1").unwrap(), true); + assert_eq!(bool::from_config_str("false").unwrap(), false); + assert_eq!(bool::from_config_str("0").unwrap(), false); + } + + #[test] + fn u32_from_config_str() { + assert_eq!(u32::from_config_str("42").unwrap(), 42u32); + } + + #[test] + fn u32_type_mismatch() { + assert!(u32::from_config_str("not-a-number").is_err()); + } + + #[test] + fn env_key_mapping() { + let src = EnvVarSource::new(); + // app.name → EL_APP_NAME + assert_eq!(src.env_key("app.name"), "EL_APP_NAME"); + } + + #[test] + fn env_source_name() { + let src = EnvVarSource::new(); + assert_eq!(src.name(), "environment"); + } +} diff --git a/crates/el-i18n/Cargo.toml b/crates/el-i18n/Cargo.toml new file mode 100644 index 0000000..540b909 --- /dev/null +++ b/crates/el-i18n/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "el-i18n" +version = "0.1.0" +edition = "2021" +description = "el-ui localization — RTL-aware, plural forms, CLDR-based formatting" +license = "MIT" + +[lib] +name = "el_i18n" +path = "src/lib.rs" + +[dependencies] +thiserror = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" + +[dev-dependencies] diff --git a/crates/el-i18n/src/bundle.rs b/crates/el-i18n/src/bundle.rs new file mode 100644 index 0000000..ee60792 --- /dev/null +++ b/crates/el-i18n/src/bundle.rs @@ -0,0 +1,299 @@ +/// LocaleBundle — loads and caches translation strings. +/// +/// A bundle holds all translation strings for one locale. Strings are +/// keyed by dot-delimited paths (e.g. "profile.followers"). The bundle +/// supports both flat strings and plural forms. + +use std::collections::HashMap; +use crate::locale::Locale; +use crate::plural::{plural_form, PluralForm}; + +/// A single translation value — either a simple string or a plural map. +#[derive(Debug, Clone)] +pub enum TranslationValue { + /// A simple translated string. May contain `{key}` interpolation placeholders. + Simple(String), + /// A plural-form map. Keys are form names: "zero", "one", "two", "few", "many", "other". + Plural(HashMap), +} + +/// A bundle of translations for a single locale. +#[derive(Debug, Clone)] +pub struct LocaleBundle { + pub locale: Locale, + translations: HashMap, +} + +impl LocaleBundle { + /// Create an empty bundle for a locale. + pub fn new(locale: Locale) -> Self { + Self { + locale, + translations: HashMap::new(), + } + } + + /// Insert a simple translation. + pub fn insert(&mut self, key: impl Into, value: impl Into) { + self.translations.insert( + key.into(), + TranslationValue::Simple(value.into()), + ); + } + + /// Insert a plural translation. + pub fn insert_plural( + &mut self, + key: impl Into, + forms: HashMap, + ) { + self.translations + .insert(key.into(), TranslationValue::Plural(forms)); + } + + /// Look up a key and return the simple string (no interpolation). + pub fn get_raw(&self, key: &str) -> Option<&str> { + match self.translations.get(key)? { + TranslationValue::Simple(s) => Some(s.as_str()), + TranslationValue::Plural(_) => None, + } + } + + /// Look up a key with variable interpolation. + /// + /// Replaces `{name}` placeholders with values from `vars`. + pub fn translate(&self, key: &str, vars: &HashMap<&str, String>) -> Option { + let raw = match self.translations.get(key)? { + TranslationValue::Simple(s) => s.clone(), + TranslationValue::Plural(forms) => { + // For translate(), use "other" as default + forms.get("other")?.clone() + } + }; + Some(interpolate(&raw, vars)) + } + + /// Look up a plural key with a count. + /// + /// Selects the correct plural form for the locale's language and count, + /// then interpolates `{n}` and any other `vars`. + pub fn translate_plural( + &self, + key: &str, + count: i64, + vars: &HashMap<&str, String>, + ) -> Option { + let forms = match self.translations.get(key)? { + TranslationValue::Plural(f) => f, + TranslationValue::Simple(s) => { + // Fall through: treat the simple string as "other" + let mut result_vars = vars.clone(); + result_vars.insert("n", count.to_string()); + return Some(interpolate(s, &result_vars)); + } + }; + + let form = plural_form(&self.locale.language, count); + let form_key = match form { + PluralForm::Zero => "zero", + PluralForm::One => "one", + PluralForm::Two => "two", + PluralForm::Few => "few", + PluralForm::Many => "many", + PluralForm::Other => "other", + }; + + let template = forms + .get(form_key) + .or_else(|| forms.get("other"))?; + + let mut result_vars = vars.clone(); + result_vars.insert("n", count.to_string()); + Some(interpolate(template, &result_vars)) + } + + /// Number of translations loaded. + pub fn len(&self) -> usize { + self.translations.len() + } + + pub fn is_empty(&self) -> bool { + self.translations.is_empty() + } +} + +/// Replace `{key}` placeholders in `template` with values from `vars`. +fn interpolate(template: &str, vars: &HashMap<&str, String>) -> String { + let mut result = template.to_string(); + for (key, value) in vars { + result = result.replace(&format!("{{{}}}", key), value); + } + result +} + +/// Load a bundle from a TOML string. +/// +/// Format: +/// ```toml +/// [profile] +/// follow = "Follow" +/// followers = { one = "{n} Follower", other = "{n} Followers" } +/// ``` +pub fn load_toml(locale: Locale, toml_str: &str) -> Result { + let value: toml::Value = toml::from_str(toml_str) + .map_err(|e| format!("TOML parse error: {}", e))?; + + let mut bundle = LocaleBundle::new(locale); + + if let toml::Value::Table(table) = value { + load_table(&mut bundle, &table, ""); + } + + Ok(bundle) +} + +fn load_table(bundle: &mut LocaleBundle, table: &toml::value::Table, prefix: &str) { + for (key, value) in table { + let full_key = if prefix.is_empty() { + key.clone() + } else { + format!("{}.{}", prefix, key) + }; + + match value { + toml::Value::String(s) => { + bundle.insert(full_key, s.clone()); + } + toml::Value::Table(inner) => { + // Check if it's a plural table (has "one", "other", etc.) + let is_plural = inner.contains_key("one") + || inner.contains_key("other") + || inner.contains_key("zero") + || inner.contains_key("few") + || inner.contains_key("many"); + + if is_plural { + let mut forms = HashMap::new(); + for (form, form_val) in inner { + if let toml::Value::String(s) = form_val { + forms.insert(form.clone(), s.clone()); + } + } + bundle.insert_plural(full_key, forms); + } else { + // Nested namespace + load_table(bundle, inner, &full_key); + } + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn en_bundle() -> LocaleBundle { + let mut b = LocaleBundle::new(Locale::en_us()); + b.insert("profile.follow", "Follow"); + b.insert("profile.bio", "Bio"); + let mut forms = HashMap::new(); + forms.insert("one".to_string(), "{n} Follower".to_string()); + forms.insert("other".to_string(), "{n} Followers".to_string()); + b.insert_plural("profile.followers", forms); + b + } + + #[test] + fn get_raw_simple() { + let b = en_bundle(); + assert_eq!(b.get_raw("profile.follow"), Some("Follow")); + } + + #[test] + fn get_raw_missing() { + let b = en_bundle(); + assert_eq!(b.get_raw("nonexistent.key"), None); + } + + #[test] + fn translate_simple() { + let b = en_bundle(); + let vars = HashMap::new(); + assert_eq!( + b.translate("profile.follow", &vars), + Some("Follow".to_string()) + ); + } + + #[test] + fn translate_plural_one() { + let b = en_bundle(); + let vars = HashMap::new(); + assert_eq!( + b.translate_plural("profile.followers", 1, &vars), + Some("1 Follower".to_string()) + ); + } + + #[test] + fn translate_plural_many() { + let b = en_bundle(); + let vars = HashMap::new(); + assert_eq!( + b.translate_plural("profile.followers", 42, &vars), + Some("42 Followers".to_string()) + ); + } + + #[test] + fn interpolation_replaces_placeholder() { + let mut b = LocaleBundle::new(Locale::en_us()); + b.insert("greeting", "Hello, {name}!"); + let mut vars = HashMap::new(); + vars.insert("name", "Alice".to_string()); + assert_eq!( + b.translate("greeting", &vars), + Some("Hello, Alice!".to_string()) + ); + } + + #[test] + fn bundle_len() { + let b = en_bundle(); + assert_eq!(b.len(), 3); + } + + #[test] + fn load_toml_simple() { + let toml = r#" +[profile] +follow = "Follow" +bio = "Bio" +"#; + let bundle = load_toml(Locale::en_us(), toml).unwrap(); + assert_eq!(bundle.get_raw("profile.follow"), Some("Follow")); + assert_eq!(bundle.get_raw("profile.bio"), Some("Bio")); + } + + #[test] + fn load_toml_plural() { + let toml = r#" +[profile] +followers = { one = "{n} Follower", other = "{n} Followers" } +"#; + let bundle = load_toml(Locale::en_us(), toml).unwrap(); + let vars = HashMap::new(); + assert_eq!( + bundle.translate_plural("profile.followers", 1, &vars), + Some("1 Follower".to_string()) + ); + } + + #[test] + fn load_toml_invalid() { + let result = load_toml(Locale::en_us(), "not valid toml %%%"); + assert!(result.is_err()); + } +} diff --git a/crates/el-i18n/src/format.rs b/crates/el-i18n/src/format.rs new file mode 100644 index 0000000..865380c --- /dev/null +++ b/crates/el-i18n/src/format.rs @@ -0,0 +1,221 @@ +/// Number, date, and currency formatting per locale. +/// +/// Formatting is locale-sensitive: number grouping, decimal separators, +/// currency symbol placement, and date ordering all vary by locale. +/// Use these formatters rather than hardcoding formatting logic. + +use crate::locale::Locale; + +/// Format a number with locale-appropriate grouping and decimals. +/// +/// Examples: +/// - en-US: 1,234,567.89 +/// - de-DE: 1.234.567,89 +/// - fr-FR: 1 234 567,89 +pub fn format_number(value: f64, locale: &Locale, decimal_places: usize) -> String { + let (group_sep, decimal_sep) = separators_for_locale(locale); + + let rounded = round_to(value, decimal_places); + let is_negative = rounded < 0.0; + let abs_value = rounded.abs(); + + let int_part = abs_value.trunc() as u64; + let frac_part = ((abs_value.fract() * 10f64.powi(decimal_places as i32)).round()) as u64; + + let int_str = format_integer_with_grouping(int_part, group_sep); + + let result = if decimal_places > 0 { + format!( + "{}{}{}", + int_str, + decimal_sep, + format!("{:0>width$}", frac_part, width = decimal_places) + ) + } else { + int_str + }; + + if is_negative { + format!("-{}", result) + } else { + result + } +} + +/// Format a currency value with locale-appropriate symbol and placement. +/// +/// Examples: +/// - en-US / USD: $1,234.56 +/// - de-DE / EUR: 1.234,56 € +/// - ja / JPY: ¥1,235 +pub fn format_currency(value: f64, locale: &Locale, currency_code: &str) -> String { + let (symbol, prefix, decimals) = currency_info(currency_code); + let formatted = format_number(value, locale, decimals); + + if prefix { + format!("{}{}", symbol, formatted) + } else { + format!("{} {}", formatted, symbol) + } +} + +/// Format an integer with locale-appropriate grouping separators. +pub fn format_integer(value: i64, locale: &Locale) -> String { + let (group_sep, _) = separators_for_locale(locale); + let is_negative = value < 0; + let abs_val = value.unsigned_abs(); + let grouped = format_integer_with_grouping(abs_val, group_sep); + if is_negative { + format!("-{}", grouped) + } else { + grouped + } +} + +/// Format a percentage (0.85 → "85%", locale-aware). +pub fn format_percent(value: f64, locale: &Locale, decimal_places: usize) -> String { + let pct = value * 100.0; + let (_, decimal_sep) = separators_for_locale(locale); + let int_part = pct.trunc() as u64; + let frac = ((pct.fract() * 10f64.powi(decimal_places as i32)).round()) as u64; + + if decimal_places > 0 { + format!( + "{}{}{}%", + int_part, + decimal_sep, + format!("{:0>width$}", frac, width = decimal_places) + ) + } else { + format!("{}%", int_part) + } +} + +// --- Internal helpers --- + +fn separators_for_locale(locale: &Locale) -> (char, char) { + match locale.language.as_str() { + // Comma grouping, period decimal (en-US style) + "en" | "ja" | "ko" | "zh" | "th" => (',', '.'), + // Period grouping, comma decimal (European style) + "de" | "nl" | "it" | "pt" | "es" | "tr" | "pl" | "ru" | "uk" | "el" => ('.', ','), + // Thin space grouping, comma decimal (French style) + "fr" | "sv" | "no" | "nb" | "da" | "fi" => ('\u{202F}', ','), + // Default: comma grouping, period decimal + _ => (',', '.'), + } +} + +fn format_integer_with_grouping(value: u64, sep: char) -> String { + let s = value.to_string(); + if s.len() <= 3 { + return s; + } + let mut result = String::new(); + let chars: Vec = s.chars().collect(); + let len = chars.len(); + for (i, &ch) in chars.iter().enumerate() { + if i > 0 && (len - i) % 3 == 0 { + result.push(sep); + } + result.push(ch); + } + result +} + +fn round_to(value: f64, places: usize) -> f64 { + let factor = 10f64.powi(places as i32); + (value * factor).round() / factor +} + +fn currency_info(code: &str) -> (&'static str, bool, usize) { + // (symbol, prefix, decimal_places) + match code.to_uppercase().as_str() { + "USD" => ("$", true, 2), + "EUR" => ("€", false, 2), + "GBP" => ("£", true, 2), + "JPY" => ("¥", true, 0), + "CNY" => ("¥", true, 2), + "KRW" => ("₩", true, 0), + "INR" => ("₹", true, 2), + "CHF" => ("CHF", true, 2), + "CAD" => ("CA$", true, 2), + "AUD" => ("A$", true, 2), + "BRL" => ("R$", true, 2), + "MXN" => ("MX$", true, 2), + "RUB" => ("₽", false, 2), + "SEK" => ("kr", false, 2), + "NOK" => ("kr", false, 2), + "DKK" => ("kr", false, 2), + "PLN" => ("zł", false, 2), + "TRY" => ("₺", true, 2), + "SAR" => ("﷼", false, 2), + "AED" => ("د.إ", false, 2), + _ => ("¤", true, 2), // generic currency sign for unknown codes + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_number_en_us() { + let locale = Locale::en_us(); + assert_eq!(format_number(1234567.89, &locale, 2), "1,234,567.89"); + } + + #[test] + fn format_number_de() { + let locale = Locale::new("de-DE"); + assert_eq!(format_number(1234.56, &locale, 2), "1.234,56"); + } + + #[test] + fn format_number_no_decimals() { + let locale = Locale::en_us(); + assert_eq!(format_number(42.0, &locale, 0), "42"); + } + + #[test] + fn format_number_negative() { + let locale = Locale::en_us(); + assert_eq!(format_number(-1000.0, &locale, 2), "-1,000.00"); + } + + #[test] + fn format_currency_usd() { + let locale = Locale::en_us(); + assert_eq!(format_currency(1234.56, &locale, "USD"), "$1,234.56"); + } + + #[test] + fn format_currency_jpy_no_decimals() { + let locale = Locale::ja(); + assert_eq!(format_currency(1234.0, &locale, "JPY"), "¥1,234"); + } + + #[test] + fn format_integer_groups() { + let locale = Locale::en_us(); + assert_eq!(format_integer(1000000, &locale), "1,000,000"); + } + + #[test] + fn format_integer_small() { + let locale = Locale::en_us(); + assert_eq!(format_integer(42, &locale), "42"); + } + + #[test] + fn format_percent_whole() { + let locale = Locale::en_us(); + assert_eq!(format_percent(0.85, &locale, 0), "85%"); + } + + #[test] + fn format_percent_with_decimal() { + let locale = Locale::en_us(); + assert_eq!(format_percent(0.856, &locale, 1), "85.6%"); + } +} diff --git a/crates/el-i18n/src/lib.rs b/crates/el-i18n/src/lib.rs new file mode 100644 index 0000000..06876d7 --- /dev/null +++ b/crates/el-i18n/src/lib.rs @@ -0,0 +1,44 @@ +//! el-i18n — Localization for el-ui. +//! +//! RTL-aware, plural forms, CLDR-based number/currency formatting. +//! +//! ## Quick start +//! +//! ``` +//! use el_i18n::prelude::*; +//! use std::collections::HashMap; +//! +//! // Build a bundle +//! let mut bundle = LocaleBundle::new(Locale::en_us()); +//! bundle.insert("profile.follow", "Follow"); +//! let mut forms = HashMap::new(); +//! forms.insert("one".to_string(), "{n} Follower".to_string()); +//! forms.insert("other".to_string(), "{n} Followers".to_string()); +//! bundle.insert_plural("profile.followers", forms); +//! +//! // Create a context +//! let ctx = LocaleContext::new(Locale::en_us(), bundle); +//! +//! // Translate +//! assert_eq!(ctx.t("profile.follow"), "Follow"); +//! assert_eq!(ctx.t_plural("profile.followers", 1), "1 Follower"); +//! assert_eq!(ctx.t_plural("profile.followers", 42), "42 Followers"); +//! ``` + +#![deny(warnings)] + +pub mod bundle; +pub mod format; +pub mod locale; +pub mod plural; +pub mod t; + +pub mod prelude { + pub use crate::bundle::{load_toml, LocaleBundle, TranslationValue}; + pub use crate::format::{format_currency, format_integer, format_number, format_percent}; + pub use crate::locale::{Locale, TextDirection}; + pub use crate::plural::{plural_form, PluralForm}; + pub use crate::t::LocaleContext; +} + +pub use prelude::*; diff --git a/crates/el-i18n/src/locale.rs b/crates/el-i18n/src/locale.rs new file mode 100644 index 0000000..9ba968f --- /dev/null +++ b/crates/el-i18n/src/locale.rs @@ -0,0 +1,182 @@ +/// Locale — language + optional region + directionality. +/// +/// Locale identifies both the language for translation lookup and the +/// region for number/date/currency formatting. + +/// Text direction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextDirection { + LeftToRight, + RightToLeft, +} + +/// A locale identifier. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Locale { + /// BCP 47 language tag (e.g. "en", "ar", "zh-Hant"). + pub language: String, + /// Optional region (e.g. "US", "GB", "TW"). + pub region: Option, +} + +impl Locale { + /// Create from a BCP 47 tag like "en-US" or "ar". + pub fn new(tag: impl Into) -> Self { + let tag = tag.into(); + if let Some(idx) = tag.find('-') { + let (lang, rest) = tag.split_at(idx); + let region = rest.trim_start_matches('-'); + Self { + language: lang.to_lowercase(), + region: if region.is_empty() { + None + } else { + Some(region.to_uppercase()) + }, + } + } else { + Self { + language: tag.to_lowercase(), + region: None, + } + } + } + + /// The full BCP 47 tag (e.g. "en-US"). + pub fn tag(&self) -> String { + match &self.region { + Some(r) => format!("{}-{}", self.language, r), + None => self.language.clone(), + } + } + + /// The text direction for this locale. + pub fn direction(&self) -> TextDirection { + if self.is_rtl() { + TextDirection::RightToLeft + } else { + TextDirection::LeftToRight + } + } + + /// Whether this locale uses right-to-left script. + pub fn is_rtl(&self) -> bool { + // RTL language codes per Unicode CLDR + matches!( + self.language.as_str(), + "ar" // Arabic + | "he" | "iw" // Hebrew + | "fa" | "per" // Persian/Farsi + | "ur" // Urdu + | "ps" // Pashto + | "ug" // Uyghur + | "yi" // Yiddish + | "dv" // Maldivian/Dhivehi + | "ku" // Kurdish (some scripts) + | "sd" // Sindhi + ) + } + + /// English (US). + pub fn en_us() -> Self { + Self::new("en-US") + } + + /// English (GB). + pub fn en_gb() -> Self { + Self::new("en-GB") + } + + /// Arabic (a common RTL locale). + pub fn ar() -> Self { + Self::new("ar") + } + + /// Arabic (Saudi Arabia). + pub fn ar_sa() -> Self { + Self::new("ar-SA") + } + + /// Spanish (Spain). + pub fn es_es() -> Self { + Self::new("es-ES") + } + + /// French (France). + pub fn fr_fr() -> Self { + Self::new("fr-FR") + } + + /// Japanese. + pub fn ja() -> Self { + Self::new("ja") + } + + /// Chinese (Traditional). + pub fn zh_hant() -> Self { + Self::new("zh-Hant") + } +} + +impl std::fmt::Display for Locale { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.tag()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn locale_parse_with_region() { + let l = Locale::new("en-US"); + assert_eq!(l.language, "en"); + assert_eq!(l.region, Some("US".to_string())); + } + + #[test] + fn locale_parse_without_region() { + let l = Locale::new("ja"); + assert_eq!(l.language, "ja"); + assert_eq!(l.region, None); + } + + #[test] + fn locale_tag_roundtrip() { + let l = Locale::new("fr-FR"); + assert_eq!(l.tag(), "fr-FR"); + } + + #[test] + fn arabic_is_rtl() { + assert!(Locale::new("ar").is_rtl()); + assert!(Locale::new("ar-SA").is_rtl()); + } + + #[test] + fn hebrew_is_rtl() { + assert!(Locale::new("he").is_rtl()); + } + + #[test] + fn persian_is_rtl() { + assert!(Locale::new("fa").is_rtl()); + } + + #[test] + fn english_is_ltr() { + assert!(!Locale::new("en").is_rtl()); + assert_eq!(Locale::new("en-US").direction(), TextDirection::LeftToRight); + } + + #[test] + fn rtl_direction() { + assert_eq!(Locale::ar().direction(), TextDirection::RightToLeft); + } + + #[test] + fn locale_display() { + assert_eq!(format!("{}", Locale::en_us()), "en-US"); + } +} diff --git a/crates/el-i18n/src/plural.rs b/crates/el-i18n/src/plural.rs new file mode 100644 index 0000000..b4dc6be --- /dev/null +++ b/crates/el-i18n/src/plural.rs @@ -0,0 +1,189 @@ +/// Plural forms — handles language-specific plurality rules. +/// +/// Different languages have very different plural forms. English has two +/// (one / other). Russian has four. Arabic has six. This module maps counts +/// to the correct form for a given locale. + +/// Named plural categories per Unicode CLDR. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PluralForm { + /// Exactly zero (some languages have a special zero form). + Zero, + /// Exactly one. + One, + /// Small numbers (e.g. 2–4 in Slavic languages). + Two, + /// Few (language-specific). + Few, + /// Many (language-specific). + Many, + /// The catch-all / default. + Other, +} + +/// Determine the plural form for a count in a given language. +/// +/// Rules are a simplified implementation of CLDR plural rules for the most +/// common languages. The full CLDR spec covers hundreds of languages; these +/// cover the major cases. +pub fn plural_form(language: &str, count: i64) -> PluralForm { + let n = count.unsigned_abs(); // absolute value for matching + + match language { + // English and most Western European languages: one / other + "en" | "de" | "nl" | "sv" | "da" | "no" | "nb" | "nn" | "fi" + | "et" | "hu" | "tr" | "pt" | "it" | "es" | "ca" | "el" | "id" + | "ms" | "th" | "zh" | "ja" | "ko" | "vi" | "ur" => { + if n == 1 { PluralForm::One } else { PluralForm::Other } + } + + // French: one for 0 and 1, other for rest + "fr" => { + if n <= 1 { PluralForm::One } else { PluralForm::Other } + } + + // Russian, Ukrainian, Belarusian: complex Slavic rules + "ru" | "uk" | "be" => { + let n10 = n % 10; + let n100 = n % 100; + if n10 == 1 && n100 != 11 { + PluralForm::One + } else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) { + PluralForm::Few + } else { + PluralForm::Many + } + } + + // Polish: similar Slavic rules + "pl" => { + let n10 = n % 10; + let n100 = n % 100; + if n == 1 { + PluralForm::One + } else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) { + PluralForm::Few + } else { + PluralForm::Many + } + } + + // Czech, Slovak + "cs" | "sk" => { + if n == 1 { + PluralForm::One + } else if (2..=4).contains(&n) { + PluralForm::Few + } else { + PluralForm::Other + } + } + + // Arabic: 6 forms + "ar" => { + let n100 = n % 100; + if n == 0 { + PluralForm::Zero + } else if n == 1 { + PluralForm::One + } else if n == 2 { + PluralForm::Two + } else if (3..=10).contains(&n100) { + PluralForm::Few + } else if (11..=99).contains(&n100) { + PluralForm::Many + } else { + PluralForm::Other + } + } + + // Hebrew + "he" | "iw" => { + if n == 1 { + PluralForm::One + } else if n == 2 { + PluralForm::Two + } else if n >= 11 && n % 10 == 0 { + PluralForm::Many + } else { + PluralForm::Other + } + } + + // Default: one / other + _ => { + if n == 1 { PluralForm::One } else { PluralForm::Other } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn english_one() { + assert_eq!(plural_form("en", 1), PluralForm::One); + } + + #[test] + fn english_other() { + assert_eq!(plural_form("en", 0), PluralForm::Other); + assert_eq!(plural_form("en", 2), PluralForm::Other); + assert_eq!(plural_form("en", 100), PluralForm::Other); + } + + #[test] + fn french_zero_is_one() { + assert_eq!(plural_form("fr", 0), PluralForm::One); + assert_eq!(plural_form("fr", 1), PluralForm::One); + assert_eq!(plural_form("fr", 2), PluralForm::Other); + } + + #[test] + fn russian_one() { + assert_eq!(plural_form("ru", 1), PluralForm::One); + assert_eq!(plural_form("ru", 21), PluralForm::One); + assert_eq!(plural_form("ru", 101), PluralForm::One); + } + + #[test] + fn russian_few() { + assert_eq!(plural_form("ru", 2), PluralForm::Few); + assert_eq!(plural_form("ru", 3), PluralForm::Few); + assert_eq!(plural_form("ru", 22), PluralForm::Few); + } + + #[test] + fn russian_many() { + assert_eq!(plural_form("ru", 5), PluralForm::Many); + assert_eq!(plural_form("ru", 11), PluralForm::Many); + assert_eq!(plural_form("ru", 20), PluralForm::Many); + } + + #[test] + fn arabic_zero() { + assert_eq!(plural_form("ar", 0), PluralForm::Zero); + } + + #[test] + fn arabic_two() { + assert_eq!(plural_form("ar", 2), PluralForm::Two); + } + + #[test] + fn arabic_few() { + assert_eq!(plural_form("ar", 5), PluralForm::Few); + assert_eq!(plural_form("ar", 10), PluralForm::Few); + } + + #[test] + fn arabic_many() { + assert_eq!(plural_form("ar", 15), PluralForm::Many); + } + + #[test] + fn hebrew_two() { + assert_eq!(plural_form("he", 2), PluralForm::Two); + } +} diff --git a/crates/el-i18n/src/t.rs b/crates/el-i18n/src/t.rs new file mode 100644 index 0000000..281f25a --- /dev/null +++ b/crates/el-i18n/src/t.rs @@ -0,0 +1,209 @@ +/// The t() translation function and LocaleContext. +/// +/// Components call `ctx.t("key")` or `ctx.t_plural("key", count)`. +/// The context flows down from the experience root and carries the active bundle. + +use std::collections::HashMap; +use crate::bundle::LocaleBundle; +use crate::locale::Locale; + +/// The active localization context. +/// +/// Holds the current locale and its translation bundle. Passed down +/// through the component tree. When the locale changes, the context +/// is updated and all components that used it re-render. +#[derive(Debug, Clone)] +pub struct LocaleContext { + pub locale: Locale, + bundle: LocaleBundle, + /// Fallback bundle (typically English) used when a key is missing. + fallback: Option, +} + +impl LocaleContext { + /// Create a context with a locale and its bundle. + pub fn new(locale: Locale, bundle: LocaleBundle) -> Self { + Self { + locale, + bundle, + fallback: None, + } + } + + /// Set a fallback bundle for missing keys. + pub fn with_fallback(mut self, fallback: LocaleBundle) -> Self { + self.fallback = Some(fallback); + self + } + + /// Translate a key with optional variable interpolation. + /// + /// Returns the key itself if not found (never panics). + pub fn t(&self, key: &str) -> String { + self.t_vars(key, &HashMap::new()) + } + + /// Translate a key with variables. + pub fn t_vars(&self, key: &str, vars: &HashMap<&str, String>) -> String { + // Try primary bundle + if let Some(s) = self.bundle.translate(key, vars) { + return s; + } + // Try fallback bundle + if let Some(ref fb) = self.fallback { + if let Some(s) = fb.translate(key, vars) { + return s; + } + } + // Return the key itself — visible but never panics + key.to_string() + } + + /// Translate a plural key with a count. + pub fn t_plural(&self, key: &str, count: i64) -> String { + self.t_plural_vars(key, count, &HashMap::new()) + } + + /// Translate a plural key with a count and extra variables. + pub fn t_plural_vars( + &self, + key: &str, + count: i64, + vars: &HashMap<&str, String>, + ) -> String { + if let Some(s) = self.bundle.translate_plural(key, count, vars) { + return s; + } + if let Some(ref fb) = self.fallback { + if let Some(s) = fb.translate_plural(key, count, vars) { + return s; + } + } + key.to_string() + } + + /// The current locale tag (e.g. "en-US"). + pub fn locale_tag(&self) -> String { + self.locale.tag() + } + + /// Whether the current locale is RTL. + pub fn is_rtl(&self) -> bool { + self.locale.is_rtl() + } +} + +/// Convenience macro for translation calls. +/// +/// ```ignore +/// // Simple +/// let text = t!(ctx, "profile.follow"); +/// +/// // With variables +/// let text = t!(ctx, "greeting", name => "Alice"); +/// +/// // Plural +/// let text = t_n!(ctx, "profile.followers", count); +/// ``` +#[macro_export] +macro_rules! t { + ($ctx:expr, $key:expr) => { + $ctx.t($key) + }; + ($ctx:expr, $key:expr, $($var:ident => $val:expr),+) => {{ + let mut vars = std::collections::HashMap::new(); + $(vars.insert(stringify!($var), $val.to_string());)+ + $ctx.t_vars($key, &vars) + }}; +} + +#[macro_export] +macro_rules! t_n { + ($ctx:expr, $key:expr, $count:expr) => { + $ctx.t_plural($key, $count as i64) + }; + ($ctx:expr, $key:expr, $count:expr, $($var:ident => $val:expr),+) => {{ + let mut vars = std::collections::HashMap::new(); + $(vars.insert(stringify!($var), $val.to_string());)+ + $ctx.t_plural_vars($key, $count as i64, &vars) + }}; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bundle::LocaleBundle; + + fn make_ctx() -> LocaleContext { + let mut bundle = LocaleBundle::new(Locale::en_us()); + bundle.insert("profile.follow", "Follow"); + bundle.insert("greeting", "Hello, {name}!"); + let mut forms = HashMap::new(); + forms.insert("one".to_string(), "{n} Follower".to_string()); + forms.insert("other".to_string(), "{n} Followers".to_string()); + bundle.insert_plural("profile.followers", forms); + LocaleContext::new(Locale::en_us(), bundle) + } + + #[test] + fn t_simple() { + let ctx = make_ctx(); + assert_eq!(ctx.t("profile.follow"), "Follow"); + } + + #[test] + fn t_missing_returns_key() { + let ctx = make_ctx(); + assert_eq!(ctx.t("no.such.key"), "no.such.key"); + } + + #[test] + fn t_vars_interpolation() { + let ctx = make_ctx(); + let mut vars = HashMap::new(); + vars.insert("name", "Bob".to_string()); + assert_eq!(ctx.t_vars("greeting", &vars), "Hello, Bob!"); + } + + #[test] + fn t_plural_one() { + let ctx = make_ctx(); + assert_eq!(ctx.t_plural("profile.followers", 1), "1 Follower"); + } + + #[test] + fn t_plural_many() { + let ctx = make_ctx(); + assert_eq!(ctx.t_plural("profile.followers", 42), "42 Followers"); + } + + #[test] + fn is_rtl_english() { + let ctx = make_ctx(); + assert!(!ctx.is_rtl()); + } + + #[test] + fn is_rtl_arabic() { + let bundle = LocaleBundle::new(Locale::ar()); + let ctx = LocaleContext::new(Locale::ar(), bundle); + assert!(ctx.is_rtl()); + } + + #[test] + fn fallback_bundle_used() { + let primary_bundle = LocaleBundle::new(Locale::new("fr")); + // Key not in French bundle + let mut fallback = LocaleBundle::new(Locale::en_us()); + fallback.insert("app.name", "My App"); + let ctx = LocaleContext::new(Locale::new("fr"), primary_bundle) + .with_fallback(fallback); + assert_eq!(ctx.t("app.name"), "My App"); + } + + #[test] + fn locale_tag() { + let ctx = make_ctx(); + assert_eq!(ctx.locale_tag(), "en-US"); + } +} diff --git a/crates/el-layout/Cargo.toml b/crates/el-layout/Cargo.toml new file mode 100644 index 0000000..bf74a21 --- /dev/null +++ b/crates/el-layout/Cargo.toml @@ -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] diff --git a/crates/el-layout/src/breakpoint.rs b/crates/el-layout/src/breakpoint.rs new file mode 100644 index 0000000..ce45afe --- /dev/null +++ b/crates/el-layout/src/breakpoint.rs @@ -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()); + } + } +} diff --git a/crates/el-layout/src/constraints.rs b/crates/el-layout/src/constraints.rs new file mode 100644 index 0000000..16f94cf --- /dev/null +++ b/crates/el-layout/src/constraints.rs @@ -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()); + } +} diff --git a/crates/el-layout/src/flex.rs b/crates/el-layout/src/flex.rs new file mode 100644 index 0000000..7217b20 --- /dev/null +++ b/crates/el-layout/src/flex.rs @@ -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); + } +} diff --git a/crates/el-layout/src/grid.rs b/crates/el-layout/src/grid.rs new file mode 100644 index 0000000..c6613ea --- /dev/null +++ b/crates/el-layout/src/grid.rs @@ -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); + } +} diff --git a/crates/el-layout/src/lib.rs b/crates/el-layout/src/lib.rs new file mode 100644 index 0000000..76f2115 --- /dev/null +++ b/crates/el-layout/src/lib.rs @@ -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 = 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::*; diff --git a/crates/el-layout/src/platform.rs b/crates/el-layout/src/platform.rs new file mode 100644 index 0000000..f99a94f --- /dev/null +++ b/crates/el-layout/src/platform.rs @@ -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); + } +} diff --git a/crates/el-layout/src/responsive.rs b/crates/el-layout/src/responsive.rs new file mode 100644 index 0000000..0526cee --- /dev/null +++ b/crates/el-layout/src/responsive.rs @@ -0,0 +1,154 @@ +/// Responsive — 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 for layout — VStack and Grid handle +/// reflow automatically. Use Responsive 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 { + /// Mobile-first base value. Always required. + pub base: T, + /// 640dp+. If None, uses `base`. + pub sm: Option, + /// 768dp+. If None, uses `sm` or `base`. + pub md: Option, + /// 1024dp+. If None, uses `md`, `sm`, or `base`. + pub lg: Option, + /// 1280dp+. If None, uses `lg`, `md`, `sm`, or `base`. + pub xl: Option, +} + +impl Responsive { + /// 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, + md: Option, + lg: Option, + xl: Option, + ) -> 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"); + } +} diff --git a/crates/el-layout/src/scroll.rs b/crates/el-layout/src/scroll.rs new file mode 100644 index 0000000..6fff06e --- /dev/null +++ b/crates/el-layout/src/scroll.rs @@ -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); + } +} diff --git a/crates/el-layout/src/stack.rs b/crates/el-layout/src/stack.rs new file mode 100644 index 0000000..64057be --- /dev/null +++ b/crates/el-layout/src/stack.rs @@ -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); + } +} diff --git a/crates/el-secrets/Cargo.toml b/crates/el-secrets/Cargo.toml new file mode 100644 index 0000000..78922eb --- /dev/null +++ b/crates/el-secrets/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "el-secrets" +version = "0.1.0" +edition = "2021" +description = "el-ui secrets management — typed, never-logged, source-agnostic" +license = "MIT" + +[lib] +name = "el_secrets" +path = "src/lib.rs" + +[dependencies] +thiserror = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[dev-dependencies] diff --git a/crates/el-secrets/src/error.rs b/crates/el-secrets/src/error.rs new file mode 100644 index 0000000..029c490 --- /dev/null +++ b/crates/el-secrets/src/error.rs @@ -0,0 +1,19 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SecretsError { + #[error("secret '{key}' not found in source '{source_name}'")] + NotFound { key: String, source_name: String }, + + #[error("secret '{key}' not found in any configured source")] + NotFoundInAnySource { key: String }, + + #[error("secrets source '{source_name}' unavailable: {reason}")] + SourceUnavailable { source_name: String, reason: String }, + + #[error("required secrets missing at startup: {keys:?}")] + MissingRequired { keys: Vec }, + + #[error("secret type conversion failed for key '{key}': {reason}")] + TypeError { key: String, reason: String }, +} diff --git a/crates/el-secrets/src/lib.rs b/crates/el-secrets/src/lib.rs new file mode 100644 index 0000000..d0911fa --- /dev/null +++ b/crates/el-secrets/src/lib.rs @@ -0,0 +1,51 @@ +//! el-secrets — Secrets management for el-ui applications. +//! +//! Secrets are NEVER in code. They are: +//! - `EnvVarSource` — environment variables (`EL_SECRET_JWT_KEY=...`) +//! - `VaultSource` — HashiCorp Vault (stub, ready for HTTP client integration) +//! - `AwsSecretsSource` — AWS Secrets Manager (stub) +//! - `InMemorySource` — for testing only +//! +//! All secrets are wrapped in `Secret` which: +//! - Displays as `[REDACTED]` in all logs and debug output +//! - Serializes as `"[REDACTED]"` in JSON — never the actual value +//! - Requires explicit `.expose()` to read +//! +//! ## Quick start +//! +//! ``` +//! use el_secrets::prelude::*; +//! +//! // Load secrets at startup — fails early if anything is missing +//! let mut src = InMemorySource::new(); +//! src.insert("jwt.key", "test-key-for-testing-only"); +//! +//! let secrets = SecretsResolver::new() +//! .source(Box::new(src)) +//! .require("jwt.key") +//! .resolve() +//! .expect("required secrets must be present at startup"); +//! +//! let jwt_key = secrets.require("jwt.key"); +//! // jwt_key displays as [REDACTED] +//! // jwt_key.expose() gives the actual value +//! assert_eq!(jwt_key.expose(), "test-key-for-testing-only"); +//! ``` + +#![deny(warnings)] + +pub mod error; +pub mod resolver; +pub mod secret; +pub mod source; + +pub mod prelude { + pub use crate::error::SecretsError; + pub use crate::resolver::{ResolvedSecrets, SecretsResolver}; + pub use crate::secret::{Secret, SecretGuard}; + pub use crate::source::{ + AwsSecretsSource, EnvVarSource, InMemorySource, SecretsSource, VaultSource, + }; +} + +pub use prelude::*; diff --git a/crates/el-secrets/src/resolver.rs b/crates/el-secrets/src/resolver.rs new file mode 100644 index 0000000..c7dddeb --- /dev/null +++ b/crates/el-secrets/src/resolver.rs @@ -0,0 +1,282 @@ +/// SecretsResolver — loads all required secrets at startup. +/// +/// The resolver validates that all required secrets are present before the +/// application starts. If any are missing, startup fails with a clear error +/// listing what's missing — not a runtime panic deep in the app. + +use std::collections::HashMap; +use crate::error::SecretsError; +use crate::secret::Secret; +use crate::source::SecretsSource; + +/// A resolved, validated set of secrets. +/// +/// Created by SecretsResolver at startup. Once resolved, all secrets are +/// available and guaranteed to have been present at startup time. +pub struct ResolvedSecrets { + values: HashMap>, +} + +impl ResolvedSecrets { + /// Get a secret by key. + /// + /// Returns None if the key wasn't declared as required. + /// In normal use you will always have the keys you declared. + pub fn get(&self, key: &str) -> Option<&Secret> { + self.values.get(key) + } + + /// Get a secret or panic with a clear message. + /// + /// Use this for secrets that are truly required and were declared in + /// the resolver — if the resolver passed, this will always succeed. + pub fn require(&self, key: &str) -> &Secret { + self.values.get(key).unwrap_or_else(|| { + panic!( + "Secret '{}' was not declared as required in SecretsResolver. \ + Declare all required secrets before calling resolve().", + key + ) + }) + } + + /// The number of resolved secrets. + pub fn len(&self) -> usize { + self.values.len() + } + + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } +} + +/// Loads and validates all required secrets at startup. +/// +/// Usage: +/// ```ignore +/// let secrets = SecretsResolver::new() +/// .source(EnvVarSource::new()) +/// .require("jwt.secret_key") +/// .require("database.password") +/// .resolve()?; +/// +/// let jwt_key = secrets.require("jwt.secret_key"); +/// ``` +pub struct SecretsResolver { + sources: Vec>, + required: Vec, +} + +impl SecretsResolver { + pub fn new() -> Self { + Self { + sources: Vec::new(), + required: Vec::new(), + } + } + + /// Add a secret source. Sources are tried in order; first success wins. + pub fn source(mut self, source: Box) -> Self { + self.sources.push(source); + self + } + + /// Declare a required secret key. + /// + /// All required keys must be present in at least one source. + /// resolve() fails if any are missing. + pub fn require(mut self, key: impl Into) -> Self { + self.required.push(key.into()); + self + } + + /// Declare multiple required secret keys. + pub fn require_all(mut self, keys: &[&str]) -> Self { + for key in keys { + self.required.push(key.to_string()); + } + self + } + + /// Load all required secrets and validate they are all present. + /// + /// Returns an error listing ALL missing secrets — not just the first one — + /// so you can fix them all in one go. + pub fn resolve(self) -> Result { + let mut values = HashMap::new(); + let mut missing = Vec::new(); + + for key in &self.required { + match self.fetch_from_sources(key) { + Ok(secret) => { + values.insert(key.clone(), secret); + } + Err(_) => { + missing.push(key.clone()); + } + } + } + + if !missing.is_empty() { + return Err(SecretsError::MissingRequired { keys: missing }); + } + + Ok(ResolvedSecrets { values }) + } + + /// Attempt to fetch a key from sources in order. + fn fetch_from_sources(&self, key: &str) -> Result, SecretsError> { + for source in &self.sources { + if let Ok(secret) = source.get(key) { + return Ok(secret); + } + } + Err(SecretsError::NotFoundInAnySource { + key: key.to_string(), + }) + } + + /// Resolve without requiring all keys — useful when you want + /// to load whatever is available. + pub fn resolve_optional(self) -> ResolvedSecrets { + let mut values = HashMap::new(); + for key in &self.required { + if let Ok(secret) = self.fetch_from_sources(key) { + values.insert(key.clone(), secret); + } + } + ResolvedSecrets { values } + } +} + +impl Default for SecretsResolver { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::source::InMemorySource; + + fn make_source(pairs: &[(&str, &str)]) -> InMemorySource { + let mut src = InMemorySource::new(); + for (k, v) in pairs { + src.insert(*k, *v); + } + src + } + + #[test] + fn resolve_all_present() { + let src = make_source(&[("jwt.key", "secret"), ("db.password", "pass123")]); + let secrets = SecretsResolver::new() + .source(Box::new(src)) + .require("jwt.key") + .require("db.password") + .resolve() + .unwrap(); + + assert_eq!(secrets.require("jwt.key").expose(), "secret"); + assert_eq!(secrets.require("db.password").expose(), "pass123"); + } + + #[test] + fn resolve_missing_fails_with_list() { + let src = make_source(&[("jwt.key", "secret")]); + let result = SecretsResolver::new() + .source(Box::new(src)) + .require("jwt.key") + .require("db.password") // missing + .require("stripe.key") // also missing + .resolve(); + + match result { + Err(SecretsError::MissingRequired { keys }) => { + assert!(keys.contains(&"db.password".to_string())); + assert!(keys.contains(&"stripe.key".to_string())); + assert_eq!(keys.len(), 2); + } + _ => panic!("expected MissingRequired error"), + } + } + + #[test] + fn resolve_optional_skips_missing() { + let src = make_source(&[("jwt.key", "secret")]); + let secrets = SecretsResolver::new() + .source(Box::new(src)) + .require("jwt.key") + .require("missing.key") + .resolve_optional(); + + assert!(secrets.get("jwt.key").is_some()); + assert!(secrets.get("missing.key").is_none()); + } + + #[test] + fn multiple_sources_fallback() { + let mut primary = InMemorySource::new(); + primary.insert("jwt.key", "from-primary"); + let mut secondary = InMemorySource::new(); + secondary.insert("db.password", "from-secondary"); + + let secrets = SecretsResolver::new() + .source(Box::new(primary)) + .source(Box::new(secondary)) + .require("jwt.key") + .require("db.password") + .resolve() + .unwrap(); + + assert_eq!(secrets.require("jwt.key").expose(), "from-primary"); + assert_eq!(secrets.require("db.password").expose(), "from-secondary"); + } + + #[test] + fn first_source_wins() { + let mut s1 = InMemorySource::new(); + s1.insert("key", "value-1"); + let mut s2 = InMemorySource::new(); + s2.insert("key", "value-2"); + + let secrets = SecretsResolver::new() + .source(Box::new(s1)) + .source(Box::new(s2)) + .require("key") + .resolve() + .unwrap(); + + assert_eq!(secrets.require("key").expose(), "value-1"); + } + + #[test] + fn require_all() { + let src = make_source(&[("a", "1"), ("b", "2"), ("c", "3")]); + let secrets = SecretsResolver::new() + .source(Box::new(src)) + .require_all(&["a", "b", "c"]) + .resolve() + .unwrap(); + + assert_eq!(secrets.len(), 3); + } + + #[test] + fn resolved_len() { + let src = make_source(&[("k", "v")]); + let secrets = SecretsResolver::new() + .source(Box::new(src)) + .require("k") + .resolve() + .unwrap(); + assert_eq!(secrets.len(), 1); + } + + #[test] + fn resolved_is_empty() { + let resolved = ResolvedSecrets { values: HashMap::new() }; + assert!(resolved.is_empty()); + } +} diff --git a/crates/el-secrets/src/secret.rs b/crates/el-secrets/src/secret.rs new file mode 100644 index 0000000..5c7ea92 --- /dev/null +++ b/crates/el-secrets/src/secret.rs @@ -0,0 +1,179 @@ +/// Secret — a typed value that never leaks via Display/Debug. +/// +/// The wrapper ensures that accidental logging or serialization of a secret +/// never reveals the actual value. You must explicitly call `.expose()` to +/// read it, which creates a visible opt-in point in the code. + +use serde::{Deserialize, Serialize, Serializer}; + +/// A secret value. Never prints the inner value via Display or Debug. +/// +/// Always displays as `[REDACTED]`. To access the inner value: +/// ``` +/// use el_secrets::Secret; +/// let key = Secret::new("my-secret-key".to_string()); +/// let actual: &str = key.expose(); // explicit opt-in +/// ``` +#[derive(Clone)] +pub struct Secret(T); + +impl Secret { + /// Wrap a value in a Secret. + pub fn new(value: T) -> Self { + Self(value) + } + + /// Access the inner value. + /// + /// This is the ONLY way to get the actual secret value out. + /// Name it `expose` so it's searchable in code review. + pub fn expose(&self) -> &T { + &self.0 + } + + /// Consume the Secret and return the inner value. + pub fn into_inner(self) -> T { + self.0 + } + + /// Map the inner value to a new type, wrapping in a new Secret. + pub fn map U>(self, f: F) -> Secret { + Secret(f(self.0)) + } +} + +/// Debug never reveals the secret value. +impl std::fmt::Debug for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Secret([REDACTED])") + } +} + +/// Display never reveals the secret value. +impl std::fmt::Display for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[REDACTED]") + } +} + +/// Serialize writes [REDACTED], never the actual value. +/// This prevents secrets from appearing in JSON logs, API responses, etc. +impl Serialize for Secret { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str("[REDACTED]") + } +} + +/// Deserialize from a string — used when loading secrets from files/env. +/// Only implemented for Secret since we always load as strings. +impl<'de> Deserialize<'de> for Secret { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Ok(Secret::new(s)) + } +} + +/// A guard that prevents a value from being accidentally exposed. +/// +/// Use this on struct fields that should never be serialized or logged. +#[derive(Clone)] +pub struct SecretGuard { + inner: Secret, + /// A hint shown in Debug output (not the value itself). + label: &'static str, +} + +impl SecretGuard { + pub fn new(value: T, label: &'static str) -> Self { + Self { + inner: Secret::new(value), + label, + } + } + + pub fn expose(&self) -> &T { + self.inner.expose() + } +} + +impl std::fmt::Debug for SecretGuard { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "SecretGuard({}: [REDACTED])", self.label) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn secret_expose() { + let s = Secret::new("my-api-key".to_string()); + assert_eq!(s.expose(), "my-api-key"); + } + + #[test] + fn secret_debug_redacted() { + let s = Secret::new("super-secret".to_string()); + let debug = format!("{:?}", s); + assert_eq!(debug, "Secret([REDACTED])"); + assert!(!debug.contains("super-secret")); + } + + #[test] + fn secret_display_redacted() { + let s = Secret::new(12345u32); + let display = format!("{}", s); + assert_eq!(display, "[REDACTED]"); + assert!(!display.contains("12345")); + } + + #[test] + fn secret_serialize_redacted() { + let s = Secret::new("should-not-appear".to_string()); + let json = serde_json::to_string(&s).unwrap(); + assert_eq!(json, r#""[REDACTED]""#); + assert!(!json.contains("should-not-appear")); + } + + #[test] + fn secret_deserialize() { + let s: Secret = serde_json::from_str(r#""my-secret""#).unwrap(); + assert_eq!(s.expose(), "my-secret"); + } + + #[test] + fn secret_map() { + let s = Secret::new("42".to_string()); + let n: Secret = s.map(|v| v.parse().unwrap()); + assert_eq!(*n.expose(), 42u32); + } + + #[test] + fn secret_into_inner() { + let s = Secret::new("value".to_string()); + assert_eq!(s.into_inner(), "value"); + } + + #[test] + fn secret_guard_debug() { + let g = SecretGuard::new("token".to_string(), "jwt_token"); + let debug = format!("{:?}", g); + assert!(debug.contains("jwt_token")); + assert!(!debug.contains("token\"")); + } + + #[test] + fn secret_guard_expose() { + let g = SecretGuard::new("secret-value".to_string(), "api_key"); + assert_eq!(g.expose(), "secret-value"); + } + + #[test] + fn secret_clone_does_not_expose() { + let s1 = Secret::new("clone-me".to_string()); + let s2 = s1.clone(); + assert_eq!(s2.expose(), "clone-me"); + assert!(!format!("{:?}", s2).contains("clone-me")); + } +} diff --git a/crates/el-secrets/src/source.rs b/crates/el-secrets/src/source.rs new file mode 100644 index 0000000..08191cf --- /dev/null +++ b/crates/el-secrets/src/source.rs @@ -0,0 +1,251 @@ +/// SecretsSource trait and built-in implementations. +/// +/// Each source knows how to retrieve a named secret. Sources are tried in +/// order by the SecretsResolver until one succeeds. + +use crate::error::SecretsError; +use crate::secret::Secret; + +/// A source of secret values. +pub trait SecretsSource: Send + Sync { + /// The name of this source. + fn name(&self) -> &str; + + /// Retrieve a secret by key. + fn get(&self, key: &str) -> Result, SecretsError>; + + /// List available secret keys (may not be supported by all sources). + fn list(&self) -> Result, SecretsError>; +} + +/// Reads secrets from environment variables. +/// +/// Key mapping: `jwt.key` → `EL_SECRET_JWT_KEY` (uppercased, dots → underscores). +pub struct EnvVarSource { + prefix: String, +} + +impl EnvVarSource { + pub fn new() -> Self { + Self { prefix: "EL_SECRET".to_string() } + } + + pub fn with_prefix(prefix: impl Into) -> Self { + Self { prefix: prefix.into() } + } + + fn env_key(&self, key: &str) -> String { + let normalized = key.replace('.', "_").replace('-', "_").to_uppercase(); + format!("{}_{}", self.prefix, normalized) + } +} + +impl Default for EnvVarSource { + fn default() -> Self { + Self::new() + } +} + +impl SecretsSource for EnvVarSource { + fn name(&self) -> &str { + "env-var" + } + + fn get(&self, key: &str) -> Result, SecretsError> { + let env_key = self.env_key(key); + std::env::var(&env_key) + .map(Secret::new) + .map_err(|_| SecretsError::NotFound { + key: key.to_string(), + source_name: "env-var".to_string(), + }) + } + + fn list(&self) -> Result, SecretsError> { + let prefix = format!("{}_", self.prefix); + let keys: Vec = std::env::vars() + .filter(|(k, _)| k.starts_with(&prefix)) + .map(|(k, _)| { + k.strip_prefix(&prefix) + .unwrap_or(&k) + .to_lowercase() + .replace('_', ".") + }) + .collect(); + Ok(keys) + } +} + +/// In-memory secrets source for testing. +/// +/// NEVER use in production — secrets are in plaintext in memory +/// and this source is not safe for production credentials. +#[derive(Default)] +pub struct InMemorySource { + secrets: std::collections::HashMap, +} + +impl InMemorySource { + pub fn new() -> Self { + Self::default() + } + + /// Insert a secret. Only for testing. + pub fn insert(&mut self, key: impl Into, value: impl Into) { + self.secrets.insert(key.into(), value.into()); + } +} + +impl SecretsSource for InMemorySource { + fn name(&self) -> &str { + "in-memory" + } + + fn get(&self, key: &str) -> Result, SecretsError> { + self.secrets + .get(key) + .map(|v| Secret::new(v.clone())) + .ok_or_else(|| SecretsError::NotFound { + key: key.to_string(), + source_name: "in-memory".to_string(), + }) + } + + fn list(&self) -> Result, SecretsError> { + Ok(self.secrets.keys().cloned().collect()) + } +} + +/// Vault source stub — HashiCorp Vault integration. +/// +/// This is a stub that defines the interface. A real implementation +/// would make HTTP calls to the Vault API. The stub is sufficient +/// for the type system and resolver to work correctly. +pub struct VaultSource { + /// Vault server URL (e.g. "https://vault.example.com"). + pub address: String, + /// Mount path for the KV engine (e.g. "secret"). + pub mount: String, + /// The Vault path prefix for this app's secrets. + pub path: String, +} + +impl VaultSource { + pub fn new(address: impl Into, mount: impl Into, path: impl Into) -> Self { + Self { + address: address.into(), + mount: mount.into(), + path: path.into(), + } + } +} + +impl SecretsSource for VaultSource { + fn name(&self) -> &str { + "vault" + } + + fn get(&self, key: &str) -> Result, SecretsError> { + // Stub: in a real impl, make HTTP GET to Vault KV API + Err(SecretsError::SourceUnavailable { + source_name: "vault".to_string(), + reason: format!( + "Vault HTTP client not implemented in stub — would fetch {}/{}/{}/{}", + self.address, self.mount, self.path, key + ), + }) + } + + fn list(&self) -> Result, SecretsError> { + Err(SecretsError::SourceUnavailable { + source_name: "vault".to_string(), + reason: "Vault HTTP client not implemented in stub".to_string(), + }) + } +} + +/// AWS Secrets Manager source stub. +pub struct AwsSecretsSource { + pub region: String, + pub path_prefix: String, +} + +impl AwsSecretsSource { + pub fn new(region: impl Into, path_prefix: impl Into) -> Self { + Self { + region: region.into(), + path_prefix: path_prefix.into(), + } + } +} + +impl SecretsSource for AwsSecretsSource { + fn name(&self) -> &str { + "aws-secrets-manager" + } + + fn get(&self, key: &str) -> Result, SecretsError> { + Err(SecretsError::SourceUnavailable { + source_name: "aws-secrets-manager".to_string(), + reason: format!( + "AWS SDK not linked — would fetch {}/{} in {}", + self.path_prefix, key, self.region + ), + }) + } + + fn list(&self) -> Result, SecretsError> { + Err(SecretsError::SourceUnavailable { + source_name: "aws-secrets-manager".to_string(), + reason: "AWS SDK not linked".to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn in_memory_get() { + let mut src = InMemorySource::new(); + src.insert("jwt.key", "secret-jwt-key"); + let val = src.get("jwt.key").unwrap(); + assert_eq!(val.expose(), "secret-jwt-key"); + } + + #[test] + fn in_memory_missing() { + let src = InMemorySource::new(); + assert!(src.get("missing.key").is_err()); + } + + #[test] + fn in_memory_list() { + let mut src = InMemorySource::new(); + src.insert("key.a", "a"); + src.insert("key.b", "b"); + let mut keys = src.list().unwrap(); + keys.sort(); + assert_eq!(keys, vec!["key.a", "key.b"]); + } + + #[test] + fn env_key_mapping() { + let src = EnvVarSource::new(); + assert_eq!(src.env_key("jwt.key"), "EL_SECRET_JWT_KEY"); + assert_eq!(src.env_key("database.password"), "EL_SECRET_DATABASE_PASSWORD"); + } + + #[test] + fn vault_source_stub_returns_error() { + let src = VaultSource::new("https://vault.example.com", "secret", "myapp"); + assert!(src.get("jwt.key").is_err()); + } + + #[test] + fn aws_source_stub_returns_error() { + let src = AwsSecretsSource::new("us-east-1", "myapp"); + assert!(src.get("jwt.key").is_err()); + } +} diff --git a/crates/el-style/Cargo.toml b/crates/el-style/Cargo.toml new file mode 100644 index 0000000..ac47d28 --- /dev/null +++ b/crates/el-style/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "el-style" +version = "0.1.0" +edition = "2021" +description = "el-ui design system — theme-driven, platform-agnostic style representation" +license = "MIT" + +[lib] +name = "el_style" +path = "src/lib.rs" + +[dependencies] +thiserror = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[dev-dependencies] diff --git a/crates/el-style/src/color.rs b/crates/el-style/src/color.rs new file mode 100644 index 0000000..c15b2a6 --- /dev/null +++ b/crates/el-style/src/color.rs @@ -0,0 +1,255 @@ +/// Semantic color tokens and the Color type. +/// +/// Prefer semantic tokens (Primary, Surface, etc.) over explicit values. +/// The theme maps tokens to actual colors — swap the theme, everything updates. +/// Use Hex/Rgba only as an escape hatch when you need a one-off value. + +/// A color value — either a semantic token or an explicit value. +#[derive(Debug, Clone, PartialEq)] +pub enum Color { + // --- Semantic tokens (preferred) --- + /// Brand primary color. Use for key actions and highlights. + Primary, + /// Content drawn on top of Primary. + OnPrimary, + /// A less prominent container for primary-branded content. + PrimaryContainer, + + /// Secondary accent color. + Secondary, + /// Content drawn on top of Secondary. + OnSecondary, + + /// The page/screen background. + Background, + /// Content drawn on top of Background. + OnBackground, + + /// Surface color for cards, sheets, dialogs. + Surface, + /// Content drawn on top of Surface. + OnSurface, + /// A surface variant, slightly different from Surface. + SurfaceVariant, + + /// Error state color. + Error, + /// Content drawn on top of Error. + OnError, + + /// Outline/border color. + Outline, + /// Muted outline (dividers, subtle borders). + OutlineVariant, + + // --- Escape hatches --- + /// An explicit hex color string (e.g. "#3b82f6" or "#3b82f6ff"). + Hex(String), + /// An explicit RGBA color (channels 0–255, alpha 0.0–1.0). + Rgba(u8, u8, u8, f32), + + /// Apply opacity to any color. + Opacity(Box, f32), +} + +impl Color { + /// Create an Opacity variant. + pub fn with_opacity(self, opacity: f32) -> Self { + Color::Opacity(Box::new(self), opacity.clamp(0.0, 1.0)) + } + + /// Convenience: fully transparent. + pub fn transparent() -> Self { + Color::Rgba(0, 0, 0, 0.0) + } + + /// Convenience: pure white. + pub fn white() -> Self { + Color::Rgba(255, 255, 255, 1.0) + } + + /// Convenience: pure black. + pub fn black() -> Self { + Color::Rgba(0, 0, 0, 1.0) + } + + /// Resolve a hex string like "#rrggbb" or "#rrggbbaa" to Rgba. + /// Returns None if the string is not a valid hex color. + pub fn parse_hex(s: &str) -> Option { + let s = s.trim_start_matches('#'); + match s.len() { + 6 => { + let r = u8::from_str_radix(&s[0..2], 16).ok()?; + let g = u8::from_str_radix(&s[2..4], 16).ok()?; + let b = u8::from_str_radix(&s[4..6], 16).ok()?; + Some(Color::Rgba(r, g, b, 1.0)) + } + 8 => { + let r = u8::from_str_radix(&s[0..2], 16).ok()?; + let g = u8::from_str_radix(&s[2..4], 16).ok()?; + let b = u8::from_str_radix(&s[4..6], 16).ok()?; + let a = u8::from_str_radix(&s[6..8], 16).ok()?; + Some(Color::Rgba(r, g, b, a as f32 / 255.0)) + } + _ => None, + } + } +} + +/// Maps semantic color tokens to actual RGBA values. +/// One ColorScheme per theme mode (light/dark). +#[derive(Debug, Clone)] +pub struct ColorScheme { + pub primary: (u8, u8, u8, f32), + pub on_primary: (u8, u8, u8, f32), + pub primary_container: (u8, u8, u8, f32), + pub secondary: (u8, u8, u8, f32), + pub on_secondary: (u8, u8, u8, f32), + pub background: (u8, u8, u8, f32), + pub on_background: (u8, u8, u8, f32), + pub surface: (u8, u8, u8, f32), + pub on_surface: (u8, u8, u8, f32), + pub surface_variant: (u8, u8, u8, f32), + pub error: (u8, u8, u8, f32), + pub on_error: (u8, u8, u8, f32), + pub outline: (u8, u8, u8, f32), + pub outline_variant: (u8, u8, u8, f32), +} + +impl ColorScheme { + /// Default light color scheme. + pub fn light() -> Self { + Self { + primary: (59, 130, 246, 1.0), // blue-500 + on_primary: (255, 255, 255, 1.0), + primary_container: (219, 234, 254, 1.0), // blue-100 + secondary: (100, 116, 139, 1.0), // slate-500 + on_secondary: (255, 255, 255, 1.0), + background: (255, 255, 255, 1.0), + on_background: (15, 23, 42, 1.0), // slate-900 + surface: (248, 250, 252, 1.0), // slate-50 + on_surface: (15, 23, 42, 1.0), + surface_variant: (241, 245, 249, 1.0), // slate-100 + error: (239, 68, 68, 1.0), // red-500 + on_error: (255, 255, 255, 1.0), + outline: (203, 213, 225, 1.0), // slate-300 + outline_variant: (226, 232, 240, 1.0), // slate-200 + } + } + + /// Default dark color scheme. + pub fn dark() -> Self { + Self { + primary: (96, 165, 250, 1.0), // blue-400 + on_primary: (15, 23, 42, 1.0), + primary_container: (30, 58, 138, 1.0), // blue-900 + secondary: (148, 163, 184, 1.0), // slate-400 + on_secondary: (15, 23, 42, 1.0), + background: (15, 23, 42, 1.0), // slate-900 + on_background: (248, 250, 252, 1.0), + surface: (30, 41, 59, 1.0), // slate-800 + on_surface: (248, 250, 252, 1.0), + surface_variant: (51, 65, 85, 1.0), // slate-700 + error: (248, 113, 113, 1.0), // red-400 + on_error: (15, 23, 42, 1.0), + outline: (71, 85, 105, 1.0), // slate-600 + outline_variant: (51, 65, 85, 1.0), // slate-700 + } + } + + /// Resolve a semantic Color token to its RGBA tuple. + pub fn resolve(&self, color: &Color) -> (u8, u8, u8, f32) { + match color { + Color::Primary => self.primary, + Color::OnPrimary => self.on_primary, + Color::PrimaryContainer => self.primary_container, + Color::Secondary => self.secondary, + Color::OnSecondary => self.on_secondary, + Color::Background => self.background, + Color::OnBackground => self.on_background, + Color::Surface => self.surface, + Color::OnSurface => self.on_surface, + Color::SurfaceVariant => self.surface_variant, + Color::Error => self.error, + Color::OnError => self.on_error, + Color::Outline => self.outline, + Color::OutlineVariant => self.outline_variant, + Color::Hex(s) => Color::parse_hex(s) + .map(|c| self.resolve(&c)) + .unwrap_or((0, 0, 0, 1.0)), + Color::Rgba(r, g, b, a) => (*r, *g, *b, *a), + Color::Opacity(inner, opacity) => { + let (r, g, b, a) = self.resolve(inner); + (r, g, b, a * opacity) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_hex_6_chars() { + let c = Color::parse_hex("#3b82f6").unwrap(); + assert_eq!(c, Color::Rgba(0x3b, 0x82, 0xf6, 1.0)); + } + + #[test] + fn parse_hex_8_chars() { + let c = Color::parse_hex("#3b82f680").unwrap(); + if let Color::Rgba(r, g, b, a) = c { + assert_eq!(r, 0x3b); + assert_eq!(g, 0x82); + assert_eq!(b, 0xf6); + assert!((a - 0x80 as f32 / 255.0).abs() < 0.01); + } else { + panic!("expected Rgba"); + } + } + + #[test] + fn parse_hex_invalid() { + assert!(Color::parse_hex("not-a-color").is_none()); + assert!(Color::parse_hex("#gg0000").is_none()); + } + + #[test] + fn with_opacity() { + let c = Color::Primary.with_opacity(0.5); + assert!(matches!(c, Color::Opacity(_, _))); + } + + #[test] + fn color_scheme_light_resolves_primary() { + let scheme = ColorScheme::light(); + let (r, g, b, a) = scheme.resolve(&Color::Primary); + assert_eq!((r, g, b), (59, 130, 246)); + assert!((a - 1.0).abs() < 0.001); + } + + #[test] + fn color_scheme_dark_resolves_background() { + let scheme = ColorScheme::dark(); + let (r, g, b, _) = scheme.resolve(&Color::Background); + assert_eq!((r, g, b), (15, 23, 42)); + } + + #[test] + fn opacity_modifies_alpha() { + let scheme = ColorScheme::light(); + let color = Color::Primary.with_opacity(0.5); + let (_, _, _, a) = scheme.resolve(&color); + assert!((a - 0.5).abs() < 0.001); + } + + #[test] + fn rgba_passthrough() { + let scheme = ColorScheme::light(); + let color = Color::Rgba(10, 20, 30, 0.8); + let (r, g, b, a) = scheme.resolve(&color); + assert_eq!((r, g, b), (10, 20, 30)); + assert!((a - 0.8).abs() < 0.001); + } +} diff --git a/crates/el-style/src/lib.rs b/crates/el-style/src/lib.rs new file mode 100644 index 0000000..b32ce17 --- /dev/null +++ b/crates/el-style/src/lib.rs @@ -0,0 +1,51 @@ +//! el-style — The el-ui design system. +//! +//! Platform-agnostic, theme-driven style representation. Every value in the +//! system is a semantic token — the theme maps tokens to actual colors, sizes, +//! and shadows. Components never hardcode visual values. +//! +//! ## Hierarchy +//! +//! 1. **Theme** — the single source of truth. Set at the experience root. +//! 2. **Semantic tokens** — Color::Primary, Spacing::Lg, Radius::Md, etc. +//! 3. **StyleModifier** — fluent builder trait on every component. +//! 4. **StyleSheet** — named rule sets, applied with `.apply("card")`. +//! +//! ## Usage +//! +//! ``` +//! use el_style::prelude::*; +//! +//! // Components use semantic tokens, not raw values: +//! // button.background(Color::Primary).foreground(Color::OnPrimary).padding(16) +//! +//! // Theme provides the actual values at render time: +//! let theme = Theme::default_light(); +//! let (r, g, b, a) = theme.colors.resolve(&Color::Primary); +//! assert!(r > 0 || g > 0 || b > 0 || a > 0.0); +//! ``` + +#![deny(warnings)] + +pub mod color; +pub mod modifier; +pub mod radius; +pub mod shadow; +pub mod spacing; +pub mod stylesheet; +pub mod theme; +pub mod typography; + +/// Everything you need in one import. +pub mod prelude { + pub use crate::color::{Color, ColorScheme}; + pub use crate::modifier::{ButtonVariant, CardStyle, Dimension, InputStyle, StyleModifier, StyleSet}; + pub use crate::radius::Radius; + pub use crate::shadow::Shadow; + pub use crate::spacing::Spacing; + pub use crate::stylesheet::StyleSheet; + pub use crate::theme::{Theme, ThemeMode}; + pub use crate::typography::{FontWeight, TextAlign, TextDecoration, TextOverflow, TextStyle}; +} + +pub use prelude::*; diff --git a/crates/el-style/src/modifier.rs b/crates/el-style/src/modifier.rs new file mode 100644 index 0000000..2a76185 --- /dev/null +++ b/crates/el-style/src/modifier.rs @@ -0,0 +1,382 @@ +/// StyleModifier trait — fluent, zero-cost style chaining. +/// +/// Every el-ui component implements StyleModifier so callers can chain style +/// adjustments in a natural builder pattern. The trait is compile-time only; +/// there is no runtime allocation or dynamic dispatch. + +use crate::color::Color; +use crate::radius::Radius; +use crate::shadow::Shadow; +use crate::typography::TextStyle; + +/// A dimension — how wide or tall something should be. +#[derive(Debug, Clone, PartialEq)] +pub enum Dimension { + /// Exact density-independent pixels. + Fixed(u32), + /// Fill all available space from the parent. + Fill, + /// Shrink to fit the content. + Wrap, + /// Fraction of the parent's dimension (0.0–1.0). + Fraction(f32), + /// Minimum of two dimensions. + Min(Box, Box), + /// Maximum of two dimensions. + Max(Box, Box), +} + +impl Dimension { + pub fn fraction(f: f32) -> Self { + Dimension::Fraction(f.clamp(0.0, 1.0)) + } + + pub fn half() -> Self { + Dimension::Fraction(0.5) + } + + pub fn full() -> Self { + Dimension::Fill + } +} + +/// A complete set of style properties that can be applied to a component. +/// +/// All fields are optional — only the ones set by the caller are applied. +/// The platform backend reads these when rendering and maps them to native +/// style properties. +#[derive(Debug, Clone, Default)] +pub struct StyleSet { + pub padding: Option<[u32; 4]>, // top, right, bottom, left + pub margin: Option<[u32; 4]>, + pub background: Option, + pub foreground: Option, + pub font: Option, + pub radius: Option<[u32; 4]>, // top-left, top-right, bottom-right, bottom-left + pub shadow: Option, + pub opacity: Option, + pub border_width: Option, + pub border_color: Option, + pub width: Option, + pub height: Option, + pub max_width: Option, + pub max_height: Option, + pub min_width: Option, + pub min_height: Option, + pub flex_grow: Option, + pub flex_shrink: Option, + pub z_index: Option, + pub hidden: bool, + pub clip: bool, +} + +/// The core trait every styled el-ui component implements. +/// +/// Returns `Self` — the methods consume and return the component for +/// fluent chaining without allocation. +pub trait StyleModifier: Sized { + /// Access the mutable StyleSet for this component. + fn style_mut(&mut self) -> &mut StyleSet; + + /// Apply uniform padding on all four sides. + fn padding(mut self, value: u32) -> Self { + self.style_mut().padding = Some([value; 4]); + self + } + + /// Apply horizontal (x) and vertical (y) padding. + fn padding_xy(mut self, x: u32, y: u32) -> Self { + self.style_mut().padding = Some([y, x, y, x]); + self + } + + /// Apply padding individually: top, right, bottom, left. + fn padding_sides(mut self, top: u32, right: u32, bottom: u32, left: u32) -> Self { + self.style_mut().padding = Some([top, right, bottom, left]); + self + } + + /// Apply uniform margin on all four sides. + fn margin(mut self, value: u32) -> Self { + self.style_mut().margin = Some([value; 4]); + self + } + + /// Apply horizontal and vertical margin. + fn margin_xy(mut self, x: u32, y: u32) -> Self { + self.style_mut().margin = Some([y, x, y, x]); + self + } + + /// Set the background color. + fn background(mut self, color: Color) -> Self { + self.style_mut().background = Some(color); + self + } + + /// Set the foreground (text/icon) color. + fn foreground(mut self, color: Color) -> Self { + self.style_mut().foreground = Some(color); + self + } + + /// Set the text/font style. + fn font(mut self, style: TextStyle) -> Self { + self.style_mut().font = Some(style); + self + } + + /// Set a uniform border radius on all four corners. + fn radius(mut self, radius: Radius) -> Self { + let r = radius.dp(); + self.style_mut().radius = Some([r; 4]); + self + } + + /// Set the shadow/elevation. + fn shadow(mut self, elevation: Shadow) -> Self { + self.style_mut().shadow = Some(elevation); + self + } + + /// Set the opacity (0.0 = invisible, 1.0 = fully visible). + fn opacity(mut self, value: f32) -> Self { + self.style_mut().opacity = Some(value.clamp(0.0, 1.0)); + self + } + + /// Set a border with width and color. + fn border(mut self, width: u32, color: Color) -> Self { + let s = self.style_mut(); + s.border_width = Some(width); + s.border_color = Some(color); + self + } + + /// Set the width. + fn width(mut self, value: Dimension) -> Self { + self.style_mut().width = Some(value); + self + } + + /// Set the height. + fn height(mut self, value: Dimension) -> Self { + self.style_mut().height = Some(value); + self + } + + /// Set the maximum width. + fn max_width(mut self, value: Dimension) -> Self { + self.style_mut().max_width = Some(value); + self + } + + /// Set the maximum height. + fn max_height(mut self, value: Dimension) -> Self { + self.style_mut().max_height = Some(value); + self + } + + /// Set the minimum width. + fn min_width(mut self, value: Dimension) -> Self { + self.style_mut().min_width = Some(value); + self + } + + /// Set the minimum height. + fn min_height(mut self, value: Dimension) -> Self { + self.style_mut().min_height = Some(value); + self + } + + /// How much this component grows to fill available space (flex-grow). + fn grow(mut self, factor: f32) -> Self { + self.style_mut().flex_grow = Some(factor); + self + } + + /// Z-index for layering. + fn z_index(mut self, z: i32) -> Self { + self.style_mut().z_index = Some(z); + self + } + + /// Clip content that overflows the component's bounds. + fn clip(mut self) -> Self { + self.style_mut().clip = true; + self + } + + /// Hide this component (still occupies space in layout). + fn hidden(mut self, value: bool) -> Self { + self.style_mut().hidden = value; + self + } +} + +/// Pre-composed style variants for common component types. + +/// Button style variants. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ButtonVariant { + /// Filled primary-color button (the main CTA). + Primary, + /// Filled secondary-color button. + Secondary, + /// Destructive action (red). + Destructive, + /// Text-only, no fill or border. + Ghost, + /// Inline hyperlink style. + Link, +} + +/// Card style variants. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CardStyle { + /// Card with a drop shadow. + Elevated, + /// Card with an outline border, no shadow. + Outlined, + /// Card with a filled background color, no shadow. + Filled, +} + +/// Text input style variants. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InputStyle { + /// Standard input (platform default). + Default, + /// Outlined/bordered input. + Outlined, + /// Filled background input. + Filled, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::color::Color; + use crate::radius::Radius; + use crate::shadow::Shadow; + use crate::typography::TextStyle; + + /// A minimal test component that implements StyleModifier. + #[derive(Default)] + struct TestWidget { + style: StyleSet, + } + + impl StyleModifier for TestWidget { + fn style_mut(&mut self) -> &mut StyleSet { + &mut self.style + } + } + + #[test] + fn padding_sets_all_sides() { + let w = TestWidget::default().padding(16); + assert_eq!(w.style.padding, Some([16; 4])); + } + + #[test] + fn padding_xy_sets_correctly() { + let w = TestWidget::default().padding_xy(8, 16); + // [top, right, bottom, left] = [y, x, y, x] + assert_eq!(w.style.padding, Some([16, 8, 16, 8])); + } + + #[test] + fn background_stored() { + let w = TestWidget::default().background(Color::Primary); + assert_eq!(w.style.background, Some(Color::Primary)); + } + + #[test] + fn foreground_stored() { + let w = TestWidget::default().foreground(Color::OnPrimary); + assert_eq!(w.style.foreground, Some(Color::OnPrimary)); + } + + #[test] + fn font_stored() { + let w = TestWidget::default().font(TextStyle::Body); + assert_eq!(w.style.font, Some(TextStyle::Body)); + } + + #[test] + fn radius_stored() { + let w = TestWidget::default().radius(Radius::Md); + assert_eq!(w.style.radius, Some([8; 4])); + } + + #[test] + fn shadow_stored() { + let w = TestWidget::default().shadow(Shadow::Md); + assert_eq!(w.style.shadow, Some(Shadow::Md)); + } + + #[test] + fn opacity_clamped() { + let w = TestWidget::default().opacity(2.5); + assert_eq!(w.style.opacity, Some(1.0)); + } + + #[test] + fn opacity_valid() { + let w = TestWidget::default().opacity(0.5); + assert!((w.style.opacity.unwrap() - 0.5).abs() < 0.001); + } + + #[test] + fn border_stored() { + let w = TestWidget::default().border(2, Color::Outline); + assert_eq!(w.style.border_width, Some(2)); + assert_eq!(w.style.border_color, Some(Color::Outline)); + } + + #[test] + fn chain_multiple_modifiers() { + let w = TestWidget::default() + .padding(16) + .background(Color::Surface) + .radius(Radius::Lg) + .shadow(Shadow::Sm) + .opacity(0.9); + assert!(w.style.padding.is_some()); + assert!(w.style.background.is_some()); + assert!(w.style.radius.is_some()); + assert!(w.style.shadow.is_some()); + assert!(w.style.opacity.is_some()); + } + + #[test] + fn hidden_flag() { + let w = TestWidget::default().hidden(true); + assert!(w.style.hidden); + } + + #[test] + fn clip_flag() { + let w = TestWidget::default().clip(); + assert!(w.style.clip); + } + + #[test] + fn dimension_fraction_clamped() { + let d = Dimension::fraction(1.5); + assert_eq!(d, Dimension::Fraction(1.0)); + } + + #[test] + fn width_stored() { + let w = TestWidget::default().width(Dimension::Fill); + assert_eq!(w.style.width, Some(Dimension::Fill)); + } + + #[test] + fn max_width_stored() { + let w = TestWidget::default().max_width(Dimension::Fixed(600)); + assert_eq!(w.style.max_width, Some(Dimension::Fixed(600))); + } +} diff --git a/crates/el-style/src/radius.rs b/crates/el-style/src/radius.rs new file mode 100644 index 0000000..da0361f --- /dev/null +++ b/crates/el-style/src/radius.rs @@ -0,0 +1,116 @@ +/// Border radius scale. +/// +/// Use named tokens, not raw pixel values. Swap out the RadiusScale +/// in the theme to change the visual "softness" of the entire UI at once. + +/// Named border radius tokens. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Radius { + /// 0 — sharp corners + None, + /// 4dp — subtle rounding (list items, small chips) + Sm, + /// 8dp — default card / button rounding + Md, + /// 12dp — more prominent rounding (modals, drawers) + Lg, + /// 16dp — very rounded (bottom sheets, large cards) + Xl, + /// 9999dp — fully pill-shaped + Full, + /// Explicit dp value (escape hatch) + Custom(u32), +} + +impl Radius { + /// Resolve to dp. + pub fn dp(&self) -> u32 { + match self { + Radius::None => 0, + Radius::Sm => 4, + Radius::Md => 8, + Radius::Lg => 12, + Radius::Xl => 16, + Radius::Full => 9999, + Radius::Custom(v) => *v, + } + } + + /// CSS border-radius string. + pub fn to_css(&self) -> String { + match self { + Radius::Full => "9999px".to_string(), + other => format!("{}px", other.dp()), + } + } +} + +/// Theme-level radius scale. +#[derive(Debug, Clone)] +pub struct RadiusScale { + pub sm: u32, + pub md: u32, + pub lg: u32, + pub xl: u32, +} + +impl RadiusScale { + pub fn default() -> Self { + Self { + sm: 4, + md: 8, + lg: 12, + xl: 16, + } + } + + /// Resolve a Radius token to dp using this scale. + pub fn resolve(&self, radius: &Radius) -> u32 { + match radius { + Radius::None => 0, + Radius::Sm => self.sm, + Radius::Md => self.md, + Radius::Lg => self.lg, + Radius::Xl => self.xl, + Radius::Full => 9999, + Radius::Custom(v) => *v, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn radius_none_is_zero() { + assert_eq!(Radius::None.dp(), 0); + } + + #[test] + fn radius_full_is_large() { + assert_eq!(Radius::Full.dp(), 9999); + } + + #[test] + fn radius_css_full() { + assert_eq!(Radius::Full.to_css(), "9999px"); + } + + #[test] + fn radius_css_md() { + assert_eq!(Radius::Md.to_css(), "8px"); + } + + #[test] + fn radius_custom() { + assert_eq!(Radius::Custom(20).dp(), 20); + } + + #[test] + fn radius_scale_default() { + let scale = RadiusScale::default(); + assert_eq!(scale.resolve(&Radius::Md), 8); + assert_eq!(scale.resolve(&Radius::Lg), 12); + } +} diff --git a/crates/el-style/src/shadow.rs b/crates/el-style/src/shadow.rs new file mode 100644 index 0000000..1f00137 --- /dev/null +++ b/crates/el-style/src/shadow.rs @@ -0,0 +1,190 @@ +/// Elevation shadow scale. +/// +/// Shadows communicate visual hierarchy and z-depth. Use the named scale — +/// it maps to appropriate platform-native elevation on each backend. + +/// Named shadow/elevation tokens. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Shadow { + /// No shadow — flat, on-surface elements. + None, + /// Subtle drop shadow — slightly elevated cards. + Sm, + /// Standard card shadow — interactive elements. + Md, + /// Prominent shadow — dialogs, dropdowns, popovers. + Lg, + /// Maximum elevation — toasts, context menus, tooltips. + Xl, +} + +/// A fully-resolved shadow specification. +#[derive(Debug, Clone)] +pub struct ShadowSpec { + /// X offset in dp. + pub offset_x: f32, + /// Y offset in dp (positive = down). + pub offset_y: f32, + /// Blur radius in dp. + pub blur: f32, + /// Spread radius in dp. + pub spread: f32, + /// Shadow color (RGBA). + pub color: (u8, u8, u8, f32), +} + +impl ShadowSpec { + /// CSS box-shadow string for this spec. + pub fn to_css(&self) -> String { + format!( + "{}px {}px {}px {}px rgba({},{},{},{})", + self.offset_x, + self.offset_y, + self.blur, + self.spread, + self.color.0, + self.color.1, + self.color.2, + self.color.3, + ) + } +} + +/// Maps Shadow tokens to ShadowSpecs. +#[derive(Debug, Clone)] +pub struct ShadowScale { + pub sm: ShadowSpec, + pub md: ShadowSpec, + pub lg: ShadowSpec, + pub xl: ShadowSpec, +} + +impl ShadowScale { + /// Default light-mode shadow scale. + pub fn light() -> Self { + Self { + sm: ShadowSpec { + offset_x: 0.0, + offset_y: 1.0, + blur: 3.0, + spread: 0.0, + color: (0, 0, 0, 0.12), + }, + md: ShadowSpec { + offset_x: 0.0, + offset_y: 4.0, + blur: 12.0, + spread: -2.0, + color: (0, 0, 0, 0.15), + }, + lg: ShadowSpec { + offset_x: 0.0, + offset_y: 8.0, + blur: 24.0, + spread: -4.0, + color: (0, 0, 0, 0.18), + }, + xl: ShadowSpec { + offset_x: 0.0, + offset_y: 16.0, + blur: 48.0, + spread: -8.0, + color: (0, 0, 0, 0.22), + }, + } + } + + /// Default dark-mode shadow scale (more subtle, less visible on dark bg). + pub fn dark() -> Self { + Self { + sm: ShadowSpec { + offset_x: 0.0, + offset_y: 1.0, + blur: 3.0, + spread: 0.0, + color: (0, 0, 0, 0.3), + }, + md: ShadowSpec { + offset_x: 0.0, + offset_y: 4.0, + blur: 12.0, + spread: -2.0, + color: (0, 0, 0, 0.4), + }, + lg: ShadowSpec { + offset_x: 0.0, + offset_y: 8.0, + blur: 24.0, + spread: -4.0, + color: (0, 0, 0, 0.5), + }, + xl: ShadowSpec { + offset_x: 0.0, + offset_y: 16.0, + blur: 48.0, + spread: -8.0, + color: (0, 0, 0, 0.6), + }, + } + } + + /// Resolve a Shadow token to a ShadowSpec. + /// Returns None for Shadow::None (no spec needed). + pub fn resolve(&self, shadow: &Shadow) -> Option<&ShadowSpec> { + match shadow { + Shadow::None => None, + Shadow::Sm => Some(&self.sm), + Shadow::Md => Some(&self.md), + Shadow::Lg => Some(&self.lg), + Shadow::Xl => Some(&self.xl), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shadow_none_resolves_to_none() { + let scale = ShadowScale::light(); + assert!(scale.resolve(&Shadow::None).is_none()); + } + + #[test] + fn shadow_sm_resolves() { + let scale = ShadowScale::light(); + let spec = scale.resolve(&Shadow::Sm).unwrap(); + assert!(spec.blur > 0.0); + } + + #[test] + fn shadow_xl_has_larger_blur_than_sm() { + let scale = ShadowScale::light(); + let sm = scale.resolve(&Shadow::Sm).unwrap(); + let xl = scale.resolve(&Shadow::Xl).unwrap(); + assert!(xl.blur > sm.blur); + } + + #[test] + fn shadow_spec_to_css() { + let spec = ShadowSpec { + offset_x: 0.0, + offset_y: 4.0, + blur: 12.0, + spread: -2.0, + color: (0, 0, 0, 0.15), + }; + let css = spec.to_css(); + assert!(css.contains("rgba(0,0,0,0.15)")); + } + + #[test] + fn dark_shadows_are_more_opaque() { + let light = ShadowScale::light(); + let dark = ShadowScale::dark(); + let l_alpha = light.resolve(&Shadow::Md).unwrap().color.3; + let d_alpha = dark.resolve(&Shadow::Md).unwrap().color.3; + assert!(d_alpha > l_alpha); + } +} diff --git a/crates/el-style/src/spacing.rs b/crates/el-style/src/spacing.rs new file mode 100644 index 0000000..2038ebc --- /dev/null +++ b/crates/el-style/src/spacing.rs @@ -0,0 +1,137 @@ +/// Spacing scale — 4px base grid. +/// +/// Use named scale values, not raw numbers. This ensures visual consistency +/// and makes it easy to tweak the entire system by changing the base unit. + +/// Named spacing scale values. +/// +/// The base unit is 4dp/pt. All values are multiples of 4. +/// Use these for padding, margin, gap, and any other spatial measurement. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Spacing { + /// 0 — no spacing + None, + /// 4dp — hairline gap, tight list items + Xs, + /// 8dp — default tight padding, icon margins + Sm, + /// 12dp — compact component padding + Md, + /// 16dp — standard component padding (the workhorse) + Lg, + /// 24dp — generous padding, card internal spacing + Xl, + /// 32dp — section separation + Xxl, + /// 48dp — hero sections, major layout gaps + Xxxl, + /// 64dp — page-level margins, maximum separation + Max, + /// Custom value in dp (escape hatch) + Custom(u32), +} + +impl Spacing { + /// Resolve to a concrete dp/pt value. + pub fn dp(&self) -> u32 { + match self { + Spacing::None => 0, + Spacing::Xs => 4, + Spacing::Sm => 8, + Spacing::Md => 12, + Spacing::Lg => 16, + Spacing::Xl => 24, + Spacing::Xxl => 32, + Spacing::Xxxl => 48, + Spacing::Max => 64, + Spacing::Custom(v) => *v, + } + } + + /// Resolve to a concrete CSS pixel string. + pub fn to_css(&self) -> String { + format!("{}px", self.dp()) + } +} + +/// The spacing scale exposed by a theme. +/// Provides the mapping from scale names to concrete values. +#[derive(Debug, Clone)] +pub struct SpacingScale { + /// Base unit in dp (default: 4). + pub base: u32, +} + +impl SpacingScale { + pub fn default() -> Self { + Self { base: 4 } + } + + /// Resolve a Spacing token to dp. + pub fn resolve(&self, spacing: &Spacing) -> u32 { + match spacing { + Spacing::Custom(v) => *v, + other => { + let multiplier = match other { + Spacing::None => 0, + Spacing::Xs => 1, + Spacing::Sm => 2, + Spacing::Md => 3, + Spacing::Lg => 4, + Spacing::Xl => 6, + Spacing::Xxl => 8, + Spacing::Xxxl => 12, + Spacing::Max => 16, + Spacing::Custom(_) => unreachable!(), + }; + self.base * multiplier + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spacing_none_is_zero() { + assert_eq!(Spacing::None.dp(), 0); + } + + #[test] + fn spacing_lg_is_16() { + assert_eq!(Spacing::Lg.dp(), 16); + } + + #[test] + fn spacing_max_is_64() { + assert_eq!(Spacing::Max.dp(), 64); + } + + #[test] + fn spacing_custom() { + assert_eq!(Spacing::Custom(20).dp(), 20); + } + + #[test] + fn spacing_scale_resolves_base_unit() { + let scale = SpacingScale { base: 4 }; + assert_eq!(scale.resolve(&Spacing::Xs), 4); + assert_eq!(scale.resolve(&Spacing::Sm), 8); + assert_eq!(scale.resolve(&Spacing::Lg), 16); + } + + #[test] + fn spacing_to_css() { + assert_eq!(Spacing::Lg.to_css(), "16px"); + assert_eq!(Spacing::None.to_css(), "0px"); + } + + #[test] + fn spacing_scale_custom_scale() { + let scale = SpacingScale { base: 8 }; + assert_eq!(scale.resolve(&Spacing::Xs), 8); + assert_eq!(scale.resolve(&Spacing::Sm), 16); + } +} diff --git a/crates/el-style/src/stylesheet.rs b/crates/el-style/src/stylesheet.rs new file mode 100644 index 0000000..f2068b0 --- /dev/null +++ b/crates/el-style/src/stylesheet.rs @@ -0,0 +1,160 @@ +/// StyleSheet — named style rules for components. +/// +/// Instead of applying modifiers inline everywhere, you can define named +/// style rules in a StyleSheet and apply them with `.apply("card")`. +/// This is the el-ui equivalent of CSS class names, but type-safe and +/// resolved at compile time. + +use std::collections::HashMap; +use crate::modifier::StyleSet; + +/// A stylesheet: a named collection of StyleSet rules. +#[derive(Debug, Clone, Default)] +pub struct StyleSheet { + rules: HashMap, +} + +impl StyleSheet { + pub fn new() -> Self { + Self::default() + } + + /// Define a named style rule. + pub fn define(&mut self, name: impl Into, style: StyleSet) -> &mut Self { + self.rules.insert(name.into(), style); + self + } + + /// Look up a named style rule. + /// Returns None if the rule doesn't exist. + pub fn get(&self, name: &str) -> Option<&StyleSet> { + self.rules.get(name) + } + + /// Apply a named style to a StyleSet by merging — caller's explicit + /// values win, the stylesheet fills in the rest. + /// + /// This is an additive operation: fields that are Some in the named + /// rule overwrite fields that are None in the target. + pub fn apply(&self, name: &str, target: &mut StyleSet) { + if let Some(rule) = self.rules.get(name) { + if target.padding.is_none() { + target.padding = rule.padding; + } + if target.margin.is_none() { + target.margin = rule.margin; + } + if target.background.is_none() { + target.background = rule.background.clone(); + } + if target.foreground.is_none() { + target.foreground = rule.foreground.clone(); + } + if target.font.is_none() { + target.font = rule.font.clone(); + } + if target.radius.is_none() { + target.radius = rule.radius; + } + if target.shadow.is_none() { + target.shadow = rule.shadow; + } + if target.opacity.is_none() { + target.opacity = rule.opacity; + } + if target.border_width.is_none() { + target.border_width = rule.border_width; + } + if target.border_color.is_none() { + target.border_color = rule.border_color.clone(); + } + if target.width.is_none() { + target.width = rule.width.clone(); + } + if target.height.is_none() { + target.height = rule.height.clone(); + } + if target.max_width.is_none() { + target.max_width = rule.max_width.clone(); + } + } + } + + /// The number of rules defined. + pub fn len(&self) -> usize { + self.rules.len() + } + + pub fn is_empty(&self) -> bool { + self.rules.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::color::Color; + use crate::modifier::StyleSet; + + fn card_style() -> StyleSet { + let mut s = StyleSet::default(); + s.padding = Some([16; 4]); + s.radius = Some([8; 4]); + s.background = Some(Color::Surface); + s + } + + #[test] + fn stylesheet_define_and_get() { + let mut ss = StyleSheet::new(); + ss.define("card", card_style()); + assert!(ss.get("card").is_some()); + } + + #[test] + fn stylesheet_missing_rule_returns_none() { + let ss = StyleSheet::new(); + assert!(ss.get("nonexistent").is_none()); + } + + #[test] + fn stylesheet_apply_fills_missing_values() { + let mut ss = StyleSheet::new(); + ss.define("card", card_style()); + + let mut target = StyleSet::default(); + ss.apply("card", &mut target); + + assert_eq!(target.padding, Some([16; 4])); + assert_eq!(target.background, Some(Color::Surface)); + } + + #[test] + fn stylesheet_apply_does_not_overwrite_existing() { + let mut ss = StyleSheet::new(); + ss.define("card", card_style()); + + let mut target = StyleSet::default(); + target.background = Some(Color::Primary); // already set + ss.apply("card", &mut target); + + // Should NOT be overwritten by the stylesheet's Surface + assert_eq!(target.background, Some(Color::Primary)); + } + + #[test] + fn stylesheet_len() { + let mut ss = StyleSheet::new(); + assert_eq!(ss.len(), 0); + ss.define("card", card_style()); + assert_eq!(ss.len(), 1); + } + + #[test] + fn stylesheet_apply_nonexistent_is_noop() { + let ss = StyleSheet::new(); + let mut target = StyleSet::default(); + ss.apply("does-not-exist", &mut target); // should not panic + assert!(target.padding.is_none()); + } +} diff --git a/crates/el-style/src/theme.rs b/crates/el-style/src/theme.rs new file mode 100644 index 0000000..ba6d0fd --- /dev/null +++ b/crates/el-style/src/theme.rs @@ -0,0 +1,144 @@ +/// Theme — the single source of truth for all visual values. +/// +/// Components never hardcode colors, sizes, or shadows. They reference +/// semantic tokens; the Theme resolves them. Swap the theme node at the +/// root of the experience and every component updates automatically. + +use crate::color::ColorScheme; +use crate::radius::RadiusScale; +use crate::shadow::ShadowScale; +use crate::spacing::SpacingScale; +use crate::typography::TypographyScheme; + +/// Whether the theme follows the OS preference or is explicitly light/dark. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ThemeMode { + /// Explicit light mode. + Light, + /// Explicit dark mode. + Dark, + /// Follow the OS preference (default). + System, +} + +/// The complete design system for an experience. +/// +/// One Theme per experience, flowing down through the component tree. +/// Components read from it; they do not hold their own style values. +#[derive(Debug, Clone)] +pub struct Theme { + pub mode: ThemeMode, + pub colors: ColorScheme, + pub typography: TypographyScheme, + pub spacing: SpacingScale, + pub radius: RadiusScale, + pub shadows: ShadowScale, +} + +impl Theme { + /// The default light theme. + pub fn default_light() -> Self { + Self { + mode: ThemeMode::Light, + colors: ColorScheme::light(), + typography: TypographyScheme::default(), + spacing: SpacingScale::default(), + radius: RadiusScale::default(), + shadows: ShadowScale::light(), + } + } + + /// The default dark theme. + pub fn default_dark() -> Self { + Self { + mode: ThemeMode::Dark, + colors: ColorScheme::dark(), + typography: TypographyScheme::default(), + spacing: SpacingScale::default(), + radius: RadiusScale::default(), + shadows: ShadowScale::dark(), + } + } + + /// A system-following theme (uses light values as the base; + /// the runtime swaps to dark when the OS signals dark mode). + pub fn system() -> Self { + Self { + mode: ThemeMode::System, + colors: ColorScheme::light(), + typography: TypographyScheme::default(), + spacing: SpacingScale::default(), + radius: RadiusScale::default(), + shadows: ShadowScale::light(), + } + } + + /// Return a copy of this theme in the specified mode, + /// updating colors and shadows to match. + pub fn with_mode(&self, mode: ThemeMode) -> Self { + let (colors, shadows) = match &mode { + ThemeMode::Dark => (ColorScheme::dark(), ShadowScale::dark()), + ThemeMode::Light | ThemeMode::System => { + (ColorScheme::light(), ShadowScale::light()) + } + }; + Self { + mode, + colors, + shadows, + typography: self.typography.clone(), + spacing: self.spacing.clone(), + radius: self.radius.clone(), + } + } + + /// Whether dark mode is currently active. + pub fn is_dark(&self) -> bool { + matches!(self.mode, ThemeMode::Dark) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::color::Color; + + #[test] + fn light_theme_not_dark() { + assert!(!Theme::default_light().is_dark()); + } + + #[test] + fn dark_theme_is_dark() { + assert!(Theme::default_dark().is_dark()); + } + + #[test] + fn system_theme_mode() { + assert_eq!(Theme::system().mode, ThemeMode::System); + } + + #[test] + fn with_mode_switches_colors() { + let light = Theme::default_light(); + let dark = light.with_mode(ThemeMode::Dark); + let (_, _, _, _la) = light.colors.resolve(&Color::Background); + let (r, g, b, _) = dark.colors.resolve(&Color::Background); + // Dark background should be dark (low values) + assert!(r < 50 && g < 50 && b < 50); + } + + #[test] + fn theme_spacing_resolves() { + use crate::spacing::Spacing; + let theme = Theme::default_light(); + assert_eq!(theme.spacing.resolve(&Spacing::Lg), 16); + } + + #[test] + fn theme_radius_resolves() { + use crate::radius::Radius; + let theme = Theme::default_light(); + assert_eq!(theme.radius.resolve(&Radius::Md), 8); + } +} diff --git a/crates/el-style/src/typography.rs b/crates/el-style/src/typography.rs new file mode 100644 index 0000000..fb1092c --- /dev/null +++ b/crates/el-style/src/typography.rs @@ -0,0 +1,235 @@ +/// Typography scale — named text styles with semantic meaning. +/// +/// Don't hardcode font sizes. Use the named scale. The theme maps each +/// style to the right size, weight, and line height for the platform. + +/// Named text style levels. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum TextStyle { + /// Largest — hero headings, splash screens. + Display, + /// Page-level headings. + Headline, + /// Section headings, dialog titles. + Title, + /// Default readable body copy. + Body, + /// UI labels, button text, captions. + Label, + /// Monospaced — code, terminal output. + Code, + /// Fine print, footnotes, timestamps. + Caption, +} + +/// Font weight. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FontWeight { + Thin, // 100 + Light, // 300 + Regular, // 400 + Medium, // 500 + SemiBold, // 600 + Bold, // 700 + ExtraBold, // 800 + Black, // 900 +} + +impl FontWeight { + /// Numeric CSS/platform font weight value. + pub fn value(&self) -> u32 { + match self { + FontWeight::Thin => 100, + FontWeight::Light => 300, + FontWeight::Regular => 400, + FontWeight::Medium => 500, + FontWeight::SemiBold => 600, + FontWeight::Bold => 700, + FontWeight::ExtraBold => 800, + FontWeight::Black => 900, + } + } +} + +/// Text alignment — logical (RTL-aware), not physical. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TextAlign { + /// Aligns to the start of reading direction (left in LTR, right in RTL). + Start, + /// Center. + Center, + /// Aligns to the end of reading direction. + End, + /// Justify. + Justify, +} + +/// Text decoration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TextDecoration { + None, + Underline, + LineThrough, + Overline, +} + +/// Overflow behavior for text that doesn't fit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TextOverflow { + Clip, + Ellipsis, + Wrap, +} + +/// Fully-resolved spec for a single text style level. +#[derive(Debug, Clone)] +pub struct TextSpec { + /// Font size in density-independent pixels. + pub size: f32, + /// Line height multiplier (1.5 = 150% of font size). + pub line_height: f32, + /// Letter spacing in em units. + pub letter_spacing: f32, + pub weight: FontWeight, +} + +/// Maps each TextStyle level to a concrete TextSpec. +#[derive(Debug, Clone)] +pub struct TypographyScheme { + pub display: TextSpec, + pub headline: TextSpec, + pub title: TextSpec, + pub body: TextSpec, + pub label: TextSpec, + pub code: TextSpec, + pub caption: TextSpec, +} + +impl TypographyScheme { + /// Default typography scale (4px base grid, 16px body). + pub fn default() -> Self { + Self { + display: TextSpec { + size: 57.0, + line_height: 1.12, + letter_spacing: -0.025, + weight: FontWeight::Regular, + }, + headline: TextSpec { + size: 32.0, + line_height: 1.25, + letter_spacing: -0.015, + weight: FontWeight::SemiBold, + }, + title: TextSpec { + size: 22.0, + line_height: 1.3, + letter_spacing: -0.01, + weight: FontWeight::SemiBold, + }, + body: TextSpec { + size: 16.0, + line_height: 1.5, + letter_spacing: 0.0, + weight: FontWeight::Regular, + }, + label: TextSpec { + size: 14.0, + line_height: 1.4, + letter_spacing: 0.005, + weight: FontWeight::Medium, + }, + code: TextSpec { + size: 14.0, + line_height: 1.6, + letter_spacing: 0.0, + weight: FontWeight::Regular, + }, + caption: TextSpec { + size: 12.0, + line_height: 1.33, + letter_spacing: 0.01, + weight: FontWeight::Regular, + }, + } + } + + /// Resolve a TextStyle variant to its TextSpec. + pub fn resolve(&self, style: &TextStyle) -> &TextSpec { + match style { + TextStyle::Display => &self.display, + TextStyle::Headline => &self.headline, + TextStyle::Title => &self.title, + TextStyle::Body => &self.body, + TextStyle::Label => &self.label, + TextStyle::Code => &self.code, + TextStyle::Caption => &self.caption, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn typography_scheme_body_size() { + let scheme = TypographyScheme::default(); + let spec = scheme.resolve(&TextStyle::Body); + assert_eq!(spec.size, 16.0); + } + + #[test] + fn typography_scheme_display_largest() { + let scheme = TypographyScheme::default(); + let display = scheme.resolve(&TextStyle::Display); + let caption = scheme.resolve(&TextStyle::Caption); + assert!(display.size > caption.size); + } + + #[test] + fn font_weight_values() { + assert_eq!(FontWeight::Regular.value(), 400); + assert_eq!(FontWeight::Bold.value(), 700); + assert_eq!(FontWeight::SemiBold.value(), 600); + } + + #[test] + fn typography_minimum_size() { + // All text styles must be >= 11pt (accessibility minimum) + let scheme = TypographyScheme::default(); + for style in [ + TextStyle::Display, + TextStyle::Headline, + TextStyle::Title, + TextStyle::Body, + TextStyle::Label, + TextStyle::Code, + TextStyle::Caption, + ] { + let spec = scheme.resolve(&style); + assert!( + spec.size >= 11.0, + "{:?} size {} is below 11pt minimum", + style, + spec.size + ); + } + } + + #[test] + fn typography_code_is_readable_size() { + let scheme = TypographyScheme::default(); + let spec = scheme.resolve(&TextStyle::Code); + assert!(spec.size >= 12.0); + } + + #[test] + fn typography_line_heights_positive() { + let scheme = TypographyScheme::default(); + for style in [TextStyle::Body, TextStyle::Caption, TextStyle::Title] { + let spec = scheme.resolve(&style); + assert!(spec.line_height > 1.0); + } + } +} diff --git a/examples/profile-card/Cargo.toml b/examples/profile-card/Cargo.toml new file mode 100644 index 0000000..1a0dabd --- /dev/null +++ b/examples/profile-card/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "profile-card" +version = "0.1.0" +edition = "2021" +description = "el-ui profile card example — styling, layout, i18n, config, auth" + +[[bin]] +name = "profile-card" +path = "src/main.rs" + +[dependencies] +el-style = { path = "../../crates/el-style" } +el-layout = { path = "../../crates/el-layout" } +el-i18n = { path = "../../crates/el-i18n" } +el-config = { path = "../../crates/el-config" } +el-secrets = { path = "../../crates/el-secrets" } diff --git a/examples/profile-card/src/main.rs b/examples/profile-card/src/main.rs new file mode 100644 index 0000000..83a4105 --- /dev/null +++ b/examples/profile-card/src/main.rs @@ -0,0 +1,350 @@ +//! Profile card example — demonstrates el-ui styling, layout, i18n, config, and secrets. +//! +//! This example shows what building with el-ui looks like: +//! +//! - Styling via semantic tokens and the StyleModifier trait +//! - Responsive layout: VStack/HStack that wrap automatically +//! - Localization via LocaleContext and t()/t_plural() +//! - Configuration from el.toml / env vars +//! - Secrets that never appear in logs + +use std::collections::HashMap; + +use el_config::prelude::*; +use el_i18n::prelude::*; +use el_layout::prelude::*; +use el_secrets::prelude::*; +use el_style::prelude::*; + +// --- Domain model --- + +struct UserProfile { + handle: String, + display_name: String, + bio: String, + follower_count: i64, + following_count: i64, + is_verified: bool, + is_following: bool, +} + +// --- Profile card component --- + +/// A profile card component. +/// +/// In a real el-ui app, this would be a .el component file compiled by +/// el-ui-compiler. Here we show the same patterns in pure Rust so the +/// example is self-contained and runnable. +struct ProfileCard { + profile: UserProfile, + theme: Theme, + style: StyleSet, + locale: LocaleContext, +} + +impl StyleModifier for ProfileCard { + fn style_mut(&mut self) -> &mut StyleSet { + &mut self.style + } +} + +impl ProfileCard { + fn new(profile: UserProfile, theme: Theme, locale: LocaleContext) -> Self { + Self { + profile, + theme, + style: StyleSet::default(), + locale, + } + } + + /// "Render" the card — in a real app the backend converts this to + /// native views. Here we produce a human-readable description. + fn render(&self) -> String { + let t = &self.locale; + let theme = &self.theme; + + // --- Header HStack (avatar + name + verified badge) --- + // HStack wraps automatically if the container is narrow (mobile-first) + let header = HStack::new() + .spacing(12) + .alignment(VAlign::Center) + .wrap(true); + + // --- Name text: Title style --- + let name_style = theme.typography.resolve(&TextStyle::Title); + + // --- Body text: Body style --- + let body_style = theme.typography.resolve(&TextStyle::Body); + + // --- Stats HStack (followers / following) --- + let _stats_layout = HStack::new().spacing(24).wrap(true); + + // --- Follow button --- + // VStack wraps children that don't fit, so this works on any screen width + let _card_layout = VStack::new() + .spacing(16) + .alignment(HAlign::Leading) + .wrap(true); + + // --- Localized strings --- + let follow_label = if self.profile.is_following { + t.t("profile.following") + } else { + t.t("profile.follow") + }; + + let followers_label = + t.t_plural("profile.followers", self.profile.follower_count); + let following_label = + t.t_plural("profile.following_count", self.profile.following_count); + + // --- Color resolution --- + let (bg_r, bg_g, bg_b, _) = theme.colors.resolve(&Color::Surface); + let (text_r, text_g, text_b, _) = theme.colors.resolve(&Color::OnSurface); + let (primary_r, primary_g, primary_b, _) = theme.colors.resolve(&Color::Primary); + + // --- Shadow --- + let shadow_css = theme + .shadows + .resolve(&Shadow::Md) + .map(|s| s.to_css()) + .unwrap_or_default(); + + // --- Formatted stats (locale-aware numbers) --- + let follower_count_fmt = format_integer(self.profile.follower_count, &self.locale.locale); + let following_count_fmt = format_integer(self.profile.following_count, &self.locale.locale); + + // --- RTL layout signal --- + let layout_direction = if self.locale.is_rtl() { "rtl" } else { "ltr" }; + + // --- Build the output --- + format!( + r#" +┌─ ProfileCard ───────────────────────────────────────────── +│ Layout direction: {} +│ Theme mode: {:?} +│ +│ [Card background: rgb({},{},{}), shadow: {}] +│ +│ {} {} [Header HStack, spacing=12, wrap=true] +│ Avatar [44×44pt, radius=Full — meets touch target] +│ {} [font: {}pt weight={}, color: rgb({},{},{})] +│ {} [verified badge] +│ +│ {} [Body style, {}pt, color: rgb({},{},{})] +│ +│ [Stats HStack, spacing=24, wrap=true] +│ {} {} — follower count (locale-formatted) +│ {} {} — following count +│ +│ [Button: Primary variant] +│ {} [color: rgb({},{},{})] +│ +│ [Card ends] +└──────────────────────────────────────────────────────────── +"#, + layout_direction, + theme.mode, + bg_r, bg_g, bg_b, + shadow_css, + header.spacing, + if header.wrap { "wrap=true" } else { "wrap=false" }, + self.profile.display_name, + name_style.size, + name_style.weight.value(), + text_r, text_g, text_b, + if self.profile.is_verified { "✓" } else { "" }, + self.profile.bio, + body_style.size, + text_r, text_g, text_b, + follower_count_fmt, + followers_label, + following_count_fmt, + following_label, + follow_label, + primary_r, primary_g, primary_b, + ) + } +} + +// --- Application entry point --- + +fn main() { + // 1. Configuration — layered, typed + let el_toml = r#" +[config] +app.name = "ProfileCard Example" +app.version = "1.0.0" +profile.max_bio_length = "160" + +[env.development] +app.debug = "true" +"#; + + let toml_source = load_from_toml(el_toml, &Environment::Development) + .expect("el.toml should be valid"); + + let mut config = Config::new(Environment::Development); + config.push_source(Box::new(toml_source)); + + let app_name = config.get::("app.name").unwrap_or_default(); + let app_version = config.get::("app.version").unwrap_or_default(); + let max_bio: u32 = config.get_or("profile.max_bio_length", 160u32); + let debug: bool = config.get_or("app.debug", false); + + println!("=== {} v{} ===", app_name, app_version); + println!("Environment: {}", config.environment); + println!("Debug mode: {}", debug); + println!("Max bio: {} chars", max_bio); + + // 2. Secrets — loaded at startup, never logged + let mut secret_src = InMemorySource::new(); + secret_src.insert("analytics.key", "ana_abc123xyz"); + + let secrets = SecretsResolver::new() + .source(Box::new(secret_src)) + .require("analytics.key") + .resolve() + .expect("required secrets must be present at startup"); + + let analytics_key = secrets.require("analytics.key"); + // This will always print [REDACTED] — never the actual key + println!("Analytics key: {} (safely [REDACTED] in logs)", analytics_key); + // To actually use it: + let _actual_key: &str = analytics_key.expose(); + + // 3. Localization — English + let mut en_bundle = LocaleBundle::new(Locale::en_us()); + en_bundle.insert("profile.follow", "Follow"); + en_bundle.insert("profile.following", "Following"); + let mut forms = HashMap::new(); + forms.insert("one".to_string(), "{n} Follower".to_string()); + forms.insert("other".to_string(), "{n} Followers".to_string()); + en_bundle.insert_plural("profile.followers", forms); + let mut following_forms = HashMap::new(); + following_forms.insert("one".to_string(), "{n} Following".to_string()); + following_forms.insert("other".to_string(), "{n} Following".to_string()); + en_bundle.insert_plural("profile.following_count", following_forms); + + let en_ctx = LocaleContext::new(Locale::en_us(), en_bundle); + + // 4. Theme — light, system colors + let light_theme = Theme::default_light(); + let dark_theme = Theme::default_dark(); + + // 5. Profile data + let profile = UserProfile { + handle: "alice".to_string(), + display_name: "Alice Chen".to_string(), + bio: "Building beautiful things with el-ui. Rust enthusiast.".to_string(), + follower_count: 12_483, + following_count: 342, + is_verified: true, + is_following: false, + }; + + // 6. Render the card (light theme, English) + println!("\n=== Light Theme, English ==="); + let card = ProfileCard::new( + UserProfile { + handle: profile.handle.clone(), + display_name: profile.display_name.clone(), + bio: profile.bio.clone(), + follower_count: profile.follower_count, + following_count: profile.following_count, + is_verified: profile.is_verified, + is_following: profile.is_following, + }, + light_theme, + en_ctx.clone(), + ); + // Apply style modifiers — fluent, zero-cost at compile time + let styled_card = card + .padding(16) + .background(Color::Surface) + .radius(Radius::Lg) + .shadow(Shadow::Md) + .max_width(Dimension::Fixed(480)); + + println!("{}", styled_card.render()); + + // 7. Render with dark theme + println!("=== Dark Theme, English ==="); + let card_dark = ProfileCard::new( + UserProfile { + handle: profile.handle.clone(), + display_name: profile.display_name.clone(), + bio: profile.bio.clone(), + follower_count: 1, // test singular + following_count: profile.following_count, + is_verified: profile.is_verified, + is_following: true, + }, + dark_theme, + en_ctx, + ); + let styled_dark = card_dark + .padding(16) + .background(Color::Surface) + .radius(Radius::Lg) + .shadow(Shadow::Lg); + + println!("{}", styled_dark.render()); + + // 8. Layout demonstration + println!("=== Layout Engine Demo ==="); + + // Grid: auto columns — picks 1, 2, 3... based on container width + let grid = GridLayout::new().columns_auto(200.0).gap(16); + for width in [300.0f32, 600.0, 900.0, 1200.0] { + println!( + " Container {}px → {} columns", + width, + grid.active_columns(width) + ); + } + + // Responsive value + let cols: Responsive = Responsive::fixed(1).md(2).lg(3); + println!("\n Responsive columns:"); + for bp in [ + Breakpoint::Base, + Breakpoint::Sm, + Breakpoint::Md, + Breakpoint::Lg, + Breakpoint::Xl, + ] { + println!(" {:?}: {} col(s)", bp, cols.resolve(bp)); + } + + // Platform sizing + let ios_sizing = PlatformSizing::for_platform(PlatformFamily::Ios); + let android_sizing = PlatformSizing::for_platform(PlatformFamily::Android); + println!("\n Min touch targets:"); + println!(" iOS: {}pt", ios_sizing.min_touch_target); + println!(" Android: {}dp", android_sizing.min_touch_target); + + // 9. Locale formatting + println!("\n=== Locale-Aware Formatting ==="); + let number = 1_234_567.89; + for (tag, locale) in [ + ("en-US", Locale::en_us()), + ("de-DE", Locale::new("de-DE")), + ("fr-FR", Locale::fr_fr()), + ("ja-JP", Locale::ja()), + ] { + let formatted = format_number(number, &locale, 2); + let currency = format_currency(1234.56, &locale, "USD"); + println!(" {}: {} | {}", tag, formatted, currency); + } + + // 10. RTL detection + println!("\n=== RTL Detection ==="); + for tag in ["en-US", "ar-SA", "he", "fa", "zh-TW"] { + let locale = Locale::new(tag); + println!(" {}: {}", tag, if locale.is_rtl() { "RTL" } else { "LTR" }); + } + + println!("\nAll systems operational. el-ui is ready."); +}