use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::sync::{OnceLock, RwLock};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
impl Color {
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b, a: 255 }
}
pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
pub fn from_hex(s: &str) -> Option<Self> {
let s = s.strip_prefix('#')?;
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let (r, g, b, a) = match s.len() {
6 => (
u8::from_str_radix(&s[0..2], 16).ok()?,
u8::from_str_radix(&s[2..4], 16).ok()?,
u8::from_str_radix(&s[4..6], 16).ok()?,
255,
),
8 => (
u8::from_str_radix(&s[0..2], 16).ok()?,
u8::from_str_radix(&s[2..4], 16).ok()?,
u8::from_str_radix(&s[4..6], 16).ok()?,
u8::from_str_radix(&s[6..8], 16).ok()?,
),
_ => return None,
};
Some(Self { r, g, b, a })
}
pub fn try_from_hex_over(s: &str, bg: Self) -> Option<Self> {
let stripped = s.strip_prefix('#')?;
if stripped.len() != 8 {
return Self::from_hex(s);
}
if !stripped.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let r = u8::from_str_radix(&stripped[0..2], 16).ok()?;
let g = u8::from_str_radix(&stripped[2..4], 16).ok()?;
let b = u8::from_str_radix(&stripped[4..6], 16).ok()?;
let a = u8::from_str_radix(&stripped[6..8], 16).ok()?;
let alpha = a as f64 / 255.0;
let blend = |fg: u8, bg: u8| -> u8 {
(fg as f64 * alpha + bg as f64 * (1.0 - alpha)).round() as u8
};
Some(Self {
r: blend(r, bg.r),
g: blend(g, bg.g),
b: blend(b, bg.b),
a: 255,
})
}
pub fn lighten(self, amount: f64) -> Self {
let f = amount.clamp(0.0, 1.0);
Self {
r: (self.r as f64 + (255.0 - self.r as f64) * f) as u8,
g: (self.g as f64 + (255.0 - self.g as f64) * f) as u8,
b: (self.b as f64 + (255.0 - self.b as f64) * f) as u8,
a: self.a,
}
}
pub fn darken(self, amount: f64) -> Self {
let f = amount.clamp(0.0, 1.0);
Self {
r: (self.r as f64 * (1.0 - f)) as u8,
g: (self.g as f64 * (1.0 - f)) as u8,
b: (self.b as f64 * (1.0 - f)) as u8,
a: self.a,
}
}
pub fn with_alpha(self, alpha: f64) -> Self {
Self {
a: (255.0 * alpha.clamp(0.0, 1.0)).round() as u8,
..self
}
}
pub fn blend(self, over: Self, alpha: f64) -> Self {
let alpha = alpha.clamp(0.0, 1.0);
let mix =
|b: u8, o: u8| -> u8 { (b as f64 * (1.0 - alpha) + o as f64 * alpha).round() as u8 };
Self::rgb(
mix(self.r, over.r),
mix(self.g, over.g),
mix(self.b, over.b),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StyledSpan {
pub text: String,
pub fg: Option<Color>,
pub bg: Option<Color>,
#[serde(default)]
pub bold: bool,
#[serde(default)]
pub italic: bool,
#[serde(default)]
pub underline: bool,
}
impl StyledSpan {
pub fn plain(text: impl Into<String>) -> Self {
Self {
text: text.into(),
fg: None,
bg: None,
bold: false,
italic: false,
underline: false,
}
}
pub fn with_fg(text: impl Into<String>, fg: Color) -> Self {
Self {
text: text.into(),
fg: Some(fg),
bg: None,
bold: false,
italic: false,
underline: false,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct StyledText {
pub spans: Vec<StyledSpan>,
}
impl StyledText {
pub fn plain(text: impl Into<String>) -> Self {
Self {
spans: vec![StyledSpan::plain(text)],
}
}
pub fn colored(text: impl Into<String>, fg: Color) -> Self {
Self {
spans: vec![StyledSpan::with_fg(text, fg)],
}
}
pub fn visible_width(&self) -> usize {
self.spans
.iter()
.map(|s| crate::text_util::display_width(&s.text))
.sum()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Icon {
pub glyph: String,
pub fallback: String,
#[serde(default)]
pub color: Option<Color>,
}
impl Icon {
pub fn new(glyph: impl Into<String>, fallback: impl Into<String>) -> Self {
Self {
glyph: glyph.into(),
fallback: fallback.into(),
color: None,
}
}
pub fn with_color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
}
impl From<&str> for Icon {
fn from(s: &str) -> Self {
Self::new(s, s)
}
}
impl From<String> for Icon {
fn from(s: String) -> Self {
Self::new(s.clone(), s)
}
}
struct WidgetIdTable {
strings: Vec<&'static str>,
index: HashMap<&'static str, u32>,
}
impl WidgetIdTable {
fn new() -> Self {
Self {
strings: Vec::new(),
index: HashMap::new(),
}
}
}
impl Default for WidgetIdTable {
fn default() -> Self {
Self::new()
}
}
fn widget_id_table() -> &'static RwLock<WidgetIdTable> {
static TABLE: OnceLock<RwLock<WidgetIdTable>> = OnceLock::new();
TABLE.get_or_init(|| RwLock::new(WidgetIdTable::new()))
}
fn intern_widget_id(label: impl Into<String>) -> u32 {
let label = label.into();
{
let table = widget_id_table()
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(&idx) = table.index.get(label.as_str()) {
return idx;
}
}
let mut table = widget_id_table()
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(&idx) = table.index.get(label.as_str()) {
return idx;
}
let leaked: &'static str = Box::leak(label.into_boxed_str());
let idx = table.strings.len() as u32;
table.strings.push(leaked);
table.index.insert(leaked, idx);
idx
}
fn resolve_widget_id(idx: u32) -> &'static str {
widget_id_table()
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.strings[idx as usize]
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct WidgetId(u32);
impl WidgetId {
pub fn new(id: impl Into<String>) -> Self {
Self(intern_widget_id(id.into()))
}
pub fn as_str(&self) -> &'static str {
resolve_widget_id(self.0)
}
}
impl fmt::Debug for WidgetId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("WidgetId").field(&self.as_str()).finish()
}
}
impl fmt::Display for WidgetId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl Serialize for WidgetId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for WidgetId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
String::deserialize(deserializer).map(WidgetId::new)
}
}
impl From<&str> for WidgetId {
fn from(s: &str) -> Self {
Self::new(s)
}
}
impl From<String> for WidgetId {
fn from(s: String) -> Self {
Self::new(s)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Modifiers {
#[serde(default)]
pub shift: bool,
#[serde(default)]
pub ctrl: bool,
#[serde(default)]
pub alt: bool,
#[serde(default)]
pub cmd: bool,
}
pub type TreePath = Vec<u16>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum SelectionMode {
None,
#[default]
Single,
Multi,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Decoration {
#[default]
Normal,
Header,
Muted,
Error,
Warning,
Modified,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Badge {
pub text: String,
pub fg: Option<Color>,
pub bg: Option<Color>,
}
impl Badge {
pub fn plain(text: impl Into<String>) -> Self {
Self {
text: text.into(),
fg: None,
bg: None,
}
}
pub fn colored(text: impl Into<String>, fg: Color) -> Self {
Self {
text: text.into(),
fg: Some(fg),
bg: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreeStyle {
pub indent: u16,
pub show_chevrons: bool,
pub chevron_expanded: String,
pub chevron_collapsed: String,
#[serde(default = "TreeStyle::default_row_height")]
pub row_height: Option<u16>,
}
impl TreeStyle {
fn default_row_height() -> Option<u16> {
Some(22)
}
}
impl Default for TreeStyle {
fn default() -> Self {
Self {
indent: 2,
show_chevrons: true,
chevron_expanded: "โพ".into(),
chevron_collapsed: "โธ".into(),
row_height: Self::default_row_height(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn with_alpha_scales_a_leaves_rgb_untouched() {
let c = Color::rgb(10, 20, 30).with_alpha(0.5);
assert_eq!((c.r, c.g, c.b, c.a), (10, 20, 30, 128));
}
#[test]
fn with_alpha_clamps_out_of_range_input() {
assert_eq!(Color::rgb(1, 2, 3).with_alpha(-1.0).a, 0);
assert_eq!(Color::rgb(1, 2, 3).with_alpha(2.0).a, 255);
}
#[test]
fn blend_zero_alpha_is_base_one_alpha_is_over() {
let base = Color::rgb(0, 0, 0);
let over = Color::rgb(200, 100, 50);
assert_eq!(base.blend(over, 0.0), Color::rgb(0, 0, 0));
assert_eq!(base.blend(over, 1.0), Color::rgb(200, 100, 50));
}
#[test]
fn blend_midpoint_averages_channels_and_is_opaque() {
let base = Color::rgba(0, 0, 0, 10);
let over = Color::rgba(200, 100, 50, 20);
let mixed = base.blend(over, 0.5);
assert_eq!((mixed.r, mixed.g, mixed.b, mixed.a), (100, 50, 25, 255));
}
#[test]
fn from_hex_parses_rgb() {
assert_eq!(
Color::from_hex("#ff0080"),
Some(Color::rgb(0xff, 0x00, 0x80))
);
}
#[test]
fn from_hex_parses_rgba() {
assert_eq!(
Color::from_hex("#ff008040"),
Some(Color::rgba(0xff, 0x00, 0x80, 0x40))
);
}
#[test]
fn from_hex_rejects_missing_prefix() {
assert_eq!(Color::from_hex("ff0080"), None);
}
#[test]
fn from_hex_rejects_wrong_length() {
assert_eq!(Color::from_hex("#ff008"), None);
}
#[test]
fn from_hex_rejects_non_hex_ascii() {
assert_eq!(Color::from_hex("#zzzzzz"), None);
}
#[test]
fn from_hex_multibyte_char_returns_none_instead_of_panicking() {
assert_eq!(Color::from_hex("#\u{20ac}abc"), None);
}
#[test]
fn visible_width_ascii_matches_char_count() {
assert_eq!(StyledText::plain("hello").visible_width(), 5);
assert_eq!(StyledText::plain("").visible_width(), 0);
}
#[test]
fn visible_width_cjk_counts_double_width() {
let text = StyledText::plain("ๆฅๆฌ่ช");
assert_eq!(text.visible_width(), 6);
assert_ne!(text.visible_width(), text.spans[0].text.chars().count());
}
#[test]
fn visible_width_sums_across_spans() {
let text = StyledText {
spans: vec![
StyledSpan::plain("ไธญ"),
StyledSpan::plain("a"),
StyledSpan::plain("ๆ"),
],
};
assert_eq!(text.visible_width(), 5);
}
#[test]
fn visible_width_combining_mark_counts_zero() {
let text = StyledText::plain("e\u{0301}");
assert_eq!(text.visible_width(), 1);
}
#[test]
fn color_from_hex_rgb() {
let c = Color::from_hex("#1a2b3c").unwrap();
assert_eq!(c, Color::rgb(0x1a, 0x2b, 0x3c));
}
#[test]
fn color_from_hex_rgba() {
let c = Color::from_hex("#1a2b3c80").unwrap();
assert_eq!(c, Color::rgba(0x1a, 0x2b, 0x3c, 0x80));
}
#[test]
fn color_from_hex_invalid() {
assert!(Color::from_hex("not a hex").is_none());
assert!(Color::from_hex("#xyz").is_none());
assert!(Color::from_hex("#12345").is_none()); }
#[test]
fn try_from_hex_over_blends_alpha_against_background() {
let bg = Color::rgb(0, 0, 0);
let c = Color::try_from_hex_over("#ff008040", bg).unwrap();
let alpha = 0x40 as f64 / 255.0;
let blend = |fg: u8| -> u8 { (fg as f64 * alpha).round() as u8 };
assert_eq!(c, Color::rgba(blend(0xff), blend(0x00), blend(0x80), 255));
}
#[test]
fn try_from_hex_over_full_alpha_ignores_background() {
let bg = Color::rgb(10, 20, 30);
let c = Color::try_from_hex_over("#ff0080ff", bg).unwrap();
assert_eq!(c, Color::rgba(0xff, 0x00, 0x80, 255));
}
#[test]
fn try_from_hex_over_zero_alpha_equals_background() {
let bg = Color::rgb(10, 20, 30);
let c = Color::try_from_hex_over("#ff008000", bg).unwrap();
assert_eq!(c, Color::rgba(10, 20, 30, 255));
}
#[test]
fn try_from_hex_over_falls_back_to_from_hex_without_alpha() {
let bg = Color::rgb(10, 20, 30);
assert_eq!(
Color::try_from_hex_over("#1a2b3c", bg),
Color::from_hex("#1a2b3c")
);
}
#[test]
fn try_from_hex_over_rejects_malformed_input() {
let bg = Color::rgb(0, 0, 0);
assert_eq!(Color::try_from_hex_over("not a hex", bg), None);
assert_eq!(Color::try_from_hex_over("#zzzzzzzz", bg), None);
assert_eq!(Color::try_from_hex_over("#12345", bg), None);
}
#[test]
fn widget_id_roundtrip() {
let id = WidgetId::new("sc-tree");
assert_eq!(id.as_str(), "sc-tree");
let id2: WidgetId = "sc-tree".into();
assert_eq!(id, id2);
}
#[test]
fn widget_id_is_copy_not_just_clone() {
let id = WidgetId::new("copy-check");
assert_eq!(id.as_str(), "copy-check");
assert_eq!(id.as_str(), "copy-check");
}
#[test]
fn widget_id_serializes_as_plain_string() {
let id = WidgetId::new("wire-format-check");
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, "\"wire-format-check\"");
let back: WidgetId = serde_json::from_str(&json).unwrap();
assert_eq!(back, id);
}
#[test]
fn widget_id_interns_equal_labels_to_equal_handles() {
let a = WidgetId::new("shared-label");
let b = WidgetId::new("shared-label".to_string());
assert_eq!(a, b);
assert_eq!(a.as_str(), b.as_str());
}
#[test]
fn styled_text_visible_width() {
let text = StyledText {
spans: vec![
StyledSpan::plain("hello "),
StyledSpan::with_fg("world", Color::rgb(255, 0, 0)),
],
};
assert_eq!(text.visible_width(), 11);
}
}