diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index d260422..bae2f59 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -40,7 +40,9 @@ code_view = [ ] diff_view = ["code_view"] markdown_view = [ - "code_view", + "dep:gpui-native-core", + "gpui-native-core/button", + "gpui-native-core/scrollbar", "dep:pulldown-cmark", "dep:gpui-native-icons", ] diff --git a/crates/editor/src/markdown.rs b/crates/editor/src/markdown.rs index 25cadc2..992d2e3 100644 --- a/crates/editor/src/markdown.rs +++ b/crates/editor/src/markdown.rs @@ -1,24 +1,32 @@ //! The markdown view: a read-only rendering of a markdown document as //! formatted text. Parsing runs once per source change and produces blocks; //! rendering virtualizes the visible blocks and resolves every style from -//! the theme at draw time. Images, footnotes, math, and HTML render nothing; -//! long code lines clip rather than scroll. +//! the theme at draw time. Images render inline or as standalone blocks; +//! text, code, and tables drag-select and copy; task checkboxes report +//! toggles and code blocks offer a copy action. Footnotes, math, and HTML +//! render nothing; long code lines clip rather than scroll. use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, Context, Div, ElementId, FontStyle, FontWeight, HighlightStyle, - InteractiveElement as _, InteractiveText, IntoElement, ListAlignment, ListOffset, ListState, - ParentElement as _, Pixels, Render, SharedString, StatefulInteractiveElement as _, - StrikethroughStyle, Styled as _, StyledText, UnderlineStyle, Window, div, list, px, relative, + AnyElement, App, Bounds, ClipboardItem, Context, Div, Element, ElementId, Entity, FontStyle, + FontWeight, GlobalElementId, HighlightStyle, InspectorElementId, InteractiveElement as _, + InteractiveText, IntoElement, KeyBinding, LayoutId, ListAlignment, ListOffset, ListState, + MouseButton, MouseDownEvent, MouseMoveEvent, ObjectFit, ParentElement as _, Pixels, + Refineable as _, Render, SharedString, StatefulInteractiveElement as _, StrikethroughStyle, + StyleRefinement, Styled, StyledImage as _, StyledText, TextLayout, UnderlineStyle, Window, + actions, div, img, list, px, relative, }; +use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::scrollbar::{Scrollbar, ScrollbarVisibility}; use gpui_native_icons::{IconRole, icon}; use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, CODE_OVERSCROLL_MAX}; use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; +use std::borrow::Borrow as _; use std::collections::HashMap; use std::hash::{DefaultHasher, Hash, Hasher}; use std::ops::Range; +use std::sync::Arc; use crate::highlight::{HighlightedCode, Language, highlight, plain}; use crate::utils::code_line; @@ -39,6 +47,10 @@ const INDENT_STEP: f32 = 1.5; const BODY_LINE_HEIGHT: f32 = 1.6; /// The code line height inside fenced blocks. const CODE_LINE_HEIGHT: f32 = 1.5; +/// The tallest a standalone image renders, in root font sizes. +const IMAGE_MAX_HEIGHT: f32 = 14.0; +/// The side of an inline image atom relative to the body text size. +const INLINE_IMAGE_SCALE: f32 = 1.15; /// A heading level, 1 to 6. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -83,6 +95,16 @@ pub struct InlineSpan { pub style: InlineStyle, } +/// One inline item of a paragraph or list item: styled text or an image. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum InlineItem { + Text(InlineSpan), + Image { + source: SharedString, + alt: SharedString, + }, +} + /// The inline style a text run carries. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum InlineStyle { @@ -130,7 +152,7 @@ pub enum MarkdownBlock { quote: bool, }, Paragraph { - spans: Vec, + items: Vec, /// How many lists the paragraph's content sits under. indent: usize, /// Whether a blockquote wraps the paragraph. @@ -138,11 +160,20 @@ pub enum MarkdownBlock { }, ListItem { marker: ListMarker, - spans: Vec, + items: Vec, /// How many lists wrap this item's list, zero for the outermost. indent: usize, quote: bool, }, + /// An image that stands alone in its paragraph. + Image { + source: SharedString, + alt: SharedString, + /// How many lists the block sits under. + indent: usize, + /// Whether a blockquote wraps the block. + quote: bool, + }, Code { language: Option, lines: HighlightedCode, @@ -171,6 +202,28 @@ impl MarkdownBlock { } } +/// The key context the text view declares while it holds focus. +pub const TEXT_VIEW_CONTEXT: &str = "TextView"; + +/// The callback a view invokes when the user clicks a markdown link. +pub type LinkClickHandler = Arc; +/// The callback a view invokes when the user toggles a task checkbox. +pub type TaskToggleHandler = Arc; + +actions!(text_view, [Copy]); + +/// Binds the text view's copy chord: cmd-c on macOS, ctrl-c elsewhere. +/// +/// Call once at startup. Without it, selection copy has no key. +pub fn bind_text_view_keys(cx: &mut App) { + let context = Some(TEXT_VIEW_CONTEXT); + #[cfg(target_os = "macos")] + let copy = "cmd-c"; + #[cfg(not(target_os = "macos"))] + let copy = "ctrl-c"; + cx.bind_keys([KeyBinding::new(copy, Copy, context)]); +} + /// Retained state for a markdown view. pub struct MarkdownViewState { id: ElementId, @@ -182,6 +235,13 @@ pub struct MarkdownViewState { /// Drives the vertical scroll of the virtualized block list. handle: ListState, a11y_label: Option, + link_handler: Option, + task_toggle_handler: Option, + /// `(anchor, head)` in the order they were set, over the rendered text. + selection: Option<(usize, usize)>, + /// Whether a drag is still extending the selection. + selecting: bool, + focus_handle: Option, } impl MarkdownViewState { @@ -205,6 +265,11 @@ impl MarkdownViewState { highlight_code: true, handle: ListState::new(count, ListAlignment::Top, CODE_OVERSCROLL_MAX), a11y_label: None, + link_handler: None, + task_toggle_handler: None, + selection: None, + selecting: false, + focus_handle: None, } } @@ -234,6 +299,8 @@ impl MarkdownViewState { let block_ids = Self::stable_block_ids(&self.id, &blocks); self.blocks = blocks; self.block_ids = block_ids; + self.selection = None; + self.selecting = false; self.handle.reset(self.blocks.len()); self.handle.scroll_to_reveal_item(0); } @@ -292,28 +359,110 @@ impl MarkdownViewState { self.a11y_label = Some(label.into()); self } + + /// Invokes `handler` when the user clicks a link, instead of opening it. + pub fn on_link_click(mut self, handler: F) -> Self + where + F: Fn(&SharedString, &mut Window, &mut App) + 'static, + { + self.link_handler = Some(Arc::new(handler)); + self + } + + /// Replaces the link click handler. + pub fn set_link_handler(&mut self, handler: LinkClickHandler) { + self.link_handler = Some(handler); + } + + /// Invokes `handler(block, checked)` when the user toggles a task checkbox. + pub fn on_task_toggle(mut self, handler: F) -> Self + where + F: Fn(usize, bool, &mut Window, &mut App) + 'static, + { + self.task_toggle_handler = Some(Arc::new(handler)); + self + } + + /// Replaces the task toggle handler. + pub fn set_task_toggle_handler(&mut self, handler: TaskToggleHandler) { + self.task_toggle_handler = Some(handler); + } + + /// Selects a range over the rendered text, endpoints in byte offsets. + pub fn select_range(&mut self, start: usize, end: usize, cx: &mut Context) { + self.selection = Some((start, end)); + self.selecting = false; + cx.notify(); + } + + /// The selected range over the rendered text, endpoints in document order, when there is one. + pub fn selection(&self) -> Option<(usize, usize)> { + let (anchor, head) = self.selection?; + Some(if anchor <= head { + (anchor, head) + } else { + (head, anchor) + }) + } + + /// The selected text, or `None` when nothing is selected. + pub fn selected_text(&self) -> Option { + let document = text_document(&self.blocks); + let (start, end) = self.selection()?; + let end = end.min(document.len); + let start = start.min(end); + Some(document.text(&self.blocks)[start..end].to_string()).filter(|text| !text.is_empty()) + } + + /// The focus handle the view claims on click, created on first use. + fn focus_handle(&mut self, cx: &mut App) -> gpui::FocusHandle { + self.focus_handle + .get_or_insert_with(|| cx.focus_handle().tab_stop(true)) + .clone() + } } impl Render for MarkdownViewState { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let id = self.id.clone(); let state = cx.entity(); + let focus_handle = self.focus_handle(cx); + let frame = std::rc::Rc::new(ViewFrame { + state: state.clone(), + link_handler: self.link_handler.clone(), + task_toggle: self.task_toggle_handler.clone(), + selection: self.selection(), + focus_handle, + pressed_on_piece: std::rc::Rc::new(std::cell::Cell::new(false)), + }); + let doc = std::rc::Rc::new(text_document(&self.blocks)); - let blocks = list(self.handle.clone(), move |block_index, _window, cx| { - let view = state.read(cx); - match view - .blocks - .get(block_index) - .zip(view.block_ids.get(block_index)) - { - Some((block, block_id)) => render_block(block, block_index, block_id, cx), - None => div().into_any_element(), + let blocks = list(self.handle.clone(), { + let frame = frame.clone(); + let doc = doc.clone(); + move |block_index, _window, cx| { + let view = state.read(cx); + match view + .blocks + .get(block_index) + .zip(view.block_ids.get(block_index)) + { + Some((block, block_id)) => { + let ctx = BlockContext { + frame: &frame, + doc: &doc, + block_index, + block_id, + }; + render_block(block, block_index, &ctx, cx) + } + None => div().into_any_element(), + } } }) - .h_full() - .w_full(); + .size_full(); - div() + view_container(&frame) .id((id.clone(), "view")) .relative() .size_full() @@ -328,6 +477,423 @@ impl Render for MarkdownViewState { } } +/// The per-frame context the view's renderers wire themselves from. +struct ViewFrame { + state: Entity, + link_handler: Option, + task_toggle: Option, + /// The selection over the rendered text, endpoints in document order. + selection: Option<(usize, usize)>, + focus_handle: gpui::FocusHandle, + /// Set when a press landed on a piece. + pressed_on_piece: std::rc::Rc>, +} + +/// The per-block context, one per block as it renders. +struct BlockContext<'a> { + frame: &'a ViewFrame, + doc: &'a TextDocument, + block_index: usize, + block_id: &'a ElementId, +} + +impl BlockContext<'_> { + /// The block's pieces, in render order. + fn pieces(&self) -> &[TextPiece] { + let start = self + .doc + .pieces + .partition_point(|piece| piece.block_index < self.block_index); + + let count = self.doc.pieces[start..] + .iter() + .take_while(|piece| piece.block_index == self.block_index) + .count(); + + &self.doc.pieces[start..start + count] + } + + /// The piece at `offset`. + fn piece_context(&self, offset: usize) -> Option { + let piece = self.pieces().get(offset)?; + let selected = self.frame.selection.and_then(|(start, end)| { + let selected = start.max(piece.range.start)..end.min(piece.range.end); + (!selected.is_empty()) + .then(|| (selected.start - piece.range.start)..(selected.end - piece.range.start)) + }); + + Some(PieceContext { + state: self.frame.state.clone(), + link_handler: self.frame.link_handler.clone(), + doc_range: piece.range.clone(), + selected, + pressed_on_piece: self.frame.pressed_on_piece.clone(), + }) + } +} + +/// What one selectable text element wires itself from. +struct PieceContext { + state: Entity, + link_handler: Option, + /// The piece's byte range in the rendered text. + doc_range: Range, + /// The selection overlap, in piece-local bytes. + selected: Option>, + pressed_on_piece: std::rc::Rc>, +} + +/// The view's base container: selection wiring, focus, and copy. +fn view_container(frame: &ViewFrame) -> Div { + let clear_flag = frame.pressed_on_piece.clone(); + + div() + .w_full() + .flex() + .flex_col() + .key_context(TEXT_VIEW_CONTEXT) + .track_focus(&frame.focus_handle) + .on_action({ + let state = frame.state.clone(); + move |_: &Copy, _window, cx| { + if let Some(text) = state.read(cx).selected_text() { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + } + }) + .on_mouse_down(MouseButton::Left, { + let state = frame.state.clone(); + move |_, window, cx| { + let focus_handle = state.update(cx, |view, cx| view.focus_handle(cx)); + window.focus(&focus_handle, cx); + if !clear_flag.replace(false) { + state.update(cx, |view, cx| { + if view.selection.take().is_some() { + cx.notify(); + } + }); + } + } + }) + .on_mouse_up(MouseButton::Left, { + let state = frame.state.clone(); + move |_, _, cx| { + if state.read(cx).selecting { + state.update(cx, |view, cx| { + view.selecting = false; + cx.notify(); + }); + } + } + }) +} + +/// Renders every block stacked, without virtualization or scrolling. +fn render_blocks( + blocks: &[MarkdownBlock], + frame: &ViewFrame, + doc: &TextDocument, + block_ids: &[ElementId], + cx: &App, +) -> Div { + view_container(frame).children(blocks.iter().zip(block_ids).enumerate().map( + |(block_index, (block, block_id))| { + let ctx = BlockContext { + frame, + doc, + block_index, + block_id, + }; + render_block(block, block_index, &ctx, cx) + }, + )) +} + +/// One selectable stretch of rendered text. +#[derive(Clone, Debug)] +pub struct TextPiece { + /// The block the piece belongs to. + pub block_index: usize, + /// Which selectable unit of its block this is, in render order. + pub sub_index: usize, + /// The piece's byte range in the view's rendered text. + pub range: Range, +} + +/// The rendered text of a document: its selectable pieces and total length. +#[derive(Clone, Debug, Default)] +pub struct TextDocument { + /// The pieces, in document order. + pub pieces: Vec, + /// The rendered text length in bytes. + pub len: usize, +} + +impl TextDocument { + /// The rendered text, built on demand. + fn text(&self, blocks: &[MarkdownBlock]) -> String { + let mut out = String::with_capacity(self.len); + let mut piece_ix = 0; + + for (block_index, block) in blocks.iter().enumerate() { + if piece_ix >= self.pieces.len() || self.pieces[piece_ix].block_index != block_index { + continue; + } + + let block_end = self.pieces[piece_ix..] + .iter() + .position(|piece| piece.block_index != block_index) + .map_or(self.pieces.len(), |offset| piece_ix + offset); + let units = block_text_units(block); + + for piece in &self.pieces[piece_ix..block_end] { + let Some(unit) = units.get(piece.sub_index).filter(|unit| !unit.is_empty()) else { + continue; + }; + if !out.is_empty() { + out.push('\n'); + } + out.push_str(unit); + } + piece_ix = block_end; + } + + out + } +} + +fn spans_text_of(spans: &[InlineSpan]) -> String { + spans.iter().map(|span| span.text.as_ref()).collect() +} + +/// The selectable text units of a block. +fn block_text_units(block: &MarkdownBlock) -> Vec { + match block { + MarkdownBlock::Heading { spans, .. } => vec![spans_text_of(spans)], + MarkdownBlock::Paragraph { items, .. } | MarkdownBlock::ListItem { items, .. } => { + let mut units: Vec = Vec::new(); + for item in items { + match item { + InlineItem::Text(span) => match units.last_mut() { + // Consecutive text items share the unit between images. + Some(unit) => unit.push_str(&span.text), + None => units.push(span.text.as_ref().to_string()), + }, + // An image starts the next unit; the render emits no + // element for it. + InlineItem::Image { .. } => units.push(String::new()), + } + } + units + } + MarkdownBlock::Code { lines, .. } => lines.lines.iter().map(|line| line.text()).collect(), + MarkdownBlock::Table { table, .. } => table + .rows + .iter() + .flat_map(|row| row.cells.iter()) + .map(|cell| spans_text_of(&cell.spans)) + .collect(), + MarkdownBlock::Image { .. } | MarkdownBlock::ThematicBreak { .. } => Vec::new(), + } +} + +/// Enumerates the selectable text pieces of `blocks`, in render order. +fn text_document(blocks: &[MarkdownBlock]) -> TextDocument { + let mut pieces = Vec::new(); + let mut len = 0usize; + + for (block_index, block) in blocks.iter().enumerate() { + // Paragraphs render no element for an empty unit. + let skip_empty = matches!( + block, + MarkdownBlock::Paragraph { .. } | MarkdownBlock::ListItem { .. } + ); + + for (sub_index, unit) in block_text_units(block).into_iter().enumerate() { + if unit.is_empty() && skip_empty { + continue; + } + + let mut range = len..len; + if !unit.is_empty() { + if len > 0 { + len += 1; // The newline that joins pieces. + } + range = len..len + unit.len(); + len = range.end; + } + + pieces.push(TextPiece { + block_index, + sub_index, + range, + }); + } + } + + TextDocument { pieces, len } +} + +/// An embeddable markdown text view: fit-content height, no scrolling. +pub struct TextView { + id: ElementId, + source: Option, + state: Option>, + link_handler: Option, + task_toggle_handler: Option, + style: StyleRefinement, +} + +impl TextView { + /// Renders `source`, keeping its parsed state across frames under `id`. + pub fn markdown(id: I, source: S) -> Self + where + I: Into, + S: Into, + { + Self { + id: id.into(), + source: Some(source.into()), + state: None, + link_handler: None, + task_toggle_handler: None, + style: StyleRefinement::default(), + } + } + + /// Wraps an owned state; its source drives what the view renders. + pub fn new(state: &Entity) -> Self { + Self { + id: ElementId::from(SharedString::from(format!("{}", state.entity_id()))), + source: None, + state: Some(state.clone()), + link_handler: None, + task_toggle_handler: None, + style: StyleRefinement::default(), + } + } + + /// Invokes `handler` when the user clicks a link, instead of opening it. + pub fn on_link_click(mut self, handler: F) -> Self + where + F: Fn(&SharedString, &mut Window, &mut App) + 'static, + { + self.link_handler = Some(Arc::new(handler)); + self + } + + /// Invokes `handler(block, checked)` when the user toggles a task checkbox. + pub fn on_task_toggle(mut self, handler: F) -> Self + where + F: Fn(usize, bool, &mut Window, &mut App) + 'static, + { + self.task_toggle_handler = Some(Arc::new(handler)); + self + } +} + +impl Styled for TextView { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl Element for TextView { + type RequestLayoutState = AnyElement; + type PrepaintState = (); + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let state = match self.state.clone() { + Some(state) => state, + None => { + let view_id = self.id.clone(); + let source = self.source.clone().unwrap_or_default(); + let state_key = SharedString::from(format!("{}/state", self.id)); + window.use_keyed_state(state_key, cx, move |_, _| { + MarkdownViewState::new(view_id, source) + }) + } + }; + + let source = self.source.clone(); + let link_handler = self.link_handler.clone(); + let task_toggle = self.task_toggle_handler.clone(); + + let (selection, focus_handle) = + state.update(cx, |view, cx| (view.selection(), view.focus_handle(cx))); + + state.update(cx, |view, cx| { + if let Some(source) = source { + view.set_source(source, cx); + } + if let Some(link_handler) = link_handler { + view.set_link_handler(link_handler); + } + if let Some(task_toggle) = task_toggle { + view.set_task_toggle_handler(task_toggle); + } + }); + + let view = state.read(cx); + let frame = ViewFrame { + state: state.clone(), + link_handler: view.link_handler.clone(), + task_toggle: view.task_toggle_handler.clone(), + selection, + focus_handle, + pressed_on_piece: std::rc::Rc::new(std::cell::Cell::new(false)), + }; + let doc = text_document(&view.blocks); + + let mut element = render_blocks(&view.blocks, &frame, &doc, &view.block_ids, cx); + element.style().refine(&self.style); + + let mut element = element.into_any_element(); + let layout_id = element.request_layout(window, cx); + + (layout_id, element) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) { + request_layout.prepaint(window, cx); + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + request_layout.paint(window, cx); + } +} + /// One open list, tracking ordered numbering and an in-flight task marker. struct ListFrame { ordered: bool, @@ -423,6 +989,7 @@ fn heading(level: HeadingLevel) -> MarkdownHeading { fn parse(source: &str, highlight_code: bool) -> Vec { let mut blocks = Vec::new(); let mut inline = InlineBuilder::default(); + let mut inline_items: Vec = Vec::new(); let mut lists: Vec = Vec::new(); // Whether each open list item already flushed content rows. let mut items: Vec = Vec::new(); @@ -431,12 +998,15 @@ fn parse(source: &str, highlight_code: bool) -> Vec { let mut code: Option = None; // The fence info string arrives on Start, its text follows. let mut code_language: Option = None; + // The image being parsed: its source and its alt text so far. + let mut image: Option<(SharedString, String)> = None; for event in Parser::new_ext(source, options()) { match event { Event::Start(tag) => match tag { Tag::Paragraph | Tag::TableCell | Tag::Item => { inline = InlineBuilder::default(); + inline_items = Vec::new(); if matches!(tag, Tag::Item) { // The marker is only in flight while its own item is open. if let Some(frame) = lists.last_mut() { @@ -448,12 +1018,14 @@ fn parse(source: &str, highlight_code: bool) -> Vec { Tag::Heading { level, .. } => { flush_item_inline( &mut inline, + &mut inline_items, &mut items, &mut lists, &mut blocks, quote_depth, ); inline = InlineBuilder::default(); + inline_items = Vec::new(); blocks.push(MarkdownBlock::Heading { level: heading(level), spans: Vec::new(), @@ -464,6 +1036,7 @@ fn parse(source: &str, highlight_code: bool) -> Vec { Tag::BlockQuote(_) => { flush_item_inline( &mut inline, + &mut inline_items, &mut items, &mut lists, &mut blocks, @@ -474,6 +1047,7 @@ fn parse(source: &str, highlight_code: bool) -> Vec { Tag::List(start) => { flush_item_inline( &mut inline, + &mut inline_items, &mut items, &mut lists, &mut blocks, @@ -488,6 +1062,7 @@ fn parse(source: &str, highlight_code: bool) -> Vec { Tag::CodeBlock(kind) => { flush_item_inline( &mut inline, + &mut inline_items, &mut items, &mut lists, &mut blocks, @@ -503,6 +1078,7 @@ fn parse(source: &str, highlight_code: bool) -> Vec { Tag::Table(_) => { flush_item_inline( &mut inline, + &mut inline_items, &mut items, &mut lists, &mut blocks, @@ -537,14 +1113,18 @@ fn parse(source: &str, highlight_code: bool) -> Vec { Tag::Link { dest_url, .. } => inline.push_style(Some(InlineStyle::Link { destination: SharedString::from(dest_url.to_string()), })), - // Images render as their alt text. - Tag::Image { .. } => inline.push_style(None), + Tag::Image { dest_url, .. } => { + image = Some((SharedString::from(dest_url.to_string()), String::new())) + } Tag::HtmlBlock | Tag::MetadataBlock(_) => {} _ => {} }, Event::End(tag_end) => match tag_end { TagEnd::Paragraph => { - let spans = inline.finish(); + let mut paragraph_items = std::mem::take(&mut inline_items); + for span in inline.finish() { + paragraph_items.push(InlineItem::Text(span)); + } let quote = quote_depth > 0; match items.last_mut() { // The item's first paragraph carries its marker row. @@ -552,7 +1132,7 @@ fn parse(source: &str, highlight_code: bool) -> Vec { *flushed = true; blocks.push(MarkdownBlock::ListItem { marker: take_marker(&mut lists), - spans, + items: paragraph_items, indent: lists.len().saturating_sub(1), quote, }); @@ -561,11 +1141,7 @@ fn parse(source: &str, highlight_code: bool) -> Vec { if let Some(item) = items.last_mut() { *item = true; } - blocks.push(MarkdownBlock::Paragraph { - spans, - indent: lists.len(), - quote, - }); + push_paragraph_items(paragraph_items, lists.len(), quote, &mut blocks); } } } @@ -581,9 +1157,12 @@ fn parse(source: &str, highlight_code: bool) -> Vec { } TagEnd::Item => { let flushed = items.pop().unwrap_or(false); - let spans = inline.finish(); + let mut block_items = std::mem::take(&mut inline_items); + for span in inline.finish() { + block_items.push(InlineItem::Text(span)); + } - if spans.is_empty() && flushed { + if block_items.is_empty() && flushed { continue; } @@ -591,15 +1170,11 @@ fn parse(source: &str, highlight_code: bool) -> Vec { let quote = quote_depth > 0; if flushed { - blocks.push(MarkdownBlock::Paragraph { - spans, - indent, - quote, - }); + push_paragraph_items(block_items, indent, quote, &mut blocks); } else { blocks.push(MarkdownBlock::ListItem { marker: take_marker(&mut lists), - spans, + items: block_items, indent: indent.saturating_sub(1), quote, }); @@ -642,29 +1217,61 @@ fn parse(source: &str, highlight_code: bool) -> Vec { }; row.cells.push(MarkdownTableCell { spans }); } - TagEnd::Emphasis - | TagEnd::Strong - | TagEnd::Strikethrough - | TagEnd::Link - | TagEnd::Image => inline.pop_style(), + TagEnd::Image => { + if let Some((source, alt)) = image.take() { + let in_cell = table.is_some(); + let in_heading = matches!( + blocks.last(), + Some(MarkdownBlock::Heading { spans, .. }) if spans.is_empty() + ); + if in_cell || in_heading { + // Headings and table cells keep images as alt text. + inline.push_text(&alt); + } else { + for span in inline.finish() { + inline_items.push(InlineItem::Text(span)); + } + inline_items.push(InlineItem::Image { + source, + alt: SharedString::from(alt), + }); + } + } + } + TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough | TagEnd::Link => { + inline.pop_style() + } _ => {} }, Event::Text(text) => { if let Some(buffer) = code.as_mut() { buffer.push_str(&text); + } else if let Some((_, alt)) = &mut image { + alt.push_str(&text); } else { inline.push_text(&text); } } Event::Code(text) => { - inline.push_style(Some(InlineStyle::Code)); - inline.push_text(&text); - inline.pop_style(); + if let Some((_, alt)) = &mut image { + alt.push_str(&text); + } else { + inline.push_style(Some(InlineStyle::Code)); + inline.push_text(&text); + inline.pop_style(); + } + } + Event::SoftBreak | Event::HardBreak => { + if let Some((_, alt)) = &mut image { + alt.push(' '); + } else { + inline.push_text(" "); + } } - Event::SoftBreak | Event::HardBreak => inline.push_text(" "), Event::Rule => { flush_item_inline( &mut inline, + &mut inline_items, &mut items, &mut lists, &mut blocks, @@ -711,9 +1318,46 @@ fn take_marker(lists: &mut [ListFrame]) -> ListMarker { marker } +/// Pushes `items` as standalone image blocks when they hold nothing but images. +fn push_paragraph_items( + items: Vec, + indent: usize, + quote: bool, + blocks: &mut Vec, +) { + let blank = |item: &InlineItem| match item { + InlineItem::Image { .. } => true, + InlineItem::Text(span) => span.text.chars().all(char::is_whitespace), + }; + let image_only = !items.is_empty() + && items + .iter() + .any(|item| matches!(item, InlineItem::Image { .. })) + && items.iter().all(blank); + if image_only { + for item in items { + if let InlineItem::Image { source, alt } = item { + blocks.push(MarkdownBlock::Image { + source, + alt, + indent, + quote, + }); + } + } + } else { + blocks.push(MarkdownBlock::Paragraph { + items, + indent, + quote, + }); + } +} + /// Flushes an open tight item's inline text as its marker row. fn flush_item_inline( inline: &mut InlineBuilder, + inline_items: &mut Vec, items: &mut [bool], lists: &mut [ListFrame], blocks: &mut Vec, @@ -723,15 +1367,19 @@ fn flush_item_inline( return; }; - let spans = inline.finish(); - if spans.is_empty() { + let mut block_items = std::mem::take(inline_items); + for span in inline.finish() { + block_items.push(InlineItem::Text(span)); + } + + if block_items.is_empty() { return; } *item = true; blocks.push(MarkdownBlock::ListItem { marker: take_marker(lists), - spans, + items: block_items, indent: lists.len().saturating_sub(1), quote: quote_depth > 0, }); @@ -741,7 +1389,7 @@ fn flush_item_inline( fn render_block( block: &MarkdownBlock, block_index: usize, - block_id: &ElementId, + ctx: &BlockContext, cx: &App, ) -> AnyElement { let theme = cx.theme(); @@ -759,40 +1407,46 @@ fn render_block( *indent, *quote, if block_index == 0 { px(0.) } else { gap * 0.5 }, - block_id, + ctx, cx, ), MarkdownBlock::Paragraph { - spans, + items, indent, quote, - } => text_row(spans, *indent, *quote, gap, None, block_id, cx), + } => text_row(items, *indent, *quote, gap, None, ctx, cx), MarkdownBlock::ListItem { marker, - spans, + items, indent, quote, } => text_row( - spans, + items, *indent, *quote, theme.root_font_size * ITEM_GAP, Some(*marker), - block_id, + ctx, cx, ), + MarkdownBlock::Image { + source, + alt, + indent, + quote, + } => image_row(source, alt, *indent, *quote, ctx, cx), MarkdownBlock::Code { + language, lines, indent, quote, - .. - } => code_row(lines, *indent, *quote, block_id, cx), - MarkdownBlock::ThematicBreak { indent, quote } => rule_row(*indent, *quote, block_id, cx), + } => code_row(lines, language.as_ref(), *indent, *quote, ctx, cx), + MarkdownBlock::ThematicBreak { indent, quote } => rule_row(*indent, *quote, ctx, cx), MarkdownBlock::Table { table, indent, quote, - } => table_row(table, *indent, *quote, block_id, cx), + } => table_row(table, *indent, *quote, ctx, cx), } } @@ -835,38 +1489,46 @@ fn block_frame( } } +#[allow(clippy::too_many_arguments)] fn heading_row( level: MarkdownHeading, spans: &[InlineSpan], indent: usize, quote: bool, top: Pixels, - id: &ElementId, + ctx: &BlockContext, cx: &App, ) -> AnyElement { let theme = cx.theme(); let scale = HEADING_SCALES[(level.level() - 1).min(HEADING_SCALES.len() - 1)]; - block_frame(indent, quote, top, theme.root_font_size * BLOCK_GAP, id, cx) - .text_size(theme.root_font_size * scale) - .line_height(relative(1.3)) - .font_weight(FontWeight::SEMIBOLD) - .child(text_child(spans, id, cx)) - .into_any_element() + block_frame( + indent, + quote, + top, + theme.root_font_size * BLOCK_GAP, + ctx.block_id, + cx, + ) + .text_size(theme.root_font_size * scale) + .line_height(relative(1.3)) + .font_weight(FontWeight::SEMIBOLD) + .child(text_child(spans, ctx.block_id, ctx.piece_context(0), cx)) + .into_any_element() } fn text_row( - spans: &[InlineSpan], + items: &[InlineItem], indent: usize, quote: bool, bottom: Pixels, marker: Option, - id: &ElementId, + ctx: &BlockContext, cx: &App, ) -> AnyElement { let theme = cx.theme(); - block_frame(indent, quote, px(0.), bottom, id, cx) + block_frame(indent, quote, px(0.), bottom, ctx.block_id, cx) .map(|frame| match marker { Some(marker) => { let line_height = theme.sizes.small.text_size * BODY_LINE_HEIGHT; @@ -888,22 +1550,130 @@ fn text_row( .whitespace_nowrap() .child(format!("{number}.")) .into_any_element(), - ListMarker::Task(checked) => task_box(checked, cx), + ListMarker::Task(checked) => task_toggle(checked, ctx, cx), }); frame.child( - div() - .flex() - .items_start() - .child(marker_cell) - .child(div().flex_1().min_w_0().child(text_child(spans, id, cx))), + div().flex().items_start().child(marker_cell).child( + div() + .flex_1() + .min_w_0() + .child(paragraph_child(items, ctx, cx)), + ), ) } - None => frame.child(text_child(spans, id, cx)), + None => frame.child(paragraph_child(items, ctx, cx)), }) .into_any_element() } +/// The task marker: a clickable checkbox when the view toggles tasks. +fn task_toggle(checked: bool, ctx: &BlockContext, cx: &App) -> AnyElement { + match ctx.frame.task_toggle.clone() { + Some(handler) => { + let block_index = ctx.block_index; + let selector = SharedString::from(format!("task-toggle-{block_index}")); + div() + .id((ctx.block_id.clone(), "task")) + .debug_selector(move || selector.to_string()) + .flex_none() + .child(task_box(checked, cx)) + .on_click(move |_, window, cx| handler(block_index, !checked, window, cx)) + .into_any_element() + } + None => task_box(checked, cx), + } +} + +/// Builds a paragraph's content: one text run, or a wrapping flow of text +/// segments and image atoms when the paragraph holds images. +fn paragraph_child(items: &[InlineItem], ctx: &BlockContext, cx: &App) -> AnyElement { + let has_image = items + .iter() + .any(|item| matches!(item, InlineItem::Image { .. })); + + if !has_image { + let spans = items.iter().filter_map(|item| match item { + InlineItem::Text(span) => Some(span), + InlineItem::Image { .. } => None, + }); + return text_child(spans, ctx.block_id, ctx.piece_context(0), cx); + } + + let theme = cx.theme(); + let side = theme.sizes.small.text_size * INLINE_IMAGE_SCALE; + let mut flow = div().flex().flex_wrap().items_center(); + let mut segment: Vec<&InlineSpan> = Vec::new(); + let mut segment_index = 0; + for item in items { + match item { + InlineItem::Text(span) => segment.push(span), + InlineItem::Image { source, .. } => { + if !segment.is_empty() { + flow = flow.child(text_child( + segment, + &text_segment_id(ctx.block_id, segment_index), + ctx.piece_context(segment_index), + cx, + )); + segment = Vec::new(); + segment_index += 1; + } + flow = flow.child( + img(source.clone()) + .size(side) + .object_fit(ObjectFit::ScaleDown) + .flex_none(), + ); + } + } + } + if !segment.is_empty() { + flow = flow.child(text_child( + segment, + &text_segment_id(ctx.block_id, segment_index), + ctx.piece_context(segment_index), + cx, + )); + } + flow.into_any_element() +} + +/// A standalone image block, capped in height. +fn image_row( + source: &SharedString, + alt: &SharedString, + indent: usize, + quote: bool, + ctx: &BlockContext, + cx: &App, +) -> AnyElement { + let theme = cx.theme(); + block_frame( + indent, + quote, + px(0.), + theme.root_font_size * BLOCK_GAP, + ctx.block_id, + cx, + ) + .child( + img(source.clone()) + .max_h(theme.root_font_size * IMAGE_MAX_HEIGHT) + .max_w_full() + .rounded(theme.radius.control) + .overflow_hidden(), + ) + .id((ctx.block_id.clone(), "image")) + .when(!alt.is_empty(), |frame| frame.aria_label(alt.clone())) + .into_any_element() +} + +/// The element id of one text segment of a paragraph with images. +fn text_segment_id(id: &ElementId, segment_index: usize) -> ElementId { + ElementId::from(SharedString::from(format!("{id}-text-{segment_index}"))) +} + /// A read-only task item indicator, matching the checkbox component. fn task_box(checked: bool, cx: &App) -> AnyElement { let theme = cx.theme(); @@ -935,14 +1705,44 @@ fn task_box(checked: bool, cx: &App) -> AnyElement { fn code_row( lines: &HighlightedCode, + language: Option<&SharedString>, indent: usize, quote: bool, - id: &ElementId, + ctx: &BlockContext, cx: &App, ) -> AnyElement { let theme = cx.theme(); let text_size = theme.sizes.small.text_size; let line_height = text_size * CODE_LINE_HEIGHT; + let plain = lines + .lines + .iter() + .map(|line| line.text()) + .collect::>() + .join("\n"); + + let actions_row = div() + .flex() + .items_center() + .justify_between() + .pb_1() + .when_some(language.cloned(), |row, language| { + row.child( + div() + .text_xs() + .text_color(theme.colors.muted_foreground) + .child(language), + ) + }) + .child( + Button::new((ctx.block_id.clone(), "copy-code")) + .label("Copy") + .variant(ButtonVariant::Ghost) + .debug_selector(|| "copy-code".to_string()) + .on_click(move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(plain.clone())); + }), + ); let plate = div() .w_full() @@ -955,12 +1755,19 @@ fn code_row( .text_size(text_size) .line_height(relative(CODE_LINE_HEIGHT)) .overflow_hidden() - .children(lines.lines.iter().map(|line| { - div() - .h(line_height) - .whitespace_nowrap() - .overflow_hidden() - .child(code_line(line, theme.colors, theme.ramps)) + .child(actions_row) + .children(lines.lines.iter().enumerate().map(|(line_ix, line)| { + let styled = code_line(line, theme.colors, theme.ramps); + let layout = styled.layout().clone(); + with_selection_handlers( + div() + .h(line_height) + .whitespace_nowrap() + .overflow_hidden() + .child(styled), + ctx.piece_context(line_ix), + layout, + ) })); block_frame( @@ -968,21 +1775,21 @@ fn code_row( quote, px(0.), theme.root_font_size * BLOCK_GAP, - id, + ctx.block_id, cx, ) .child(plate) .into_any_element() } -fn rule_row(indent: usize, quote: bool, id: &ElementId, cx: &App) -> AnyElement { +fn rule_row(indent: usize, quote: bool, ctx: &BlockContext, cx: &App) -> AnyElement { let theme = cx.theme(); block_frame( indent, quote, px(0.), theme.root_font_size * BLOCK_GAP, - id, + ctx.block_id, cx, ) .child(div().h(BORDER_WIDTH).w_full().bg(theme.colors.border)) @@ -993,7 +1800,7 @@ fn table_row( table: &MarkdownTable, indent: usize, quote: bool, - id: &ElementId, + ctx: &BlockContext, cx: &App, ) -> AnyElement { let theme = cx.theme(); @@ -1005,6 +1812,7 @@ fn table_row( .max() .unwrap_or(0); let row_count = table.rows.len(); + let mut cell_ix = 0usize; let grid = div() .w_full() @@ -1028,8 +1836,11 @@ fn table_row( .enumerate() .map(|(column_index, cell)| { let cell_id = ElementId::from(SharedString::from(format!( - "{id}-cell-{row_index}-{column_index}" + "{}-cell-{row_index}-{column_index}", + ctx.block_id ))); + let piece = ctx.piece_context(cell_ix); + cell_ix += 1; div() .flex_1() .min_w_0() @@ -1038,7 +1849,7 @@ fn table_row( .when(row.header, |cell_element| { cell_element.font_weight(FontWeight::SEMIBOLD) }) - .child(text_child(&cell.spans, &cell_id, cx)) + .child(text_child(&cell.spans, &cell_id, piece, cx)) }) // Ragged tables pad missing cells so rows align. .chain( @@ -1053,15 +1864,96 @@ fn table_row( quote, px(0.), theme.root_font_size * BLOCK_GAP, - id, + ctx.block_id, cx, ) .child(grid) .into_any_element() } -/// Builds the block's text element. -fn text_child(spans: &[InlineSpan], id: &ElementId, cx: &App) -> AnyElement { +/// Attaches drag-selection handlers to `element` when it renders a piece. +fn with_selection_handlers(element: Div, piece: Option, layout: TextLayout) -> Div { + let Some(piece) = piece else { + return element; + }; + + let state = piece.state.clone(); + let doc_range = piece.doc_range.clone(); + let pressed_on_piece = piece.pressed_on_piece.clone(); + let layout_for_move = layout.clone(); + element + .on_mouse_down( + MouseButton::Left, + move |event: &MouseDownEvent, window: &mut Window, cx: &mut App| { + pressed_on_piece.set(true); + let focus_handle = state.update(cx, |view, cx| view.focus_handle(cx)); + window.focus(&focus_handle, cx); + + let local = match layout.index_for_position(event.position) { + Ok(index) | Err(index) => index, + }; + let index = doc_range.start + local; + state.update(cx, |view, cx| { + if event.modifiers.shift { + if let Some((anchor, _)) = view.selection { + view.selection = Some((anchor, index)); + } else { + view.selection = Some((index, index)); + } + } else { + view.selection = Some((index, index)); + } + view.selecting = true; + cx.notify(); + }); + }, + ) + .on_mouse_move({ + let state = piece.state.clone(); + let doc_range = piece.doc_range.clone(); + let layout = layout_for_move; + move |event: &MouseMoveEvent, _window: &mut Window, cx: &mut App| { + if !state.read(cx).selecting { + return; + } + let local = match layout.index_for_position(event.position) { + Ok(index) | Err(index) => index, + }; + let index = doc_range.start + local; + let changed = state + .read(cx) + .selection + .map(|(_, head)| head != index) + .unwrap_or(true); + if changed { + state.update(cx, |view, cx| { + if let Some((anchor, _)) = view.selection { + view.selection = Some((anchor, index)); + cx.notify(); + } + }); + } + } + }) + .on_mouse_up(MouseButton::Left, { + let state = piece.state.clone(); + move |_, _, cx| { + if state.read(cx).selecting { + state.update(cx, |view, cx| { + view.selecting = false; + cx.notify(); + }); + } + } + }) +} + +/// Builds the block's text element, selectable when it renders a piece. +fn text_child(spans: I, id: &ElementId, piece: Option, cx: &App) -> AnyElement +where + I: IntoIterator, + I::Item: std::borrow::Borrow, +{ let theme = cx.theme(); let mut text = String::new(); let mut highlights: Vec<(Range, HighlightStyle)> = Vec::new(); @@ -1070,6 +1962,7 @@ fn text_child(spans: &[InlineSpan], id: &ElementId, cx: &App) -> AnyElement { let mut destinations: Vec = Vec::new(); for span in spans { + let span = span.borrow(); let start = text.len(); text.push_str(&span.text); @@ -1138,19 +2031,41 @@ fn text_child(spans: &[InlineSpan], id: &ElementId, cx: &App) -> AnyElement { } } + if let Some(local) = piece.as_ref().and_then(|piece| piece.selected.clone()) { + highlights.push(( + local, + HighlightStyle { + background_color: Some(theme.colors.selection), + ..Default::default() + }, + )); + } + let styled = StyledText::new(text) .with_highlights(highlights) .with_font_family_overrides(family_overrides); + let layout = styled.layout().clone(); - if destinations.is_empty() { - return styled.into_any_element(); + let inner = if destinations.is_empty() { + styled.into_any_element() + } else { + let link_handler = piece.as_ref().and_then(|piece| piece.link_handler.clone()); + InteractiveText::new(id.clone(), styled) + .on_click(link_ranges, move |range_ix, window, cx| { + let Some(destination) = destinations.get(range_ix) else { + return; + }; + match link_handler.as_ref() { + Some(handler) => handler(destination, window, cx), + None => cx.open_url(destination.as_ref()), + } + }) + .into_any_element() + }; + + let mut element = div().child(inner); + if let Some(piece) = piece { + element = with_selection_handlers(element, Some(piece), layout); } - - InteractiveText::new(id.clone(), styled) - .on_click(link_ranges, move |range_ix, _, cx| { - if let Some(destination) = destinations.get(range_ix) { - cx.open_url(destination.as_ref()); - } - }) - .into_any_element() + element.into_any_element() } diff --git a/crates/editor/src/utils.rs b/crates/editor/src/utils.rs index 7c91334..6a9ba56 100644 --- a/crates/editor/src/utils.rs +++ b/crates/editor/src/utils.rs @@ -1,24 +1,24 @@ //! Shared helpers for the editor's views: gutter cells, role colors and run //! styles, code line text, language mapping and change washes. -#[cfg(feature = "markdown_view")] +#[cfg(any(feature = "code_view", feature = "markdown_view"))] use std::ops::Range; #[cfg(feature = "markdown_view")] use crate::highlight::{Language, Line}; #[cfg(feature = "markdown_view")] use gpui::StyledText; -#[cfg(feature = "code_view")] +#[cfg(any(feature = "code_view", feature = "markdown_view"))] use gpui::{App, HighlightStyle, Hsla, IntoElement, ParentElement as _, Styled as _, div}; -#[cfg(feature = "code_view")] +#[cfg(any(feature = "code_view", feature = "markdown_view"))] use gpui_native_theme::{ActiveTheme as _, ColorTokens, Ramps}; #[cfg(feature = "diff_view")] use gpui_native_theme::{WASH_ALPHA, WASH_ALPHA_GUTTER, WASH_ALPHA_STRONG}; -#[cfg(feature = "code_view")] +#[cfg(any(feature = "code_view", feature = "markdown_view"))] use crate::highlight::Role; -#[cfg(feature = "code_view")] +#[cfg(any(feature = "code_view", feature = "markdown_view"))] impl Role { /// Maps the role to the theme's semantic palette. pub(crate) fn color(self, colors: ColorTokens, ramps: Ramps) -> Hsla { diff --git a/crates/editor/tests/markdown.rs b/crates/editor/tests/markdown.rs index e063e2a..cdc68c1 100644 --- a/crates/editor/tests/markdown.rs +++ b/crates/editor/tests/markdown.rs @@ -2,15 +2,27 @@ use std::cell::RefCell; use std::rc::Rc; use gpui::{ - App, AppContext as _, Context, Entity, IntoElement, MouseButton, MouseDownEvent, MouseUpEvent, - ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point, - px, + App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, MouseButton, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Render, Styled as _, + TestAppContext, VisualTestContext, Window, div, point, px, rems, }; use gpui_native_editor::highlight::Role; use gpui_native_editor::markdown::{ - InlineSpan, InlineStyle, ListMarker, MarkdownBlock, MarkdownHeading, MarkdownViewState, + InlineItem, InlineSpan, InlineStyle, ListMarker, MarkdownBlock, MarkdownHeading, + MarkdownViewState, TextView, bind_text_view_keys, }; +fn items_text(items: &[InlineItem]) -> String { + items + .iter() + .filter_map(|item| match item { + InlineItem::Text(span) => Some(span.text.as_ref()), + InlineItem::Image { .. } => None, + }) + .collect::>() + .join("") +} + fn spans_text(spans: &[InlineSpan]) -> String { spans .iter() @@ -19,13 +31,13 @@ fn spans_text(spans: &[InlineSpan]) -> String { .join("") } -fn paragraph_of(blocks: &[MarkdownBlock], index: usize) -> (&Vec, usize, bool) { +fn paragraph_of(blocks: &[MarkdownBlock], index: usize) -> (&Vec, usize, bool) { match &blocks[index] { MarkdownBlock::Paragraph { - spans, + items, indent, quote, - } => (spans, *indent, *quote), + } => (items, *indent, *quote), other => panic!("block {index} is {other:?}, not a paragraph"), } } @@ -34,10 +46,10 @@ fn item_of(blocks: &[MarkdownBlock], index: usize) -> (ListMarker, String, usize match &blocks[index] { MarkdownBlock::ListItem { marker, - spans, + items, indent, quote, - } => (*marker, spans_text(spans), *indent, *quote), + } => (*marker, items_text(items), *indent, *quote), other => panic!("block {index} is {other:?}, not a list item"), } } @@ -46,11 +58,14 @@ fn item_of(blocks: &[MarkdownBlock], index: usize) -> (ListMarker, String, usize fn inline_styles_survive_nesting_and_merge_runs() { let blocks = parse_body("a **bold *both* b** `code` [link](https://example.com) done"); assert_eq!(blocks.len(), 1); - let (spans, ..) = paragraph_of(&blocks, 0); + let (items, ..) = paragraph_of(&blocks, 0); - let styled: Vec<(String, &InlineStyle)> = spans + let styled: Vec<(String, &InlineStyle)> = items .iter() - .map(|span| (span.text.to_string(), &span.style)) + .filter_map(|item| match item { + InlineItem::Text(span) => Some((span.text.to_string(), &span.style)), + InlineItem::Image { .. } => None, + }) .collect(); assert!(styled.contains(&("bold ".to_string(), &InlineStyle::Strong))); @@ -133,8 +148,8 @@ fn tight_items_stay_tight_but_block_content_flushes_before_them() { assert_eq!(text, "item text"); assert_eq!(indent, 0); - let (spans, indent, _) = paragraph_of(&blocks, 1); - assert_eq!(spans_text(spans), "second paragraph"); + let (items, indent, _) = paragraph_of(&blocks, 1); + assert_eq!(items_text(items), "second paragraph"); assert_eq!( indent, 1, "loose item continuations indent under the item's text" @@ -174,8 +189,8 @@ fn tight_item_content_after_a_nested_block_stays_in_the_item() { } )); - let (spans, indent, _) = paragraph_of(&blocks, 2); - assert_eq!(spans_text(spans), "after"); + let (items, indent, _) = paragraph_of(&blocks, 2); + assert_eq!(items_text(items), "after"); assert_eq!(indent, 1, "the continuation stays under the item's text"); } @@ -250,10 +265,11 @@ fn quotes_mark_their_paragraphs_and_lists() { assert!(!paragraph_of(&blocks, 2).2); } +#[cfg(feature = "rust")] #[test] fn fenced_code_highlights_known_languages_and_keeps_the_info_string() { - let blocks = parse_body("```rust\nfn main() {}\n```\n\n```text\nplain\n```"); - assert_eq!(blocks.len(), 2); + let blocks = parse_body("```rust\nfn main() {}\n```\n"); + assert_eq!(blocks.len(), 1); let MarkdownBlock::Code { language, lines, .. @@ -270,12 +286,18 @@ fn fenced_code_highlights_known_languages_and_keeps_the_info_string() { .any(|span| span.role == Role::Keyword), "rust fences highlight through the grammar" ); +} + +#[test] +fn fenced_code_keeps_the_info_string_and_renders_unknown_languages_plain() { + let blocks = parse_body("```text\nplain\n```"); + assert_eq!(blocks.len(), 1); let MarkdownBlock::Code { language, lines, .. - } = &blocks[1] + } = &blocks[0] else { - panic!("the second block is code"); + panic!("the block is code"); }; assert_eq!(language.as_deref(), Some("text")); assert!( @@ -309,11 +331,59 @@ fn tables_parse_header_first_with_column_positions() { } #[test] -fn images_render_their_alt_text_and_html_renders_nothing() { +fn images_render_inline_and_standalone_and_html_renders_nothing() { + // An image alone in its paragraph becomes a standalone image block. + let blocks = parse_body("![the logo](logo.png)"); + assert_eq!(blocks.len(), 1); + let MarkdownBlock::Image { source, alt, .. } = &blocks[0] else { + panic!("the block is an image"); + }; + assert_eq!(source.as_ref(), "logo.png"); + assert_eq!(alt.as_ref(), "the logo"); + + // Two consecutive images in one paragraph become two blocks. + let blocks = parse_body("![a](one.png)\n![b](two.png)"); + assert_eq!(blocks.len(), 2); + assert_eq!( + blocks + .iter() + .filter(|block| matches!(block, MarkdownBlock::Image { .. })) + .count(), + 2 + ); + + // An image among text renders as an inline atom; the spaces around it + // stay in their runs, and its alt text is dropped. let blocks = parse_body("see ![the logo](logo.png) now\n\n
hidden
\n"); assert_eq!(blocks.len(), 1, "the html block renders no block at all"); - let (spans, ..) = paragraph_of(&blocks, 0); - assert_eq!(spans_text(spans), "see the logo now"); + let (items, ..) = paragraph_of(&blocks, 0); + assert_eq!(items_text(items), "see now"); + assert_eq!( + items + .iter() + .filter(|item| matches!(item, InlineItem::Image { .. })) + .count(), + 1 + ); + + // Images inside headings and table cells fall back to their alt text. + let blocks = parse_body("# ![alt h](h.png) head\n\n| ![cell](c.png) |\n|---|\n| x |\n"); + let MarkdownBlock::Heading { spans, .. } = &blocks[0] else { + panic!("the first block is a heading"); + }; + assert!( + spans_text(spans).contains("alt h"), + "the heading image renders as its alt text" + ); + let MarkdownBlock::Table { table, .. } = &blocks[1] else { + panic!("the second block is a table"); + }; + let cell_text: String = table.rows[0].cells[0] + .spans + .iter() + .map(|span| span.text.as_ref()) + .collect(); + assert_eq!(cell_text, "cell"); } #[test] @@ -500,3 +570,356 @@ fn a_large_document_swaps_and_renders_without_freezing() { "blocks past the viewport never render" ); } + +#[test] +fn block_ids_survive_prepending_and_edits_around_them() { + let mut cx = TestAppContext::single(); + cx.update(gpui_native_theme::Theme::init); + let state = cx.new(|_| MarkdownViewState::new("feed", "first\n\nsecond\n")); + + let (first, second) = cx.update(|cx| { + let state = state.read(cx); + ( + state.block_id(0).expect("the first block has an id"), + state.block_id(1).expect("the second block has an id"), + ) + }); + + cx.update(|cx| { + state.update(cx, |state, cx| { + state.set_source("new\n\nfirst\n\nsecond\n", cx) + }); + }); + + cx.update(|cx| { + let state = state.read(cx); + assert_eq!( + state.block_id(1), + Some(first.clone()), + "prepending a block keeps the id of the old first block" + ); + assert_eq!( + state.block_id(2), + Some(second.clone()), + "prepending a block keeps the id of the old second block" + ); + }); + + // Editing a block's text changes only that block's id. + cx.update(|cx| { + state.update(cx, |state, cx| { + state.set_source("new\n\nfirst edited\n\nsecond\n", cx) + }); + }); + cx.update(|cx| { + let state = state.read(cx); + assert_eq!( + state.block_id(2), + Some(second), + "the untouched block keeps its id" + ); + assert_ne!( + state.block_id(1), + Some(first), + "the edited block gets a new id" + ); + }); +} + +#[test] +fn selection_spans_pieces_and_images_contribute_no_text() { + let mut cx = TestAppContext::single(); + cx.update(gpui_native_theme::Theme::init); + let state = cx.new(|_| { + MarkdownViewState::new( + "sel", + "alpha\n\nbravo ![e](u.png) charlie\n\n- delta\n- echo\n", + ) + }); + + let selected = cx.update(|cx| { + state.update(cx, |view, cx| { + view.select_range(0, 30, cx); + view.selected_text() + }) + }); + + assert_eq!( + selected.as_deref(), + Some("alpha\nbravo \n charlie\ndelta\nec"), + "pieces join with newlines and images contribute no text" + ); +} + +#[test] +fn selection_is_empty_past_the_document_end_and_clears_on_reparse() { + let mut cx = TestAppContext::single(); + cx.update(gpui_native_theme::Theme::init); + let state = cx.new(|_| MarkdownViewState::new("sel", "only\n")); + + let (overshoot, after_reparse) = cx.update(|cx| { + state.update(cx, |view, cx| { + view.select_range(0, 10_000, cx); + let overshoot = view.selected_text(); + view.set_source("other\n", cx); + (overshoot, view.selected_text()) + }) + }); + + assert_eq!( + overshoot.as_deref(), + Some("only"), + "ranges clamp to the document" + ); + assert_eq!(after_reparse, None, "reparsing clears the selection"); +} + +struct TextViewHost { + link_handler: Rc>>, +} + +impl Render for TextViewHost { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let clicks = self.link_handler.clone(); + div() + .debug_selector(|| "text-view".to_string()) + .size_full() + .child( + TextView::markdown( + "text-view", + "[a very long link label that fills the line](https://example.com/welcome)", + ) + .on_link_click(move |url, _window, _cx| { + clicks.borrow_mut().push(url.to_string()); + }), + ) + } +} + +#[test] +fn text_view_clicks_route_through_the_link_handler() { + let mut cx = TestAppContext::single(); + cx.update(gpui_native_theme::Theme::init); + let clicks: Rc>> = Rc::new(RefCell::new(Vec::new())); + let clicks_cell = clicks.clone(); + let window = cx.add_window(move |_, _| TextViewHost { + link_handler: clicks_cell, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + + let bounds = visual + .debug_bounds("text-view") + .expect("the text view renders"); + let position = point(bounds.left() + px(4.), bounds.top() + px(10.)); + visual.simulate_event(MouseDownEvent { + position, + button: MouseButton::Left, + ..Default::default() + }); + visual.simulate_event(MouseUpEvent { + position, + button: MouseButton::Left, + ..Default::default() + }); + + assert_eq!( + clicks.borrow().as_slice(), + ["https://example.com/welcome".to_string()], + "the click lands on the link and routes through the handler" + ); +} + +struct SelectionHost { + state: Entity, +} + +impl Render for SelectionHost { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().child(div().w(rems(30.)).child(TextView::new(&self.state))) + } +} + +fn selection_window( + source: &str, +) -> (TestAppContext, VisualTestContext, Entity) { + let mut cx = TestAppContext::single(); + cx.update(gpui_native_theme::Theme::init); + cx.update(bind_text_view_keys); + let state = MarkdownViewState::new("sel-view", source); + let state_cell: Rc>>> = Rc::new(RefCell::new(None)); + let cell = state_cell.clone(); + let window = cx.add_window(move |_, cx| { + let view = cx.new(|_| state); + cell.borrow_mut().replace(view.clone()); + SelectionHost { state: view } + }); + let state = state_cell.borrow().clone().expect("state captured"); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + (cx, visual, state) +} + +/// Leaks a block id so it can feed `debug_bounds`, which needs `&'static str`. +fn leaked_block_id( + state: &Entity, + cx: &App, + block_index: usize, +) -> &'static str { + Box::::leak( + state + .read(cx) + .block_id(block_index) + .expect("the block has an id") + .to_string() + .into_boxed_str(), + ) +} + +fn click(visual: &mut VisualTestContext, position: gpui::Point) { + visual.simulate_event(MouseDownEvent { + position, + button: MouseButton::Left, + ..Default::default() + }); + visual.simulate_event(MouseUpEvent { + position, + button: MouseButton::Left, + ..Default::default() + }); +} + +#[test] +fn dragging_across_paragraphs_selects_and_copies() { + let (cx, mut visual, state) = selection_window("alpha bravo\n\ncharlie delta\n"); + + let first = cx.update(|cx| leaked_block_id(&state, cx, 0)); + let second = cx.update(|cx| leaked_block_id(&state, cx, 1)); + + let start = { + let bounds = visual.debug_bounds(first).expect("the first block renders"); + gpui::point(bounds.left() + px(4.), bounds.top() + px(8.)) + }; + visual.simulate_event(MouseDownEvent { + position: start, + button: MouseButton::Left, + ..Default::default() + }); + let end = { + let bounds = visual + .debug_bounds(second) + .expect("the second block renders"); + gpui::point(bounds.left() + px(40.), bounds.top() + px(8.)) + }; + visual.simulate_event(MouseMoveEvent { + position: end, + ..Default::default() + }); + visual.simulate_event(MouseUpEvent { + position: end, + button: MouseButton::Left, + ..Default::default() + }); + + let selected = cx.update(|cx| state.read(cx).selected_text()); + assert_eq!( + selected.as_deref(), + Some("alpha bravo\ncharli"), + "the drag selects from the press through the pointer" + ); + + visual.dispatch_action(gpui_native_editor::markdown::Copy); + let clipboard = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text())); + assert_eq!( + clipboard.as_deref(), + selected.as_deref(), + "the copy action copies the selected text" + ); +} + +struct TaskHost { + toggles: Rc>>, +} + +impl Render for TaskHost { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let toggles = self.toggles.clone(); + div().size_full().child( + TextView::markdown("task-view", "- [ ] first\n- [x] second\n").on_task_toggle( + move |block, checked, _window, _cx| { + toggles.borrow_mut().push((block, checked)); + }, + ), + ) + } +} + +#[test] +fn clicking_a_task_checkbox_toggles_it_through_the_handler() { + let mut cx = TestAppContext::single(); + cx.update(gpui_native_theme::Theme::init); + let toggles: Rc>> = Rc::new(RefCell::new(Vec::new())); + let toggles_cell = toggles.clone(); + let window = cx.add_window(move |_, _| TaskHost { + toggles: toggles_cell, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + + let bounds = visual + .debug_bounds("task-toggle-0") + .expect("the task renders"); + click( + &mut visual, + gpui::point(bounds.center().x, bounds.center().y), + ); + + assert_eq!( + toggles.borrow().as_slice(), + [(0, true)], + "checking the first task reports its block and the new state" + ); +} + +struct CodeHost; + +impl Render for CodeHost { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(TextView::markdown( + "code-host", + "```text\nfn main() {}\n```\n", + )) + } +} + +#[test] +fn the_code_block_copy_button_copies_the_fenced_source() { + let mut cx = TestAppContext::single(); + cx.update(gpui_native_theme::Theme::init); + let window = cx.add_window(|_, _| CodeHost); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + + let bounds = visual + .debug_bounds("copy-code") + .expect("the copy button renders"); + click( + &mut visual, + gpui::point(bounds.center().x, bounds.center().y), + ); + + let clipboard = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text())); + assert_eq!( + clipboard.as_deref(), + Some("fn main() {}"), + "the copy button copies the fenced source" + ); +} diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 20db55a..4e84454 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -46,7 +46,7 @@ use gpui_native_data::tree::{Tree, TreeItem}; use gpui_native_editor::code_view::CodeViewState; use gpui_native_editor::diff_view::{DiffRowKind, DiffViewState}; use gpui_native_editor::highlight::Language; -use gpui_native_editor::markdown::{MarkdownBlock, MarkdownViewState}; +use gpui_native_editor::markdown::{MarkdownBlock, MarkdownViewState, TextView}; use gpui_native_icons::{IconAssets, IconRole, icon}; use gpui_native_layout::dock::{Dock, DockSidebar, DockState, PanelDescriptor, SplitDirection}; use gpui_native_layout::resizable::{Resizable, ResizableState}; @@ -98,6 +98,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[ ("code-view", "Code view"), ("diff-view", "Diff view"), ("markdown-view", "Markdown view"), + ("text-view", "Text view"), ], ), ( @@ -612,6 +613,9 @@ impl Gallery { "Markdown view", |_, cx| cx.new(MarkdownViewDemo::new).into(), )); + dock.register_panel(PanelDescriptor::new("text-view", "Text view", |_, cx| { + cx.new(TextViewDemo::new).into() + })); dock.register_panel(PanelDescriptor::new("sidebar", "Sidebar", |_, cx| { cx.new(|_| SidebarDemo { selected: None }).into() })); @@ -2191,30 +2195,28 @@ impl Render for TreeDemo { let mut paths = Vec::new(); Self::flatten(&self.nodes, 0, &[], "", &mut items, &mut paths); - ScrollArea::new("tree-demo") - .size_full() - .child(demo_section( - "outline", - colors, - Tree::new("tree-demo-tree") - .selected(self.selected) - .on_change(move |selected, _, cx| { - view.update(cx, |demo, cx| { - demo.selected = selected; - cx.notify(); - }); - }) - .on_disclosure(move |index, _, cx| { - view_disclosure.update(cx, |demo, cx| { - let paths = TreeDemo::paths(&demo.nodes); - if let Some(path) = paths.get(index) { - TreeDemo::toggle(&mut demo.nodes, path); - } - cx.notify(); - }); - }) - .items(move |_, _| items.clone()), - )) + ScrollArea::new("tree-demo").size_full().child(demo_section( + "outline", + colors, + Tree::new("tree-demo-tree") + .selected(self.selected) + .on_change(move |selected, _, cx| { + view.update(cx, |demo, cx| { + demo.selected = selected; + cx.notify(); + }); + }) + .on_disclosure(move |index, _, cx| { + view_disclosure.update(cx, |demo, cx| { + let paths = TreeDemo::paths(&demo.nodes); + if let Some(path) = paths.get(index) { + TreeDemo::toggle(&mut demo.nodes, path); + } + cx.notify(); + }); + }) + .items(move |_, _| items.clone()), + )) } } @@ -2645,42 +2647,40 @@ impl Render for MenuDemo { let view = cx.entity(); let colors = cx.theme().colors; - ScrollArea::new("menu-demo") - .size_full() - .child(demo_section( - "trigger and items", - colors, - Menu::new("menu-demo-menu") - .open(self.open) - .on_change(move |open, _, cx| { - view.update(cx, |demo, cx| { - demo.open = open; - cx.notify(); - }); - }) - .trigger(|_, _| { - Button::new("menu-demo-trigger") - .label("Actions") - .variant(ButtonVariant::Secondary) - .into_any_element() - }) - .items(|_, _| { - vec![ - MenuItem::new("Cut").shortcut("cmd-x").handler(|_, cx| { - eprintln!("menu: cut"); - let _ = cx; - }), - MenuItem::new("Copy").shortcut("cmd-c"), - MenuItem::new("Paste").shortcut("cmd-v"), - MenuItem::separator(), - MenuItem::new("Use quiet mode").checked(true), - MenuItem::new("Show hidden files"), - MenuItem::separator(), - MenuItem::new("Unavailable").disabled(true), - MenuItem::new("Delete").destructive(true), - ] - }), - )) + ScrollArea::new("menu-demo").size_full().child(demo_section( + "trigger and items", + colors, + Menu::new("menu-demo-menu") + .open(self.open) + .on_change(move |open, _, cx| { + view.update(cx, |demo, cx| { + demo.open = open; + cx.notify(); + }); + }) + .trigger(|_, _| { + Button::new("menu-demo-trigger") + .label("Actions") + .variant(ButtonVariant::Secondary) + .into_any_element() + }) + .items(|_, _| { + vec![ + MenuItem::new("Cut").shortcut("cmd-x").handler(|_, cx| { + eprintln!("menu: cut"); + let _ = cx; + }), + MenuItem::new("Copy").shortcut("cmd-c"), + MenuItem::new("Paste").shortcut("cmd-v"), + MenuItem::separator(), + MenuItem::new("Use quiet mode").checked(true), + MenuItem::new("Show hidden files"), + MenuItem::separator(), + MenuItem::new("Unavailable").disabled(true), + MenuItem::new("Delete").destructive(true), + ] + }), + )) } } @@ -2835,30 +2835,28 @@ impl Render for DropdownMenuDemo { ] }); - ScrollArea::new("dropdown-menu-demo") - .size_full() - .child( - v_flex() - .gap_3() - .child(demo_section( - "menu-only trigger and split button", - colors, - h_flex().gap_3().child(export).child(save), - )) - .child(demo_section( - "trigger variants", - colors, - h_flex().gap_3().child(duplicate).child(delete).child(more), - )) - .child(demo_section( - "status", - colors, - div() - .text_sm() - .text_color(colors.muted_foreground) - .child(status), - )), - ) + ScrollArea::new("dropdown-menu-demo").size_full().child( + v_flex() + .gap_3() + .child(demo_section( + "menu-only trigger and split button", + colors, + h_flex().gap_3().child(export).child(save), + )) + .child(demo_section( + "trigger variants", + colors, + h_flex().gap_3().child(duplicate).child(delete).child(more), + )) + .child(demo_section( + "status", + colors, + div() + .text_sm() + .text_color(colors.muted_foreground) + .child(status), + )), + ) } } @@ -2871,51 +2869,49 @@ impl Render for ContextMenuDemo { let view = cx.entity(); let colors = cx.theme().colors; - ScrollArea::new("context-menu-demo") - .size_full() - .child( - v_flex() - .gap_3() - .child(demo_section( - "on any target", - colors, - ContextMenu::new("context-menu-demo-menu") - .target(move |_, cx| { - let theme = cx.theme(); - div() - .px_3() - .py_2() - .rounded(theme.radius.overlay) - .border_1() - .border_color(theme.colors.border) - .bg(theme.colors.surface) - .text_size(theme.sizes.medium.text_size) - .child("Right-click this card") - .into_any_element() - }) - .items(move |_, _| { - CONTEXT_ACTIONS - .iter() - .map(|action| { - let view = view.clone(); - MenuItem::new(*action).handler(move |_, cx| { - view.update(cx, |demo, cx| { - demo.action = SharedString::from(*action); - cx.notify(); - }); - }) + ScrollArea::new("context-menu-demo").size_full().child( + v_flex() + .gap_3() + .child(demo_section( + "on any target", + colors, + ContextMenu::new("context-menu-demo-menu") + .target(move |_, cx| { + let theme = cx.theme(); + div() + .px_3() + .py_2() + .rounded(theme.radius.overlay) + .border_1() + .border_color(theme.colors.border) + .bg(theme.colors.surface) + .text_size(theme.sizes.medium.text_size) + .child("Right-click this card") + .into_any_element() + }) + .items(move |_, _| { + CONTEXT_ACTIONS + .iter() + .map(|action| { + let view = view.clone(); + MenuItem::new(*action).handler(move |_, cx| { + view.update(cx, |demo, cx| { + demo.action = SharedString::from(*action); + cx.notify(); + }); }) - .collect() - }) - .accessibility_label("Card actions"), - )) - .child( - div() - .text_sm() - .text_color(colors.muted_foreground) - .child(self.action.clone()), - ), - ) + }) + .collect() + }) + .accessibility_label("Card actions"), + )) + .child( + div() + .text_sm() + .text_color(colors.muted_foreground) + .child(self.action.clone()), + ), + ) } } @@ -3734,6 +3730,68 @@ impl Render for MarkdownViewDemo { } } +/// The text view demo's sample document. +const TEXT_VIEW_SAMPLE: &str = r###"Try the [relay docs](https://example.com/relay) or jump to [issue #71](https://example.com/issues/71). + +Links route through the handler below — nothing opens on its own. +"###; + +/// The text view demo: an embeddable markdown view whose links the app routes. +struct TextViewDemo { + clicked_links: Vec, +} + +impl TextViewDemo { + fn new(_cx: &mut Context) -> Self { + Self { + clicked_links: Vec::new(), + } + } +} + +impl Render for TextViewDemo { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.theme().colors; + let radius = cx.theme().radius; + let clicks = cx.entity().downgrade(); + let last_clicked = self.clicked_links.last().cloned(); + + ScrollArea::new("text-view-demo").size_full().child( + v_flex().gap_3().child(demo_section( + "embedded, fit-content; links route through the app", + colors, + v_flex() + .gap_2() + .child( + div() + .w_full() + .rounded(radius.control) + .border_1() + .border_color(colors.border) + .p_3() + .child( + TextView::markdown("text-view-demo", TEXT_VIEW_SAMPLE) + .on_link_click(move |url, _window, cx| { + clicks + .update(cx, |demo, cx| { + demo.clicked_links.push(url.to_string()); + cx.notify(); + }) + .log_err(); + }), + ), + ) + .child(div().text_xs().text_color(colors.muted_foreground).child( + match last_clicked { + Some(url) => format!("last routed link: {url}"), + None => "click a link to route it".to_string(), + }, + )), + )), + ) + } +} + struct SidebarDemo { selected: Option, } @@ -4053,6 +4111,7 @@ fn main() { .run(|cx: &mut App| { gpui_native_core::init(cx); gpui_native_editor::code_view::bind_code_view_keys(cx); + gpui_native_editor::markdown::bind_text_view_keys(cx); cx.bind_keys([ KeyBinding::new("cmd-k", OpenCommandPalette, None),