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