add el-style, el-layout, el-i18n, el-config, el-secrets: responsive by default, theme-driven, zero breakpoints

This commit is contained in:
Will Anderson
2026-04-27 20:18:47 -05:00
parent a1159eec65
commit 361c958618
43 changed files with 6384 additions and 0 deletions
Generated
+116
View File
@@ -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"
+6
View File
@@ -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"
+18
View File
@@ -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]
+337
View File
@@ -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.<current>] 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<Box<dyn ConfigSource>>,
/// 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<dyn ConfigSource>) {
self.sources.push(source);
}
/// Add a source at the highest priority (beginning of the stack).
pub fn prepend_source(&mut self, source: Box<dyn ConfigSource>) {
self.sources.insert(0, source);
}
/// Add defaults as the lowest-priority source.
pub fn set_defaults(&mut self, defaults: HashMap<String, String>) {
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<String> {
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<T: FromConfigStr>(&self, key: &str) -> Result<T, ConfigError> {
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<T: FromConfigStr>(&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<T: FromConfigStr>(&self, key: &str) -> Result<Option<T>, 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<String, String> {
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.<environment>]`.
pub fn load_from_toml(toml_str: &str, env: &Environment) -> Result<MapSource, ConfigError> {
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.<name>] 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<String, String>,
) {
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::<String>("app.name").unwrap(), "TestApp");
}
#[test]
fn get_u32() {
let cfg = make_config(&[("server.port", "8080")]);
assert_eq!(cfg.get::<u32>("server.port").unwrap(), 8080u32);
}
#[test]
fn get_bool() {
let cfg = make_config(&[("feature.enabled", "true")]);
assert_eq!(cfg.get::<bool>("feature.enabled").unwrap(), true);
}
#[test]
fn get_missing_returns_error() {
let cfg = Config::new(Environment::Development);
assert!(cfg.get::<String>("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::<String>("key").unwrap(), "high-value");
}
#[test]
fn get_opt_missing_is_none() {
let cfg = Config::new(Environment::Development);
assert_eq!(cfg.get_opt::<String>("missing").unwrap(), None);
}
#[test]
fn get_opt_present_is_some() {
let cfg = make_config(&[("key", "value")]);
assert_eq!(
cfg.get_opt::<String>("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());
}
}
+114
View File
@@ -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");
}
}
+20
View File
@@ -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 },
}
+42
View File
@@ -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.<current>]` 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::<String>("app.name").unwrap();
//! let port = cfg.get::<u32>("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::*;
+248
View File
@@ -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<String>;
/// All key→value pairs from this source.
fn all(&self) -> HashMap<String, String>;
}
/// 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<String>) -> 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<String> {
std::env::var(self.env_key(key)).ok()
}
fn all(&self) -> HashMap<String, String> {
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<String, String>,
}
impl MapSource {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
values: HashMap::new(),
}
}
pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.values.insert(key.into(), value.into());
}
pub fn from_map(name: impl Into<String>, map: HashMap<String, String>) -> Self {
Self {
name: name.into(),
values: map,
}
}
}
impl ConfigSource for MapSource {
fn name(&self) -> &str {
&self.name
}
fn get_raw(&self, key: &str) -> Option<String> {
self.values.get(key).cloned()
}
fn all(&self) -> HashMap<String, String> {
self.values.clone()
}
}
/// Typed config value extractor.
pub trait FromConfigStr: Sized {
fn from_config_str(s: &str) -> Result<Self, ConfigError>;
}
impl FromConfigStr for String {
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
Ok(s.to_string())
}
}
impl FromConfigStr for u32 {
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
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<Self, ConfigError> {
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<Self, ConfigError> {
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<Self, ConfigError> {
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<Self, ConfigError> {
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<Self, ConfigError> {
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<Self, ConfigError> {
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");
}
}
+18
View File
@@ -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]
+299
View File
@@ -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<String, String>),
}
/// A bundle of translations for a single locale.
#[derive(Debug, Clone)]
pub struct LocaleBundle {
pub locale: Locale,
translations: HashMap<String, TranslationValue>,
}
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<String>, value: impl Into<String>) {
self.translations.insert(
key.into(),
TranslationValue::Simple(value.into()),
);
}
/// Insert a plural translation.
pub fn insert_plural(
&mut self,
key: impl Into<String>,
forms: HashMap<String, String>,
) {
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<String> {
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<String> {
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<LocaleBundle, String> {
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());
}
}
+221
View File
@@ -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<char> = 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" => ("", 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%");
}
}
+44
View File
@@ -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::*;
+182
View File
@@ -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<String>,
}
impl Locale {
/// Create from a BCP 47 tag like "en-US" or "ar".
pub fn new(tag: impl Into<String>) -> 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");
}
}
+189
View File
@@ -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. 24 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);
}
}
+209
View File
@@ -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<LocaleBundle>,
}
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");
}
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "el-layout"
version = "0.1.0"
edition = "2021"
description = "el-ui responsive layout engine — responsive by default, zero breakpoints"
license = "MIT"
[lib]
name = "el_layout"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
el-style = { path = "../el-style" }
[dev-dependencies]
+98
View File
@@ -0,0 +1,98 @@
/// Breakpoints — named viewport width thresholds.
///
/// These are provided for the rare cases where you need explicit breakpoint
/// logic. For most cases, use the automatic layout in VStack/HStack/Grid.
/// Named breakpoint sizes (in dp/logical pixels).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Breakpoint {
/// Default — any width (the base / mobile-first value).
Base,
/// Small — 640dp+ (large phones, landscape phones).
Sm,
/// Medium — 768dp+ (tablets, small laptops).
Md,
/// Large — 1024dp+ (laptops, most desktops).
Lg,
/// Extra large — 1280dp+ (large desktops, wide monitors).
Xl,
}
impl Breakpoint {
/// The minimum width (dp) at which this breakpoint activates.
pub fn min_width(&self) -> f32 {
match self {
Breakpoint::Base => 0.0,
Breakpoint::Sm => 640.0,
Breakpoint::Md => 768.0,
Breakpoint::Lg => 1024.0,
Breakpoint::Xl => 1280.0,
}
}
/// Classify a container width into its active breakpoint.
pub fn for_width(width: f32) -> Self {
if width >= 1280.0 {
Breakpoint::Xl
} else if width >= 1024.0 {
Breakpoint::Lg
} else if width >= 768.0 {
Breakpoint::Md
} else if width >= 640.0 {
Breakpoint::Sm
} else {
Breakpoint::Base
}
}
/// True if this breakpoint is at least as wide as `other`.
pub fn at_least(&self, other: Breakpoint) -> bool {
self >= &other
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn breakpoint_for_small_width() {
assert_eq!(Breakpoint::for_width(320.0), Breakpoint::Base);
}
#[test]
fn breakpoint_for_tablet_width() {
assert_eq!(Breakpoint::for_width(800.0), Breakpoint::Md);
}
#[test]
fn breakpoint_for_desktop_width() {
assert_eq!(Breakpoint::for_width(1440.0), Breakpoint::Xl);
}
#[test]
fn breakpoint_ordering() {
assert!(Breakpoint::Xl > Breakpoint::Base);
assert!(Breakpoint::Lg > Breakpoint::Sm);
}
#[test]
fn at_least() {
assert!(Breakpoint::Lg.at_least(Breakpoint::Md));
assert!(!Breakpoint::Sm.at_least(Breakpoint::Md));
}
#[test]
fn min_widths_ascending() {
let bps = [
Breakpoint::Base,
Breakpoint::Sm,
Breakpoint::Md,
Breakpoint::Lg,
Breakpoint::Xl,
];
for i in 1..bps.len() {
assert!(bps[i].min_width() > bps[i - 1].min_width());
}
}
}
+162
View File
@@ -0,0 +1,162 @@
/// Layout constraints — what the parent is offering the child.
///
/// A parent passes a LayoutConstraints to each child during layout.
/// The child must produce a size within these constraints.
/// Constraints flow down; sizes flow back up.
/// Constraints passed from a parent to a child during layout.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LayoutConstraints {
/// Minimum width the child must be (dp).
pub min_width: f32,
/// Maximum width available to the child (dp). f32::INFINITY = unbounded.
pub max_width: f32,
/// Minimum height the child must be (dp).
pub min_height: f32,
/// Maximum height available to the child (dp). f32::INFINITY = unbounded.
pub max_height: f32,
}
impl LayoutConstraints {
/// Unconstrained — the child can be any size.
pub fn unbounded() -> Self {
Self {
min_width: 0.0,
max_width: f32::INFINITY,
min_height: 0.0,
max_height: f32::INFINITY,
}
}
/// Constrained to a specific width, unconstrained height.
pub fn with_max_width(max_width: f32) -> Self {
Self {
min_width: 0.0,
max_width,
min_height: 0.0,
max_height: f32::INFINITY,
}
}
/// Constrained to an exact width and height.
pub fn tight(width: f32, height: f32) -> Self {
Self {
min_width: width,
max_width: width,
min_height: height,
max_height: height,
}
}
/// Return constraints loosened to allow any size up to the maximums.
pub fn loosen(&self) -> Self {
Self {
min_width: 0.0,
max_width: self.max_width,
min_height: 0.0,
max_height: self.max_height,
}
}
/// Deflate the constraints by padding amounts.
/// Useful when a parent applies its own padding before offering space to a child.
pub fn deflate(&self, horizontal: f32, vertical: f32) -> Self {
Self {
min_width: (self.min_width - horizontal).max(0.0),
max_width: (self.max_width - horizontal).max(0.0),
min_height: (self.min_height - vertical).max(0.0),
max_height: (self.max_height - vertical).max(0.0),
}
}
/// Is the width dimension bounded?
pub fn has_bounded_width(&self) -> bool {
self.max_width.is_finite()
}
/// Is the height dimension bounded?
pub fn has_bounded_height(&self) -> bool {
self.max_height.is_finite()
}
/// Clamp a proposed size to fit within these constraints.
pub fn clamp_size(&self, width: f32, height: f32) -> (f32, f32) {
(
width.clamp(self.min_width, self.max_width),
height.clamp(self.min_height, self.max_height),
)
}
}
/// The size a child reports back to its parent after layout.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Size {
pub width: f32,
pub height: f32,
}
impl Size {
pub fn new(width: f32, height: f32) -> Self {
Self { width, height }
}
pub fn zero() -> Self {
Self { width: 0.0, height: 0.0 }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unbounded_has_no_max() {
let c = LayoutConstraints::unbounded();
assert!(c.max_width.is_infinite());
assert!(c.max_height.is_infinite());
}
#[test]
fn tight_constraints() {
let c = LayoutConstraints::tight(100.0, 50.0);
assert_eq!(c.min_width, 100.0);
assert_eq!(c.max_width, 100.0);
}
#[test]
fn deflate_reduces_available_space() {
let c = LayoutConstraints::tight(200.0, 100.0);
let deflated = c.deflate(16.0, 8.0);
assert_eq!(deflated.max_width, 184.0);
assert_eq!(deflated.max_height, 92.0);
}
#[test]
fn deflate_does_not_go_negative() {
let c = LayoutConstraints::tight(10.0, 10.0);
let deflated = c.deflate(20.0, 20.0);
assert_eq!(deflated.max_width, 0.0);
assert_eq!(deflated.max_height, 0.0);
}
#[test]
fn clamp_size() {
let c = LayoutConstraints {
min_width: 50.0,
max_width: 200.0,
min_height: 30.0,
max_height: 100.0,
};
let (w, h) = c.clamp_size(250.0, 20.0);
assert_eq!(w, 200.0);
assert_eq!(h, 30.0);
}
#[test]
fn bounded_width_detection() {
let bounded = LayoutConstraints::with_max_width(400.0);
let unbounded = LayoutConstraints::unbounded();
assert!(bounded.has_bounded_width());
assert!(!unbounded.has_bounded_width());
}
}
+178
View File
@@ -0,0 +1,178 @@
/// FlexLayout — the underlying flex engine for VStack, HStack.
///
/// This is the power behind the stacks. Most developers use VStack/HStack
/// directly; FlexLayout is available when you need full control.
/// Direction of the flex axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FlexDirection {
/// Children arranged top-to-bottom (VStack).
Column,
/// Children arranged left-to-right (HStack) — respects RTL automatically.
Row,
/// Like Column, but children wrap to new columns when they overflow.
ColumnWrap,
/// Like Row, but children wrap to new rows when they overflow.
RowWrap,
}
impl FlexDirection {
pub fn is_horizontal(&self) -> bool {
matches!(self, FlexDirection::Row | FlexDirection::RowWrap)
}
pub fn is_vertical(&self) -> bool {
matches!(self, FlexDirection::Column | FlexDirection::ColumnWrap)
}
pub fn wraps(&self) -> bool {
matches!(
self,
FlexDirection::ColumnWrap | FlexDirection::RowWrap
)
}
}
/// Alignment along the cross axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CrossAxisAlignment {
/// Stretch children to fill the cross axis.
Stretch,
/// Align children to the start of the cross axis.
Start,
/// Center children on the cross axis.
Center,
/// Align children to the end of the cross axis.
End,
/// Align text baselines (horizontal stacks only).
Baseline,
}
/// Alignment along the main axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MainAxisAlignment {
/// Pack children toward the start.
Start,
/// Center children.
Center,
/// Pack children toward the end.
End,
/// Distribute space between children.
SpaceBetween,
/// Distribute space around children.
SpaceAround,
/// Distribute space evenly around children.
SpaceEvenly,
}
/// How much space the main axis should occupy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MainAxisSize {
/// Shrink to minimum size needed.
Min,
/// Expand to fill all available space.
Max,
}
/// Logical horizontal alignment (RTL-aware).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HAlign {
/// Toward the reading-start (left in LTR, right in RTL).
Leading,
Center,
/// Toward the reading-end (right in LTR, left in RTL).
Trailing,
}
/// Vertical alignment.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VAlign {
Top,
Center,
Bottom,
Baseline,
}
/// Full flex layout specification.
#[derive(Debug, Clone)]
pub struct FlexLayout {
pub direction: FlexDirection,
pub main_axis_alignment: MainAxisAlignment,
pub cross_axis_alignment: CrossAxisAlignment,
pub main_axis_size: MainAxisSize,
/// Gap between children in dp.
pub gap: u32,
}
impl FlexLayout {
/// VStack defaults (column, wrapping, leading-aligned).
pub fn vstack(spacing: u32, wrap: bool) -> Self {
Self {
direction: if wrap {
FlexDirection::ColumnWrap
} else {
FlexDirection::Column
},
main_axis_alignment: MainAxisAlignment::Start,
cross_axis_alignment: CrossAxisAlignment::Stretch,
main_axis_size: MainAxisSize::Max,
gap: spacing,
}
}
/// HStack defaults (row, wrapping, center-aligned vertically).
pub fn hstack(spacing: u32, wrap: bool) -> Self {
Self {
direction: if wrap {
FlexDirection::RowWrap
} else {
FlexDirection::Row
},
main_axis_alignment: MainAxisAlignment::Start,
cross_axis_alignment: CrossAxisAlignment::Center,
main_axis_size: MainAxisSize::Max,
gap: spacing,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flex_direction_horizontal() {
assert!(FlexDirection::Row.is_horizontal());
assert!(FlexDirection::RowWrap.is_horizontal());
assert!(!FlexDirection::Column.is_horizontal());
}
#[test]
fn flex_direction_vertical() {
assert!(FlexDirection::Column.is_vertical());
assert!(!FlexDirection::Row.is_vertical());
}
#[test]
fn flex_direction_wraps() {
assert!(FlexDirection::RowWrap.wraps());
assert!(!FlexDirection::Row.wraps());
}
#[test]
fn vstack_layout_defaults() {
let layout = FlexLayout::vstack(8, true);
assert!(layout.direction.is_vertical());
assert!(layout.direction.wraps());
assert_eq!(layout.gap, 8);
}
#[test]
fn hstack_layout_defaults() {
let layout = FlexLayout::hstack(16, false);
assert!(layout.direction.is_horizontal());
assert!(!layout.direction.wraps());
assert_eq!(layout.gap, 16);
assert_eq!(layout.cross_axis_alignment, CrossAxisAlignment::Center);
}
}
+187
View File
@@ -0,0 +1,187 @@
/// GridLayout — a responsive column grid.
///
/// The auto column mode (GridColumns::Auto) automatically computes how many
/// columns fit given a minimum column width. No breakpoints needed.
/// Fixed column count (GridColumns::Fixed) puts exactly N columns in a row.
use el_style::modifier::{StyleModifier, StyleSet};
/// How to determine the number of grid columns.
#[derive(Debug, Clone, PartialEq)]
pub enum GridColumns {
/// A fixed number of equally-wide columns.
Fixed(u32),
/// As many columns as fit with each column at least `min_width` dp wide.
/// This is how you get responsive grids without breakpoints.
Auto { min_width: f32 },
}
impl GridColumns {
/// Compute the actual column count given a container width.
pub fn count_for_width(&self, container_width: f32) -> u32 {
match self {
GridColumns::Fixed(n) => *n,
GridColumns::Auto { min_width } => {
if container_width <= 0.0 || *min_width <= 0.0 {
return 1;
}
let cols = (container_width / min_width).floor() as u32;
cols.max(1)
}
}
}
/// Compute the width of each column given container width and gap.
pub fn column_width(&self, container_width: f32, gap: f32) -> f32 {
let cols = self.count_for_width(container_width) as f32;
let total_gap = gap * (cols - 1.0).max(0.0);
((container_width - total_gap) / cols).max(0.0)
}
}
/// Row height specification.
#[derive(Debug, Clone, PartialEq)]
pub enum GridRows {
/// All rows are the same height (dp).
Fixed(f32),
/// Rows take the height of their tallest item.
Auto,
}
/// A responsive grid layout.
#[derive(Debug, Clone)]
pub struct GridLayout {
pub columns: GridColumns,
pub rows: GridRows,
/// Horizontal gap between columns (dp).
pub column_gap: u32,
/// Vertical gap between rows (dp).
pub row_gap: u32,
pub style: StyleSet,
}
impl Default for GridLayout {
fn default() -> Self {
Self {
columns: GridColumns::Auto { min_width: 200.0 },
rows: GridRows::Auto,
column_gap: 16,
row_gap: 16,
style: StyleSet::default(),
}
}
}
impl GridLayout {
pub fn new() -> Self {
Self::default()
}
/// Set a fixed column count.
pub fn columns_fixed(mut self, n: u32) -> Self {
self.columns = GridColumns::Fixed(n);
self
}
/// Set auto columns with a minimum column width.
pub fn columns_auto(mut self, min_width: f32) -> Self {
self.columns = GridColumns::Auto { min_width };
self
}
/// Set gap (same for both axes).
pub fn gap(mut self, dp: u32) -> Self {
self.column_gap = dp;
self.row_gap = dp;
self
}
/// Set column and row gaps separately.
pub fn gap_xy(mut self, column_gap: u32, row_gap: u32) -> Self {
self.column_gap = column_gap;
self.row_gap = row_gap;
self
}
/// How many columns are active at a given container width?
pub fn active_columns(&self, container_width: f32) -> u32 {
self.columns.count_for_width(container_width)
}
}
impl StyleModifier for GridLayout {
fn style_mut(&mut self) -> &mut StyleSet {
&mut self.style
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixed_columns_always_returns_n() {
let cols = GridColumns::Fixed(3);
assert_eq!(cols.count_for_width(100.0), 3);
assert_eq!(cols.count_for_width(2000.0), 3);
}
#[test]
fn auto_columns_small_container() {
let cols = GridColumns::Auto { min_width: 200.0 };
assert_eq!(cols.count_for_width(300.0), 1);
}
#[test]
fn auto_columns_medium_container() {
let cols = GridColumns::Auto { min_width: 200.0 };
assert_eq!(cols.count_for_width(500.0), 2);
}
#[test]
fn auto_columns_wide_container() {
let cols = GridColumns::Auto { min_width: 200.0 };
assert_eq!(cols.count_for_width(1200.0), 6);
}
#[test]
fn auto_columns_minimum_one() {
let cols = GridColumns::Auto { min_width: 500.0 };
assert_eq!(cols.count_for_width(100.0), 1);
}
#[test]
fn column_width_with_gap() {
let cols = GridColumns::Fixed(3);
// 300px wide, 3 cols, 16px gap between each = 2 gaps
// (300 - 32) / 3 = 268/3 ≈ 89.33
let w = cols.column_width(300.0, 16.0);
assert!((w - (300.0 - 32.0) / 3.0).abs() < 0.01);
}
#[test]
fn grid_defaults() {
let grid = GridLayout::new();
assert_eq!(grid.column_gap, 16);
assert_eq!(grid.row_gap, 16);
}
#[test]
fn grid_active_columns_auto() {
let grid = GridLayout::new().columns_auto(200.0);
assert_eq!(grid.active_columns(600.0), 3);
}
#[test]
fn grid_active_columns_fixed() {
let grid = GridLayout::new().columns_fixed(4);
assert_eq!(grid.active_columns(100.0), 4);
}
#[test]
fn grid_gap_xy() {
let grid = GridLayout::new().gap_xy(8, 24);
assert_eq!(grid.column_gap, 8);
assert_eq!(grid.row_gap, 24);
}
}
+50
View File
@@ -0,0 +1,50 @@
//! el-layout — Responsive layout engine for el-ui.
//!
//! **Responsive by default.** VStack and HStack wrap automatically.
//! Grid uses auto columns. You don't write breakpoints for basic layouts.
//!
//! ## Layout primitives
//!
//! - [`VStack`] — vertical stack, wraps by default
//! - [`HStack`] — horizontal stack, wraps by default, RTL-aware
//! - [`ZStack`] — depth stack, children overlap
//! - [`GridLayout`] — responsive grid, auto or fixed columns
//! - [`ScrollView`] — scrollable container
//!
//! ## Responsive values
//!
//! For the rare case where you need a value to change at a specific breakpoint:
//! ```
//! use el_layout::prelude::*;
//!
//! // Most specific value that applies cascades down to less specific
//! let cols: Responsive<u32> = Responsive::fixed(1).md(2).lg(3);
//! assert_eq!(*cols.resolve(Breakpoint::Sm), 1);
//! assert_eq!(*cols.resolve(Breakpoint::Md), 2);
//! assert_eq!(*cols.resolve(Breakpoint::Lg), 3);
//! assert_eq!(*cols.resolve(Breakpoint::Xl), 3); // cascades from lg
//! ```
#![deny(warnings)]
pub mod breakpoint;
pub mod constraints;
pub mod flex;
pub mod grid;
pub mod platform;
pub mod responsive;
pub mod scroll;
pub mod stack;
pub mod prelude {
pub use crate::breakpoint::Breakpoint;
pub use crate::constraints::{LayoutConstraints, Size};
pub use crate::flex::{CrossAxisAlignment, FlexDirection, FlexLayout, HAlign, MainAxisAlignment, MainAxisSize, VAlign};
pub use crate::grid::{GridColumns, GridLayout, GridRows};
pub use crate::platform::{PlatformFamily, PlatformSizing, SafeAreaInsets};
pub use crate::responsive::Responsive;
pub use crate::scroll::{ScrollAxis, ScrollIndicator, ScrollView};
pub use crate::stack::{HStack, Spacer, VStack, ZStack};
}
pub use prelude::*;
+197
View File
@@ -0,0 +1,197 @@
/// Platform-aware sizing constants.
///
/// Platform HIG minimum touch targets, safe area handling, and
/// density-independent pixel conventions.
/// Which platform family the app is running on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlatformFamily {
/// iOS / iPadOS
Ios,
/// Android
Android,
/// macOS
Macos,
/// Windows
Windows,
/// Linux desktop
Linux,
/// Web browser
Web,
}
/// Platform-specific sizing constants.
#[derive(Debug, Clone)]
pub struct PlatformSizing {
/// Minimum touch target dimension in dp (height and width).
pub min_touch_target: f32,
/// Standard icon size in dp.
pub icon_size: f32,
/// Standard small icon size in dp.
pub icon_size_sm: f32,
/// Standard navigation bar height in dp.
pub nav_bar_height: f32,
/// Standard tab bar height in dp.
pub tab_bar_height: f32,
/// Standard status bar height in dp (approximate; actual is platform-provided).
pub status_bar_height: f32,
}
impl PlatformSizing {
/// Sizing constants for a given platform.
pub fn for_platform(platform: PlatformFamily) -> Self {
match platform {
PlatformFamily::Ios => Self {
min_touch_target: 44.0, // Apple HIG
icon_size: 24.0,
icon_size_sm: 16.0,
nav_bar_height: 44.0,
tab_bar_height: 49.0,
status_bar_height: 44.0, // approximate; varies with notch
},
PlatformFamily::Android => Self {
min_touch_target: 48.0, // Material Design
icon_size: 24.0,
icon_size_sm: 18.0,
nav_bar_height: 56.0,
tab_bar_height: 56.0,
status_bar_height: 24.0,
},
PlatformFamily::Macos => Self {
min_touch_target: 44.0, // macOS HIG
icon_size: 16.0,
icon_size_sm: 12.0,
nav_bar_height: 28.0,
tab_bar_height: 36.0,
status_bar_height: 0.0, // macOS status bar is system chrome
},
PlatformFamily::Windows => Self {
min_touch_target: 44.0,
icon_size: 16.0,
icon_size_sm: 12.0,
nav_bar_height: 40.0,
tab_bar_height: 40.0,
status_bar_height: 0.0,
},
PlatformFamily::Linux => Self {
min_touch_target: 44.0,
icon_size: 16.0,
icon_size_sm: 12.0,
nav_bar_height: 36.0,
tab_bar_height: 36.0,
status_bar_height: 0.0,
},
PlatformFamily::Web => Self {
min_touch_target: 44.0, // WCAG 2.5.5 recommended
icon_size: 20.0,
icon_size_sm: 16.0,
nav_bar_height: 64.0,
tab_bar_height: 48.0,
status_bar_height: 0.0,
},
}
}
/// Is the given width adequate for a touch target?
pub fn is_touch_adequate(&self, width: f32, height: f32) -> bool {
width >= self.min_touch_target && height >= self.min_touch_target
}
}
/// Safe area insets — space reserved by system chrome.
///
/// These are provided at runtime by the platform. The values here
/// are conservative defaults for simulation.
#[derive(Debug, Clone, Copy, Default)]
pub struct SafeAreaInsets {
pub top: f32,
pub right: f32,
pub bottom: f32,
pub left: f32,
}
impl SafeAreaInsets {
/// No safe area insets (desktop platforms).
pub fn none() -> Self {
Self::default()
}
/// Typical iPhone safe area (notch at top, home indicator at bottom).
pub fn iphone_notch() -> Self {
Self {
top: 44.0,
right: 0.0,
bottom: 34.0,
left: 0.0,
}
}
/// Dynamic island (iPhone 14 Pro+).
pub fn iphone_dynamic_island() -> Self {
Self {
top: 59.0,
right: 0.0,
bottom: 34.0,
left: 0.0,
}
}
/// Total horizontal safe area.
pub fn horizontal(&self) -> f32 {
self.left + self.right
}
/// Total vertical safe area.
pub fn vertical(&self) -> f32 {
self.top + self.bottom
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ios_min_touch_target() {
let sizing = PlatformSizing::for_platform(PlatformFamily::Ios);
assert_eq!(sizing.min_touch_target, 44.0);
}
#[test]
fn android_min_touch_target() {
let sizing = PlatformSizing::for_platform(PlatformFamily::Android);
assert_eq!(sizing.min_touch_target, 48.0);
}
#[test]
fn touch_adequate_check() {
let sizing = PlatformSizing::for_platform(PlatformFamily::Ios);
assert!(sizing.is_touch_adequate(44.0, 44.0));
assert!(!sizing.is_touch_adequate(40.0, 44.0));
assert!(!sizing.is_touch_adequate(44.0, 40.0));
}
#[test]
fn safe_area_horizontal() {
let sa = SafeAreaInsets {
top: 44.0,
right: 16.0,
bottom: 34.0,
left: 16.0,
};
assert_eq!(sa.horizontal(), 32.0);
}
#[test]
fn safe_area_vertical() {
let sa = SafeAreaInsets::iphone_notch();
assert_eq!(sa.vertical(), 78.0);
}
#[test]
fn no_safe_area_is_zero() {
let sa = SafeAreaInsets::none();
assert_eq!(sa.horizontal(), 0.0);
assert_eq!(sa.vertical(), 0.0);
}
}
+154
View File
@@ -0,0 +1,154 @@
/// Responsive<T> — a value that varies by breakpoint.
///
/// The base value is always required (mobile-first). `sm`, `md`, `lg`, `xl`
/// are all optional — if not set, the nearest smaller value is used.
///
/// You generally don't need Responsive<T> for layout — VStack and Grid handle
/// reflow automatically. Use Responsive<T> when you need to vary a non-layout
/// value (e.g. font size, column count, visibility) at specific breakpoints.
use crate::breakpoint::Breakpoint;
/// A value that varies by viewport breakpoint.
///
/// Follows mobile-first cascade: base < sm < md < lg < xl.
/// If a breakpoint value is not set, it inherits from the next smaller one.
#[derive(Debug, Clone, PartialEq)]
pub struct Responsive<T: Clone> {
/// Mobile-first base value. Always required.
pub base: T,
/// 640dp+. If None, uses `base`.
pub sm: Option<T>,
/// 768dp+. If None, uses `sm` or `base`.
pub md: Option<T>,
/// 1024dp+. If None, uses `md`, `sm`, or `base`.
pub lg: Option<T>,
/// 1280dp+. If None, uses `lg`, `md`, `sm`, or `base`.
pub xl: Option<T>,
}
impl<T: Clone> Responsive<T> {
/// Create a responsive value with only the base (same on all screen sizes).
pub fn fixed(value: T) -> Self {
Self {
base: value,
sm: None,
md: None,
lg: None,
xl: None,
}
}
/// Create a fully-specified responsive value.
pub fn new(
base: T,
sm: Option<T>,
md: Option<T>,
lg: Option<T>,
xl: Option<T>,
) -> Self {
Self { base, sm, md, lg, xl }
}
/// Resolve to the most-specific value that applies at the given breakpoint.
///
/// Cascades downward: xl → lg → md → sm → base.
pub fn resolve(&self, breakpoint: Breakpoint) -> &T {
match breakpoint {
Breakpoint::Xl => {
self.xl.as_ref()
.or(self.lg.as_ref())
.or(self.md.as_ref())
.or(self.sm.as_ref())
.unwrap_or(&self.base)
}
Breakpoint::Lg => {
self.lg.as_ref()
.or(self.md.as_ref())
.or(self.sm.as_ref())
.unwrap_or(&self.base)
}
Breakpoint::Md => {
self.md.as_ref()
.or(self.sm.as_ref())
.unwrap_or(&self.base)
}
Breakpoint::Sm => {
self.sm.as_ref().unwrap_or(&self.base)
}
Breakpoint::Base => &self.base,
}
}
/// Set the sm value (builder pattern).
pub fn sm(mut self, value: T) -> Self {
self.sm = Some(value);
self
}
/// Set the md value.
pub fn md(mut self, value: T) -> Self {
self.md = Some(value);
self
}
/// Set the lg value.
pub fn lg(mut self, value: T) -> Self {
self.lg = Some(value);
self
}
/// Set the xl value.
pub fn xl(mut self, value: T) -> Self {
self.xl = Some(value);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixed_always_returns_base() {
let r = Responsive::fixed(42u32);
assert_eq!(*r.resolve(Breakpoint::Base), 42);
assert_eq!(*r.resolve(Breakpoint::Xl), 42);
}
#[test]
fn resolves_most_specific() {
let r = Responsive::fixed(1u32).sm(2).md(3).lg(4).xl(5);
assert_eq!(*r.resolve(Breakpoint::Base), 1);
assert_eq!(*r.resolve(Breakpoint::Sm), 2);
assert_eq!(*r.resolve(Breakpoint::Md), 3);
assert_eq!(*r.resolve(Breakpoint::Lg), 4);
assert_eq!(*r.resolve(Breakpoint::Xl), 5);
}
#[test]
fn cascades_down_when_specific_missing() {
let r = Responsive::fixed(1u32).md(3);
// sm not set → falls back to base
assert_eq!(*r.resolve(Breakpoint::Sm), 1);
// lg not set → falls back to md
assert_eq!(*r.resolve(Breakpoint::Lg), 3);
// xl not set → falls back to md (lg not set either)
assert_eq!(*r.resolve(Breakpoint::Xl), 3);
}
#[test]
fn cascade_xl_to_lg_to_sm_to_base() {
let r = Responsive::fixed("base").sm("sm");
assert_eq!(*r.resolve(Breakpoint::Md), "sm");
assert_eq!(*r.resolve(Breakpoint::Lg), "sm");
assert_eq!(*r.resolve(Breakpoint::Xl), "sm");
}
#[test]
fn responsive_with_strings() {
let r = Responsive::fixed("mobile").lg("desktop");
assert_eq!(*r.resolve(Breakpoint::Base), "mobile");
assert_eq!(*r.resolve(Breakpoint::Lg), "desktop");
}
}
+141
View File
@@ -0,0 +1,141 @@
/// ScrollView — scrollable container.
///
/// Wraps content that may exceed the available space. Scrolling axis can be
/// vertical (default), horizontal, or both. On platforms with native scroll
/// behaviors (iOS, Android), the backend translates this to a native scroll
/// container — you get momentum, overscroll, pull-to-refresh for free.
use el_style::modifier::{StyleModifier, StyleSet};
/// Which axis (or axes) can scroll.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollAxis {
/// Vertical scrolling only (default). Most content views.
Vertical,
/// Horizontal scrolling only. Carousels, horizontal lists.
Horizontal,
/// Free scrolling in both directions. Maps, canvases.
Both,
}
/// Scroll indicator visibility.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollIndicator {
/// Show when scrolling, hide otherwise (platform default).
Automatic,
/// Always show.
Always,
/// Never show.
Never,
}
/// A scrollable container.
#[derive(Debug, Clone)]
pub struct ScrollView {
pub axis: ScrollAxis,
pub indicator: ScrollIndicator,
/// Whether the user can zoom (pinch-to-zoom). Default: false.
pub zoomable: bool,
/// Minimum zoom scale (only relevant when zoomable = true).
pub min_zoom: f32,
/// Maximum zoom scale (only relevant when zoomable = true).
pub max_zoom: f32,
/// Whether to clip content to the scroll view bounds. Default: true.
pub clips_to_bounds: bool,
pub style: StyleSet,
}
impl Default for ScrollView {
fn default() -> Self {
Self {
axis: ScrollAxis::Vertical,
indicator: ScrollIndicator::Automatic,
zoomable: false,
min_zoom: 1.0,
max_zoom: 3.0,
clips_to_bounds: true,
style: StyleSet::default(),
}
}
}
impl ScrollView {
pub fn new() -> Self {
Self::default()
}
pub fn horizontal() -> Self {
Self {
axis: ScrollAxis::Horizontal,
..Self::default()
}
}
pub fn both_axes() -> Self {
Self {
axis: ScrollAxis::Both,
..Self::default()
}
}
pub fn indicator(mut self, indicator: ScrollIndicator) -> Self {
self.indicator = indicator;
self
}
pub fn zoomable(mut self, min: f32, max: f32) -> Self {
self.zoomable = true;
self.min_zoom = min;
self.max_zoom = max;
self
}
}
impl StyleModifier for ScrollView {
fn style_mut(&mut self) -> &mut StyleSet {
&mut self.style
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scroll_default_is_vertical() {
let sv = ScrollView::new();
assert_eq!(sv.axis, ScrollAxis::Vertical);
}
#[test]
fn scroll_horizontal_constructor() {
let sv = ScrollView::horizontal();
assert_eq!(sv.axis, ScrollAxis::Horizontal);
}
#[test]
fn scroll_both_axes() {
let sv = ScrollView::both_axes();
assert_eq!(sv.axis, ScrollAxis::Both);
}
#[test]
fn scroll_zoomable() {
let sv = ScrollView::new().zoomable(0.5, 4.0);
assert!(sv.zoomable);
assert_eq!(sv.min_zoom, 0.5);
assert_eq!(sv.max_zoom, 4.0);
}
#[test]
fn scroll_clips_by_default() {
let sv = ScrollView::new();
assert!(sv.clips_to_bounds);
}
#[test]
fn scroll_indicator_setting() {
let sv = ScrollView::new().indicator(ScrollIndicator::Never);
assert_eq!(sv.indicator, ScrollIndicator::Never);
}
}
+286
View File
@@ -0,0 +1,286 @@
/// VStack, HStack, ZStack — the primary layout building blocks.
///
/// These are the component API. FlexLayout is the engine underneath.
/// VStack and HStack wrap automatically by default — that's what makes
/// the layout responsive without writing breakpoints.
use crate::flex::{CrossAxisAlignment, FlexLayout, HAlign, VAlign};
use el_style::modifier::{StyleModifier, StyleSet};
/// A vertical stack — children arranged from top to bottom.
///
/// Wraps automatically when height is constrained (mobile-first).
/// The default gap is 8dp. Cross-axis fills the container width.
#[derive(Debug, Clone)]
pub struct VStack {
/// Space between children in dp (default: 8).
pub spacing: u32,
/// Horizontal alignment of children (default: Leading).
pub alignment: HAlign,
/// Whether to wrap to a new column when out of vertical space (default: true).
pub wrap: bool,
pub style: StyleSet,
}
impl Default for VStack {
fn default() -> Self {
Self {
spacing: 8,
alignment: HAlign::Leading,
wrap: true,
style: StyleSet::default(),
}
}
}
impl VStack {
pub fn new() -> Self {
Self::default()
}
pub fn spacing(mut self, dp: u32) -> Self {
self.spacing = dp;
self
}
pub fn alignment(mut self, align: HAlign) -> Self {
self.alignment = align;
self
}
/// Enable or disable wrapping.
pub fn wrap(mut self, wrap: bool) -> Self {
self.wrap = wrap;
self
}
/// Convert to FlexLayout spec.
pub fn to_flex(&self) -> FlexLayout {
let mut flex = FlexLayout::vstack(self.spacing, self.wrap);
flex.cross_axis_alignment = match self.alignment {
HAlign::Leading => CrossAxisAlignment::Start,
HAlign::Center => CrossAxisAlignment::Center,
HAlign::Trailing => CrossAxisAlignment::End,
};
flex
}
}
impl StyleModifier for VStack {
fn style_mut(&mut self) -> &mut StyleSet {
&mut self.style
}
}
/// A horizontal stack — children arranged from leading to trailing.
///
/// Respects reading direction (RTL reverses automatically).
/// Wraps to new rows by default when width is limited (mobile-first).
#[derive(Debug, Clone)]
pub struct HStack {
/// Space between children in dp (default: 8).
pub spacing: u32,
/// Vertical alignment of children (default: Center).
pub alignment: VAlign,
/// Whether to wrap to a new row when out of horizontal space (default: true).
pub wrap: bool,
pub style: StyleSet,
}
impl Default for HStack {
fn default() -> Self {
Self {
spacing: 8,
alignment: VAlign::Center,
wrap: true,
style: StyleSet::default(),
}
}
}
impl HStack {
pub fn new() -> Self {
Self::default()
}
pub fn spacing(mut self, dp: u32) -> Self {
self.spacing = dp;
self
}
pub fn alignment(mut self, align: VAlign) -> Self {
self.alignment = align;
self
}
pub fn wrap(mut self, wrap: bool) -> Self {
self.wrap = wrap;
self
}
/// Convert to FlexLayout spec.
pub fn to_flex(&self) -> FlexLayout {
let mut flex = FlexLayout::hstack(self.spacing, self.wrap);
flex.cross_axis_alignment = match self.alignment {
VAlign::Top => CrossAxisAlignment::Start,
VAlign::Center => CrossAxisAlignment::Center,
VAlign::Bottom => CrossAxisAlignment::End,
VAlign::Baseline => CrossAxisAlignment::Baseline,
};
flex
}
}
impl StyleModifier for HStack {
fn style_mut(&mut self) -> &mut StyleSet {
&mut self.style
}
}
/// A depth stack — children layered on top of each other.
///
/// ZStack places all children at the same position, overlapping.
/// Later children appear on top of earlier children.
#[derive(Debug, Clone)]
pub struct ZStack {
pub h_align: HAlign,
pub v_align: VAlign,
pub style: StyleSet,
}
impl Default for ZStack {
fn default() -> Self {
Self {
h_align: HAlign::Center,
v_align: VAlign::Center,
style: StyleSet::default(),
}
}
}
impl ZStack {
pub fn new() -> Self {
Self::default()
}
pub fn h_align(mut self, align: HAlign) -> Self {
self.h_align = align;
self
}
pub fn v_align(mut self, align: VAlign) -> Self {
self.v_align = align;
self
}
}
impl StyleModifier for ZStack {
fn style_mut(&mut self) -> &mut StyleSet {
&mut self.style
}
}
/// A spacer that expands to fill available space in a stack.
///
/// In HStack: expands horizontally. In VStack: expands vertically.
#[derive(Debug, Clone, Default)]
pub struct Spacer {
/// Minimum size in dp (0 = truly flexible).
pub min_size: u32,
}
impl Spacer {
pub fn new() -> Self {
Self::default()
}
pub fn min(mut self, dp: u32) -> Self {
self.min_size = dp;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::flex::CrossAxisAlignment;
use el_style::modifier::StyleModifier;
use el_style::color::Color;
#[test]
fn vstack_default_spacing() {
let v = VStack::new();
assert_eq!(v.spacing, 8);
}
#[test]
fn vstack_default_wraps() {
let v = VStack::new();
assert!(v.wrap);
}
#[test]
fn vstack_to_flex_direction() {
let v = VStack::new();
let flex = v.to_flex();
assert!(flex.direction.is_vertical());
}
#[test]
fn vstack_center_alignment() {
let v = VStack::new().alignment(HAlign::Center);
let flex = v.to_flex();
assert_eq!(flex.cross_axis_alignment, CrossAxisAlignment::Center);
}
#[test]
fn hstack_default_alignment() {
let h = HStack::new();
assert_eq!(h.alignment, VAlign::Center);
}
#[test]
fn hstack_default_wraps() {
let h = HStack::new();
assert!(h.wrap);
}
#[test]
fn hstack_to_flex_direction() {
let h = HStack::new();
let flex = h.to_flex();
assert!(flex.direction.is_horizontal());
}
#[test]
fn hstack_no_wrap() {
let h = HStack::new().wrap(false);
let flex = h.to_flex();
assert!(!flex.direction.wraps());
}
#[test]
fn zstack_defaults() {
let z = ZStack::new();
assert_eq!(z.h_align, HAlign::Center);
assert_eq!(z.v_align, VAlign::Center);
}
#[test]
fn vstack_style_modifier() {
let v = VStack::new().background(Color::Surface);
assert_eq!(v.style.background, Some(Color::Surface));
}
#[test]
fn spacer_min_size() {
let s = Spacer::new().min(16);
assert_eq!(s.min_size, 16);
}
#[test]
fn spacer_default_zero() {
let s = Spacer::new();
assert_eq!(s.min_size, 0);
}
}
+17
View File
@@ -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]
+19
View File
@@ -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<String> },
#[error("secret type conversion failed for key '{key}': {reason}")]
TypeError { key: String, reason: String },
}
+51
View File
@@ -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<T>` 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::*;
+282
View File
@@ -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<String, Secret<String>>,
}
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<String>> {
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<String> {
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<Box<dyn SecretsSource>>,
required: Vec<String>,
}
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<dyn SecretsSource>) -> 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<String>) -> 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<ResolvedSecrets, SecretsError> {
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<Secret<String>, 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());
}
}
+179
View File
@@ -0,0 +1,179 @@
/// Secret<T> — 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: Clone>(T);
impl<T: Clone> Secret<T> {
/// 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: Clone, F: FnOnce(T) -> U>(self, f: F) -> Secret<U> {
Secret(f(self.0))
}
}
/// Debug never reveals the secret value.
impl<T: Clone> std::fmt::Debug for Secret<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Secret([REDACTED])")
}
}
/// Display never reveals the secret value.
impl<T: Clone> std::fmt::Display for Secret<T> {
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<T: Clone> Serialize for Secret<T> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str("[REDACTED]")
}
}
/// Deserialize from a string — used when loading secrets from files/env.
/// Only implemented for Secret<String> since we always load as strings.
impl<'de> Deserialize<'de> for Secret<String> {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<T: Clone> {
inner: Secret<T>,
/// A hint shown in Debug output (not the value itself).
label: &'static str,
}
impl<T: Clone> SecretGuard<T> {
pub fn new(value: T, label: &'static str) -> Self {
Self {
inner: Secret::new(value),
label,
}
}
pub fn expose(&self) -> &T {
self.inner.expose()
}
}
impl<T: Clone> std::fmt::Debug for SecretGuard<T> {
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<String> = 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<u32> = 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"));
}
}
+251
View File
@@ -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<Secret<String>, SecretsError>;
/// List available secret keys (may not be supported by all sources).
fn list(&self) -> Result<Vec<String>, 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<String>) -> 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<Secret<String>, 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<Vec<String>, SecretsError> {
let prefix = format!("{}_", self.prefix);
let keys: Vec<String> = 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<String, String>,
}
impl InMemorySource {
pub fn new() -> Self {
Self::default()
}
/// Insert a secret. Only for testing.
pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.secrets.insert(key.into(), value.into());
}
}
impl SecretsSource for InMemorySource {
fn name(&self) -> &str {
"in-memory"
}
fn get(&self, key: &str) -> Result<Secret<String>, 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<Vec<String>, 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<String>, mount: impl Into<String>, path: impl Into<String>) -> 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<Secret<String>, 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<Vec<String>, 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<String>, path_prefix: impl Into<String>) -> 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<Secret<String>, 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<Vec<String>, 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());
}
}
+17
View File
@@ -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]
+255
View File
@@ -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 0255, alpha 0.01.0).
Rgba(u8, u8, u8, f32),
/// Apply opacity to any color.
Opacity(Box<Color>, 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<Self> {
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);
}
}
+51
View File
@@ -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::*;
+382
View File
@@ -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.01.0).
Fraction(f32),
/// Minimum of two dimensions.
Min(Box<Dimension>, Box<Dimension>),
/// Maximum of two dimensions.
Max(Box<Dimension>, Box<Dimension>),
}
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<Color>,
pub foreground: Option<Color>,
pub font: Option<TextStyle>,
pub radius: Option<[u32; 4]>, // top-left, top-right, bottom-right, bottom-left
pub shadow: Option<Shadow>,
pub opacity: Option<f32>,
pub border_width: Option<u32>,
pub border_color: Option<Color>,
pub width: Option<Dimension>,
pub height: Option<Dimension>,
pub max_width: Option<Dimension>,
pub max_height: Option<Dimension>,
pub min_width: Option<Dimension>,
pub min_height: Option<Dimension>,
pub flex_grow: Option<f32>,
pub flex_shrink: Option<f32>,
pub z_index: Option<i32>,
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)));
}
}
+116
View File
@@ -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);
}
}
+190
View File
@@ -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);
}
}
+137
View File
@@ -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);
}
}
+160
View File
@@ -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<String, StyleSet>,
}
impl StyleSheet {
pub fn new() -> Self {
Self::default()
}
/// Define a named style rule.
pub fn define(&mut self, name: impl Into<String>, 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());
}
}
+144
View File
@@ -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);
}
}
+235
View File
@@ -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);
}
}
}
+16
View File
@@ -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" }
+350
View File
@@ -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::<String>("app.name").unwrap_or_default();
let app_version = config.get::<String>("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<u32> = 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.");
}