1use 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
16pub(crate) fn icon_named(name: &str) -> Option<IconName> {
18 IconName::ALL.iter().copied().find(|icon| icon.name() == name)
19}
20
21fn 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
43fn parameter_of(kind: &FieldKind, text: &str) -> Result<Value, String> {
45 let typed = text.trim();
46 let bad = || format!("\u{201c}{typed}\u{201d}?");
47 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#[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 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 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 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 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 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#[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 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 pub fn style(mut self, style: ButtonStyle) -> Self {
256 self.style = style;
257 self
258 }
259
260 pub fn size(mut self, size: ButtonSize) -> Self {
262 self.size = size;
263 self
264 }
265
266 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}