add el-style, el-layout, el-i18n, el-config, el-secrets: responsive by default, theme-driven, zero breakpoints
This commit is contained in:
Generated
+116
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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" => ("zł", false, 2),
|
||||
"TRY" => ("₺", true, 2),
|
||||
"SAR" => ("﷼", false, 2),
|
||||
"AED" => ("د.إ", false, 2),
|
||||
_ => ("¤", true, 2), // generic currency sign for unknown codes
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn format_number_en_us() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_number(1234567.89, &locale, 2), "1,234,567.89");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_number_de() {
|
||||
let locale = Locale::new("de-DE");
|
||||
assert_eq!(format_number(1234.56, &locale, 2), "1.234,56");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_number_no_decimals() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_number(42.0, &locale, 0), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_number_negative() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_number(-1000.0, &locale, 2), "-1,000.00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_currency_usd() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_currency(1234.56, &locale, "USD"), "$1,234.56");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_currency_jpy_no_decimals() {
|
||||
let locale = Locale::ja();
|
||||
assert_eq!(format_currency(1234.0, &locale, "JPY"), "¥1,234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_integer_groups() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_integer(1000000, &locale), "1,000,000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_integer_small() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_integer(42, &locale), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_percent_whole() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_percent(0.85, &locale, 0), "85%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_percent_with_decimal() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_percent(0.856, &locale, 1), "85.6%");
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/// Plural forms — handles language-specific plurality rules.
|
||||
///
|
||||
/// Different languages have very different plural forms. English has two
|
||||
/// (one / other). Russian has four. Arabic has six. This module maps counts
|
||||
/// to the correct form for a given locale.
|
||||
|
||||
/// Named plural categories per Unicode CLDR.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PluralForm {
|
||||
/// Exactly zero (some languages have a special zero form).
|
||||
Zero,
|
||||
/// Exactly one.
|
||||
One,
|
||||
/// Small numbers (e.g. 2–4 in Slavic languages).
|
||||
Two,
|
||||
/// Few (language-specific).
|
||||
Few,
|
||||
/// Many (language-specific).
|
||||
Many,
|
||||
/// The catch-all / default.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Determine the plural form for a count in a given language.
|
||||
///
|
||||
/// Rules are a simplified implementation of CLDR plural rules for the most
|
||||
/// common languages. The full CLDR spec covers hundreds of languages; these
|
||||
/// cover the major cases.
|
||||
pub fn plural_form(language: &str, count: i64) -> PluralForm {
|
||||
let n = count.unsigned_abs(); // absolute value for matching
|
||||
|
||||
match language {
|
||||
// English and most Western European languages: one / other
|
||||
"en" | "de" | "nl" | "sv" | "da" | "no" | "nb" | "nn" | "fi"
|
||||
| "et" | "hu" | "tr" | "pt" | "it" | "es" | "ca" | "el" | "id"
|
||||
| "ms" | "th" | "zh" | "ja" | "ko" | "vi" | "ur" => {
|
||||
if n == 1 { PluralForm::One } else { PluralForm::Other }
|
||||
}
|
||||
|
||||
// French: one for 0 and 1, other for rest
|
||||
"fr" => {
|
||||
if n <= 1 { PluralForm::One } else { PluralForm::Other }
|
||||
}
|
||||
|
||||
// Russian, Ukrainian, Belarusian: complex Slavic rules
|
||||
"ru" | "uk" | "be" => {
|
||||
let n10 = n % 10;
|
||||
let n100 = n % 100;
|
||||
if n10 == 1 && n100 != 11 {
|
||||
PluralForm::One
|
||||
} else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) {
|
||||
PluralForm::Few
|
||||
} else {
|
||||
PluralForm::Many
|
||||
}
|
||||
}
|
||||
|
||||
// Polish: similar Slavic rules
|
||||
"pl" => {
|
||||
let n10 = n % 10;
|
||||
let n100 = n % 100;
|
||||
if n == 1 {
|
||||
PluralForm::One
|
||||
} else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) {
|
||||
PluralForm::Few
|
||||
} else {
|
||||
PluralForm::Many
|
||||
}
|
||||
}
|
||||
|
||||
// Czech, Slovak
|
||||
"cs" | "sk" => {
|
||||
if n == 1 {
|
||||
PluralForm::One
|
||||
} else if (2..=4).contains(&n) {
|
||||
PluralForm::Few
|
||||
} else {
|
||||
PluralForm::Other
|
||||
}
|
||||
}
|
||||
|
||||
// Arabic: 6 forms
|
||||
"ar" => {
|
||||
let n100 = n % 100;
|
||||
if n == 0 {
|
||||
PluralForm::Zero
|
||||
} else if n == 1 {
|
||||
PluralForm::One
|
||||
} else if n == 2 {
|
||||
PluralForm::Two
|
||||
} else if (3..=10).contains(&n100) {
|
||||
PluralForm::Few
|
||||
} else if (11..=99).contains(&n100) {
|
||||
PluralForm::Many
|
||||
} else {
|
||||
PluralForm::Other
|
||||
}
|
||||
}
|
||||
|
||||
// Hebrew
|
||||
"he" | "iw" => {
|
||||
if n == 1 {
|
||||
PluralForm::One
|
||||
} else if n == 2 {
|
||||
PluralForm::Two
|
||||
} else if n >= 11 && n % 10 == 0 {
|
||||
PluralForm::Many
|
||||
} else {
|
||||
PluralForm::Other
|
||||
}
|
||||
}
|
||||
|
||||
// Default: one / other
|
||||
_ => {
|
||||
if n == 1 { PluralForm::One } else { PluralForm::Other }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn english_one() {
|
||||
assert_eq!(plural_form("en", 1), PluralForm::One);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_other() {
|
||||
assert_eq!(plural_form("en", 0), PluralForm::Other);
|
||||
assert_eq!(plural_form("en", 2), PluralForm::Other);
|
||||
assert_eq!(plural_form("en", 100), PluralForm::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn french_zero_is_one() {
|
||||
assert_eq!(plural_form("fr", 0), PluralForm::One);
|
||||
assert_eq!(plural_form("fr", 1), PluralForm::One);
|
||||
assert_eq!(plural_form("fr", 2), PluralForm::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_one() {
|
||||
assert_eq!(plural_form("ru", 1), PluralForm::One);
|
||||
assert_eq!(plural_form("ru", 21), PluralForm::One);
|
||||
assert_eq!(plural_form("ru", 101), PluralForm::One);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_few() {
|
||||
assert_eq!(plural_form("ru", 2), PluralForm::Few);
|
||||
assert_eq!(plural_form("ru", 3), PluralForm::Few);
|
||||
assert_eq!(plural_form("ru", 22), PluralForm::Few);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_many() {
|
||||
assert_eq!(plural_form("ru", 5), PluralForm::Many);
|
||||
assert_eq!(plural_form("ru", 11), PluralForm::Many);
|
||||
assert_eq!(plural_form("ru", 20), PluralForm::Many);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_zero() {
|
||||
assert_eq!(plural_form("ar", 0), PluralForm::Zero);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_two() {
|
||||
assert_eq!(plural_form("ar", 2), PluralForm::Two);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_few() {
|
||||
assert_eq!(plural_form("ar", 5), PluralForm::Few);
|
||||
assert_eq!(plural_form("ar", 10), PluralForm::Few);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_many() {
|
||||
assert_eq!(plural_form("ar", 15), PluralForm::Many);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hebrew_two() {
|
||||
assert_eq!(plural_form("he", 2), PluralForm::Two);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "el-layout"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui responsive layout engine — responsive by default, zero breakpoints"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_layout"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
el-style = { path = "../el-style" }
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,98 @@
|
||||
/// Breakpoints — named viewport width thresholds.
|
||||
///
|
||||
/// These are provided for the rare cases where you need explicit breakpoint
|
||||
/// logic. For most cases, use the automatic layout in VStack/HStack/Grid.
|
||||
|
||||
/// Named breakpoint sizes (in dp/logical pixels).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Breakpoint {
|
||||
/// Default — any width (the base / mobile-first value).
|
||||
Base,
|
||||
/// Small — 640dp+ (large phones, landscape phones).
|
||||
Sm,
|
||||
/// Medium — 768dp+ (tablets, small laptops).
|
||||
Md,
|
||||
/// Large — 1024dp+ (laptops, most desktops).
|
||||
Lg,
|
||||
/// Extra large — 1280dp+ (large desktops, wide monitors).
|
||||
Xl,
|
||||
}
|
||||
|
||||
impl Breakpoint {
|
||||
/// The minimum width (dp) at which this breakpoint activates.
|
||||
pub fn min_width(&self) -> f32 {
|
||||
match self {
|
||||
Breakpoint::Base => 0.0,
|
||||
Breakpoint::Sm => 640.0,
|
||||
Breakpoint::Md => 768.0,
|
||||
Breakpoint::Lg => 1024.0,
|
||||
Breakpoint::Xl => 1280.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a container width into its active breakpoint.
|
||||
pub fn for_width(width: f32) -> Self {
|
||||
if width >= 1280.0 {
|
||||
Breakpoint::Xl
|
||||
} else if width >= 1024.0 {
|
||||
Breakpoint::Lg
|
||||
} else if width >= 768.0 {
|
||||
Breakpoint::Md
|
||||
} else if width >= 640.0 {
|
||||
Breakpoint::Sm
|
||||
} else {
|
||||
Breakpoint::Base
|
||||
}
|
||||
}
|
||||
|
||||
/// True if this breakpoint is at least as wide as `other`.
|
||||
pub fn at_least(&self, other: Breakpoint) -> bool {
|
||||
self >= &other
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn breakpoint_for_small_width() {
|
||||
assert_eq!(Breakpoint::for_width(320.0), Breakpoint::Base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakpoint_for_tablet_width() {
|
||||
assert_eq!(Breakpoint::for_width(800.0), Breakpoint::Md);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakpoint_for_desktop_width() {
|
||||
assert_eq!(Breakpoint::for_width(1440.0), Breakpoint::Xl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakpoint_ordering() {
|
||||
assert!(Breakpoint::Xl > Breakpoint::Base);
|
||||
assert!(Breakpoint::Lg > Breakpoint::Sm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_least() {
|
||||
assert!(Breakpoint::Lg.at_least(Breakpoint::Md));
|
||||
assert!(!Breakpoint::Sm.at_least(Breakpoint::Md));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_widths_ascending() {
|
||||
let bps = [
|
||||
Breakpoint::Base,
|
||||
Breakpoint::Sm,
|
||||
Breakpoint::Md,
|
||||
Breakpoint::Lg,
|
||||
Breakpoint::Xl,
|
||||
];
|
||||
for i in 1..bps.len() {
|
||||
assert!(bps[i].min_width() > bps[i - 1].min_width());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/// Layout constraints — what the parent is offering the child.
|
||||
///
|
||||
/// A parent passes a LayoutConstraints to each child during layout.
|
||||
/// The child must produce a size within these constraints.
|
||||
/// Constraints flow down; sizes flow back up.
|
||||
|
||||
/// Constraints passed from a parent to a child during layout.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct LayoutConstraints {
|
||||
/// Minimum width the child must be (dp).
|
||||
pub min_width: f32,
|
||||
/// Maximum width available to the child (dp). f32::INFINITY = unbounded.
|
||||
pub max_width: f32,
|
||||
/// Minimum height the child must be (dp).
|
||||
pub min_height: f32,
|
||||
/// Maximum height available to the child (dp). f32::INFINITY = unbounded.
|
||||
pub max_height: f32,
|
||||
}
|
||||
|
||||
impl LayoutConstraints {
|
||||
/// Unconstrained — the child can be any size.
|
||||
pub fn unbounded() -> Self {
|
||||
Self {
|
||||
min_width: 0.0,
|
||||
max_width: f32::INFINITY,
|
||||
min_height: 0.0,
|
||||
max_height: f32::INFINITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constrained to a specific width, unconstrained height.
|
||||
pub fn with_max_width(max_width: f32) -> Self {
|
||||
Self {
|
||||
min_width: 0.0,
|
||||
max_width,
|
||||
min_height: 0.0,
|
||||
max_height: f32::INFINITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constrained to an exact width and height.
|
||||
pub fn tight(width: f32, height: f32) -> Self {
|
||||
Self {
|
||||
min_width: width,
|
||||
max_width: width,
|
||||
min_height: height,
|
||||
max_height: height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return constraints loosened to allow any size up to the maximums.
|
||||
pub fn loosen(&self) -> Self {
|
||||
Self {
|
||||
min_width: 0.0,
|
||||
max_width: self.max_width,
|
||||
min_height: 0.0,
|
||||
max_height: self.max_height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deflate the constraints by padding amounts.
|
||||
/// Useful when a parent applies its own padding before offering space to a child.
|
||||
pub fn deflate(&self, horizontal: f32, vertical: f32) -> Self {
|
||||
Self {
|
||||
min_width: (self.min_width - horizontal).max(0.0),
|
||||
max_width: (self.max_width - horizontal).max(0.0),
|
||||
min_height: (self.min_height - vertical).max(0.0),
|
||||
max_height: (self.max_height - vertical).max(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the width dimension bounded?
|
||||
pub fn has_bounded_width(&self) -> bool {
|
||||
self.max_width.is_finite()
|
||||
}
|
||||
|
||||
/// Is the height dimension bounded?
|
||||
pub fn has_bounded_height(&self) -> bool {
|
||||
self.max_height.is_finite()
|
||||
}
|
||||
|
||||
/// Clamp a proposed size to fit within these constraints.
|
||||
pub fn clamp_size(&self, width: f32, height: f32) -> (f32, f32) {
|
||||
(
|
||||
width.clamp(self.min_width, self.max_width),
|
||||
height.clamp(self.min_height, self.max_height),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The size a child reports back to its parent after layout.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Size {
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
impl Size {
|
||||
pub fn new(width: f32, height: f32) -> Self {
|
||||
Self { width, height }
|
||||
}
|
||||
|
||||
pub fn zero() -> Self {
|
||||
Self { width: 0.0, height: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unbounded_has_no_max() {
|
||||
let c = LayoutConstraints::unbounded();
|
||||
assert!(c.max_width.is_infinite());
|
||||
assert!(c.max_height.is_infinite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tight_constraints() {
|
||||
let c = LayoutConstraints::tight(100.0, 50.0);
|
||||
assert_eq!(c.min_width, 100.0);
|
||||
assert_eq!(c.max_width, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deflate_reduces_available_space() {
|
||||
let c = LayoutConstraints::tight(200.0, 100.0);
|
||||
let deflated = c.deflate(16.0, 8.0);
|
||||
assert_eq!(deflated.max_width, 184.0);
|
||||
assert_eq!(deflated.max_height, 92.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deflate_does_not_go_negative() {
|
||||
let c = LayoutConstraints::tight(10.0, 10.0);
|
||||
let deflated = c.deflate(20.0, 20.0);
|
||||
assert_eq!(deflated.max_width, 0.0);
|
||||
assert_eq!(deflated.max_height, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_size() {
|
||||
let c = LayoutConstraints {
|
||||
min_width: 50.0,
|
||||
max_width: 200.0,
|
||||
min_height: 30.0,
|
||||
max_height: 100.0,
|
||||
};
|
||||
let (w, h) = c.clamp_size(250.0, 20.0);
|
||||
assert_eq!(w, 200.0);
|
||||
assert_eq!(h, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_width_detection() {
|
||||
let bounded = LayoutConstraints::with_max_width(400.0);
|
||||
let unbounded = LayoutConstraints::unbounded();
|
||||
assert!(bounded.has_bounded_width());
|
||||
assert!(!unbounded.has_bounded_width());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/// FlexLayout — the underlying flex engine for VStack, HStack.
|
||||
///
|
||||
/// This is the power behind the stacks. Most developers use VStack/HStack
|
||||
/// directly; FlexLayout is available when you need full control.
|
||||
|
||||
/// Direction of the flex axis.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FlexDirection {
|
||||
/// Children arranged top-to-bottom (VStack).
|
||||
Column,
|
||||
/// Children arranged left-to-right (HStack) — respects RTL automatically.
|
||||
Row,
|
||||
/// Like Column, but children wrap to new columns when they overflow.
|
||||
ColumnWrap,
|
||||
/// Like Row, but children wrap to new rows when they overflow.
|
||||
RowWrap,
|
||||
}
|
||||
|
||||
impl FlexDirection {
|
||||
pub fn is_horizontal(&self) -> bool {
|
||||
matches!(self, FlexDirection::Row | FlexDirection::RowWrap)
|
||||
}
|
||||
|
||||
pub fn is_vertical(&self) -> bool {
|
||||
matches!(self, FlexDirection::Column | FlexDirection::ColumnWrap)
|
||||
}
|
||||
|
||||
pub fn wraps(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
FlexDirection::ColumnWrap | FlexDirection::RowWrap
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Alignment along the cross axis.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CrossAxisAlignment {
|
||||
/// Stretch children to fill the cross axis.
|
||||
Stretch,
|
||||
/// Align children to the start of the cross axis.
|
||||
Start,
|
||||
/// Center children on the cross axis.
|
||||
Center,
|
||||
/// Align children to the end of the cross axis.
|
||||
End,
|
||||
/// Align text baselines (horizontal stacks only).
|
||||
Baseline,
|
||||
}
|
||||
|
||||
/// Alignment along the main axis.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MainAxisAlignment {
|
||||
/// Pack children toward the start.
|
||||
Start,
|
||||
/// Center children.
|
||||
Center,
|
||||
/// Pack children toward the end.
|
||||
End,
|
||||
/// Distribute space between children.
|
||||
SpaceBetween,
|
||||
/// Distribute space around children.
|
||||
SpaceAround,
|
||||
/// Distribute space evenly around children.
|
||||
SpaceEvenly,
|
||||
}
|
||||
|
||||
/// How much space the main axis should occupy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MainAxisSize {
|
||||
/// Shrink to minimum size needed.
|
||||
Min,
|
||||
/// Expand to fill all available space.
|
||||
Max,
|
||||
}
|
||||
|
||||
/// Logical horizontal alignment (RTL-aware).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HAlign {
|
||||
/// Toward the reading-start (left in LTR, right in RTL).
|
||||
Leading,
|
||||
Center,
|
||||
/// Toward the reading-end (right in LTR, left in RTL).
|
||||
Trailing,
|
||||
}
|
||||
|
||||
/// Vertical alignment.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VAlign {
|
||||
Top,
|
||||
Center,
|
||||
Bottom,
|
||||
Baseline,
|
||||
}
|
||||
|
||||
/// Full flex layout specification.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlexLayout {
|
||||
pub direction: FlexDirection,
|
||||
pub main_axis_alignment: MainAxisAlignment,
|
||||
pub cross_axis_alignment: CrossAxisAlignment,
|
||||
pub main_axis_size: MainAxisSize,
|
||||
/// Gap between children in dp.
|
||||
pub gap: u32,
|
||||
}
|
||||
|
||||
impl FlexLayout {
|
||||
/// VStack defaults (column, wrapping, leading-aligned).
|
||||
pub fn vstack(spacing: u32, wrap: bool) -> Self {
|
||||
Self {
|
||||
direction: if wrap {
|
||||
FlexDirection::ColumnWrap
|
||||
} else {
|
||||
FlexDirection::Column
|
||||
},
|
||||
main_axis_alignment: MainAxisAlignment::Start,
|
||||
cross_axis_alignment: CrossAxisAlignment::Stretch,
|
||||
main_axis_size: MainAxisSize::Max,
|
||||
gap: spacing,
|
||||
}
|
||||
}
|
||||
|
||||
/// HStack defaults (row, wrapping, center-aligned vertically).
|
||||
pub fn hstack(spacing: u32, wrap: bool) -> Self {
|
||||
Self {
|
||||
direction: if wrap {
|
||||
FlexDirection::RowWrap
|
||||
} else {
|
||||
FlexDirection::Row
|
||||
},
|
||||
main_axis_alignment: MainAxisAlignment::Start,
|
||||
cross_axis_alignment: CrossAxisAlignment::Center,
|
||||
main_axis_size: MainAxisSize::Max,
|
||||
gap: spacing,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn flex_direction_horizontal() {
|
||||
assert!(FlexDirection::Row.is_horizontal());
|
||||
assert!(FlexDirection::RowWrap.is_horizontal());
|
||||
assert!(!FlexDirection::Column.is_horizontal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flex_direction_vertical() {
|
||||
assert!(FlexDirection::Column.is_vertical());
|
||||
assert!(!FlexDirection::Row.is_vertical());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flex_direction_wraps() {
|
||||
assert!(FlexDirection::RowWrap.wraps());
|
||||
assert!(!FlexDirection::Row.wraps());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vstack_layout_defaults() {
|
||||
let layout = FlexLayout::vstack(8, true);
|
||||
assert!(layout.direction.is_vertical());
|
||||
assert!(layout.direction.wraps());
|
||||
assert_eq!(layout.gap, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hstack_layout_defaults() {
|
||||
let layout = FlexLayout::hstack(16, false);
|
||||
assert!(layout.direction.is_horizontal());
|
||||
assert!(!layout.direction.wraps());
|
||||
assert_eq!(layout.gap, 16);
|
||||
assert_eq!(layout.cross_axis_alignment, CrossAxisAlignment::Center);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/// GridLayout — a responsive column grid.
|
||||
///
|
||||
/// The auto column mode (GridColumns::Auto) automatically computes how many
|
||||
/// columns fit given a minimum column width. No breakpoints needed.
|
||||
/// Fixed column count (GridColumns::Fixed) puts exactly N columns in a row.
|
||||
|
||||
use el_style::modifier::{StyleModifier, StyleSet};
|
||||
|
||||
/// How to determine the number of grid columns.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum GridColumns {
|
||||
/// A fixed number of equally-wide columns.
|
||||
Fixed(u32),
|
||||
/// As many columns as fit with each column at least `min_width` dp wide.
|
||||
/// This is how you get responsive grids without breakpoints.
|
||||
Auto { min_width: f32 },
|
||||
}
|
||||
|
||||
impl GridColumns {
|
||||
/// Compute the actual column count given a container width.
|
||||
pub fn count_for_width(&self, container_width: f32) -> u32 {
|
||||
match self {
|
||||
GridColumns::Fixed(n) => *n,
|
||||
GridColumns::Auto { min_width } => {
|
||||
if container_width <= 0.0 || *min_width <= 0.0 {
|
||||
return 1;
|
||||
}
|
||||
let cols = (container_width / min_width).floor() as u32;
|
||||
cols.max(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the width of each column given container width and gap.
|
||||
pub fn column_width(&self, container_width: f32, gap: f32) -> f32 {
|
||||
let cols = self.count_for_width(container_width) as f32;
|
||||
let total_gap = gap * (cols - 1.0).max(0.0);
|
||||
((container_width - total_gap) / cols).max(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Row height specification.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum GridRows {
|
||||
/// All rows are the same height (dp).
|
||||
Fixed(f32),
|
||||
/// Rows take the height of their tallest item.
|
||||
Auto,
|
||||
}
|
||||
|
||||
/// A responsive grid layout.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GridLayout {
|
||||
pub columns: GridColumns,
|
||||
pub rows: GridRows,
|
||||
/// Horizontal gap between columns (dp).
|
||||
pub column_gap: u32,
|
||||
/// Vertical gap between rows (dp).
|
||||
pub row_gap: u32,
|
||||
pub style: StyleSet,
|
||||
}
|
||||
|
||||
impl Default for GridLayout {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
columns: GridColumns::Auto { min_width: 200.0 },
|
||||
rows: GridRows::Auto,
|
||||
column_gap: 16,
|
||||
row_gap: 16,
|
||||
style: StyleSet::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GridLayout {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Set a fixed column count.
|
||||
pub fn columns_fixed(mut self, n: u32) -> Self {
|
||||
self.columns = GridColumns::Fixed(n);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set auto columns with a minimum column width.
|
||||
pub fn columns_auto(mut self, min_width: f32) -> Self {
|
||||
self.columns = GridColumns::Auto { min_width };
|
||||
self
|
||||
}
|
||||
|
||||
/// Set gap (same for both axes).
|
||||
pub fn gap(mut self, dp: u32) -> Self {
|
||||
self.column_gap = dp;
|
||||
self.row_gap = dp;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set column and row gaps separately.
|
||||
pub fn gap_xy(mut self, column_gap: u32, row_gap: u32) -> Self {
|
||||
self.column_gap = column_gap;
|
||||
self.row_gap = row_gap;
|
||||
self
|
||||
}
|
||||
|
||||
/// How many columns are active at a given container width?
|
||||
pub fn active_columns(&self, container_width: f32) -> u32 {
|
||||
self.columns.count_for_width(container_width)
|
||||
}
|
||||
}
|
||||
|
||||
impl StyleModifier for GridLayout {
|
||||
fn style_mut(&mut self) -> &mut StyleSet {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fixed_columns_always_returns_n() {
|
||||
let cols = GridColumns::Fixed(3);
|
||||
assert_eq!(cols.count_for_width(100.0), 3);
|
||||
assert_eq!(cols.count_for_width(2000.0), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_columns_small_container() {
|
||||
let cols = GridColumns::Auto { min_width: 200.0 };
|
||||
assert_eq!(cols.count_for_width(300.0), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_columns_medium_container() {
|
||||
let cols = GridColumns::Auto { min_width: 200.0 };
|
||||
assert_eq!(cols.count_for_width(500.0), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_columns_wide_container() {
|
||||
let cols = GridColumns::Auto { min_width: 200.0 };
|
||||
assert_eq!(cols.count_for_width(1200.0), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_columns_minimum_one() {
|
||||
let cols = GridColumns::Auto { min_width: 500.0 };
|
||||
assert_eq!(cols.count_for_width(100.0), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_width_with_gap() {
|
||||
let cols = GridColumns::Fixed(3);
|
||||
// 300px wide, 3 cols, 16px gap between each = 2 gaps
|
||||
// (300 - 32) / 3 = 268/3 ≈ 89.33
|
||||
let w = cols.column_width(300.0, 16.0);
|
||||
assert!((w - (300.0 - 32.0) / 3.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_defaults() {
|
||||
let grid = GridLayout::new();
|
||||
assert_eq!(grid.column_gap, 16);
|
||||
assert_eq!(grid.row_gap, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_active_columns_auto() {
|
||||
let grid = GridLayout::new().columns_auto(200.0);
|
||||
assert_eq!(grid.active_columns(600.0), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_active_columns_fixed() {
|
||||
let grid = GridLayout::new().columns_fixed(4);
|
||||
assert_eq!(grid.active_columns(100.0), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_gap_xy() {
|
||||
let grid = GridLayout::new().gap_xy(8, 24);
|
||||
assert_eq!(grid.column_gap, 8);
|
||||
assert_eq!(grid.row_gap, 24);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! el-layout — Responsive layout engine for el-ui.
|
||||
//!
|
||||
//! **Responsive by default.** VStack and HStack wrap automatically.
|
||||
//! Grid uses auto columns. You don't write breakpoints for basic layouts.
|
||||
//!
|
||||
//! ## Layout primitives
|
||||
//!
|
||||
//! - [`VStack`] — vertical stack, wraps by default
|
||||
//! - [`HStack`] — horizontal stack, wraps by default, RTL-aware
|
||||
//! - [`ZStack`] — depth stack, children overlap
|
||||
//! - [`GridLayout`] — responsive grid, auto or fixed columns
|
||||
//! - [`ScrollView`] — scrollable container
|
||||
//!
|
||||
//! ## Responsive values
|
||||
//!
|
||||
//! For the rare case where you need a value to change at a specific breakpoint:
|
||||
//! ```
|
||||
//! use el_layout::prelude::*;
|
||||
//!
|
||||
//! // Most specific value that applies cascades down to less specific
|
||||
//! let cols: Responsive<u32> = Responsive::fixed(1).md(2).lg(3);
|
||||
//! assert_eq!(*cols.resolve(Breakpoint::Sm), 1);
|
||||
//! assert_eq!(*cols.resolve(Breakpoint::Md), 2);
|
||||
//! assert_eq!(*cols.resolve(Breakpoint::Lg), 3);
|
||||
//! assert_eq!(*cols.resolve(Breakpoint::Xl), 3); // cascades from lg
|
||||
//! ```
|
||||
|
||||
#![deny(warnings)]
|
||||
|
||||
pub mod breakpoint;
|
||||
pub mod constraints;
|
||||
pub mod flex;
|
||||
pub mod grid;
|
||||
pub mod platform;
|
||||
pub mod responsive;
|
||||
pub mod scroll;
|
||||
pub mod stack;
|
||||
|
||||
pub mod prelude {
|
||||
pub use crate::breakpoint::Breakpoint;
|
||||
pub use crate::constraints::{LayoutConstraints, Size};
|
||||
pub use crate::flex::{CrossAxisAlignment, FlexDirection, FlexLayout, HAlign, MainAxisAlignment, MainAxisSize, VAlign};
|
||||
pub use crate::grid::{GridColumns, GridLayout, GridRows};
|
||||
pub use crate::platform::{PlatformFamily, PlatformSizing, SafeAreaInsets};
|
||||
pub use crate::responsive::Responsive;
|
||||
pub use crate::scroll::{ScrollAxis, ScrollIndicator, ScrollView};
|
||||
pub use crate::stack::{HStack, Spacer, VStack, ZStack};
|
||||
}
|
||||
|
||||
pub use prelude::*;
|
||||
@@ -0,0 +1,197 @@
|
||||
/// Platform-aware sizing constants.
|
||||
///
|
||||
/// Platform HIG minimum touch targets, safe area handling, and
|
||||
/// density-independent pixel conventions.
|
||||
|
||||
/// Which platform family the app is running on.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PlatformFamily {
|
||||
/// iOS / iPadOS
|
||||
Ios,
|
||||
/// Android
|
||||
Android,
|
||||
/// macOS
|
||||
Macos,
|
||||
/// Windows
|
||||
Windows,
|
||||
/// Linux desktop
|
||||
Linux,
|
||||
/// Web browser
|
||||
Web,
|
||||
}
|
||||
|
||||
/// Platform-specific sizing constants.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlatformSizing {
|
||||
/// Minimum touch target dimension in dp (height and width).
|
||||
pub min_touch_target: f32,
|
||||
/// Standard icon size in dp.
|
||||
pub icon_size: f32,
|
||||
/// Standard small icon size in dp.
|
||||
pub icon_size_sm: f32,
|
||||
/// Standard navigation bar height in dp.
|
||||
pub nav_bar_height: f32,
|
||||
/// Standard tab bar height in dp.
|
||||
pub tab_bar_height: f32,
|
||||
/// Standard status bar height in dp (approximate; actual is platform-provided).
|
||||
pub status_bar_height: f32,
|
||||
}
|
||||
|
||||
impl PlatformSizing {
|
||||
/// Sizing constants for a given platform.
|
||||
pub fn for_platform(platform: PlatformFamily) -> Self {
|
||||
match platform {
|
||||
PlatformFamily::Ios => Self {
|
||||
min_touch_target: 44.0, // Apple HIG
|
||||
icon_size: 24.0,
|
||||
icon_size_sm: 16.0,
|
||||
nav_bar_height: 44.0,
|
||||
tab_bar_height: 49.0,
|
||||
status_bar_height: 44.0, // approximate; varies with notch
|
||||
},
|
||||
PlatformFamily::Android => Self {
|
||||
min_touch_target: 48.0, // Material Design
|
||||
icon_size: 24.0,
|
||||
icon_size_sm: 18.0,
|
||||
nav_bar_height: 56.0,
|
||||
tab_bar_height: 56.0,
|
||||
status_bar_height: 24.0,
|
||||
},
|
||||
PlatformFamily::Macos => Self {
|
||||
min_touch_target: 44.0, // macOS HIG
|
||||
icon_size: 16.0,
|
||||
icon_size_sm: 12.0,
|
||||
nav_bar_height: 28.0,
|
||||
tab_bar_height: 36.0,
|
||||
status_bar_height: 0.0, // macOS status bar is system chrome
|
||||
},
|
||||
PlatformFamily::Windows => Self {
|
||||
min_touch_target: 44.0,
|
||||
icon_size: 16.0,
|
||||
icon_size_sm: 12.0,
|
||||
nav_bar_height: 40.0,
|
||||
tab_bar_height: 40.0,
|
||||
status_bar_height: 0.0,
|
||||
},
|
||||
PlatformFamily::Linux => Self {
|
||||
min_touch_target: 44.0,
|
||||
icon_size: 16.0,
|
||||
icon_size_sm: 12.0,
|
||||
nav_bar_height: 36.0,
|
||||
tab_bar_height: 36.0,
|
||||
status_bar_height: 0.0,
|
||||
},
|
||||
PlatformFamily::Web => Self {
|
||||
min_touch_target: 44.0, // WCAG 2.5.5 recommended
|
||||
icon_size: 20.0,
|
||||
icon_size_sm: 16.0,
|
||||
nav_bar_height: 64.0,
|
||||
tab_bar_height: 48.0,
|
||||
status_bar_height: 0.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the given width adequate for a touch target?
|
||||
pub fn is_touch_adequate(&self, width: f32, height: f32) -> bool {
|
||||
width >= self.min_touch_target && height >= self.min_touch_target
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe area insets — space reserved by system chrome.
|
||||
///
|
||||
/// These are provided at runtime by the platform. The values here
|
||||
/// are conservative defaults for simulation.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct SafeAreaInsets {
|
||||
pub top: f32,
|
||||
pub right: f32,
|
||||
pub bottom: f32,
|
||||
pub left: f32,
|
||||
}
|
||||
|
||||
impl SafeAreaInsets {
|
||||
/// No safe area insets (desktop platforms).
|
||||
pub fn none() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Typical iPhone safe area (notch at top, home indicator at bottom).
|
||||
pub fn iphone_notch() -> Self {
|
||||
Self {
|
||||
top: 44.0,
|
||||
right: 0.0,
|
||||
bottom: 34.0,
|
||||
left: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Dynamic island (iPhone 14 Pro+).
|
||||
pub fn iphone_dynamic_island() -> Self {
|
||||
Self {
|
||||
top: 59.0,
|
||||
right: 0.0,
|
||||
bottom: 34.0,
|
||||
left: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total horizontal safe area.
|
||||
pub fn horizontal(&self) -> f32 {
|
||||
self.left + self.right
|
||||
}
|
||||
|
||||
/// Total vertical safe area.
|
||||
pub fn vertical(&self) -> f32 {
|
||||
self.top + self.bottom
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ios_min_touch_target() {
|
||||
let sizing = PlatformSizing::for_platform(PlatformFamily::Ios);
|
||||
assert_eq!(sizing.min_touch_target, 44.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn android_min_touch_target() {
|
||||
let sizing = PlatformSizing::for_platform(PlatformFamily::Android);
|
||||
assert_eq!(sizing.min_touch_target, 48.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_adequate_check() {
|
||||
let sizing = PlatformSizing::for_platform(PlatformFamily::Ios);
|
||||
assert!(sizing.is_touch_adequate(44.0, 44.0));
|
||||
assert!(!sizing.is_touch_adequate(40.0, 44.0));
|
||||
assert!(!sizing.is_touch_adequate(44.0, 40.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_area_horizontal() {
|
||||
let sa = SafeAreaInsets {
|
||||
top: 44.0,
|
||||
right: 16.0,
|
||||
bottom: 34.0,
|
||||
left: 16.0,
|
||||
};
|
||||
assert_eq!(sa.horizontal(), 32.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_area_vertical() {
|
||||
let sa = SafeAreaInsets::iphone_notch();
|
||||
assert_eq!(sa.vertical(), 78.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_safe_area_is_zero() {
|
||||
let sa = SafeAreaInsets::none();
|
||||
assert_eq!(sa.horizontal(), 0.0);
|
||||
assert_eq!(sa.vertical(), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/// Responsive<T> — a value that varies by breakpoint.
|
||||
///
|
||||
/// The base value is always required (mobile-first). `sm`, `md`, `lg`, `xl`
|
||||
/// are all optional — if not set, the nearest smaller value is used.
|
||||
///
|
||||
/// You generally don't need Responsive<T> for layout — VStack and Grid handle
|
||||
/// reflow automatically. Use Responsive<T> when you need to vary a non-layout
|
||||
/// value (e.g. font size, column count, visibility) at specific breakpoints.
|
||||
|
||||
use crate::breakpoint::Breakpoint;
|
||||
|
||||
/// A value that varies by viewport breakpoint.
|
||||
///
|
||||
/// Follows mobile-first cascade: base < sm < md < lg < xl.
|
||||
/// If a breakpoint value is not set, it inherits from the next smaller one.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Responsive<T: Clone> {
|
||||
/// Mobile-first base value. Always required.
|
||||
pub base: T,
|
||||
/// 640dp+. If None, uses `base`.
|
||||
pub sm: Option<T>,
|
||||
/// 768dp+. If None, uses `sm` or `base`.
|
||||
pub md: Option<T>,
|
||||
/// 1024dp+. If None, uses `md`, `sm`, or `base`.
|
||||
pub lg: Option<T>,
|
||||
/// 1280dp+. If None, uses `lg`, `md`, `sm`, or `base`.
|
||||
pub xl: Option<T>,
|
||||
}
|
||||
|
||||
impl<T: Clone> Responsive<T> {
|
||||
/// Create a responsive value with only the base (same on all screen sizes).
|
||||
pub fn fixed(value: T) -> Self {
|
||||
Self {
|
||||
base: value,
|
||||
sm: None,
|
||||
md: None,
|
||||
lg: None,
|
||||
xl: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a fully-specified responsive value.
|
||||
pub fn new(
|
||||
base: T,
|
||||
sm: Option<T>,
|
||||
md: Option<T>,
|
||||
lg: Option<T>,
|
||||
xl: Option<T>,
|
||||
) -> Self {
|
||||
Self { base, sm, md, lg, xl }
|
||||
}
|
||||
|
||||
/// Resolve to the most-specific value that applies at the given breakpoint.
|
||||
///
|
||||
/// Cascades downward: xl → lg → md → sm → base.
|
||||
pub fn resolve(&self, breakpoint: Breakpoint) -> &T {
|
||||
match breakpoint {
|
||||
Breakpoint::Xl => {
|
||||
self.xl.as_ref()
|
||||
.or(self.lg.as_ref())
|
||||
.or(self.md.as_ref())
|
||||
.or(self.sm.as_ref())
|
||||
.unwrap_or(&self.base)
|
||||
}
|
||||
Breakpoint::Lg => {
|
||||
self.lg.as_ref()
|
||||
.or(self.md.as_ref())
|
||||
.or(self.sm.as_ref())
|
||||
.unwrap_or(&self.base)
|
||||
}
|
||||
Breakpoint::Md => {
|
||||
self.md.as_ref()
|
||||
.or(self.sm.as_ref())
|
||||
.unwrap_or(&self.base)
|
||||
}
|
||||
Breakpoint::Sm => {
|
||||
self.sm.as_ref().unwrap_or(&self.base)
|
||||
}
|
||||
Breakpoint::Base => &self.base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the sm value (builder pattern).
|
||||
pub fn sm(mut self, value: T) -> Self {
|
||||
self.sm = Some(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the md value.
|
||||
pub fn md(mut self, value: T) -> Self {
|
||||
self.md = Some(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the lg value.
|
||||
pub fn lg(mut self, value: T) -> Self {
|
||||
self.lg = Some(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the xl value.
|
||||
pub fn xl(mut self, value: T) -> Self {
|
||||
self.xl = Some(value);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fixed_always_returns_base() {
|
||||
let r = Responsive::fixed(42u32);
|
||||
assert_eq!(*r.resolve(Breakpoint::Base), 42);
|
||||
assert_eq!(*r.resolve(Breakpoint::Xl), 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_most_specific() {
|
||||
let r = Responsive::fixed(1u32).sm(2).md(3).lg(4).xl(5);
|
||||
assert_eq!(*r.resolve(Breakpoint::Base), 1);
|
||||
assert_eq!(*r.resolve(Breakpoint::Sm), 2);
|
||||
assert_eq!(*r.resolve(Breakpoint::Md), 3);
|
||||
assert_eq!(*r.resolve(Breakpoint::Lg), 4);
|
||||
assert_eq!(*r.resolve(Breakpoint::Xl), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascades_down_when_specific_missing() {
|
||||
let r = Responsive::fixed(1u32).md(3);
|
||||
// sm not set → falls back to base
|
||||
assert_eq!(*r.resolve(Breakpoint::Sm), 1);
|
||||
// lg not set → falls back to md
|
||||
assert_eq!(*r.resolve(Breakpoint::Lg), 3);
|
||||
// xl not set → falls back to md (lg not set either)
|
||||
assert_eq!(*r.resolve(Breakpoint::Xl), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_xl_to_lg_to_sm_to_base() {
|
||||
let r = Responsive::fixed("base").sm("sm");
|
||||
assert_eq!(*r.resolve(Breakpoint::Md), "sm");
|
||||
assert_eq!(*r.resolve(Breakpoint::Lg), "sm");
|
||||
assert_eq!(*r.resolve(Breakpoint::Xl), "sm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responsive_with_strings() {
|
||||
let r = Responsive::fixed("mobile").lg("desktop");
|
||||
assert_eq!(*r.resolve(Breakpoint::Base), "mobile");
|
||||
assert_eq!(*r.resolve(Breakpoint::Lg), "desktop");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/// ScrollView — scrollable container.
|
||||
///
|
||||
/// Wraps content that may exceed the available space. Scrolling axis can be
|
||||
/// vertical (default), horizontal, or both. On platforms with native scroll
|
||||
/// behaviors (iOS, Android), the backend translates this to a native scroll
|
||||
/// container — you get momentum, overscroll, pull-to-refresh for free.
|
||||
|
||||
use el_style::modifier::{StyleModifier, StyleSet};
|
||||
|
||||
/// Which axis (or axes) can scroll.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ScrollAxis {
|
||||
/// Vertical scrolling only (default). Most content views.
|
||||
Vertical,
|
||||
/// Horizontal scrolling only. Carousels, horizontal lists.
|
||||
Horizontal,
|
||||
/// Free scrolling in both directions. Maps, canvases.
|
||||
Both,
|
||||
}
|
||||
|
||||
/// Scroll indicator visibility.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ScrollIndicator {
|
||||
/// Show when scrolling, hide otherwise (platform default).
|
||||
Automatic,
|
||||
/// Always show.
|
||||
Always,
|
||||
/// Never show.
|
||||
Never,
|
||||
}
|
||||
|
||||
/// A scrollable container.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScrollView {
|
||||
pub axis: ScrollAxis,
|
||||
pub indicator: ScrollIndicator,
|
||||
/// Whether the user can zoom (pinch-to-zoom). Default: false.
|
||||
pub zoomable: bool,
|
||||
/// Minimum zoom scale (only relevant when zoomable = true).
|
||||
pub min_zoom: f32,
|
||||
/// Maximum zoom scale (only relevant when zoomable = true).
|
||||
pub max_zoom: f32,
|
||||
/// Whether to clip content to the scroll view bounds. Default: true.
|
||||
pub clips_to_bounds: bool,
|
||||
pub style: StyleSet,
|
||||
}
|
||||
|
||||
impl Default for ScrollView {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
axis: ScrollAxis::Vertical,
|
||||
indicator: ScrollIndicator::Automatic,
|
||||
zoomable: false,
|
||||
min_zoom: 1.0,
|
||||
max_zoom: 3.0,
|
||||
clips_to_bounds: true,
|
||||
style: StyleSet::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollView {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn horizontal() -> Self {
|
||||
Self {
|
||||
axis: ScrollAxis::Horizontal,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn both_axes() -> Self {
|
||||
Self {
|
||||
axis: ScrollAxis::Both,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn indicator(mut self, indicator: ScrollIndicator) -> Self {
|
||||
self.indicator = indicator;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn zoomable(mut self, min: f32, max: f32) -> Self {
|
||||
self.zoomable = true;
|
||||
self.min_zoom = min;
|
||||
self.max_zoom = max;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl StyleModifier for ScrollView {
|
||||
fn style_mut(&mut self) -> &mut StyleSet {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scroll_default_is_vertical() {
|
||||
let sv = ScrollView::new();
|
||||
assert_eq!(sv.axis, ScrollAxis::Vertical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_horizontal_constructor() {
|
||||
let sv = ScrollView::horizontal();
|
||||
assert_eq!(sv.axis, ScrollAxis::Horizontal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_both_axes() {
|
||||
let sv = ScrollView::both_axes();
|
||||
assert_eq!(sv.axis, ScrollAxis::Both);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_zoomable() {
|
||||
let sv = ScrollView::new().zoomable(0.5, 4.0);
|
||||
assert!(sv.zoomable);
|
||||
assert_eq!(sv.min_zoom, 0.5);
|
||||
assert_eq!(sv.max_zoom, 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_clips_by_default() {
|
||||
let sv = ScrollView::new();
|
||||
assert!(sv.clips_to_bounds);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_indicator_setting() {
|
||||
let sv = ScrollView::new().indicator(ScrollIndicator::Never);
|
||||
assert_eq!(sv.indicator, ScrollIndicator::Never);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/// VStack, HStack, ZStack — the primary layout building blocks.
|
||||
///
|
||||
/// These are the component API. FlexLayout is the engine underneath.
|
||||
/// VStack and HStack wrap automatically by default — that's what makes
|
||||
/// the layout responsive without writing breakpoints.
|
||||
|
||||
use crate::flex::{CrossAxisAlignment, FlexLayout, HAlign, VAlign};
|
||||
use el_style::modifier::{StyleModifier, StyleSet};
|
||||
|
||||
/// A vertical stack — children arranged from top to bottom.
|
||||
///
|
||||
/// Wraps automatically when height is constrained (mobile-first).
|
||||
/// The default gap is 8dp. Cross-axis fills the container width.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VStack {
|
||||
/// Space between children in dp (default: 8).
|
||||
pub spacing: u32,
|
||||
/// Horizontal alignment of children (default: Leading).
|
||||
pub alignment: HAlign,
|
||||
/// Whether to wrap to a new column when out of vertical space (default: true).
|
||||
pub wrap: bool,
|
||||
pub style: StyleSet,
|
||||
}
|
||||
|
||||
impl Default for VStack {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
spacing: 8,
|
||||
alignment: HAlign::Leading,
|
||||
wrap: true,
|
||||
style: StyleSet::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VStack {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn spacing(mut self, dp: u32) -> Self {
|
||||
self.spacing = dp;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn alignment(mut self, align: HAlign) -> Self {
|
||||
self.alignment = align;
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable or disable wrapping.
|
||||
pub fn wrap(mut self, wrap: bool) -> Self {
|
||||
self.wrap = wrap;
|
||||
self
|
||||
}
|
||||
|
||||
/// Convert to FlexLayout spec.
|
||||
pub fn to_flex(&self) -> FlexLayout {
|
||||
let mut flex = FlexLayout::vstack(self.spacing, self.wrap);
|
||||
flex.cross_axis_alignment = match self.alignment {
|
||||
HAlign::Leading => CrossAxisAlignment::Start,
|
||||
HAlign::Center => CrossAxisAlignment::Center,
|
||||
HAlign::Trailing => CrossAxisAlignment::End,
|
||||
};
|
||||
flex
|
||||
}
|
||||
}
|
||||
|
||||
impl StyleModifier for VStack {
|
||||
fn style_mut(&mut self) -> &mut StyleSet {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
/// A horizontal stack — children arranged from leading to trailing.
|
||||
///
|
||||
/// Respects reading direction (RTL reverses automatically).
|
||||
/// Wraps to new rows by default when width is limited (mobile-first).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HStack {
|
||||
/// Space between children in dp (default: 8).
|
||||
pub spacing: u32,
|
||||
/// Vertical alignment of children (default: Center).
|
||||
pub alignment: VAlign,
|
||||
/// Whether to wrap to a new row when out of horizontal space (default: true).
|
||||
pub wrap: bool,
|
||||
pub style: StyleSet,
|
||||
}
|
||||
|
||||
impl Default for HStack {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
spacing: 8,
|
||||
alignment: VAlign::Center,
|
||||
wrap: true,
|
||||
style: StyleSet::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HStack {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn spacing(mut self, dp: u32) -> Self {
|
||||
self.spacing = dp;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn alignment(mut self, align: VAlign) -> Self {
|
||||
self.alignment = align;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn wrap(mut self, wrap: bool) -> Self {
|
||||
self.wrap = wrap;
|
||||
self
|
||||
}
|
||||
|
||||
/// Convert to FlexLayout spec.
|
||||
pub fn to_flex(&self) -> FlexLayout {
|
||||
let mut flex = FlexLayout::hstack(self.spacing, self.wrap);
|
||||
flex.cross_axis_alignment = match self.alignment {
|
||||
VAlign::Top => CrossAxisAlignment::Start,
|
||||
VAlign::Center => CrossAxisAlignment::Center,
|
||||
VAlign::Bottom => CrossAxisAlignment::End,
|
||||
VAlign::Baseline => CrossAxisAlignment::Baseline,
|
||||
};
|
||||
flex
|
||||
}
|
||||
}
|
||||
|
||||
impl StyleModifier for HStack {
|
||||
fn style_mut(&mut self) -> &mut StyleSet {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
/// A depth stack — children layered on top of each other.
|
||||
///
|
||||
/// ZStack places all children at the same position, overlapping.
|
||||
/// Later children appear on top of earlier children.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ZStack {
|
||||
pub h_align: HAlign,
|
||||
pub v_align: VAlign,
|
||||
pub style: StyleSet,
|
||||
}
|
||||
|
||||
impl Default for ZStack {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
h_align: HAlign::Center,
|
||||
v_align: VAlign::Center,
|
||||
style: StyleSet::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ZStack {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn h_align(mut self, align: HAlign) -> Self {
|
||||
self.h_align = align;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn v_align(mut self, align: VAlign) -> Self {
|
||||
self.v_align = align;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl StyleModifier for ZStack {
|
||||
fn style_mut(&mut self) -> &mut StyleSet {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
/// A spacer that expands to fill available space in a stack.
|
||||
///
|
||||
/// In HStack: expands horizontally. In VStack: expands vertically.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Spacer {
|
||||
/// Minimum size in dp (0 = truly flexible).
|
||||
pub min_size: u32,
|
||||
}
|
||||
|
||||
impl Spacer {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn min(mut self, dp: u32) -> Self {
|
||||
self.min_size = dp;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::flex::CrossAxisAlignment;
|
||||
use el_style::modifier::StyleModifier;
|
||||
use el_style::color::Color;
|
||||
|
||||
#[test]
|
||||
fn vstack_default_spacing() {
|
||||
let v = VStack::new();
|
||||
assert_eq!(v.spacing, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vstack_default_wraps() {
|
||||
let v = VStack::new();
|
||||
assert!(v.wrap);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vstack_to_flex_direction() {
|
||||
let v = VStack::new();
|
||||
let flex = v.to_flex();
|
||||
assert!(flex.direction.is_vertical());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vstack_center_alignment() {
|
||||
let v = VStack::new().alignment(HAlign::Center);
|
||||
let flex = v.to_flex();
|
||||
assert_eq!(flex.cross_axis_alignment, CrossAxisAlignment::Center);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hstack_default_alignment() {
|
||||
let h = HStack::new();
|
||||
assert_eq!(h.alignment, VAlign::Center);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hstack_default_wraps() {
|
||||
let h = HStack::new();
|
||||
assert!(h.wrap);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hstack_to_flex_direction() {
|
||||
let h = HStack::new();
|
||||
let flex = h.to_flex();
|
||||
assert!(flex.direction.is_horizontal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hstack_no_wrap() {
|
||||
let h = HStack::new().wrap(false);
|
||||
let flex = h.to_flex();
|
||||
assert!(!flex.direction.wraps());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zstack_defaults() {
|
||||
let z = ZStack::new();
|
||||
assert_eq!(z.h_align, HAlign::Center);
|
||||
assert_eq!(z.v_align, VAlign::Center);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vstack_style_modifier() {
|
||||
let v = VStack::new().background(Color::Surface);
|
||||
assert_eq!(v.style.background, Some(Color::Surface));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacer_min_size() {
|
||||
let s = Spacer::new().min(16);
|
||||
assert_eq!(s.min_size, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacer_default_zero() {
|
||||
let s = Spacer::new();
|
||||
assert_eq!(s.min_size, 0);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -0,0 +1,255 @@
|
||||
/// Semantic color tokens and the Color type.
|
||||
///
|
||||
/// Prefer semantic tokens (Primary, Surface, etc.) over explicit values.
|
||||
/// The theme maps tokens to actual colors — swap the theme, everything updates.
|
||||
/// Use Hex/Rgba only as an escape hatch when you need a one-off value.
|
||||
|
||||
/// A color value — either a semantic token or an explicit value.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Color {
|
||||
// --- Semantic tokens (preferred) ---
|
||||
/// Brand primary color. Use for key actions and highlights.
|
||||
Primary,
|
||||
/// Content drawn on top of Primary.
|
||||
OnPrimary,
|
||||
/// A less prominent container for primary-branded content.
|
||||
PrimaryContainer,
|
||||
|
||||
/// Secondary accent color.
|
||||
Secondary,
|
||||
/// Content drawn on top of Secondary.
|
||||
OnSecondary,
|
||||
|
||||
/// The page/screen background.
|
||||
Background,
|
||||
/// Content drawn on top of Background.
|
||||
OnBackground,
|
||||
|
||||
/// Surface color for cards, sheets, dialogs.
|
||||
Surface,
|
||||
/// Content drawn on top of Surface.
|
||||
OnSurface,
|
||||
/// A surface variant, slightly different from Surface.
|
||||
SurfaceVariant,
|
||||
|
||||
/// Error state color.
|
||||
Error,
|
||||
/// Content drawn on top of Error.
|
||||
OnError,
|
||||
|
||||
/// Outline/border color.
|
||||
Outline,
|
||||
/// Muted outline (dividers, subtle borders).
|
||||
OutlineVariant,
|
||||
|
||||
// --- Escape hatches ---
|
||||
/// An explicit hex color string (e.g. "#3b82f6" or "#3b82f6ff").
|
||||
Hex(String),
|
||||
/// An explicit RGBA color (channels 0–255, alpha 0.0–1.0).
|
||||
Rgba(u8, u8, u8, f32),
|
||||
|
||||
/// Apply opacity to any color.
|
||||
Opacity(Box<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);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -0,0 +1,382 @@
|
||||
/// StyleModifier trait — fluent, zero-cost style chaining.
|
||||
///
|
||||
/// Every el-ui component implements StyleModifier so callers can chain style
|
||||
/// adjustments in a natural builder pattern. The trait is compile-time only;
|
||||
/// there is no runtime allocation or dynamic dispatch.
|
||||
|
||||
use crate::color::Color;
|
||||
use crate::radius::Radius;
|
||||
use crate::shadow::Shadow;
|
||||
use crate::typography::TextStyle;
|
||||
|
||||
/// A dimension — how wide or tall something should be.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Dimension {
|
||||
/// Exact density-independent pixels.
|
||||
Fixed(u32),
|
||||
/// Fill all available space from the parent.
|
||||
Fill,
|
||||
/// Shrink to fit the content.
|
||||
Wrap,
|
||||
/// Fraction of the parent's dimension (0.0–1.0).
|
||||
Fraction(f32),
|
||||
/// Minimum of two dimensions.
|
||||
Min(Box<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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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.");
|
||||
}
|
||||
Reference in New Issue
Block a user