#![forbid(unsafe_code)]
use crate::style::Style;
use ahash::AHashMap;
use ftui_render::cell::PackedRgba;
use std::sync::RwLock;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct StyleId(pub String);
impl StyleId {
#[inline]
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
#[inline]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for StyleId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl From<String> for StyleId {
fn from(s: String) -> Self {
Self(s)
}
}
impl AsRef<str> for StyleId {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Debug, Default)]
pub struct StyleSheet {
styles: RwLock<AHashMap<String, Style>>,
}
impl StyleSheet {
#[inline]
pub fn new() -> Self {
Self {
styles: RwLock::new(AHashMap::new()),
}
}
#[must_use]
pub fn with_defaults() -> Self {
let sheet = Self::new();
sheet.define(
"error",
Style::new().fg(PackedRgba::rgb(255, 85, 85)).bold(),
);
sheet.define("warning", Style::new().fg(PackedRgba::rgb(255, 170, 0)));
sheet.define("info", Style::new().fg(PackedRgba::rgb(85, 170, 255)));
sheet.define("success", Style::new().fg(PackedRgba::rgb(85, 255, 85)));
sheet.define(
"muted",
Style::new().fg(PackedRgba::rgb(128, 128, 128)).dim(),
);
sheet.define(
"highlight",
Style::new()
.bg(PackedRgba::rgb(255, 255, 0))
.fg(PackedRgba::rgb(0, 0, 0)),
);
sheet.define(
"link",
Style::new().fg(PackedRgba::rgb(85, 170, 255)).underline(),
);
sheet
}
pub fn define(&self, name: impl Into<String>, style: Style) {
let name = name.into();
let mut styles = self.styles.write().expect("StyleSheet lock poisoned");
styles.insert(name, style);
}
pub fn remove(&self, name: &str) -> Option<Style> {
let mut styles = self.styles.write().expect("StyleSheet lock poisoned");
styles.remove(name)
}
pub fn get(&self, name: &str) -> Option<Style> {
let styles = self.styles.read().expect("StyleSheet lock poisoned");
styles.get(name).copied()
}
pub fn get_or_default(&self, name: &str) -> Style {
self.get(name).unwrap_or_default()
}
pub fn contains(&self, name: &str) -> bool {
let styles = self.styles.read().expect("StyleSheet lock poisoned");
styles.contains_key(name)
}
pub fn len(&self) -> usize {
let styles = self.styles.read().expect("StyleSheet lock poisoned");
styles.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn names(&self) -> Vec<String> {
let styles = self.styles.read().expect("StyleSheet lock poisoned");
styles.keys().cloned().collect()
}
pub fn compose(&self, names: &[&str]) -> Style {
let styles = self.styles.read().expect("StyleSheet lock poisoned");
let mut result = Style::default();
for name in names {
if let Some(style) = styles.get(*name) {
result = style.merge(&result);
}
}
result
}
pub fn compose_strict(&self, names: &[&str]) -> Option<Style> {
let styles = self.styles.read().expect("StyleSheet lock poisoned");
let mut result = Style::default();
for name in names {
let style = styles.get(*name)?;
result = style.merge(&result);
}
Some(result)
}
pub fn extend(&self, other: &StyleSheet) {
if std::ptr::eq(self, other) {
return;
}
let other_styles = {
let other_styles = other.styles.read().expect("StyleSheet lock poisoned");
other_styles.clone()
};
let mut self_styles = self.styles.write().expect("StyleSheet lock poisoned");
self_styles.extend(other_styles);
}
pub fn clear(&self) {
let mut styles = self.styles.write().expect("StyleSheet lock poisoned");
styles.clear();
}
}
impl Clone for StyleSheet {
fn clone(&self) -> Self {
let styles = self.styles.read().expect("StyleSheet lock poisoned");
Self {
styles: RwLock::new(styles.clone()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::StyleFlags;
#[test]
fn new_stylesheet_is_empty() {
let sheet = StyleSheet::new();
assert!(sheet.is_empty());
assert_eq!(sheet.len(), 0);
}
#[test]
fn define_and_get_style() {
let sheet = StyleSheet::new();
let style = Style::new().fg(PackedRgba::rgb(255, 0, 0)).bold();
sheet.define("error", style);
assert!(!sheet.is_empty());
assert_eq!(sheet.len(), 1);
assert!(sheet.contains("error"));
let retrieved = sheet.get("error").unwrap();
assert_eq!(retrieved, style);
}
#[test]
fn get_nonexistent_returns_none() {
let sheet = StyleSheet::new();
assert!(sheet.get("nonexistent").is_none());
}
#[test]
fn get_or_default_returns_default_for_missing() {
let sheet = StyleSheet::new();
let style = sheet.get_or_default("missing");
assert!(style.is_empty());
}
#[test]
fn define_replaces_existing() {
let sheet = StyleSheet::new();
sheet.define("test", Style::new().bold());
assert!(sheet.get("test").unwrap().has_attr(StyleFlags::BOLD));
sheet.define("test", Style::new().italic());
let style = sheet.get("test").unwrap();
assert!(!style.has_attr(StyleFlags::BOLD));
assert!(style.has_attr(StyleFlags::ITALIC));
}
#[test]
fn remove_style() {
let sheet = StyleSheet::new();
sheet.define("test", Style::new().bold());
let removed = sheet.remove("test");
assert!(removed.is_some());
assert!(!sheet.contains("test"));
let removed_again = sheet.remove("test");
assert!(removed_again.is_none());
}
#[test]
fn names_returns_all_style_names() {
let sheet = StyleSheet::new();
sheet.define("a", Style::new());
sheet.define("b", Style::new());
sheet.define("c", Style::new());
let names = sheet.names();
assert_eq!(names.len(), 3);
assert!(names.contains(&"a".to_string()));
assert!(names.contains(&"b".to_string()));
assert!(names.contains(&"c".to_string()));
}
#[test]
fn compose_merges_styles() {
let sheet = StyleSheet::new();
sheet.define("base", Style::new().fg(PackedRgba::WHITE));
sheet.define("bold", Style::new().bold());
let composed = sheet.compose(&["base", "bold"]);
assert_eq!(composed.fg, Some(PackedRgba::WHITE));
assert!(composed.has_attr(StyleFlags::BOLD));
}
#[test]
fn compose_later_wins_on_conflict() {
let sheet = StyleSheet::new();
let red = PackedRgba::rgb(255, 0, 0);
let blue = PackedRgba::rgb(0, 0, 255);
sheet.define("red", Style::new().fg(red));
sheet.define("blue", Style::new().fg(blue));
let composed = sheet.compose(&["red", "blue"]);
assert_eq!(composed.fg, Some(blue));
}
#[test]
fn compose_ignores_missing() {
let sheet = StyleSheet::new();
sheet.define("exists", Style::new().bold());
let composed = sheet.compose(&["missing", "exists"]);
assert!(composed.has_attr(StyleFlags::BOLD));
}
#[test]
fn compose_strict_fails_on_missing() {
let sheet = StyleSheet::new();
sheet.define("exists", Style::new().bold());
let result = sheet.compose_strict(&["exists", "missing"]);
assert!(result.is_none());
}
#[test]
fn compose_strict_succeeds_when_all_present() {
let sheet = StyleSheet::new();
sheet.define("a", Style::new().bold());
sheet.define("b", Style::new().italic());
let result = sheet.compose_strict(&["a", "b"]);
assert!(result.is_some());
let style = result.unwrap();
assert!(style.has_attr(StyleFlags::BOLD));
assert!(style.has_attr(StyleFlags::ITALIC));
}
#[test]
fn with_defaults_has_semantic_styles() {
let sheet = StyleSheet::with_defaults();
assert!(sheet.contains("error"));
assert!(sheet.contains("warning"));
assert!(sheet.contains("info"));
assert!(sheet.contains("success"));
assert!(sheet.contains("muted"));
assert!(sheet.contains("highlight"));
assert!(sheet.contains("link"));
let error = sheet.get("error").unwrap();
assert!(error.has_attr(StyleFlags::BOLD));
assert!(error.fg.is_some());
}
#[test]
fn extend_merges_stylesheets() {
let sheet1 = StyleSheet::new();
sheet1.define("a", Style::new().bold());
let sheet2 = StyleSheet::new();
sheet2.define("b", Style::new().italic());
sheet1.extend(&sheet2);
assert!(sheet1.contains("a"));
assert!(sheet1.contains("b"));
}
#[test]
fn extend_overrides_existing() {
let sheet1 = StyleSheet::new();
sheet1.define("test", Style::new().bold());
let sheet2 = StyleSheet::new();
sheet2.define("test", Style::new().italic());
sheet1.extend(&sheet2);
let style = sheet1.get("test").unwrap();
assert!(!style.has_attr(StyleFlags::BOLD));
assert!(style.has_attr(StyleFlags::ITALIC));
}
#[test]
fn concurrent_bidirectional_extend_completes() {
use std::sync::{Arc, Barrier, mpsc};
use std::time::Duration;
let sheet1 = Arc::new(StyleSheet::new());
sheet1.define("a", Style::new().bold());
let sheet2 = Arc::new(StyleSheet::new());
sheet2.define("b", Style::new().italic());
let barrier = Arc::new(Barrier::new(3));
let (done_tx, done_rx) = mpsc::channel();
let sheet1_to_sheet2 = {
let barrier = Arc::clone(&barrier);
let done_tx = done_tx.clone();
let sheet1 = Arc::clone(&sheet1);
let sheet2 = Arc::clone(&sheet2);
std::thread::spawn(move || {
barrier.wait();
sheet1.extend(&sheet2);
done_tx.send(()).expect("completion signal");
})
};
let sheet2_to_sheet1 = {
let barrier = Arc::clone(&barrier);
let done_tx = done_tx.clone();
let sheet1 = Arc::clone(&sheet1);
let sheet2 = Arc::clone(&sheet2);
std::thread::spawn(move || {
barrier.wait();
sheet2.extend(&sheet1);
done_tx.send(()).expect("completion signal");
})
};
barrier.wait();
for _ in 0..2 {
done_rx
.recv_timeout(Duration::from_secs(1))
.expect("cross-extend should complete without deadlocking");
}
sheet1_to_sheet2.join().expect("sheet1 extend thread");
sheet2_to_sheet1.join().expect("sheet2 extend thread");
assert!(sheet1.contains("b"));
assert!(sheet2.contains("a"));
}
#[test]
fn clear_removes_all_styles() {
let sheet = StyleSheet::with_defaults();
assert!(!sheet.is_empty());
sheet.clear();
assert!(sheet.is_empty());
}
#[test]
fn clone_creates_independent_copy() {
let sheet1 = StyleSheet::new();
sheet1.define("test", Style::new().bold());
let sheet2 = sheet1.clone();
sheet1.define("other", Style::new());
assert!(sheet1.contains("other"));
assert!(!sheet2.contains("other"));
}
#[test]
fn style_id_from_str() {
let id: StyleId = "error".into();
assert_eq!(id.as_str(), "error");
}
#[test]
fn style_id_from_string() {
let id: StyleId = String::from("error").into();
assert_eq!(id.as_str(), "error");
}
#[test]
fn style_id_equality() {
let id1 = StyleId::new("error");
let id2 = StyleId::new("error");
let id3 = StyleId::new("warning");
assert_eq!(id1, id2);
assert_ne!(id1, id3);
}
#[test]
fn stylesheet_thread_safe_reads() {
use std::sync::Arc;
use std::thread;
let sheet = Arc::new(StyleSheet::new());
sheet.define("test", Style::new().bold());
let handles: Vec<_> = (0..4)
.map(|_| {
let sheet = Arc::clone(&sheet);
thread::spawn(move || {
for _ in 0..100 {
let _ = sheet.get("test");
}
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
}
#[test]
fn stylesheet_thread_safe_writes() {
use std::sync::Arc;
use std::thread;
let sheet = Arc::new(StyleSheet::new());
let handles: Vec<_> = (0..4)
.map(|i| {
let sheet = Arc::clone(&sheet);
thread::spawn(move || {
for j in 0..25 {
let name = format!("style_{}_{}", i, j);
sheet.define(&name, Style::new().bold());
}
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
assert_eq!(sheet.len(), 100);
}
#[test]
fn compose_empty_list_returns_default() {
let sheet = StyleSheet::new();
sheet.define("test", Style::new().bold());
let composed = sheet.compose(&[]);
assert!(composed.is_empty());
}
#[test]
fn compose_strict_empty_list_returns_some_default() {
let sheet = StyleSheet::new();
sheet.define("test", Style::new().bold());
let result = sheet.compose_strict(&[]);
assert!(result.is_some());
assert!(result.unwrap().is_empty());
}
#[test]
fn extend_self_is_noop() {
let sheet = StyleSheet::new();
sheet.define("test", Style::new().bold());
sheet.extend(&sheet);
assert_eq!(sheet.len(), 1);
assert!(sheet.contains("test"));
}
#[test]
fn stylesheet_default_is_empty() {
let sheet = StyleSheet::default();
assert!(sheet.is_empty());
}
#[test]
fn define_with_empty_name() {
let sheet = StyleSheet::new();
sheet.define("", Style::new().bold());
assert!(sheet.contains(""));
assert!(sheet.get("").unwrap().has_attr(StyleFlags::BOLD));
}
#[test]
fn style_id_as_ref_str() {
let id = StyleId::new("test");
let s: &str = id.as_ref();
assert_eq!(s, "test");
}
#[test]
fn style_id_clone() {
let id1 = StyleId::new("test");
let id2 = id1.clone();
assert_eq!(id1, id2);
}
#[test]
fn style_id_debug_impl() {
let id = StyleId::new("test");
let debug = format!("{:?}", id);
assert!(debug.contains("test"));
}
#[test]
fn stylesheet_debug_impl() {
let sheet = StyleSheet::new();
sheet.define("test", Style::new());
let debug = format!("{:?}", sheet);
assert!(debug.contains("StyleSheet"));
}
#[test]
fn with_defaults_error_style_is_red() {
let sheet = StyleSheet::with_defaults();
let error = sheet.get("error").unwrap();
if let Some(fg) = error.fg {
assert!(fg.r() > 200, "error style should have red foreground");
}
}
#[test]
fn with_defaults_link_style_is_underlined() {
let sheet = StyleSheet::with_defaults();
let link = sheet.get("link").unwrap();
assert!(
link.has_attr(StyleFlags::UNDERLINE),
"link style should be underlined"
);
}
#[test]
fn with_defaults_muted_style_is_dim() {
let sheet = StyleSheet::with_defaults();
let muted = sheet.get("muted").unwrap();
assert!(muted.has_attr(StyleFlags::DIM), "muted style should be dim");
}
#[test]
fn with_defaults_highlight_has_background() {
let sheet = StyleSheet::with_defaults();
let highlight = sheet.get("highlight").unwrap();
assert!(
highlight.bg.is_some(),
"highlight style should have background"
);
}
#[test]
fn compose_three_styles_in_order() {
let sheet = StyleSheet::new();
sheet.define("base", Style::new().fg(PackedRgba::WHITE));
sheet.define("bold", Style::new().bold());
sheet.define("red", Style::new().fg(PackedRgba::rgb(255, 0, 0)));
let composed = sheet.compose(&["base", "bold", "red"]);
assert_eq!(composed.fg, Some(PackedRgba::rgb(255, 0, 0)));
assert!(composed.has_attr(StyleFlags::BOLD));
}
#[test]
fn compose_layered_precedence_preserves_unset_fields() {
let sheet = StyleSheet::new();
let base_bg = PackedRgba::rgb(10, 10, 10);
let theme_fg = PackedRgba::rgb(200, 50, 50);
sheet.define("base", Style::new().fg(PackedRgba::WHITE).bg(base_bg));
sheet.define("theme", Style::new().fg(theme_fg));
sheet.define("widget", Style::new().underline());
let composed = sheet.compose(&["base", "theme", "widget"]);
assert_eq!(composed.fg, Some(theme_fg));
assert_eq!(composed.bg, Some(base_bg));
assert!(composed.has_attr(StyleFlags::UNDERLINE));
}
#[test]
fn get_or_default_returns_defined_style() {
let sheet = StyleSheet::new();
let style = Style::new().bold();
sheet.define("test", style);
let retrieved = sheet.get_or_default("test");
assert!(retrieved.has_attr(StyleFlags::BOLD));
}
#[test]
fn names_returns_empty_for_empty_sheet() {
let sheet = StyleSheet::new();
let names = sheet.names();
assert!(names.is_empty());
}
#[test]
fn style_id_hash_consistency() {
use std::collections::HashSet;
let id1 = StyleId::new("test");
let id2 = StyleId::new("test");
let id3 = StyleId::new("other");
let mut set = HashSet::new();
set.insert(id1.clone());
assert!(set.contains(&id2));
assert!(!set.contains(&id3));
}
}