markdown preview: Allow toggling checkbox by click (#10364)

Bennet Bo Fenner and Remco Smits created

Release Notes:

- Added support for toggling a checkbox in markdown preview by clicking
on it (cmd+click)
([#5226](https://github.com/zed-industries/zed/issues/5226)).

---------

Co-authored-by: Remco Smits <62463826+RemcoSmitsDev@users.noreply.github.com>

Change summary

crates/markdown_preview/src/markdown_elements.rs     |  2 
crates/markdown_preview/src/markdown_parser.rs       | 16 +-
crates/markdown_preview/src/markdown_preview_view.rs | 61 ++++++++--
crates/markdown_preview/src/markdown_renderer.rs     | 75 +++++++++++--
4 files changed, 117 insertions(+), 37 deletions(-)

Detailed changes

crates/markdown_preview/src/markdown_parser.rs 🔗

@@ -502,8 +502,8 @@ impl<'a> MarkdownParser<'a> {
                             self.cursor += 1;
                         }
 
-                        if let Some(Event::TaskListMarker(checked)) = self.current_event() {
-                            task_item = Some(*checked);
+                        if let Some((Event::TaskListMarker(checked), range)) = self.current() {
+                            task_item = Some((*checked, range.clone()));
                             self.cursor += 1;
                         }
                     }
@@ -531,8 +531,8 @@ impl<'a> MarkdownParser<'a> {
                 Event::End(TagEnd::Item) => {
                     self.cursor += 1;
 
-                    let item_type = if let Some(checked) = task_item {
-                        ParsedMarkdownListItemType::Task(checked)
+                    let item_type = if let Some((checked, range)) = task_item {
+                        ParsedMarkdownListItemType::Task(checked, range)
                     } else if let Some(order) = order {
                         ParsedMarkdownListItemType::Ordered(order)
                     } else {
@@ -906,8 +906,8 @@ Some other content
             parsed.children,
             vec![list(
                 vec![
-                    list_item(1, Task(false), vec![p("TODO", 2..5)]),
-                    list_item(1, Task(true), vec![p("Checked", 13..16)]),
+                    list_item(1, Task(false, 2..5), vec![p("TODO", 2..5)]),
+                    list_item(1, Task(true, 13..16), vec![p("Checked", 13..16)]),
                 ],
                 0..25
             ),]
@@ -929,8 +929,8 @@ Some other content
             parsed.children,
             vec![list(
                 vec![
-                    list_item(1, Task(false), vec![p("Task 1", 2..5)]),
-                    list_item(1, Task(true), vec![p("Task 2", 16..19)]),
+                    list_item(1, Task(false, 2..5), vec![p("Task 1", 2..5)]),
+                    list_item(1, Task(true, 16..19), vec![p("Task 2", 16..19)]),
                 ],
                 0..27
             ),]

crates/markdown_preview/src/markdown_preview_view.rs 🔗

@@ -144,12 +144,39 @@ impl MarkdownPreviewView {
             let list_state =
                 ListState::new(0, gpui::ListAlignment::Top, px(1000.), move |ix, cx| {
                     if let Some(view) = view.upgrade() {
-                        view.update(cx, |view, cx| {
-                            let Some(contents) = &view.contents else {
+                        view.update(cx, |this, cx| {
+                            let Some(contents) = &this.contents else {
                                 return div().into_any();
                             };
+
                             let mut render_cx =
-                                RenderContext::new(Some(view.workspace.clone()), cx);
+                                RenderContext::new(Some(this.workspace.clone()), cx)
+                                    .with_checkbox_clicked_callback({
+                                        let view = view.clone();
+                                        move |checked, source_range, cx| {
+                                            view.update(cx, |view, cx| {
+                                                if let Some(editor) = view
+                                                    .active_editor
+                                                    .as_ref()
+                                                    .map(|s| s.editor.clone())
+                                                {
+                                                    editor.update(cx, |editor, cx| {
+                                                        let task_marker =
+                                                            if checked { "[x]" } else { "[ ]" };
+
+                                                        editor.edit(
+                                                            vec![(source_range, task_marker)],
+                                                            cx,
+                                                        );
+                                                    });
+                                                    view.parse_markdown_from_active_editor(
+                                                        false, cx,
+                                                    );
+                                                    cx.notify();
+                                                }
+                                            })
+                                        }
+                                    });
                             let block = contents.children.get(ix).unwrap();
                             let rendered_block = render_markdown_block(block, &mut render_cx);
 
@@ -167,15 +194,15 @@ impl MarkdownPreviewView {
                                         }
                                     }
                                 }))
-                                .map(move |this| {
+                                .map(move |container| {
                                     let indicator = div()
                                         .h_full()
                                         .w(px(4.0))
-                                        .when(ix == view.selected_block, |this| {
+                                        .when(ix == this.selected_block, |this| {
                                             this.bg(cx.theme().colors().border)
                                         })
                                         .group_hover("markdown-block", |s| {
-                                            if ix == view.selected_block {
+                                            if ix == this.selected_block {
                                                 s
                                             } else {
                                                 s.bg(cx.theme().colors().border_variant)
@@ -183,7 +210,7 @@ impl MarkdownPreviewView {
                                         })
                                         .rounded_sm();
 
-                                    this.child(
+                                    container.child(
                                         div()
                                             .relative()
                                             .child(div().pl_4().child(rendered_block))
@@ -262,7 +289,7 @@ impl MarkdownPreviewView {
         let subscription = cx.subscribe(&editor, |this, editor, event: &EditorEvent, cx| {
             match event {
                 EditorEvent::Edited => {
-                    this.on_editor_edited(cx);
+                    this.parse_markdown_from_active_editor(true, cx);
                 }
                 EditorEvent::SelectionsChanged { .. } => {
                     let editor = editor.read(cx);
@@ -285,16 +312,20 @@ impl MarkdownPreviewView {
             _subscription: subscription,
         });
 
-        if let Some(state) = &self.active_editor {
-            self.parsing_markdown_task =
-                Some(self.parse_markdown_in_background(false, state.editor.clone(), cx));
-        }
+        self.parse_markdown_from_active_editor(false, cx);
     }
 
-    fn on_editor_edited(&mut self, cx: &mut ViewContext<Self>) {
+    fn parse_markdown_from_active_editor(
+        &mut self,
+        wait_for_debounce: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
         if let Some(state) = &self.active_editor {
-            self.parsing_markdown_task =
-                Some(self.parse_markdown_in_background(true, state.editor.clone(), cx));
+            self.parsing_markdown_task = Some(self.parse_markdown_in_background(
+                wait_for_debounce,
+                state.editor.clone(),
+                cx,
+            ));
         }
     }
 

crates/markdown_preview/src/markdown_renderer.rs 🔗

@@ -5,17 +5,22 @@ use crate::markdown_elements::{
 };
 use gpui::{
     div, px, rems, AbsoluteLength, AnyElement, DefiniteLength, Div, Element, ElementId,
-    HighlightStyle, Hsla, InteractiveText, IntoElement, ParentElement, SharedString, Styled,
-    StyledText, TextStyle, WeakView, WindowContext,
+    HighlightStyle, Hsla, InteractiveText, IntoElement, Keystroke, Modifiers, ParentElement,
+    SharedString, Styled, StyledText, TextStyle, WeakView, WindowContext,
 };
 use std::{
     ops::{Mul, Range},
     sync::Arc,
 };
 use theme::{ActiveTheme, SyntaxTheme};
-use ui::{h_flex, v_flex, Checkbox, LinkPreview, Selection};
+use ui::{
+    h_flex, v_flex, Checkbox, FluentBuilder, InteractiveElement, LinkPreview, Selection,
+    StatefulInteractiveElement, Tooltip,
+};
 use workspace::Workspace;
 
+type CheckboxClickedCallback = Arc<Box<dyn Fn(bool, Range<usize>, &mut WindowContext)>>;
+
 pub struct RenderContext {
     workspace: Option<WeakView<Workspace>>,
     next_id: usize,
@@ -27,6 +32,7 @@ pub struct RenderContext {
     code_span_background_color: Hsla,
     syntax_theme: Arc<SyntaxTheme>,
     indent: usize,
+    checkbox_clicked_callback: Option<CheckboxClickedCallback>,
 }
 
 impl RenderContext {
@@ -44,9 +50,18 @@ impl RenderContext {
             text_muted_color: theme.colors().text_muted,
             code_block_background_color: theme.colors().surface_background,
             code_span_background_color: theme.colors().editor_document_highlight_read_background,
+            checkbox_clicked_callback: None,
         }
     }
 
+    pub fn with_checkbox_clicked_callback(
+        mut self,
+        callback: impl Fn(bool, Range<usize>, &mut WindowContext) + 'static,
+    ) -> Self {
+        self.checkbox_clicked_callback = Some(Arc::new(Box::new(callback)));
+        self
+    }
+
     fn next_id(&mut self, span: &Range<usize>) -> ElementId {
         let id = format!("markdown-{}-{}-{}", self.next_id, span.start, span.end);
         self.next_id += 1;
@@ -138,19 +153,53 @@ fn render_markdown_list(parsed: &ParsedMarkdownList, cx: &mut RenderContext) ->
     for item in &parsed.children {
         let padding = rems((item.depth - 1) as f32 * 0.25);
 
-        let bullet = match item.item_type {
+        let bullet = match &item.item_type {
             Ordered(order) => format!("{}.", order).into_any_element(),
             Unordered => "•".into_any_element(),
-            Task(checked) => div()
+            Task(checked, range) => div()
+                .id(cx.next_id(range))
                 .mt(px(3.))
-                .child(Checkbox::new(
-                    "checkbox",
-                    if checked {
-                        Selection::Selected
-                    } else {
-                        Selection::Unselected
-                    },
-                ))
+                .child(
+                    Checkbox::new(
+                        "checkbox",
+                        if *checked {
+                            Selection::Selected
+                        } else {
+                            Selection::Unselected
+                        },
+                    )
+                    .when_some(
+                        cx.checkbox_clicked_callback.clone(),
+                        |this, callback| {
+                            this.on_click({
+                                let range = range.clone();
+                                move |selection, cx| {
+                                    let checked = match selection {
+                                        Selection::Selected => true,
+                                        Selection::Unselected => false,
+                                        _ => return,
+                                    };
+
+                                    if cx.modifiers().secondary() {
+                                        callback(checked, range.clone(), cx);
+                                    }
+                                }
+                            })
+                        },
+                    ),
+                )
+                .hover(|s| s.cursor_pointer())
+                .tooltip(|cx| {
+                    let secondary_modifier = Keystroke {
+                        key: "".to_string(),
+                        modifiers: Modifiers::secondary_key(),
+                        ime_key: None,
+                    };
+                    Tooltip::text(
+                        format!("{}-click to toggle the checkbox", secondary_modifier),
+                        cx,
+                    )
+                })
                 .into_any_element(),
         };
         let bullet = div().mr_2().child(bullet);