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}