Skip to main content

ravenrs_app_ui/
container.rs

1//! Where actions show.
2
3use ravenrs_app::{
4    Action, ActionCategory, ActionInput, ActionKind, ChoiceItem, FieldKind, FrameContext, Value,
5};
6use ravenrs_app_a11y::AccessibleText;
7use ravenrs_core::Key;
8use ravenrs_reactive::{Binding, State};
9use ravenrs_widgets::{
10    BuildCx, Button, ButtonSize, ButtonStyle, DropdownMenu, Hotkey, IconName, MenuItem, NodeId, TextField,
11    Toolbar, ToolbarAction, View,
12};
13
14use crate::session::Session;
15
16/// An icon by the name an action gives it.
17pub(crate) fn icon_named(name: &str) -> Option<IconName> {
18    IconName::ALL.iter().copied().find(|icon| icon.name() == name)
19}
20
21/// Reads keys such as `command+shift+s`.
22fn hotkey_of(text: &str) -> Option<Hotkey> {
23    let mut command = false;
24    let mut shift = false;
25    let mut key = None;
26    for part in text.split('+') {
27        match part.trim().to_lowercase().as_str() {
28            "command" | "cmd" | "ctrl" => command = true,
29            "shift" => shift = true,
30            "delete" => key = Some(Key::Delete),
31            "enter" => key = Some(Key::Enter),
32            other => key = Some(Key::Character(other.to_owned())),
33        }
34    }
35    let key = key?;
36    let hotkey = match (&key, command) {
37        (Key::Character(letter), true) => Hotkey::command(letter),
38        _ => Hotkey::key(key),
39    };
40    Some(if shift { hotkey.shift() } else { hotkey })
41}
42
43/// What was typed, as what the action asks for, or the words of a person who cannot read it.
44fn parameter_of(kind: &FieldKind, text: &str) -> Result<Value, String> {
45    let typed = text.trim();
46    let bad = || format!("\u{201c}{typed}\u{201d}?");
47    // Both ways of writing a number people use: 1.234,56 and 1234.56.
48    let number = || {
49        let plain =
50            if typed.contains(',') { typed.replace('.', "").replace(',', ".") } else { typed.to_owned() };
51        plain.parse::<f64>().map_err(|_| bad())
52    };
53    match kind {
54        _ if typed.is_empty() => Ok(Value::Null),
55        FieldKind::Integer => number().map(|n| Value::Number(n.trunc())),
56        FieldKind::Decimal(_) => number().map(Value::Number),
57        FieldKind::Money => number().map(|n| Value::Number((n * 100.0).round())),
58        FieldKind::Date | FieldKind::DateTime => {
59            ravenrs_criteria::DateTime::parse(typed).map(Value::Date).ok_or_else(bad)
60        }
61        FieldKind::Boolean => match typed.to_lowercase().as_str() {
62            "true" | "yes" | "sim" | "1" => Ok(Value::Bool(true)),
63            "false" | "no" | "não" | "nao" | "0" => Ok(Value::Bool(false)),
64            _ => Err(bad()),
65        },
66        FieldKind::Enum(options) => options
67            .iter()
68            .find(|option| option.code == typed || option.caption.eq_ignore_ascii_case(typed))
69            .map(|option| Value::Text(option.code.clone()))
70            .ok_or_else(bad),
71        _ => Ok(Value::Text(typed.to_owned())),
72    }
73}
74
75fn add_choice(
76    menu: DropdownMenu,
77    session: &Session,
78    action: &Action,
79    items: &[ChoiceItem],
80    prefix: &str,
81) -> DropdownMenu {
82    items.iter().fold(menu, |menu, item| {
83        let caption = format!("{prefix}{}", item.caption);
84        if item.items.is_empty() {
85            let (session, action, id) = (session.clone(), action.clone(), item.id.clone());
86            menu.item(MenuItem::new(caption).on_select(move || {
87                session.run_action(&action, ActionInput::Choice(id.clone()));
88            }))
89        } else {
90            add_choice(menu, session, action, &item.items, &format!("{caption} / "))
91        }
92    })
93}
94
95/// A place on the screen for actions of some categories: what is in place shows, what is
96/// off shows faded, and what does not fit folds into a menu. Keys work while it shows.
97#[derive(Debug)]
98#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
99pub struct ActionContainer {
100    session: Session,
101    categories: Vec<ActionCategory>,
102}
103
104#[cfg_attr(feature = "live", ravenrs_live::expose)]
105impl ActionContainer {
106    /// A place for these categories, in the order the actions came; for all of them, with none.
107    pub fn new(session: Session, categories: impl IntoIterator<Item = ActionCategory>) -> Self {
108        Self { session, categories: categories.into_iter().collect() }
109    }
110
111    fn entry(
112        &self,
113        toolbar: Toolbar,
114        action: &Action,
115        primary: bool,
116        drafts: &State<std::collections::BTreeMap<String, String>>,
117    ) -> Toolbar {
118        let session = self.session.clone();
119        match action.kind() {
120            ActionKind::SingleChoice(items) => {
121                if !action.is_enabled() {
122                    return toolbar.action(ToolbarAction::new(action.caption().to_owned()).disabled(true));
123                }
124                let menu = DropdownMenu::new(action.caption().to_owned()).size(ButtonSize::Small);
125                // Declared with no options, it offers what the model has for the view it is on.
126                let part = if action.id() == crate::ids::FILTERS { "Filters" } else { "Variants" };
127                let found: Vec<ChoiceItem> = if items.is_empty() {
128                    self.session
129                        .frame()
130                        .view()
131                        .map(|view| self.session.choices(&view.id, part))
132                        .unwrap_or_default()
133                        .into_iter()
134                        .map(|(id, caption)| ChoiceItem::new(id, caption))
135                        .collect()
136                } else {
137                    items.clone()
138                };
139                toolbar.view(120.0, add_choice(menu, &session, action, &found, ""))
140            }
141            ActionKind::Parametrized(kind) => {
142                let id = action.id().to_owned();
143                let (read, write) = (drafts.clone(), drafts.clone());
144                let (read_id, write_id) = (id.clone(), id);
145                let draft = Binding::new(
146                    move || read.with(|all| all.get(&read_id).cloned().unwrap_or_default()),
147                    move |text: String| {
148                        write.update(|all| {
149                            all.insert(write_id.clone(), text);
150                        })
151                    },
152                );
153                let (run, kind) = (action.clone(), kind.clone());
154                let field = TextField::new(draft)
155                    .placeholder(action.caption().to_owned())
156                    .compact()
157                    .disabled(!action.is_enabled())
158                    .on_submit(move |text| match parameter_of(&kind, &text) {
159                        Ok(value) => session.run_action(&run, ActionInput::Parameter(value)),
160                        Err(problem) => session.set_error(Some(problem)),
161                    });
162                toolbar.view(200.0, field)
163            }
164            _ => {
165                let mut entry =
166                    ToolbarAction::new(action.caption().to_owned()).disabled(!action.is_enabled());
167                let spoken = AccessibleText::of_action(action);
168                if spoken.renamed {
169                    entry = entry.accessible_name(spoken.name);
170                }
171                if let Some(description) = spoken.description {
172                    entry = entry.accessible_description(description);
173                }
174                if primary {
175                    entry = entry.style(ButtonStyle::Primary);
176                }
177                if let Some(icon) = action.icon_name().and_then(icon_named) {
178                    entry = entry.icon(icon);
179                    // Moving around is known by its arrows, and leaves room for the rest.
180                    if *action.action_category() == ActionCategory::Navigation {
181                        entry = entry.icon_only();
182                    }
183                }
184                if let Some(hotkey) = action.hotkey_text().and_then(hotkey_of) {
185                    entry = entry.hotkey(hotkey);
186                }
187                let run = action.clone();
188                toolbar.action(entry.on_click(move || session.run_action(&run, ActionInput::None)))
189            }
190        }
191    }
192}
193
194impl View for ActionContainer {
195    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
196        let drafts = cx.use_state(Default::default);
197        let actions = self.session.frame().actions();
198        let shown = actions.iter().filter(|action| {
199            // What goes inside a form's layout does not show above it as well.
200            let in_form = matches!(action.action_category(), ActionCategory::InForm(_));
201            action.is_active()
202                && (if self.categories.is_empty() {
203                    !in_form
204                } else {
205                    self.categories.contains(action.action_category())
206                })
207        });
208        let shown: Vec<&Action> = shown.collect();
209        // A window has one action that matters most: saving what is open, or else adding a record.
210        let primary = (self.session.frame().context() == FrameContext::Main)
211            .then(|| {
212                [crate::ids::SAVE, crate::ids::NEW]
213                    .into_iter()
214                    .find(|id| shown.iter().any(|action| action.id() == *id && action.is_enabled()))
215            })
216            .flatten();
217        let mut toolbar = Toolbar::new();
218        let mut last: Option<&ActionCategory> = None;
219        for action in shown {
220            if last.is_some_and(|category| category != action.action_category()) {
221                toolbar = toolbar.separator();
222            }
223            last = Some(action.action_category());
224            toolbar = self.entry(toolbar, action, primary == Some(action.id()), &drafts);
225        }
226        cx.child(&toolbar)
227    }
228}
229
230/// One action as a button of its own, for a screen that places its commands itself.
231#[derive(Debug)]
232#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
233pub struct ActionButton {
234    session: Session,
235    id: String,
236    style: ButtonStyle,
237    size: ButtonSize,
238    hide_when_off: bool,
239}
240
241#[cfg_attr(feature = "live", ravenrs_live::expose)]
242impl ActionButton {
243    /// The button of the action with this id.
244    pub fn new(session: Session, id: impl Into<String>) -> Self {
245        Self {
246            session,
247            id: id.into(),
248            style: ButtonStyle::Primary,
249            size: ButtonSize::Small,
250            hide_when_off: false,
251        }
252    }
253
254    /// How it looks.
255    pub fn style(mut self, style: ButtonStyle) -> Self {
256        self.style = style;
257        self
258    }
259
260    /// How big it is. Small by default.
261    pub fn size(mut self, size: ButtonSize) -> Self {
262        self.size = size;
263        self
264    }
265
266    /// Takes it away while it cannot be used, instead of fading it. Only for a button at the end
267    /// of a row of them, so nothing moves under the pointer.
268    pub fn hide_when_off(mut self) -> Self {
269        self.hide_when_off = true;
270        self
271    }
272}
273
274impl View for ActionButton {
275    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
276        let Some(action) = self.session.frame().action(&self.id) else { return cx.empty() };
277        if !action.is_active() || (self.hide_when_off && !action.is_enabled()) {
278            return cx.empty();
279        }
280        let mut button = Button::new(action.caption().to_owned())
281            .size(self.size)
282            .style(self.style)
283            .disabled(!action.is_enabled());
284        if let Some(icon) = action.icon_name().and_then(icon_named) {
285            button = button.icon(icon);
286        }
287        let session = self.session.clone();
288        cx.child(&button.on_click(move || session.run_action(&action, ActionInput::None)))
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn what_is_typed_becomes_what_the_action_asks_for() {
298        assert_eq!(parameter_of(&FieldKind::Money, "1.234,56"), Ok(Value::Number(123_456.0)));
299        assert_eq!(parameter_of(&FieldKind::Integer, "12"), Ok(Value::Number(12.0)));
300        assert_eq!(parameter_of(&FieldKind::Boolean, "sim"), Ok(Value::Bool(true)));
301        assert_eq!(parameter_of(&FieldKind::Text, "  a "), Ok(Value::Text("a".into())));
302        assert_eq!(parameter_of(&FieldKind::Integer, ""), Ok(Value::Null));
303        assert!(parameter_of(&FieldKind::Integer, "doze").is_err());
304        assert!(parameter_of(&FieldKind::Date, "ontem").is_err());
305        let kinds = FieldKind::Enum(vec![ravenrs_app::EnumOption::new("o", "Open")]);
306        assert_eq!(parameter_of(&kinds, "open"), Ok(Value::Text("o".into())));
307    }
308
309    #[test]
310    fn keys_are_read_from_their_words() {
311        let saved = hotkey_of("command+shift+s");
312        assert_eq!(saved, Some(Hotkey::command("s").shift()));
313        assert_eq!(hotkey_of("Delete"), Some(Hotkey::key(Key::Delete)));
314        assert_eq!(hotkey_of("command+s"), Some(Hotkey::command("s")));
315    }
316
317    #[test]
318    fn icons_are_found_by_their_name() {
319        assert_eq!(icon_named("trash-2"), Some(IconName::Trash2));
320        assert_eq!(icon_named("no such thing"), None);
321    }
322}