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
18pub 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 pub hide_disabled: bool,
44}
45
46#[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#[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 fn action_is_enabled(&self, action: &ActionId) -> bool;
215
216 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}