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

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>
This commit is contained in:
Bennet Bo Fenner 2024-04-11 13:09:21 +02:00 committed by GitHub
parent eb6f7c1240
commit fef0516f5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 117 additions and 37 deletions

View file

@ -58,7 +58,7 @@ pub struct ParsedMarkdownListItem {
#[cfg_attr(test, derive(PartialEq))] #[cfg_attr(test, derive(PartialEq))]
pub enum ParsedMarkdownListItemType { pub enum ParsedMarkdownListItemType {
Ordered(u64), Ordered(u64),
Task(bool), Task(bool, Range<usize>),
Unordered, Unordered,
} }

View file

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

View file

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

View file

@ -5,17 +5,22 @@ use crate::markdown_elements::{
}; };
use gpui::{ use gpui::{
div, px, rems, AbsoluteLength, AnyElement, DefiniteLength, Div, Element, ElementId, div, px, rems, AbsoluteLength, AnyElement, DefiniteLength, Div, Element, ElementId,
HighlightStyle, Hsla, InteractiveText, IntoElement, ParentElement, SharedString, Styled, HighlightStyle, Hsla, InteractiveText, IntoElement, Keystroke, Modifiers, ParentElement,
StyledText, TextStyle, WeakView, WindowContext, SharedString, Styled, StyledText, TextStyle, WeakView, WindowContext,
}; };
use std::{ use std::{
ops::{Mul, Range}, ops::{Mul, Range},
sync::Arc, sync::Arc,
}; };
use theme::{ActiveTheme, SyntaxTheme}; 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; use workspace::Workspace;
type CheckboxClickedCallback = Arc<Box<dyn Fn(bool, Range<usize>, &mut WindowContext)>>;
pub struct RenderContext { pub struct RenderContext {
workspace: Option<WeakView<Workspace>>, workspace: Option<WeakView<Workspace>>,
next_id: usize, next_id: usize,
@ -27,6 +32,7 @@ pub struct RenderContext {
code_span_background_color: Hsla, code_span_background_color: Hsla,
syntax_theme: Arc<SyntaxTheme>, syntax_theme: Arc<SyntaxTheme>,
indent: usize, indent: usize,
checkbox_clicked_callback: Option<CheckboxClickedCallback>,
} }
impl RenderContext { impl RenderContext {
@ -44,9 +50,18 @@ impl RenderContext {
text_muted_color: theme.colors().text_muted, text_muted_color: theme.colors().text_muted,
code_block_background_color: theme.colors().surface_background, code_block_background_color: theme.colors().surface_background,
code_span_background_color: theme.colors().editor_document_highlight_read_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 { fn next_id(&mut self, span: &Range<usize>) -> ElementId {
let id = format!("markdown-{}-{}-{}", self.next_id, span.start, span.end); let id = format!("markdown-{}-{}-{}", self.next_id, span.start, span.end);
self.next_id += 1; self.next_id += 1;
@ -138,19 +153,53 @@ fn render_markdown_list(parsed: &ParsedMarkdownList, cx: &mut RenderContext) ->
for item in &parsed.children { for item in &parsed.children {
let padding = rems((item.depth - 1) as f32 * 0.25); 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(), Ordered(order) => format!("{}.", order).into_any_element(),
Unordered => "".into_any_element(), Unordered => "".into_any_element(),
Task(checked) => div() Task(checked, range) => div()
.id(cx.next_id(range))
.mt(px(3.)) .mt(px(3.))
.child(Checkbox::new( .child(
Checkbox::new(
"checkbox", "checkbox",
if checked { if *checked {
Selection::Selected Selection::Selected
} else { } else {
Selection::Unselected 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(), .into_any_element(),
}; };
let bullet = div().mr_2().child(bullet); let bullet = div().mr_2().child(bullet);