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