Skip to main content

sequoia_elements/
command.rs

1use std::cell::RefCell;
2use std::rc::Rc;
3
4use sequoia_core::Length;
5use sequoia_reactive::Signal;
6use sequoia_view::VirtualList;
7use sequoia_view::{AnyView, Column, Dismiss, DismissableLayer, FocusScope, Row, Text, Unit, View};
8
9use crate::button::Button;
10use crate::floating_menu::FloatingMenu;
11use crate::modal::Modal;
12use crate::text_field::TextField;
13
14#[derive(Clone)]
15pub struct Command {
16    pub id: String,
17    pub title: String,
18    pub category: String,
19    pub shortcut: Option<String>,
20    action: Rc<dyn Fn()>,
21}
22
23impl Command {
24    pub fn run(&self) {
25        (self.action)();
26    }
27}
28
29#[derive(Clone, Default)]
30pub struct CommandRegistry {
31    inner: Rc<RefCell<Vec<Command>>>,
32}
33
34impl CommandRegistry {
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    pub fn register(
40        &self,
41        id: impl Into<String>,
42        title: impl Into<String>,
43        category: impl Into<String>,
44        shortcut: Option<impl Into<String>>,
45        action: impl Fn() + 'static,
46    ) {
47        self.inner.borrow_mut().push(Command {
48            id: id.into(),
49            title: title.into(),
50            category: category.into(),
51            shortcut: shortcut.map(|s| s.into()),
52            action: Rc::new(action),
53        });
54    }
55
56    pub fn run(&self, id: &str) -> bool {
57        let cmds = self.inner.borrow();
58        match cmds.iter().find(|c| c.id == id) {
59            Some(c) => {
60                c.run();
61                true
62            }
63            None => false,
64        }
65    }
66
67    pub fn search(&self, query: &str) -> Vec<Command> {
68        let q = query.to_lowercase();
69        self.inner
70            .borrow()
71            .iter()
72            .filter(|c| {
73                q.is_empty()
74                    || c.title.to_lowercase().contains(&q)
75                    || c.category.to_lowercase().contains(&q)
76            })
77            .cloned()
78            .collect()
79    }
80
81    pub fn categories(&self) -> Vec<String> {
82        let mut out = Vec::new();
83        for c in self.inner.borrow().iter() {
84            if !out.contains(&c.category) {
85                out.push(c.category.clone());
86            }
87        }
88        out
89    }
90
91    pub fn commands_in(&self, category: &str) -> Vec<Command> {
92        self.inner
93            .borrow()
94            .iter()
95            .filter(|c| c.category == category)
96            .cloned()
97            .collect()
98    }
99
100    pub fn len(&self) -> usize {
101        self.inner.borrow().len()
102    }
103
104    pub fn is_empty(&self) -> bool {
105        self.inner.borrow().is_empty()
106    }
107}
108
109#[derive(Clone)]
110pub struct MenuBar {
111    registry: CommandRegistry,
112}
113
114impl MenuBar {
115    pub fn new(registry: CommandRegistry) -> Self {
116        Self { registry }
117    }
118
119    pub fn registry(&self) -> CommandRegistry {
120        self.registry.clone()
121    }
122}
123
124impl View for MenuBar {
125    fn body(&self) -> impl View + 'static {
126        Unit
127    }
128
129    fn children_override(&self) -> Option<Vec<AnyView>> {
130        let mut row = Row::new().gap(2.0);
131        for cat in self.registry.categories() {
132            let mut menu = FloatingMenu::wrap(Button::new(cat.clone()))
133                .below_invoker()
134                .open_on_click()
135                .popup_width(240.0);
136            for cmd in self.registry.commands_in(&cat) {
137                let title = cmd.title.clone();
138                let shortcut = cmd.shortcut.clone().unwrap_or_default();
139                let action = cmd.action.clone();
140                menu = menu.shortcut_row("", title, shortcut, move || action());
141            }
142            row = row.child(menu);
143        }
144        Some(vec![AnyView::new(row)])
145    }
146
147    fn layout_style(&self) -> sequoia_layout::LayoutStyle {
148        sequoia_layout::LayoutStyle {
149            width: Length::Fill(1.0),
150            height: Length::Fit,
151            ..sequoia_layout::LayoutStyle::default()
152        }
153    }
154}
155
156const RESULT_H: f32 = 32.0;
157
158#[derive(Clone)]
159pub struct CommandPalette {
160    registry: CommandRegistry,
161    open: Signal<bool>,
162    query: Signal<String>,
163    selected: Signal<usize>,
164}
165
166impl CommandPalette {
167    pub fn new(registry: CommandRegistry, open: Signal<bool>) -> Self {
168        Self {
169            registry,
170            open,
171            query: Signal::new(String::new()),
172            selected: Signal::new(0),
173        }
174    }
175
176    pub fn query_signal(&self) -> Signal<String> {
177        self.query.clone()
178    }
179
180    pub fn is_open(&self) -> bool {
181        self.open.get_untracked()
182    }
183
184    pub fn close(&self) {
185        self.open.set(false);
186        self.query.set(String::new());
187        self.selected.set(0);
188    }
189
190    fn run_at(&self, index: usize) {
191        let results = self.registry.search(&self.query.get_untracked());
192        if let Some(cmd) = results.get(index) {
193            cmd.run();
194            self.close();
195        }
196    }
197
198    fn run_id(&self, id: &str) {
199        if self.registry.run(id) {
200            self.close();
201        }
202    }
203}
204
205impl View for CommandPalette {
206    fn body(&self) -> impl View + 'static {
207        Unit
208    }
209
210    fn children_override(&self) -> Option<Vec<AnyView>> {
211        let this = self.clone();
212        let submit_this = self.clone();
213        let query = self.query.clone();
214        let open = self.open.clone();
215        let field = TextField::new(query)
216            .placeholder("Enter command...")
217            .on_submit(move |_| {
218                let idx = submit_this.selected.get_untracked();
219                submit_this.run_at(idx);
220            });
221        let content = FocusScope::new().trap(true).child(
222            Column::new()
223                .gap(8.0)
224                .child(field)
225                .child(PaletteResults { palette: this }),
226        );
227        let open2 = self.open.clone();
228        let close_this = self.clone();
229        let layer = DismissableLayer::wrap(content)
230            .bind(open2)
231            .on_escape(move || close_this.close());
232        let modal = Modal::new(open)
233            .width(520.0)
234            .dismiss(Dismiss::OutsideClick)
235            .child(layer);
236        Some(vec![AnyView::new(modal)])
237    }
238
239    fn layout_style(&self) -> sequoia_layout::LayoutStyle {
240        sequoia_layout::LayoutStyle::default()
241    }
242}
243
244#[derive(Clone)]
245struct PaletteResults {
246    palette: CommandPalette,
247}
248
249impl View for PaletteResults {
250    fn body(&self) -> impl View + 'static {
251        Unit
252    }
253
254    fn children_override(&self) -> Option<Vec<AnyView>> {
255        let pal = self.palette.clone();
256        let results = pal.registry.search(&pal.query.get());
257        let _ = pal.selected.get();
258        if results.is_empty() {
259            return Some(vec![AnyView::new(Text::new("No results").size(12.0))]);
260        }
261
262        let list = VirtualList::new(move || pal.registry.search(&pal.query.get_untracked()))
263            .key(|c: &Command| {
264                let mut h = 0u64;
265                for b in c.id.bytes() {
266                    h = h.wrapping_mul(31).wrapping_add(b as u64);
267                }
268                h
269            })
270            .item({
271                let pal = self.palette.clone();
272                move |c: &Command| {
273                    let p = pal.clone();
274                    let id = c.id.clone();
275                    let label = if c.shortcut.as_deref().unwrap_or("").is_empty() {
276                        c.title.clone()
277                    } else {
278                        format!("{}  ({})", c.title, c.shortcut.clone().unwrap_or_default())
279                    };
280                    Button::new(label).on_click(move || p.run_id(&id))
281                }
282            })
283            .item_height(RESULT_H);
284        Some(vec![AnyView::new(list)])
285    }
286
287    fn layout_style(&self) -> sequoia_layout::LayoutStyle {
288        sequoia_layout::LayoutStyle {
289            width: Length::Fill(1.0),
290            height: Length::Fill(1.0),
291            ..sequoia_layout::LayoutStyle::default()
292        }
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn search_matches_title_and_category_case_insensitive() {
302        let r = CommandRegistry::new();
303        r.register("a", "Zapisz", "Plik", Some("Ctrl+S"), || {});
304        r.register("b", "Open", "Plik", None::<String>, || {});
305        r.register("c", "Cofnij", "Edycja", None::<String>, || {});
306        assert_eq!(r.search("").len(), 3);
307        assert_eq!(r.search("zap").len(), 1);
308        assert_eq!(r.search("PLIK").len(), 2);
309        assert_eq!(r.search("zzz").len(), 0);
310    }
311
312    #[test]
313    fn run_returns_false_for_unknown_id() {
314        let r = CommandRegistry::new();
315        let n = Rc::new(std::cell::Cell::new(0));
316        let n2 = n.clone();
317        r.register("x", "X", "K", None::<String>, move || n2.set(n2.get() + 1));
318        assert!(r.run("x"));
319        assert_eq!(n.get(), 1);
320        assert!(!r.run("nope"));
321    }
322
323    #[test]
324    fn categories_keep_first_seen_order() {
325        let r = CommandRegistry::new();
326        r.register("1", "A", "Plik", None::<String>, || {});
327        r.register("2", "B", "Edycja", None::<String>, || {});
328        r.register("3", "C", "Plik", None::<String>, || {});
329        assert_eq!(
330            r.categories(),
331            vec!["Plik".to_string(), "Edycja".to_string()]
332        );
333        assert_eq!(r.commands_in("Plik").len(), 2);
334    }
335}