Skip to main content

fret_ui_kit/
command.rs

1use std::collections::BTreeMap;
2use std::sync::Arc;
3
4use fret_runtime::{
5    ActionId, CommandId, CommandMeta, InputContext, InputDispatchPhase, KeymapService, Platform,
6    PlatformCapabilities, WindowCommandGatingSnapshot, format_sequence,
7};
8use fret_ui::{ElementContext, UiHost};
9
10pub fn default_fallback_input_context<H: UiHost>(app: &H) -> InputContext {
11    let caps = app
12        .global::<PlatformCapabilities>()
13        .cloned()
14        .unwrap_or_default();
15    InputContext::fallback(Platform::current(), caps)
16}
17
18/// Best-effort input context for global command discovery surfaces such as command palettes.
19pub fn command_palette_input_context<H: UiHost>(app: &H) -> InputContext {
20    let caps = app
21        .global::<PlatformCapabilities>()
22        .cloned()
23        .unwrap_or_default();
24    InputContext {
25        platform: Platform::current(),
26        caps,
27        ui_has_modal: true,
28        window_arbitration: None,
29        focus_is_text_input: false,
30        text_boundary_mode: fret_runtime::TextBoundaryMode::UnicodeWord,
31        edit_can_undo: true,
32        edit_can_redo: true,
33        router_can_back: false,
34        router_can_forward: false,
35        dispatch_phase: InputDispatchPhase::Bubble,
36    }
37}
38
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
40pub struct CommandCatalogOptions {
41    /// When `true`, commands that fail their `when` gating are excluded instead of being rendered
42    /// as disabled rows.
43    pub hide_disabled: bool,
44}
45
46/// Data-only command item derived from host command metadata and current window gating state.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct CommandCatalogItem {
49    pub label: Arc<str>,
50    pub value: Arc<str>,
51    pub disabled: bool,
52    pub keywords: Vec<Arc<str>>,
53    pub shortcut: Option<Arc<str>>,
54    pub command: CommandId,
55}
56
57impl CommandCatalogItem {
58    pub fn new(label: impl Into<Arc<str>>, command: impl Into<CommandId>) -> Self {
59        let label = label.into();
60        Self {
61            label,
62            value: Arc::from(""),
63            disabled: false,
64            keywords: Vec::new(),
65            shortcut: None,
66            command: command.into(),
67        }
68    }
69
70    pub fn value(mut self, value: impl Into<Arc<str>>) -> Self {
71        self.value = trimmed_arc(value.into());
72        self
73    }
74
75    pub fn disabled(mut self, disabled: bool) -> Self {
76        self.disabled = disabled;
77        self
78    }
79
80    pub fn keywords<I, S>(mut self, keywords: I) -> Self
81    where
82        I: IntoIterator<Item = S>,
83        S: Into<Arc<str>>,
84    {
85        self.keywords = keywords
86            .into_iter()
87            .map(|keyword| trimmed_arc(keyword.into()))
88            .collect();
89        self
90    }
91
92    pub fn shortcut(mut self, shortcut: impl Into<Arc<str>>) -> Self {
93        self.shortcut = Some(shortcut.into());
94        self
95    }
96}
97
98/// Data-only command catalog group. Group ownership belongs to component-policy layers, not to a
99/// specific recipe crate.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct CommandCatalogGroup {
102    pub heading: Arc<str>,
103    pub items: Vec<CommandCatalogItem>,
104}
105
106impl CommandCatalogGroup {
107    pub fn new(
108        heading: impl Into<Arc<str>>,
109        items: impl IntoIterator<Item = CommandCatalogItem>,
110    ) -> Self {
111        Self {
112            heading: heading.into(),
113            items: items.into_iter().collect(),
114        }
115    }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum CommandCatalogEntry {
120    Item(CommandCatalogItem),
121    Group(CommandCatalogGroup),
122}
123
124pub fn command_catalog_entries_from_host_commands<H: UiHost>(
125    cx: &mut ElementContext<'_, H>,
126) -> Vec<CommandCatalogEntry> {
127    command_catalog_entries_from_host_commands_with_options(cx, CommandCatalogOptions::default())
128}
129
130pub fn command_catalog_entries_from_host_commands_with_options<H: UiHost>(
131    cx: &mut ElementContext<'_, H>,
132    options: CommandCatalogOptions,
133) -> Vec<CommandCatalogEntry> {
134    let fallback_input_ctx = command_palette_input_context(&*cx.app);
135    let snapshot = fret_runtime::best_effort_snapshot_for_window_with_input_ctx_fallback(
136        &*cx.app,
137        cx.window,
138        fallback_input_ctx,
139    );
140
141    let mut input_ctx = snapshot.input_ctx().clone();
142    input_ctx.ui_has_modal = true;
143    input_ctx.focus_is_text_input = false;
144    input_ctx.dispatch_phase = InputDispatchPhase::Bubble;
145
146    let gating = snapshot.with_input_ctx(input_ctx);
147    command_catalog_entries_from_host_commands_with_gating_snapshot(cx, options, &gating)
148}
149
150pub fn command_catalog_entries_from_host_commands_with_gating_snapshot<H: UiHost>(
151    cx: &mut ElementContext<'_, H>,
152    options: CommandCatalogOptions,
153    gating: &WindowCommandGatingSnapshot,
154) -> Vec<CommandCatalogEntry> {
155    let mut commands: Vec<(CommandId, CommandMeta)> = cx
156        .app
157        .commands()
158        .iter()
159        .filter_map(|(id, meta)| (!meta.hidden).then_some((id.clone(), meta.clone())))
160        .collect();
161
162    commands.sort_by(|(a_id, a_meta), (b_id, b_meta)| {
163        match (&a_meta.category, &b_meta.category) {
164            (None, Some(_)) => std::cmp::Ordering::Less,
165            (Some(_), None) => std::cmp::Ordering::Greater,
166            (Some(a), Some(b)) => a.as_ref().cmp(b.as_ref()),
167            (None, None) => std::cmp::Ordering::Equal,
168        }
169        .then_with(|| a_meta.title.as_ref().cmp(b_meta.title.as_ref()))
170        .then_with(|| a_id.as_str().cmp(b_id.as_str()))
171    });
172
173    let mut root_items: Vec<CommandCatalogItem> = Vec::new();
174    let mut groups: BTreeMap<Arc<str>, Vec<CommandCatalogItem>> = BTreeMap::new();
175
176    for (id, meta) in &commands {
177        let disabled = !gating.is_enabled_for_command(id, meta);
178        if disabled && options.hide_disabled {
179            continue;
180        }
181
182        let item = command_catalog_item_from_meta_with_gating(cx, gating, id, meta);
183        if let Some(category) = meta.category.clone() {
184            groups.entry(category).or_default().push(item);
185        } else {
186            root_items.push(item);
187        }
188    }
189
190    let mut entries: Vec<CommandCatalogEntry> = Vec::new();
191    entries.extend(root_items.into_iter().map(CommandCatalogEntry::Item));
192    entries.extend(groups.into_iter().map(|(heading, items)| {
193        CommandCatalogEntry::Group(CommandCatalogGroup::new(heading, items))
194    }));
195    entries
196}
197
198pub trait ElementCommandGatingExt {
199    fn command_is_enabled(&self, command: &CommandId) -> bool;
200    fn command_is_enabled_with_fallback_input_context(
201        &self,
202        command: &CommandId,
203        fallback_input_ctx: InputContext,
204    ) -> bool;
205
206    fn dispatch_command_if_enabled(&mut self, command: CommandId) -> bool;
207    fn dispatch_command_if_enabled_with_fallback_input_context(
208        &mut self,
209        command: CommandId,
210        fallback_input_ctx: InputContext,
211    ) -> bool;
212
213    /// Action-first naming parity: `ActionId` uses the same ID strings as `CommandId` in v1.
214    fn action_is_enabled(&self, action: &ActionId) -> bool;
215
216    /// Action-first naming parity: dispatch an `ActionId` if enabled.
217    fn dispatch_action_if_enabled(&mut self, action: ActionId) -> bool;
218}
219
220impl<H: UiHost> ElementCommandGatingExt for ElementContext<'_, H> {
221    fn command_is_enabled(&self, command: &CommandId) -> bool {
222        let fallback_input_ctx = default_fallback_input_context(&*self.app);
223        fret_runtime::command_is_enabled_for_window_with_input_ctx_fallback(
224            &*self.app,
225            self.window,
226            command,
227            fallback_input_ctx,
228        )
229    }
230
231    fn command_is_enabled_with_fallback_input_context(
232        &self,
233        command: &CommandId,
234        fallback_input_ctx: InputContext,
235    ) -> bool {
236        fret_runtime::command_is_enabled_for_window_with_input_ctx_fallback(
237            &*self.app,
238            self.window,
239            command,
240            fallback_input_ctx,
241        )
242    }
243
244    fn dispatch_command_if_enabled(&mut self, command: CommandId) -> bool {
245        let fallback_input_ctx = default_fallback_input_context(&*self.app);
246        self.dispatch_command_if_enabled_with_fallback_input_context(command, fallback_input_ctx)
247    }
248
249    fn dispatch_command_if_enabled_with_fallback_input_context(
250        &mut self,
251        command: CommandId,
252        fallback_input_ctx: InputContext,
253    ) -> bool {
254        if !fret_runtime::command_is_enabled_for_window_with_input_ctx_fallback(
255            &*self.app,
256            self.window,
257            &command,
258            fallback_input_ctx,
259        ) {
260            return false;
261        }
262        self.app.push_effect(fret_runtime::Effect::Command {
263            window: Some(self.window),
264            command,
265        });
266        true
267    }
268
269    fn action_is_enabled(&self, action: &ActionId) -> bool {
270        self.command_is_enabled(action)
271    }
272
273    fn dispatch_action_if_enabled(&mut self, action: ActionId) -> bool {
274        self.dispatch_command_if_enabled(action)
275    }
276}
277
278fn command_catalog_item_from_meta_with_gating<H: UiHost>(
279    cx: &mut ElementContext<'_, H>,
280    gating: &WindowCommandGatingSnapshot,
281    id: &CommandId,
282    meta: &CommandMeta,
283) -> CommandCatalogItem {
284    let input_ctx = gating.input_ctx();
285
286    let mut keywords: Vec<Arc<str>> = meta.keywords.clone();
287    keywords.push(Arc::from(id.as_str()));
288    if let Some(category) = meta.category.as_ref() {
289        keywords.push(category.clone());
290    }
291    if let Some(description) = meta.description.as_ref() {
292        keywords.push(description.clone());
293    }
294
295    let shortcut = cx
296        .app
297        .global::<KeymapService>()
298        .and_then(|svc| {
299            svc.keymap
300                .display_shortcut_for_command_sequence_with_key_contexts(
301                    input_ctx,
302                    gating.key_contexts(),
303                    id,
304                )
305        })
306        .map(|seq| Arc::from(format_sequence(input_ctx.platform, &seq)));
307
308    let mut item = CommandCatalogItem::new(meta.title.clone(), id.clone())
309        .value(Arc::from(id.as_str()))
310        .keywords(keywords)
311        .disabled(!gating.is_enabled_for_command(id, meta));
312    if let Some(shortcut) = shortcut {
313        item = item.shortcut(shortcut);
314    }
315    item
316}
317
318fn trimmed_arc(value: Arc<str>) -> Arc<str> {
319    let trimmed = value.trim();
320    if trimmed == value.as_ref() {
321        value
322    } else {
323        Arc::<str>::from(trimmed)
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    use std::collections::HashMap;
332
333    use fret_app::App;
334    use fret_core::{AppWindowId, Point, Px, Rect, Size};
335    use fret_runtime::{
336        CommandScope, WindowCommandActionAvailabilityService, WindowCommandEnabledService,
337    };
338
339    fn bounds() -> Rect {
340        Rect::new(
341            Point::new(Px(0.0), Px(0.0)),
342            Size::new(Px(400.0), Px(240.0)),
343        )
344    }
345
346    fn find_item<'a>(
347        entries: &'a [CommandCatalogEntry],
348        command: &CommandId,
349    ) -> Option<&'a CommandCatalogItem> {
350        entries.iter().find_map(|entry| match entry {
351            CommandCatalogEntry::Item(item) if &item.command == command => Some(item),
352            CommandCatalogEntry::Group(group) => {
353                group.items.iter().find(|item| &item.command == command)
354            }
355            _ => None,
356        })
357    }
358
359    #[test]
360    fn host_command_entries_respect_window_command_enabled_overrides() {
361        let window = AppWindowId::default();
362        let mut app = App::new();
363
364        let cmd = CommandId::from("test.disabled-command");
365        app.commands_mut()
366            .register(cmd.clone(), CommandMeta::new("Disabled Command"));
367        app.set_global(WindowCommandEnabledService::default());
368        app.with_global_mut(WindowCommandEnabledService::default, |svc, _app| {
369            svc.set_enabled(window, cmd.clone(), false);
370        });
371
372        let entries =
373            fret_ui::elements::with_element_cx(&mut app, window, bounds(), "cmdk", |cx| {
374                command_catalog_entries_from_host_commands(cx)
375            });
376        let item = find_item(&entries, &cmd).expect("catalog item");
377        assert!(
378            item.disabled,
379            "expected the command entry to be disabled via WindowCommandEnabledService"
380        );
381    }
382
383    #[test]
384    fn host_command_entries_respect_widget_action_availability_snapshot() {
385        let window = AppWindowId::default();
386        let mut app = App::new();
387
388        let cmd = CommandId::from("test.widget-action");
389        app.commands_mut().register(
390            cmd.clone(),
391            CommandMeta::new("Widget Action").with_scope(CommandScope::Widget),
392        );
393
394        app.set_global(WindowCommandActionAvailabilityService::default());
395        app.with_global_mut(
396            WindowCommandActionAvailabilityService::default,
397            |svc, _app| {
398                let mut snapshot: HashMap<CommandId, bool> = HashMap::new();
399                snapshot.insert(cmd.clone(), false);
400                svc.set_snapshot(window, snapshot);
401            },
402        );
403
404        let entries =
405            fret_ui::elements::with_element_cx(&mut app, window, bounds(), "cmdk", |cx| {
406                command_catalog_entries_from_host_commands(cx)
407            });
408        let item = find_item(&entries, &cmd).expect("catalog item");
409        assert!(
410            item.disabled,
411            "expected the command entry to be disabled via WindowCommandActionAvailabilityService"
412        );
413    }
414
415    #[test]
416    fn host_command_entries_prefer_window_command_gating_snapshot_when_present() {
417        let window = AppWindowId::default();
418        let mut app = App::new();
419
420        let cmd = CommandId::from("test.widget-action");
421        app.commands_mut().register(
422            cmd.clone(),
423            CommandMeta::new("Widget Action").with_scope(CommandScope::Widget),
424        );
425
426        app.set_global(WindowCommandActionAvailabilityService::default());
427        app.with_global_mut(
428            WindowCommandActionAvailabilityService::default,
429            |svc, _app| {
430                let mut snapshot: HashMap<CommandId, bool> = HashMap::new();
431                snapshot.insert(cmd.clone(), true);
432                svc.set_snapshot(window, snapshot);
433            },
434        );
435
436        app.set_global(fret_runtime::WindowCommandGatingService::default());
437        app.with_global_mut(
438            fret_runtime::WindowCommandGatingService::default,
439            |svc, app| {
440                let input_ctx = command_palette_input_context(app);
441                let enabled_overrides: HashMap<CommandId, bool> = HashMap::new();
442                let mut availability: HashMap<CommandId, bool> = HashMap::new();
443                availability.insert(cmd.clone(), false);
444                svc.set_snapshot(
445                    window,
446                    WindowCommandGatingSnapshot::new(input_ctx, enabled_overrides)
447                        .with_action_availability(Some(Arc::new(availability))),
448                );
449            },
450        );
451
452        let entries =
453            fret_ui::elements::with_element_cx(&mut app, window, bounds(), "cmdk", |cx| {
454                command_catalog_entries_from_host_commands(cx)
455            });
456        let item = find_item(&entries, &cmd).expect("catalog item");
457        assert!(
458            item.disabled,
459            "expected the command entry to be disabled via WindowCommandGatingService snapshot"
460        );
461    }
462}