Skip to main content

editor/
slash.rs

1//! The slash menu: `/` at an empty block, then the rows the app installed with
2//! [`crate::AppExt::set_slash_items`], or [`defaults`] until it does.
3//!
4//! The editor keeps focus while the menu is open and the query is the text
5//! typed after the `/`, which is how Notion does it and why there is no second
6//! field to hand focus to.
7
8use std::rc::Rc;
9
10use gpui::{App, Global, SharedString, WeakEntity, Window};
11use ui::{
12    icons::{Icon, glyph},
13    menu::{self, Item},
14    popover::filter_indices,
15};
16
17use markdown::{Align, BlockKind, Cursor, QuoteKind, Text};
18
19/// Every block a block can be turned into, and what each is called.
20///
21/// A bookmark is deliberately absent: it needs a URL, and a card with none is a
22/// blank the reader cannot fill. A pasted URL is where a bookmark comes from.
23/// An image is here because it *can* wait — with no URL it paints the row that
24/// asks for one.
25pub fn items() -> Vec<(SharedString, BlockKind)> {
26    let text = Text::default;
27    vec![
28        ("Text".into(), BlockKind::Paragraph(text())),
29        (
30            "Heading 1".into(),
31            BlockKind::Heading {
32                level: 1,
33                text: text(),
34            },
35        ),
36        (
37            "Heading 2".into(),
38            BlockKind::Heading {
39                level: 2,
40                text: text(),
41            },
42        ),
43        (
44            "Heading 3".into(),
45            BlockKind::Heading {
46                level: 3,
47                text: text(),
48            },
49        ),
50        ("Bullet".into(), BlockKind::Bullet(text())),
51        (
52            "Numbered".into(),
53            BlockKind::Ordered {
54                number: 1,
55                text: text(),
56            },
57        ),
58        (
59            "Task".into(),
60            BlockKind::Task {
61                checked: false,
62                text: text(),
63            },
64        ),
65        (
66            "Quote".into(),
67            BlockKind::Quote {
68                kind: None,
69                text: text(),
70            },
71        ),
72        (
73            "Quote (Note)".into(),
74            BlockKind::Quote {
75                kind: Some(QuoteKind::Note),
76                text: text(),
77            },
78        ),
79        (
80            "Quote (Tip)".into(),
81            BlockKind::Quote {
82                kind: Some(QuoteKind::Tip),
83                text: text(),
84            },
85        ),
86        (
87            "Quote (Important)".into(),
88            BlockKind::Quote {
89                kind: Some(QuoteKind::Important),
90                text: text(),
91            },
92        ),
93        (
94            "Quote (Warning)".into(),
95            BlockKind::Quote {
96                kind: Some(QuoteKind::Warning),
97                text: text(),
98            },
99        ),
100        (
101            "Quote (Caution)".into(),
102            BlockKind::Quote {
103                kind: Some(QuoteKind::Caution),
104                text: text(),
105            },
106        ),
107        (
108            "Code".into(),
109            BlockKind::Code {
110                language: None,
111                code: text(),
112                height: None,
113            },
114        ),
115        (
116            "Table".into(),
117            BlockKind::Table {
118                align: vec![Align::Left; 2],
119                header: vec![text(), text()],
120                rows: vec![vec![text(), text()]],
121            },
122        ),
123        (
124            "Image".into(),
125            BlockKind::Image {
126                url: String::new(),
127                alt: text(),
128                width: None,
129            },
130        ),
131        ("Divider".into(), BlockKind::Rule),
132    ]
133}
134
135/// What a [`SlashAction::Run`] row calls.
136pub type SlashRun = Rc<dyn Fn(SlashAt, &mut Window, &mut App)>;
137
138/// What picking a slash or block menu row does.
139#[derive(Clone)]
140pub enum SlashAction {
141    /// Turn the block the row was picked for into this one.
142    Block(BlockKind),
143    /// Hand the block to the app, after the menu's own update ends. From the
144    /// slash menu the `/query` is gone from the block by then; what the block
145    /// becomes, and where the caret goes, is the app's.
146    Run(SlashRun),
147}
148
149/// The block a [`SlashAction::Run`] row was picked for.
150#[derive(Clone)]
151pub struct SlashAt {
152    pub editor: WeakEntity<crate::Editor>,
153    pub block: usize,
154}
155
156/// One row of the slash menu.
157#[derive(Clone)]
158pub struct SlashRow {
159    pub label: SharedString,
160    pub icon: Option<Icon>,
161    pub action: SlashAction,
162}
163
164/// An item of the slash or block menu.
165#[derive(Clone)]
166pub enum SlashItem {
167    /// A section's name over the items after it, shown while the query is
168    /// empty.
169    Heading(SharedString),
170    Row(SlashRow),
171    /// Rows behind a submenu. A query matches them as `Group (row)`; several
172    /// matches stay behind the submenu, and a lone one stands as its own row.
173    Group {
174        label: SharedString,
175        icon: Option<Icon>,
176        rows: Vec<SlashRow>,
177    },
178}
179
180/// The editor's blocks as slash items, each under its glyph, the quotes and
181/// callouts behind one `Quote` group.
182pub fn defaults() -> Vec<SlashItem> {
183    let mut defaults = Vec::new();
184    for (label, kind) in items() {
185        let row = SlashRow {
186            label,
187            icon: Some(glyph_of(&kind)),
188            action: SlashAction::Block(kind.clone()),
189        };
190        if !matches!(kind, BlockKind::Quote { .. }) {
191            defaults.push(SlashItem::Row(row));
192            continue;
193        }
194        let row = match row.label.strip_prefix("Quote (") {
195            Some(rest) => SlashRow {
196                label: rest.trim_end_matches(')').to_owned().into(),
197                ..row
198            },
199            None => row,
200        };
201        match defaults.last_mut() {
202            Some(SlashItem::Group { rows, .. }) => rows.push(row),
203            _ => defaults.push(SlashItem::Group {
204                label: "Quote".into(),
205                icon: Some(glyph::TextQuote.into()),
206                rows: vec![row],
207            }),
208        }
209    }
210    defaults
211}
212
213/// The glyph a block's slash row carries.
214fn glyph_of(kind: &BlockKind) -> Icon {
215    match kind {
216        BlockKind::Paragraph(_) => glyph::Pilcrow.into(),
217        BlockKind::Heading { level: 1, .. } => glyph::Heading1.into(),
218        BlockKind::Heading { level: 2, .. } => glyph::Heading2.into(),
219        BlockKind::Heading { .. } => glyph::Heading3.into(),
220        BlockKind::Bullet(_) => glyph::List.into(),
221        BlockKind::Ordered { .. } => glyph::ListOrdered.into(),
222        BlockKind::Task { .. } => glyph::ListTodo.into(),
223        BlockKind::Quote { kind, .. } => match kind {
224            None => glyph::TextQuote.into(),
225            Some(QuoteKind::Note) => glyph::Info.into(),
226            Some(QuoteKind::Tip) => glyph::Lightbulb.into(),
227            Some(QuoteKind::Important) => glyph::MessageSquareWarning.into(),
228            Some(QuoteKind::Warning) => glyph::TriangleAlert.into(),
229            Some(QuoteKind::Caution) => glyph::OctagonAlert.into(),
230        },
231        BlockKind::Code { .. } => glyph::SquareCode.into(),
232        BlockKind::Table { .. } => glyph::Table.into(),
233        BlockKind::Image { .. } => glyph::Image.into(),
234        _ => glyph::SeparatorHorizontal.into(),
235    }
236}
237
238/// What the app installed.
239pub(crate) struct Installed(pub Vec<SlashItem>);
240
241impl Global for Installed {}
242
243/// The items the app installed, or [`defaults`].
244pub(crate) fn installed(cx: &App) -> Vec<SlashItem> {
245    cx.try_global::<Installed>()
246        .map_or_else(defaults, |Installed(items)| items.clone())
247}
248
249/// One row the open menu can offer: what a query matches, what a submenu
250/// shows, the section and group it sits in, that group's glyph, and what
251/// picking it does.
252#[derive(Clone)]
253struct Entry {
254    label: SharedString,
255    short: SharedString,
256    /// The heading above it, numbered so two sections of one name stay apart.
257    section: Option<(usize, SharedString)>,
258    group: Option<(SharedString, Option<Icon>)>,
259    icon: Option<Icon>,
260    action: SlashAction,
261}
262
263/// The installed items, flattened.
264fn entries(items: Vec<SlashItem>) -> Vec<Entry> {
265    let mut entries = Vec::new();
266    let mut section = None;
267    for (ix, item) in items.into_iter().enumerate() {
268        match item {
269            SlashItem::Heading(label) => section = Some((ix, label)),
270            SlashItem::Row(row) => entries.push(Entry {
271                label: row.label.clone(),
272                short: row.label,
273                section: section.clone(),
274                group: None,
275                icon: row.icon,
276                action: row.action,
277            }),
278            SlashItem::Group { label, icon, rows } => {
279                entries.extend(rows.into_iter().map(|row| Entry {
280                    label: match row.label == label {
281                        true => label.clone(),
282                        false => format!("{label} ({})", row.label).into(),
283                    },
284                    short: row.label,
285                    section: section.clone(),
286                    group: Some((label.clone(), icon.clone())),
287                    icon: row.icon,
288                    action: row.action,
289                }))
290            }
291        }
292    }
293    entries
294}
295
296/// The [`SlashAction::Block`] rows of `items`, groups kept, without headings.
297pub(crate) fn turns(items: Vec<SlashItem>) -> Vec<SlashItem> {
298    let turns = |row: &SlashRow| matches!(row.action, SlashAction::Block(_));
299    items
300        .into_iter()
301        .filter_map(|item| match item {
302            SlashItem::Row(row) => turns(&row).then_some(SlashItem::Row(row)),
303            SlashItem::Group { label, icon, rows } => {
304                let rows: Vec<SlashRow> = rows.into_iter().filter(turns).collect();
305                (!rows.is_empty()).then_some(SlashItem::Group { label, icon, rows })
306            }
307            SlashItem::Heading(_) => None,
308        })
309        .collect()
310}
311
312/// What [`items`] calls this block, and `None` for one the menu does not offer
313/// — a bookmark, which needs a URL nobody can type into a menu row.
314///
315/// Matched on the kind alone: a row spells a heading's level and nothing else,
316/// so a numbered list at 7, a fence tagged `rs` and a table of any size are all
317/// the row they came from.
318pub fn label(kind: &BlockKind) -> Option<SharedString> {
319    items()
320        .into_iter()
321        .find(|(_, row)| same(row, kind))
322        .map(|(label, _)| label)
323}
324
325fn same(row: &BlockKind, kind: &BlockKind) -> bool {
326    match (row, kind) {
327        (BlockKind::Heading { level: a, .. }, BlockKind::Heading { level: b, .. }) => a == b,
328        (BlockKind::Quote { kind: a, .. }, BlockKind::Quote { kind: b, .. }) => a == b,
329        (row, kind) => std::mem::discriminant(row) == std::mem::discriminant(kind),
330    }
331}
332
333/// A row of the open menu: a heading, one entry, or a group of them behind a
334/// submenu. Indices are into the menu's entries.
335enum Row {
336    Heading(SharedString),
337    Block(usize),
338    Group(SharedString, Option<Icon>, Vec<usize>),
339}
340
341/// Installed items as the rows of an open menu.
342pub struct Rows {
343    rows: Vec<Row>,
344    /// Read once at open.
345    entries: Vec<Entry>,
346}
347
348impl Rows {
349    pub fn new(items: Vec<SlashItem>) -> Self {
350        let mut rows = Self {
351            rows: Vec::new(),
352            entries: entries(items),
353        };
354        rows.refilter("");
355        rows
356    }
357
358    /// A group sits behind one row where its first entry is. A query ranks
359    /// the entries, and a group's row stands where its best match does,
360    /// holding the matches in rank order — or, holding one, is that row.
361    /// Headings stand only over an unranked menu.
362    pub fn refilter(&mut self, query: &str) {
363        let order: Vec<usize> = if query.is_empty() {
364            (0..self.entries.len()).collect()
365        } else {
366            let labels: Vec<SharedString> = self
367                .entries
368                .iter()
369                .map(|entry| entry.label.clone())
370                .collect();
371            filter_indices(query, &labels)
372        };
373        let mut rows: Vec<Row> = Vec::new();
374        let mut section = None;
375        for ix in order {
376            let entry = &self.entries[ix];
377            if query.is_empty() && entry.section.as_ref().map(|(at, _)| *at) != section {
378                section = entry.section.as_ref().map(|(at, _)| *at);
379                if let Some((_, label)) = &entry.section {
380                    rows.push(Row::Heading(label.clone()));
381                }
382            }
383            let Some((group, icon)) = &entry.group else {
384                rows.push(Row::Block(ix));
385                continue;
386            };
387            match rows
388                .iter_mut()
389                .find(|row| matches!(row, Row::Group(label, ..) if label == group))
390            {
391                Some(Row::Group(_, _, held)) => held.push(ix),
392                _ => rows.push(Row::Group(group.clone(), icon.clone(), vec![ix])),
393            }
394        }
395        self.rows = rows
396            .into_iter()
397            .map(|row| match row {
398                Row::Group(_, _, held) if !query.is_empty() && held.len() == 1 => {
399                    Row::Block(held[0])
400                }
401                row => row,
402            })
403            .collect();
404    }
405
406    /// The rows as [`ui::menu::card`] paints them.
407    pub fn menu(&self) -> Vec<Item> {
408        self.rows
409            .iter()
410            .map(|row| match row {
411                Row::Heading(label) => Item::Heading(label.clone()),
412                Row::Block(ix) => self.item(*ix, &self.entries[*ix].label),
413                Row::Group(label, icon, group) => {
414                    let submenu = Item::submenu(
415                        label.clone(),
416                        group
417                            .iter()
418                            .map(|ix| self.item(*ix, &self.entries[*ix].short))
419                            .collect(),
420                    );
421                    match icon.clone() {
422                        Some(icon) => submenu.with_icon(icon),
423                        None => submenu,
424                    }
425                }
426            })
427            .collect()
428    }
429
430    fn item(&self, ix: usize, label: &SharedString) -> Item {
431        let item = Item::action(label.clone());
432        match self.entries[ix].icon.clone() {
433            Some(icon) => item.with_icon(icon),
434            None => item,
435        }
436    }
437
438    /// Whether the row at `path` is a group, which Enter opens rather than
439    /// picks.
440    fn is_group(&self, path: &[usize]) -> bool {
441        matches!(
442            (path.first().and_then(|row| self.rows.get(*row)), path.len()),
443            (Some(Row::Group(..)), 1)
444        )
445    }
446
447    /// What the row at `path` does, and `None` for a heading or a group.
448    pub fn action_at(&self, path: &[usize]) -> Option<SlashAction> {
449        let ix = match (self.rows.get(*path.first()?)?, path.get(1)) {
450            (Row::Block(ix), None) => *ix,
451            (Row::Group(_, _, group), Some(row)) => *group.get(*row)?,
452            _ => return None,
453        };
454        self.entries.get(ix).map(|entry| entry.action.clone())
455    }
456}
457
458/// An open menu: where the `/` sits, and the rows under it.
459pub struct Slash {
460    /// The `/` itself. Everything between it and the caret is the query, and
461    /// backspacing onto it closes the menu.
462    pub at: Cursor,
463    rows: Rows,
464    pub cursor: menu::Cursor,
465}
466
467impl Slash {
468    pub fn open(at: Cursor, items: Vec<SlashItem>) -> Self {
469        let mut slash = Self {
470            at,
471            rows: Rows::new(items),
472            cursor: menu::Cursor::default(),
473        };
474        slash.refilter("");
475        slash
476    }
477
478    pub fn refilter(&mut self, query: &str) {
479        self.rows.refilter(query);
480        self.cursor.clear();
481        self.cursor.step(&self.menu(), 1);
482    }
483
484    pub fn menu(&self) -> Vec<Item> {
485        self.rows.menu()
486    }
487
488    /// Walk the rows of the innermost open panel.
489    pub fn step(&mut self, delta: isize) {
490        let menu = self.menu();
491        self.cursor.step(&menu, delta);
492    }
493
494    /// Open the group under the live row. `false` when it is not one.
495    pub fn descend(&mut self) -> bool {
496        let menu = self.menu();
497        self.cursor.descend(&menu)
498    }
499
500    /// Whether the live row is a group, which Enter opens rather than picks.
501    pub fn on_group(&self) -> bool {
502        self.cursor
503            .path()
504            .is_some_and(|path| self.rows.is_group(&path))
505    }
506
507    pub fn action_at(&self, path: &[usize]) -> Option<SlashAction> {
508        self.rows.action_at(path)
509    }
510
511    /// What has been typed since the `/`, or `None` when the caret has left
512    /// the run entirely — which is what closes the menu.
513    pub fn query(&self, caret: Cursor, text: &str) -> Option<String> {
514        if caret.block != self.at.block || caret.part != self.at.part {
515            return None;
516        }
517        let start = self.at.offset + 1;
518        if caret.offset < start {
519            return None;
520        }
521        let query = text.get(start..caret.offset)?;
522        // A space ends it: `/ ` is a stray slash, not a command.
523        (!query.contains(char::is_whitespace)).then(|| query.to_string())
524    }
525}