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