diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-06 23:44:15 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-06 23:44:15 +0300 |
| commit | c7b79294e28fe7c835691a25597fabf226ecfc20 (patch) | |
| tree | 0f53309032f46aa119b7c44386524b1f02436759 /src/config | |
| parent | 2329bc181d1d21a365704f261e11ce0ee86524a5 (diff) | |
feat: add conditional styles for sidebar tokens (#3693)
Diffstat (limited to 'src/config')
| -rw-r--r-- | src/config/sidebar.rs | 139 | ||||
| -rw-r--r-- | src/config/sidebar/rules.rs | 234 |
2 files changed, 353 insertions, 20 deletions
diff --git a/src/config/sidebar.rs b/src/config/sidebar.rs index 32838c21..0b489765 100644 --- a/src/config/sidebar.rs +++ b/src/config/sidebar.rs @@ -1,3 +1,7 @@ +mod rules; + +pub use rules::SidebarTokenRule; + use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; @@ -115,6 +119,7 @@ pub enum AgentSidebarToken { Styled { token: Box<AgentSidebarToken>, style: SidebarTokenStyle, + rules: Vec<SidebarTokenRule>, }, } @@ -129,22 +134,37 @@ pub enum SpaceSidebarToken { Styled { token: Box<SpaceSidebarToken>, style: SidebarTokenStyle, + rules: Vec<SidebarTokenRule>, }, } impl AgentSidebarToken { + pub(crate) fn style_for_value(&self, value: &str) -> SidebarTokenStyle { + match self { + Self::Styled { style, rules, .. } => rules::matching_style(rules, *style, value), + _ => SidebarTokenStyle::default(), + } + } + pub(crate) fn parts(&self) -> (&Self, SidebarTokenStyle) { match self { - Self::Styled { token, style } => (token, *style), + Self::Styled { token, style, .. } => (token, *style), token => (token, SidebarTokenStyle::default()), } } } impl SpaceSidebarToken { + pub(crate) fn style_for_value(&self, value: &str) -> SidebarTokenStyle { + match self { + Self::Styled { style, rules, .. } => rules::matching_style(rules, *style, value), + _ => SidebarTokenStyle::default(), + } + } + pub(crate) fn parts(&self) -> (&Self, SidebarTokenStyle) { match self { - Self::Styled { token, style } => (token, *style), + Self::Styled { token, style, .. } => (token, *style), token => (token, SidebarTokenStyle::default()), } } @@ -160,6 +180,8 @@ struct RawStyledSidebarToken { bold: Option<bool>, #[serde(default)] dim: Option<bool>, + #[serde(default)] + rules: Vec<SidebarTokenRule>, } #[derive(Deserialize)] @@ -170,17 +192,28 @@ enum RawSidebarToken { } impl RawSidebarToken { - fn parts(self) -> (String, Option<SidebarTokenStyle>) { + fn parts(self) -> Result<(String, Option<SidebarTokenStyle>, Vec<SidebarTokenRule>), String> { match self { - Self::Plain(token) => (token, None), - Self::Styled(token) => ( - token.token, - Some(SidebarTokenStyle { - fg: token.fg, - bold: token.bold, - dim: token.dim, - }), - ), + Self::Plain(token) => Ok((token, None, Vec::new())), + Self::Styled(token) => { + if token.rules.len() > 16 { + return Err("sidebar tokens may contain at most 16 rules".into()); + } + if !token.rules.is_empty() + && matches!(token.token.as_str(), "state_icon" | "git_status") + { + return Err("sidebar rules require a text-valued token".into()); + } + Ok(( + token.token, + Some(SidebarTokenStyle { + fg: token.fg, + bold: token.bold, + dim: token.dim, + }), + token.rules, + )) + } } } } @@ -211,6 +244,7 @@ where fn serialize_styled_token<S>( name: String, style: SidebarTokenStyle, + rules: &[SidebarTokenRule], serializer: S, ) -> Result<S::Ok, S::Error> where @@ -228,6 +262,9 @@ where if let Some(dim) = style.dim { map.serialize_entry("dim", &dim)?; } + if !rules.is_empty() { + map.serialize_entry("rules", rules)?; + } map.end() } @@ -265,9 +302,11 @@ impl Serialize for AgentSidebarToken { S: serde::Serializer, { match self { - Self::Styled { token, style } => { - serialize_styled_token(agent_token_name(token), *style, serializer) - } + Self::Styled { + token, + style, + rules, + } => serialize_styled_token(agent_token_name(token), *style, rules, serializer), token => serializer.serialize_str(&agent_token_name(token)), } } @@ -284,7 +323,9 @@ impl<'de> Deserialize<'de> for AgentSidebarToken { where D: serde::Deserializer<'de>, { - let (value, style) = RawSidebarToken::deserialize(deserializer)?.parts(); + let (value, style, rules) = RawSidebarToken::deserialize(deserializer)? + .parts() + .map_err(serde::de::Error::custom)?; let token = parse_sidebar_token( value, &[ @@ -303,6 +344,7 @@ impl<'de> Deserialize<'de> for AgentSidebarToken { Ok(style.map_or(token.clone(), |style| Self::Styled { token: Box::new(token), style, + rules, })) } } @@ -313,9 +355,11 @@ impl Serialize for SpaceSidebarToken { S: serde::Serializer, { match self { - Self::Styled { token, style } => { - serialize_styled_token(space_token_name(token), *style, serializer) - } + Self::Styled { + token, + style, + rules, + } => serialize_styled_token(space_token_name(token), *style, rules, serializer), token => serializer.serialize_str(&space_token_name(token)), } } @@ -332,7 +376,9 @@ impl<'de> Deserialize<'de> for SpaceSidebarToken { where D: serde::Deserializer<'de>, { - let (value, style) = RawSidebarToken::deserialize(deserializer)?.parts(); + let (value, style, rules) = RawSidebarToken::deserialize(deserializer)? + .parts() + .map_err(serde::de::Error::custom)?; let token = parse_sidebar_token( value, &[ @@ -347,6 +393,7 @@ impl<'de> Deserialize<'de> for SpaceSidebarToken { Ok(style.map_or(token.clone(), |style| Self::Styled { token: Box::new(token), style, + rules, })) } } @@ -563,6 +610,58 @@ rows = [[{ token = "git_status", fg = "#ff00aa" }], [{ token = "$jj", bold = tru } #[test] + fn conditional_sidebar_rules_round_trip() { + let input = r##" +[agents] +rows = [[{ token = "machine", fg = "#fff", rules = [{ equals = "Local", fg = "#f00" }, { starts_with = "fed", ignore_case = true, bold = true }] }]] +[agents.rows_by_agent] +pi = [[{ token = "$load", rules = [{ gt = 80, dim = false }, { lt = 20.5, dim = true }] }]] +[spaces] +rows = [[{ token = "$status", rules = [{ contains = "error", bold = true }] }]] +"##; + let config: SidebarConfig = toml::from_str(input).expect("conditional sidebar config"); + let encoded = toml::to_string(&config).unwrap(); + assert!(encoded.contains("rules")); + assert_eq!(toml::from_str::<SidebarConfig>(&encoded).unwrap(), config); + } + + #[test] + fn conditional_sidebar_rules_reject_invalid_conditions_and_nontext_tokens() { + for rule in [ + "{ bold = true }", + "{ equals = 'x', contains = 'x' }", + "{ regex = 'x' }", + "{ gt = '80' }", + "{ equals = 80 }", + "{ gt = nan }", + "{ lt = inf }", + "{ gt = 80, ignore_case = false }", + "{ equals = 'x', underline = true }", + "{ equals = 'x', fg = 'red' }", + ] { + let input = format!("[agents]\nrows = [[{{ token = 'machine', rules = [{rule}] }}]]"); + assert!(toml::from_str::<SidebarConfig>(&input).is_err(), "{rule}"); + } + for (section, token) in [ + ("agents", "state_icon"), + ("spaces", "state_icon"), + ("spaces", "git_status"), + ] { + let input = format!( + "[{section}]\nrows = [[{{ token = '{token}', rules = [{{ equals = 'x' }}] }}]]" + ); + assert!(toml::from_str::<SidebarConfig>(&input).is_err()); + } + for count in [16, 17] { + let rules = std::iter::repeat_n("{ equals = 'x' }", count) + .collect::<Vec<_>>() + .join(","); + let input = format!("[agents]\nrows = [[{{ token = 'machine', rules = [{rules}] }}]]"); + assert_eq!(toml::from_str::<SidebarConfig>(&input).is_ok(), count == 16); + } + } + + #[test] fn rejects_invalid_occurrence_styles() { for entry in [ r##"{ token = "workspace", fg = "red" }"##, diff --git a/src/config/sidebar/rules.rs b/src/config/sidebar/rules.rs new file mode 100644 index 00000000..3ee72e56 --- /dev/null +++ b/src/config/sidebar/rules.rs @@ -0,0 +1,234 @@ +use serde::{Deserialize, Serialize}; + +use super::{SidebarTokenColor, SidebarTokenStyle}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "RawRule", into = "RawRule")] +pub struct SidebarTokenRule { + condition: Condition, + ignore_case: bool, + style: SidebarTokenStyle, +} + +// Deserialization rejects non-finite thresholds, so equality is reflexive. +impl Eq for SidebarTokenRule {} + +#[derive(Debug, Clone, PartialEq)] +enum Condition { + Equals(String), + Contains(String), + StartsWith(String), + GreaterThan(f64), + LessThan(f64), +} + +#[derive(Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct RawRule { + #[serde(skip_serializing_if = "Option::is_none")] + equals: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + contains: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + starts_with: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + gt: Option<f64>, + #[serde(skip_serializing_if = "Option::is_none")] + lt: Option<f64>, + #[serde(skip_serializing_if = "Option::is_none")] + ignore_case: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + fg: Option<SidebarTokenColor>, + #[serde(skip_serializing_if = "Option::is_none")] + bold: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + dim: Option<bool>, +} + +impl TryFrom<RawRule> for SidebarTokenRule { + type Error = String; + + fn try_from(raw: RawRule) -> Result<Self, Self::Error> { + let count = [ + raw.equals.is_some(), + raw.contains.is_some(), + raw.starts_with.is_some(), + raw.gt.is_some(), + raw.lt.is_some(), + ] + .into_iter() + .filter(|present| *present) + .count(); + if count != 1 { + return Err( + "sidebar rule requires exactly one of equals, contains, starts_with, gt, lt".into(), + ); + } + let condition = if let Some(value) = raw.equals { + Condition::Equals(value) + } else if let Some(value) = raw.contains { + Condition::Contains(value) + } else if let Some(value) = raw.starts_with { + Condition::StartsWith(value) + } else { + let (value, greater) = match (raw.gt, raw.lt) { + (Some(value), _) => (value, true), + (_, Some(value)) => (value, false), + _ => unreachable!("validated condition count"), + }; + if !value.is_finite() { + return Err("sidebar numeric rule threshold must be finite".into()); + } + if raw.ignore_case.is_some() { + return Err("ignore_case applies only to sidebar text conditions".into()); + } + if greater { + Condition::GreaterThan(value) + } else { + Condition::LessThan(value) + } + }; + Ok(Self { + condition, + ignore_case: raw.ignore_case.unwrap_or(false), + style: SidebarTokenStyle { + fg: raw.fg, + bold: raw.bold, + dim: raw.dim, + }, + }) + } +} + +impl From<SidebarTokenRule> for RawRule { + fn from(rule: SidebarTokenRule) -> Self { + let mut raw = Self { + ignore_case: rule.ignore_case.then_some(true), + fg: rule.style.fg, + bold: rule.style.bold, + dim: rule.style.dim, + ..Self::default() + }; + match rule.condition { + Condition::Equals(value) => raw.equals = Some(value), + Condition::Contains(value) => raw.contains = Some(value), + Condition::StartsWith(value) => raw.starts_with = Some(value), + Condition::GreaterThan(value) => raw.gt = Some(value), + Condition::LessThan(value) => raw.lt = Some(value), + } + raw + } +} + +impl SidebarTokenRule { + fn matches(&self, value: &str, numeric: &mut Option<Option<f64>>) -> bool { + match &self.condition { + Condition::Equals(expected) => { + if self.ignore_case { + value.eq_ignore_ascii_case(expected) + } else { + value == expected + } + } + Condition::StartsWith(expected) => { + if self.ignore_case { + value + .as_bytes() + .get(..expected.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(expected.as_bytes())) + } else { + value.starts_with(expected) + } + } + Condition::Contains(expected) => { + if self.ignore_case { + expected.is_empty() + || value + .as_bytes() + .windows(expected.len()) + .any(|part| part.eq_ignore_ascii_case(expected.as_bytes())) + } else { + value.contains(expected) + } + } + Condition::GreaterThan(threshold) | Condition::LessThan(threshold) => { + let parsed = numeric.get_or_insert_with(|| { + value + .parse::<f64>() + .ok() + .filter(|number| number.is_finite()) + }); + parsed.is_some_and(|number| match self.condition { + Condition::GreaterThan(_) => number > *threshold, + _ => number < *threshold, + }) + } + } + } +} + +pub(super) fn matching_style( + rules: &[SidebarTokenRule], + base: SidebarTokenStyle, + value: &str, +) -> SidebarTokenStyle { + let mut numeric = None; + for rule in rules { + if rule.matches(value, &mut numeric) { + return SidebarTokenStyle { + fg: rule.style.fg.or(base.fg), + bold: rule.style.bold.or(base.bold), + dim: rule.style.dim.or(base.dim), + }; + } + } + base +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn string_conditions_use_exact_case_or_ascii_folding() { + for (condition, yes, no) in [ + ("equals = 'Local'", "Local", "Localhost"), + ("contains = 'Local'", "myLocalbox", "remote"), + ("starts_with = 'Local'", "Localhost", "myLocal"), + ] { + let rule: SidebarTokenRule = toml::from_str(condition).unwrap(); + assert!(rule.matches(yes, &mut None)); + assert!(!rule.matches(no, &mut None)); + assert!(!rule.matches(&yes.to_ascii_lowercase(), &mut None)); + let folded: SidebarTokenRule = + toml::from_str(&format!("{condition}\nignore_case = true")).unwrap(); + assert!(folded.matches(&yes.to_ascii_lowercase(), &mut None)); + } + for condition in ["equals", "contains", "starts_with"] { + let rule: SidebarTokenRule = + toml::from_str(&format!("{condition} = 'ÉA'\nignore_case = true")).unwrap(); + assert!(rule.matches("Éa", &mut None)); + assert!(!rule.matches("éa", &mut None)); + } + let empty: SidebarTokenRule = toml::from_str("contains = ''\nignore_case = true").unwrap(); + assert!(empty.matches("", &mut None)); + } + + #[test] + fn numeric_conditions_require_full_finite_numbers_and_strict_comparison() { + for (condition, yes, no) in [ + ("gt = 80", ["90", "8.1e1", "+90"], "70"), + ("lt = 80", ["70", "7.9e1", "-90"], "90"), + ] { + let rule: SidebarTokenRule = toml::from_str(condition).unwrap(); + for value in yes { + assert!(rule.matches(value, &mut None), "{condition}: {value}"); + } + for value in [ + no, "80", "90%", " 90", "90 ", "", "NaN", "inf", "-inf", "1e999", + ] { + assert!(!rule.matches(value, &mut None), "{condition}: {value}"); + } + } + } +} |
