Skip to main content

ravenrs_app/
appearance.rs

1//! How things look and behave by what a record holds.
2
3use 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/// A color with a meaning, which every theme has its own version of.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13#[non_exhaustive]
14#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
15pub enum Tint {
16    /// Something wrong.
17    Danger,
18    /// Something to watch.
19    Warning,
20    /// Something right.
21    Success,
22    /// What stands out.
23    Accent,
24    /// What steps back.
25    Muted,
26}
27
28/// What a rule changes the look of.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
31pub enum AppearanceTarget {
32    /// A field: a cell of a list, an editor on a form.
33    #[default]
34    Item,
35    /// A group or a tab of a form.
36    Layout,
37    /// An action.
38    Action,
39}
40
41/// Which views a rule applies in.
42#[derive(Clone, Debug, Default, PartialEq, Eq)]
43#[non_exhaustive]
44#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
45pub enum AppearanceContext {
46    /// Every view.
47    #[default]
48    Any,
49    /// Only lists.
50    List,
51    /// Only forms.
52    Detail,
53    /// Only the view with this id.
54    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/// Changes how things look or behave, for records that meet a condition. It is about looks:
72/// it does not protect data, which is what permissions are for.
73#[derive(Clone)]
74#[non_exhaustive]
75#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
76pub struct AppearanceRule {
77    /// What it is known by.
78    pub id: String,
79    /// The kind of thing it changes.
80    pub target: AppearanceTarget,
81    /// The names of those things, where `*` stands for any text. A list that starts with a
82    /// lone `*` means all but the names after it.
83    pub names: Vec<String>,
84    /// Where it applies.
85    pub context: AppearanceContext,
86    /// The expression a record has to meet. With none, and no function, it always applies.
87    pub criteria: Option<String>,
88    /// Whichever is higher wins, for what both change.
89    pub priority: i32,
90    /// The color of the text.
91    pub font: Option<Tint>,
92    /// The color behind it.
93    pub back: Option<Tint>,
94    /// Heavier text.
95    pub bold: Option<bool>,
96    /// Whether it can be used.
97    pub enabled: Option<bool>,
98    /// Whether it shows.
99    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    /// A rule about fields with these names, as `*` or `open*`.
118    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    /// About this kind of thing.
136    pub fn target(mut self, target: AppearanceTarget) -> Self {
137        self.target = target;
138        self
139    }
140
141    /// Only in these views.
142    pub fn context(mut self, context: AppearanceContext) -> Self {
143        self.context = context;
144        self
145    }
146
147    /// For records that meet this expression.
148    pub fn criteria(mut self, expression: impl Into<String>) -> Self {
149        self.criteria = Some(expression.into());
150        self
151    }
152
153    /// For records this says yes to.
154    pub fn when(mut self, condition: impl Fn(&Object) -> bool + 'static) -> Self {
155        self.function = Some(Rc::new(condition));
156        self
157    }
158
159    /// Whichever is higher wins.
160    pub fn priority(mut self, priority: i32) -> Self {
161        self.priority = priority;
162        self
163    }
164
165    /// The color of the text.
166    pub fn font(mut self, tint: Tint) -> Self {
167        self.font = Some(tint);
168        self
169    }
170
171    /// The color behind it.
172    pub fn back(mut self, tint: Tint) -> Self {
173        self.back = Some(tint);
174        self
175    }
176
177    /// Heavier text.
178    pub fn bold(mut self) -> Self {
179        self.bold = Some(true);
180        self
181    }
182
183    /// Whether it can be used.
184    pub fn enabled(mut self, enabled: bool) -> Self {
185        self.enabled = Some(enabled);
186        self
187    }
188
189    /// Whether it shows.
190    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    /// Whether it applies to this record. With no record, as for a whole column, only a
213    /// rule with no condition does.
214    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/// What the rules that apply come to.
243#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
244#[non_exhaustive]
245#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
246pub struct Appearance {
247    /// The color of the text.
248    pub font: Option<Tint>,
249    /// The color behind it.
250    pub back: Option<Tint>,
251    /// Heavier text.
252    pub bold: bool,
253    /// Whether it can be used, if a rule said.
254    pub enabled: Option<bool>,
255    /// Whether it shows, if a rule said.
256    pub visible: Option<bool>,
257}
258
259#[cfg_attr(feature = "live", ravenrs_live::expose)]
260impl EntityInfo {
261    /// What this entity's rules say about a thing in a view, for a record.
262    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}