1use std::fmt;
4use std::rc::Rc;
5
6use ravenrs_criteria::{Criteria, Environment, Scope};
7
8use crate::frame::{ViewInfo, ViewKind};
9use crate::{EntityInfo, ModelError, Object};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13#[non_exhaustive]
14#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
15pub enum Tint {
16 Danger,
18 Warning,
20 Success,
22 Accent,
24 Muted,
26}
27
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
31pub enum AppearanceTarget {
32 #[default]
34 Item,
35 Layout,
37 Action,
39}
40
41#[derive(Clone, Debug, Default, PartialEq, Eq)]
43#[non_exhaustive]
44#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
45pub enum AppearanceContext {
46 #[default]
48 Any,
49 List,
51 Detail,
53 View(String),
55}
56
57#[cfg_attr(feature = "live", ravenrs_live::expose)]
58impl AppearanceContext {
59 fn covers(&self, view: &ViewInfo) -> bool {
60 match self {
61 Self::Any => true,
62 Self::List => view.kind == ViewKind::List,
63 Self::Detail => view.kind == ViewKind::Detail,
64 Self::View(id) => *id == view.id,
65 }
66 }
67}
68
69type Condition = Rc<dyn Fn(&Object) -> bool>;
70
71#[derive(Clone)]
74#[non_exhaustive]
75#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
76pub struct AppearanceRule {
77 pub id: String,
79 pub target: AppearanceTarget,
81 pub names: Vec<String>,
84 pub context: AppearanceContext,
86 pub criteria: Option<String>,
88 pub priority: i32,
90 pub font: Option<Tint>,
92 pub back: Option<Tint>,
94 pub bold: Option<bool>,
96 pub enabled: Option<bool>,
98 pub visible: Option<bool>,
100 function: Option<Condition>,
101}
102
103impl fmt::Debug for AppearanceRule {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 f.debug_struct("AppearanceRule").field("id", &self.id).finish_non_exhaustive()
106 }
107}
108
109impl PartialEq for AppearanceRule {
110 fn eq(&self, other: &Self) -> bool {
111 self.id == other.id
112 }
113}
114
115#[cfg_attr(feature = "live", ravenrs_live::expose)]
116impl AppearanceRule {
117 pub fn new(id: impl Into<String>, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
119 Self {
120 id: id.into(),
121 target: AppearanceTarget::Item,
122 names: names.into_iter().map(Into::into).collect(),
123 context: AppearanceContext::Any,
124 criteria: None,
125 priority: 0,
126 font: None,
127 back: None,
128 bold: None,
129 enabled: None,
130 visible: None,
131 function: None,
132 }
133 }
134
135 pub fn target(mut self, target: AppearanceTarget) -> Self {
137 self.target = target;
138 self
139 }
140
141 pub fn context(mut self, context: AppearanceContext) -> Self {
143 self.context = context;
144 self
145 }
146
147 pub fn criteria(mut self, expression: impl Into<String>) -> Self {
149 self.criteria = Some(expression.into());
150 self
151 }
152
153 pub fn when(mut self, condition: impl Fn(&Object) -> bool + 'static) -> Self {
155 self.function = Some(Rc::new(condition));
156 self
157 }
158
159 pub fn priority(mut self, priority: i32) -> Self {
161 self.priority = priority;
162 self
163 }
164
165 pub fn font(mut self, tint: Tint) -> Self {
167 self.font = Some(tint);
168 self
169 }
170
171 pub fn back(mut self, tint: Tint) -> Self {
173 self.back = Some(tint);
174 self
175 }
176
177 pub fn bold(mut self) -> Self {
179 self.bold = Some(true);
180 self
181 }
182
183 pub fn enabled(mut self, enabled: bool) -> Self {
185 self.enabled = Some(enabled);
186 self
187 }
188
189 pub fn visible(mut self, visible: bool) -> Self {
191 self.visible = Some(visible);
192 self
193 }
194
195 pub(crate) fn check(&self, entity: &EntityInfo) -> Result<(), ModelError> {
196 if let Some(text) = &self.criteria {
197 Criteria::parse(text)
198 .map_err(|e| ModelError(format!("{}: the rule {}: {e}", entity.name, self.id)))?;
199 }
200 Ok(())
201 }
202
203 fn names_cover(&self, name: &str) -> bool {
204 match self.names.split_first() {
205 Some((first, excluded)) if first == "*" => {
206 !excluded.iter().any(|pattern| matches_pattern(pattern, name))
207 }
208 _ => self.names.iter().any(|pattern| matches_pattern(pattern, name)),
209 }
210 }
211
212 fn applies(&self, object: Option<&Object>, environment: &Environment) -> bool {
215 let unconditional = self.criteria.is_none() && self.function.is_none();
216 let Some(object) = object else { return unconditional };
217 self.criteria.as_deref().is_none_or(|text| {
218 Criteria::parse(text).is_ok_and(|parsed| Scope::new(environment).row(object).matches(&parsed))
219 }) && self.function.as_ref().is_none_or(|condition| condition(object))
220 }
221}
222
223fn matches_pattern(pattern: &str, name: &str) -> bool {
224 let Some((start, rest)) = pattern.split_once('*') else { return pattern == name };
225 if !name.starts_with(start) {
226 return false;
227 }
228 let mut left = &name[start.len()..];
229 let parts: Vec<&str> = rest.split('*').collect();
230 for (i, part) in parts.iter().enumerate() {
231 if i + 1 == parts.len() {
232 return left.ends_with(part);
233 }
234 match left.find(part) {
235 Some(at) => left = &left[at + part.len()..],
236 None => return false,
237 }
238 }
239 true
240}
241
242#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
244#[non_exhaustive]
245#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
246pub struct Appearance {
247 pub font: Option<Tint>,
249 pub back: Option<Tint>,
251 pub bold: bool,
253 pub enabled: Option<bool>,
255 pub visible: Option<bool>,
257}
258
259#[cfg_attr(feature = "live", ravenrs_live::expose)]
260impl EntityInfo {
261 pub fn appearance_of(
263 &self,
264 target: AppearanceTarget,
265 name: &str,
266 view: &ViewInfo,
267 object: Option<&Object>,
268 ) -> Appearance {
269 let environment = crate::environment();
270 let mut rules: Vec<&AppearanceRule> = self
271 .appearance
272 .iter()
273 .filter(|rule| rule.target == target && rule.context.covers(view) && rule.names_cover(name))
274 .filter(|rule| rule.applies(object, &environment))
275 .collect();
276 rules.sort_by_key(|rule| rule.priority);
277 let mut result = Appearance::default();
278 for rule in rules {
279 result.font = rule.font.or(result.font);
280 result.back = rule.back.or(result.back);
281 result.bold = rule.bold.unwrap_or(result.bold);
282 result.enabled = rule.enabled.or(result.enabled);
283 result.visible = rule.visible.or(result.visible);
284 }
285 result
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn stars_stand_for_any_text() {
295 for (pattern, name, fits) in [
296 ("*", "anything", true),
297 ("name", "name", true),
298 ("name", "names", false),
299 ("open*", "opening", true),
300 ("open*", "reopen", false),
301 ("*ing", "opening", true),
302 ("*ing", "ingot", false),
303 ("o*n*g", "opening", true),
304 ("o*x*g", "opening", false),
305 ] {
306 assert_eq!(matches_pattern(pattern, name), fits, "{pattern} {name}");
307 }
308 }
309}