From 18ab3700ec9bb04c92436ab124c02eaaa33156ae Mon Sep 17 00:00:00 2001 From: "Gordon Lam (SH) (from Dev Box)" Date: Thu, 20 Aug 2026 02:25:16 +0800 Subject: [PATCH] Add Markdown preview mode Render Markdown documents in a scrollable, read-only preview using Edit's native TUI primitives. Keep preview state per document, bound parser work for responsiveness, and add regression coverage for caching, malformed input, Unicode wrapping, and adversarial delimiters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/edit/src/bin/edit/documents.rs | 116 ++++ crates/edit/src/bin/edit/draw_editor.rs | 15 +- .../src/bin/edit/draw_markdown_preview.rs | 86 +++ crates/edit/src/bin/edit/draw_menubar.rs | 21 +- crates/edit/src/bin/edit/main.rs | 8 + crates/edit/src/bin/edit/markdown_preview.rs | 429 ++++++++++++ .../src/bin/edit/markdown_preview/layout.rs | 176 +++++ .../src/bin/edit/markdown_preview/parser.rs | 615 ++++++++++++++++++ crates/edit/src/bin/edit/state.rs | 32 + crates/edit/src/tui.rs | 5 + i18n/edit.toml | 9 + 11 files changed, 1506 insertions(+), 6 deletions(-) create mode 100644 crates/edit/src/bin/edit/draw_markdown_preview.rs create mode 100644 crates/edit/src/bin/edit/markdown_preview.rs create mode 100644 crates/edit/src/bin/edit/markdown_preview/layout.rs create mode 100644 crates/edit/src/bin/edit/markdown_preview/parser.rs diff --git a/crates/edit/src/bin/edit/documents.rs b/crates/edit/src/bin/edit/documents.rs index 1ac332a2c2f..f0ac291f661 100644 --- a/crates/edit/src/bin/edit/documents.rs +++ b/crates/edit/src/bin/edit/documents.rs @@ -12,6 +12,7 @@ use edit::lsh::{FILE_ASSOCIATIONS, Language, process_file_associations}; use edit::{path, sys}; use crate::apperr; +use crate::markdown_preview::MarkdownPreview; use crate::settings::Settings; use crate::state::DisplayablePathBuf; @@ -23,9 +24,18 @@ pub struct Document { pub file_id: Option, pub new_file_counter: usize, pub language_override: Option>, + pub markdown_preview: MarkdownPreview, } impl Document { + pub fn is_markdown(&self) -> bool { + self.buffer.borrow().language().is_some_and(|language| language.id == "markdown") + } + + pub fn markdown_preview_enabled(&self) -> bool { + self.is_markdown() && self.markdown_preview.is_enabled() + } + pub fn save(&mut self, new_path: Option) -> apperr::Result<()> { let path = new_path.as_deref().unwrap_or_else(|| self.path.as_ref().unwrap().as_path()); let mut file = DocumentManager::open_for_writing(path)?; @@ -171,11 +181,15 @@ impl DocumentManager { std::ptr::copy_nonoverlapping(temp.as_ptr(), last, 1); } + self.list.last_mut().unwrap().markdown_preview.active_document_changed(); true } pub fn remove_active(&mut self) { self.list.pop(); + if let Some(document) = self.list.last_mut() { + document.markdown_preview.active_document_changed(); + } } pub fn add_untitled(&mut self) -> apperr::Result<&mut Document> { @@ -188,6 +202,7 @@ impl DocumentManager { file_id: None, new_file_counter: 0, language_override: None, + markdown_preview: Default::default(), }; self.gen_untitled_name(&mut doc); @@ -240,6 +255,7 @@ impl DocumentManager { file_id, new_file_counter: 0, language_override: None, + markdown_preview: Default::default(), }; doc.set_path(path); @@ -358,6 +374,106 @@ pub fn parse_filename_goto(path: &Path) -> (&Path, Option) { #[cfg(test)] mod tests { use super::*; + use edit::lsh::LANGUAGES; + + fn markdown_document(filename: &str, source: &str) -> Document { + let buffer = TextBuffer::new_rc(false).unwrap(); + { + let mut tb = buffer.borrow_mut(); + tb.write_raw(source.as_bytes()); + let language = LANGUAGES.iter().find(|language| language.id == "markdown").unwrap(); + tb.set_language(Some(language)); + } + + Document { + buffer, + path: None, + dir: None, + filename: filename.to_string(), + file_id: None, + new_file_counter: 0, + language_override: None, + markdown_preview: Default::default(), + } + } + + fn preview_text(document: &Document) -> String { + document + .markdown_preview + .lines() + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.text.as_str()) + .collect() + } + + #[test] + fn documents_keep_independent_preview_state_and_cache() { + let mut documents = DocumentManager::default(); + documents.list.push(markdown_document("first.md", "# First")); + documents.list.push(markdown_document("second.md", "# Second")); + + { + let second = documents.active_mut().unwrap(); + second.markdown_preview.set_enabled(true); + second.markdown_preview.prepare(&second.buffer, 80); + } + assert!(documents.update_active(|document| document.filename == "first.md")); + { + let first = documents.active_mut().unwrap(); + first.markdown_preview.set_enabled(true); + first.markdown_preview.prepare(&first.buffer, 80); + } + assert!(documents.update_active(|document| document.filename == "second.md")); + documents.active_mut().unwrap().markdown_preview.set_enabled(false); + + assert!(documents.update_active(|document| document.filename == "first.md")); + let first = documents.active().unwrap(); + assert!(first.markdown_preview_enabled()); + assert_eq!(preview_text(first), "First"); + + assert!(documents.update_active(|document| document.filename == "second.md")); + let second = documents.active().unwrap(); + assert!(!second.markdown_preview_enabled()); + assert_eq!(preview_text(second), "Second"); + } + + #[test] + fn switching_documents_requests_independent_preview_scroll_resets() { + let mut documents = DocumentManager::default(); + documents.list.push(markdown_document("first.md", "# First")); + documents.list.push(markdown_document("second.md", "# Second")); + + { + let second = documents.active_mut().unwrap(); + second.markdown_preview.set_enabled(true); + second.markdown_preview.prepare(&second.buffer, 80); + assert!(second.markdown_preview.take_scroll_reset()); + } + + assert!(documents.update_active(|document| document.filename == "first.md")); + { + let first = documents.active_mut().unwrap(); + first.markdown_preview.set_enabled(true); + first.markdown_preview.prepare(&first.buffer, 80); + assert!(first.markdown_preview.take_scroll_reset()); + } + + assert!(documents.update_active(|document| document.filename == "second.md")); + assert!(documents.active_mut().unwrap().markdown_preview.take_scroll_reset()); + + assert!(documents.update_active(|document| document.filename == "first.md")); + assert!(documents.active_mut().unwrap().markdown_preview.take_scroll_reset()); + } + + #[test] + fn non_markdown_document_never_exposes_preview() { + let mut document = markdown_document("notes.txt", "plain text"); + document.buffer.borrow_mut().set_language(None); + document.markdown_preview.set_enabled(true); + + assert!(!document.markdown_preview_enabled()); + } #[test] fn test_parse_last_numbers() { diff --git a/crates/edit/src/bin/edit/draw_editor.rs b/crates/edit/src/bin/edit/draw_editor.rs index ce03bbeb4cb..7bf69e4398b 100644 --- a/crates/edit/src/bin/edit/draw_editor.rs +++ b/crates/edit/src/bin/edit/draw_editor.rs @@ -8,6 +8,7 @@ use edit::input::{kbmod, vk}; use edit::tui::*; use stdext::string_from_utf8_lossy_owned; +use crate::draw_markdown_preview::draw_markdown_preview; use crate::localization::*; use crate::state::*; @@ -24,8 +25,18 @@ pub fn draw_editor(ctx: &mut Context, state: &mut State) { _ => 2, }; - if let Some(doc) = state.documents.active() { - ctx.textarea("textarea", doc.buffer.clone()); + if let Some(document) = state.documents.active_mut() { + let buffer = document.buffer.clone(); + if document.markdown_preview_enabled() { + draw_markdown_preview( + ctx, + &buffer, + &mut document.markdown_preview, + size.height - height_reduction, + ); + } else { + ctx.textarea("textarea", buffer); + } ctx.inherit_focus(); } else { ctx.block_begin("empty"); diff --git a/crates/edit/src/bin/edit/draw_markdown_preview.rs b/crates/edit/src/bin/edit/draw_markdown_preview.rs new file mode 100644 index 00000000000..87b81c08363 --- /dev/null +++ b/crates/edit/src/bin/edit/draw_markdown_preview.rs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use edit::buffer::RcTextBuffer; +use edit::framebuffer::{Attributes, IndexedColor}; +use edit::helpers::*; +use edit::tui::*; + +use crate::markdown_preview::{InlineStyle, MarkdownPreview, RenderedLineKind}; + +pub fn draw_markdown_preview( + ctx: &mut Context, + buffer: &RcTextBuffer, + preview: &mut MarkdownPreview, + height: CoordType, +) { + let width = (ctx.size().width - 3).max(2); + preview.prepare(buffer, width); + let reset_scroll = preview.take_scroll_reset(); + + ctx.scrollarea_begin("markdown-preview", Size { width: 0, height }); + ctx.focus_on_first_present(); + ctx.attr_padding(Rect::two(0, 1)); + if reset_scroll { + ctx.scrollarea_scroll_to(Point::default()); + } + { + for (index, line) in preview.lines().iter().enumerate() { + ctx.next_block_id_mixin(index as u64); + ctx.styled_label_begin("line"); + + let mut style = None; + for span in &line.spans { + if style != Some(span.style) { + style = Some(span.style); + ctx.styled_label_set_attributes(attributes(span.style)); + } + ctx.styled_label_add_text(&span.text); + } + + ctx.styled_label_end(); + + match line.kind { + RenderedLineKind::Text => {} + RenderedLineKind::Heading(level) => { + let color = + if level <= 2 { IndexedColor::BrightCyan } else { IndexedColor::Cyan }; + ctx.attr_foreground_rgba(ctx.indexed(color)); + } + RenderedLineKind::Quote => { + ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::BrightGreen)); + } + RenderedLineKind::Rule => { + ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::BrightBlack)); + } + RenderedLineKind::Code => { + ctx.attr_background_rgba(ctx.indexed_alpha(IndexedColor::Black, 1, 4)); + ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::BrightYellow)); + } + RenderedLineKind::Unavailable => { + ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::BrightYellow)); + } + } + } + } + ctx.scrollarea_end(); +} + +fn attributes(style: InlineStyle) -> Attributes { + let mut attributes = Attributes::None; + + if style.is_bold() { + attributes = attributes | Attributes::Bold; + } + if style.is_italic() || style.is_code() { + attributes = attributes | Attributes::Italic; + } + if style.is_strikethrough() { + attributes = attributes | Attributes::Strikethrough; + } + if style.is_link() || style.is_code() { + attributes = attributes | Attributes::Underlined; + } + + attributes +} diff --git a/crates/edit/src/bin/edit/draw_menubar.rs b/crates/edit/src/bin/edit/draw_menubar.rs index 401614786a1..4de8fdcafce 100644 --- a/crates/edit/src/bin/edit/draw_menubar.rs +++ b/crates/edit/src/bin/edit/draw_menubar.rs @@ -24,7 +24,7 @@ pub fn draw_menubar(ctx: &mut Context, state: &mut State) { ctx.steal_focus(); } if state.documents.active().is_some() { - if ctx.menubar_menu_begin(loc(LocId::Edit), 'E') { + if !state.markdown_preview_enabled() && ctx.menubar_menu_begin(loc(LocId::Edit), 'E') { draw_menu_edit(ctx, state); } if ctx.menubar_menu_begin(loc(LocId::View), 'V') { @@ -122,10 +122,18 @@ fn draw_menu_edit(ctx: &mut Context, state: &mut State) { } fn draw_menu_view(ctx: &mut Context, state: &mut State) { - if let Some(doc) = state.documents.active() { - let mut tb = doc.buffer.borrow_mut(); - let word_wrap = tb.is_word_wrap_enabled(); + if state.markdown_preview_available() + && ctx.menubar_menu_checkbox( + loc(LocId::ViewMarkdownPreview), + 'P', + kbmod::ALT | vk::P, + state.markdown_preview_enabled(), + ) + { + state.toggle_markdown_preview(); + } + if state.documents.active().is_some() { // All values on the statusbar are currently document specific. if ctx.menubar_menu_button(loc(LocId::ViewFocusStatusbar), 'S', vk::NULL) { state.wants_statusbar_focus = true; @@ -134,8 +142,13 @@ fn draw_menu_view(ctx: &mut Context, state: &mut State) { state.wants_go_to_file = true; } if ctx.menubar_menu_button(loc(LocId::FileGoto), 'G', kbmod::CTRL | vk::G) { + state.disable_markdown_preview(); state.wants_goto = true; } + + let doc = state.documents.active().unwrap(); + let mut tb = doc.buffer.borrow_mut(); + let word_wrap = tb.is_word_wrap_enabled(); if ctx.menubar_menu_checkbox(loc(LocId::ViewWordWrap), 'W', kbmod::ALT | vk::Z, word_wrap) { tb.set_word_wrap(!word_wrap); ctx.needs_rerender(); diff --git a/crates/edit/src/bin/edit/main.rs b/crates/edit/src/bin/edit/main.rs index 27ae7fab6c0..5b2deb98389 100644 --- a/crates/edit/src/bin/edit/main.rs +++ b/crates/edit/src/bin/edit/main.rs @@ -5,9 +5,11 @@ mod apperr; mod documents; mod draw_editor; mod draw_filepicker; +mod draw_markdown_preview; mod draw_menubar; mod draw_statusbar; mod localization; +mod markdown_preview; mod settings; mod state; @@ -386,6 +388,8 @@ fn draw(ctx: &mut Context, state: &mut State) { state.wants_save = true; } else if key == kbmod::CTRL_SHIFT | vk::S { state.wants_file_picker = StateFilePicker::SaveAs; + } else if key == kbmod::ALT | vk::P { + state.toggle_markdown_preview(); } else if key == kbmod::CTRL | vk::W { state.wants_close = true; } else if key == kbmod::CTRL | vk::P { @@ -393,16 +397,20 @@ fn draw(ctx: &mut Context, state: &mut State) { } else if key == kbmod::CTRL | vk::Q { state.wants_exit = true; } else if key == kbmod::CTRL | vk::G { + state.disable_markdown_preview(); state.wants_goto = true; } else if key == kbmod::CTRL | vk::F && state.wants_search.kind != StateSearchKind::Disabled { + state.disable_markdown_preview(); state.wants_search.kind = StateSearchKind::Search; state.wants_search.focus = true; } else if key == kbmod::CTRL | vk::R && state.wants_search.kind != StateSearchKind::Disabled { + state.disable_markdown_preview(); state.wants_search.kind = StateSearchKind::Replace; state.wants_search.focus = true; } else if key == vk::F3 { + state.disable_markdown_preview(); search_execute(ctx, state, SearchAction::Search); } else { return; diff --git a/crates/edit/src/bin/edit/markdown_preview.rs b/crates/edit/src/bin/edit/markdown_preview.rs new file mode 100644 index 00000000000..b0aa19a4166 --- /dev/null +++ b/crates/edit/src/bin/edit/markdown_preview.rs @@ -0,0 +1,429 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod layout; +mod parser; + +use edit::buffer::RcTextBuffer; +use edit::helpers::{CoordType, KIBI, MEBI}; + +use crate::localization::{LocId, loc}; + +use self::layout::{render_blocks, render_message}; +use self::parser::{Block, parse_blocks}; + +/// Markdown parsing and layout operate on the whole source, so previews are +/// deliberately bounded to preserve the editor's large-file responsiveness. +const MAX_PREVIEW_BYTES: usize = MEBI; +/// Match LSH's safeguard against pathological single lines. +const MAX_PREVIEW_LINE_BYTES: usize = 32 * KIBI; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct InlineStyle(u8); + +impl InlineStyle { + const BOLD: Self = Self(1); + const ITALIC: Self = Self(2); + const STRIKETHROUGH: Self = Self(4); + const CODE: Self = Self(8); + const LINK: Self = Self(16); + + pub(super) const fn with(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + pub const fn is_bold(self) -> bool { + self.0 & Self::BOLD.0 != 0 + } + + pub const fn is_italic(self) -> bool { + self.0 & Self::ITALIC.0 != 0 + } + + pub const fn is_strikethrough(self) -> bool { + self.0 & Self::STRIKETHROUGH.0 != 0 + } + + pub const fn is_code(self) -> bool { + self.0 & Self::CODE.0 != 0 + } + + pub const fn is_link(self) -> bool { + self.0 & Self::LINK.0 != 0 + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InlineSpan { + pub text: String, + pub style: InlineStyle, +} + +pub(super) fn push_span(spans: &mut Vec, text: &str, style: InlineStyle) { + if text.is_empty() { + return; + } + + if let Some(last) = spans.last_mut() + && last.style == style + { + last.text.push_str(text); + } else { + spans.push(InlineSpan { text: text.to_string(), style }); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RenderedLineKind { + Text, + Heading(u8), + Quote, + Rule, + Code, + Unavailable, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RenderedLine { + pub kind: RenderedLineKind, + pub spans: Vec, +} + +struct PreviewCache { + source: Option>, + blocks: Vec, + generation: u32, + unavailable: Option, + width: CoordType, + lines: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PreviewUnavailable { + TotalSize, + LineLength, +} + +struct SourceSnapshot { + source: Option>, + generation: u32, + unavailable: Option, +} + +#[derive(Default)] +pub struct MarkdownPreview { + enabled: bool, + cache: Option, + force_validation: bool, + reset_scroll: bool, + #[cfg(test)] + rebuild_count: usize, + #[cfg(test)] + parse_count: usize, + #[cfg(test)] + layout_count: usize, + #[cfg(test)] + source_validation_count: usize, +} + +impl MarkdownPreview { + pub fn is_enabled(&self) -> bool { + self.enabled + } + + pub fn set_enabled(&mut self, enabled: bool) { + if self.enabled == enabled { + return; + } + + self.enabled = enabled; + if enabled { + self.force_validation = true; + self.reset_scroll = true; + } + } + + pub fn active_document_changed(&mut self) { + self.reset_scroll = true; + } + + pub fn prepare(&mut self, buffer: &RcTextBuffer, width: CoordType) { + let width = width.max(2); + let generation = buffer.borrow().generation(); + let force_validation = std::mem::take(&mut self.force_validation); + + if self.cache.as_ref().is_some_and(|cache| cache.generation == generation) + && !force_validation + { + self.relayout(width); + return; + } + + #[cfg(test)] + { + self.source_validation_count += 1; + } + let snapshot = read_source(buffer); + if self.cache.as_ref().is_some_and(|cache| { + cache + .source + .as_ref() + .zip(snapshot.source.as_ref()) + .is_some_and(|(cached, source)| cached == source) + }) { + self.cache.as_mut().unwrap().generation = snapshot.generation; + self.relayout(width); + return; + } + + self.rebuild(snapshot, width); + self.reset_scroll = true; + } + + pub fn lines(&self) -> &[RenderedLine] { + self.cache.as_ref().map_or(&[], |cache| cache.lines.as_slice()) + } + + pub fn take_scroll_reset(&mut self) -> bool { + std::mem::take(&mut self.reset_scroll) + } + + fn rebuild(&mut self, snapshot: SourceSnapshot, width: CoordType) { + let blocks = if snapshot.unavailable.is_none() { + let source = String::from_utf8_lossy(snapshot.source.as_deref().unwrap_or_default()); + #[cfg(test)] + { + self.parse_count += 1; + } + parse_blocks(&source) + } else { + Vec::new() + }; + let lines = render(&blocks, snapshot.unavailable, width); + self.cache = Some(PreviewCache { + source: snapshot.source, + blocks, + generation: snapshot.generation, + unavailable: snapshot.unavailable, + width, + lines, + }); + #[cfg(test)] + { + self.rebuild_count += 1; + self.layout_count += 1; + } + } + + fn relayout(&mut self, width: CoordType) { + let Some(cache) = &mut self.cache else { + return; + }; + if cache.width != width { + cache.lines = render(&cache.blocks, cache.unavailable, width); + cache.width = width; + #[cfg(test)] + { + self.layout_count += 1; + } + } + } +} + +fn render( + blocks: &[Block], + unavailable: Option, + width: CoordType, +) -> Vec { + match unavailable { + Some(PreviewUnavailable::TotalSize) => { + render_message(loc(LocId::MarkdownPreviewUnavailableTooLarge), width) + } + Some(PreviewUnavailable::LineLength) => { + render_message(loc(LocId::MarkdownPreviewUnavailableLineTooLong), width) + } + None => render_blocks(blocks, width), + } +} + +fn read_source(buffer: &RcTextBuffer) -> SourceSnapshot { + let tb = buffer.borrow(); + let generation = tb.generation(); + if tb.text_length() > MAX_PREVIEW_BYTES { + return SourceSnapshot { + source: None, + generation, + unavailable: Some(PreviewUnavailable::TotalSize), + }; + } + + let mut bytes = Vec::with_capacity(tb.text_length()); + let mut offset = 0; + let mut line_length = 0; + let mut line_too_long = false; + + while offset < tb.text_length() { + let chunk = tb.read_forward(offset); + if chunk.is_empty() { + break; + } + bytes.extend_from_slice(chunk); + for &byte in chunk { + if byte == b'\n' { + line_length = 0; + } else { + line_length += 1; + line_too_long |= line_length > MAX_PREVIEW_LINE_BYTES; + } + } + offset += chunk.len(); + } + + SourceSnapshot { + source: Some(bytes), + generation, + unavailable: line_too_long.then_some(PreviewUnavailable::LineLength), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use edit::buffer::TextBuffer; + + fn buffer_with_bytes(source: &[u8]) -> RcTextBuffer { + let buffer = TextBuffer::new_rc(false).unwrap(); + buffer.borrow_mut().write_raw(source); + buffer + } + + fn buffer_with(source: &str) -> RcTextBuffer { + buffer_with_bytes(source.as_bytes()) + } + + fn rendered_text(preview: &MarkdownPreview) -> String { + preview + .lines() + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.text.as_str()) + .collect() + } + + #[test] + fn unchanged_valid_utf8_does_not_rebuild_or_reparse() { + let buffer = buffer_with("# Before"); + let mut preview = MarkdownPreview::default(); + + preview.prepare(&buffer, 80); + let rebuild_count = preview.rebuild_count; + let parse_count = preview.parse_count; + let source_validation_count = preview.source_validation_count; + preview.prepare(&buffer, 80); + + assert_eq!(preview.rebuild_count, rebuild_count); + assert_eq!(preview.parse_count, parse_count); + assert_eq!(preview.source_validation_count, source_validation_count); + assert_eq!(rendered_text(&preview), "Before"); + } + + #[test] + fn unchanged_invalid_utf8_does_not_rebuild_or_reparse() { + let buffer = buffer_with_bytes(b"# invalid \xff"); + let mut preview = MarkdownPreview::default(); + + preview.prepare(&buffer, 80); + let rebuild_count = preview.rebuild_count; + let parse_count = preview.parse_count; + let source_validation_count = preview.source_validation_count; + preview.prepare(&buffer, 80); + + assert_eq!(preview.rebuild_count, rebuild_count); + assert_eq!(preview.parse_count, parse_count); + assert_eq!(preview.source_validation_count, source_validation_count); + assert_eq!(rendered_text(&preview), "invalid \u{fffd}"); + } + + #[test] + fn content_change_rebuilds_and_requests_scroll_reset() { + let buffer = buffer_with("# Before"); + let mut preview = MarkdownPreview::default(); + + preview.prepare(&buffer, 80); + assert!(preview.take_scroll_reset()); + + buffer.borrow_mut().write_raw(b" and after"); + preview.prepare(&buffer, 80); + + assert_eq!(rendered_text(&preview), "Before and after"); + assert!(preview.take_scroll_reset()); + } + + #[test] + fn reenable_cannot_reuse_stale_cache_after_generation_reuse() { + let buffer = buffer_with("# Cached"); + let mut preview = MarkdownPreview::default(); + preview.set_enabled(true); + preview.prepare(&buffer, 80); + let cached_generation = buffer.borrow().generation(); + + preview.set_enabled(false); + buffer.borrow_mut().undo(); + buffer.borrow_mut().write_raw(b"# Replacement"); + assert_eq!(buffer.borrow().generation(), cached_generation); + + preview.set_enabled(true); + preview.prepare(&buffer, 80); + + assert_eq!(rendered_text(&preview), "Replacement"); + } + + #[test] + fn total_size_limit_renders_unavailable_state() { + let source = vec![b'a'; MAX_PREVIEW_BYTES + 1]; + let buffer = buffer_with_bytes(&source); + let mut preview = MarkdownPreview::default(); + + preview.prepare(&buffer, 80); + + assert_eq!(rendered_text(&preview), loc(LocId::MarkdownPreviewUnavailableTooLarge)); + assert_eq!( + preview.cache.as_ref().unwrap().unavailable, + Some(PreviewUnavailable::TotalSize) + ); + assert_eq!(preview.parse_count, 0); + } + + #[test] + fn line_length_limit_renders_unavailable_state() { + let source = vec![b'a'; MAX_PREVIEW_LINE_BYTES + 1]; + let buffer = buffer_with_bytes(&source); + let mut preview = MarkdownPreview::default(); + + preview.prepare(&buffer, 80); + + assert_eq!(rendered_text(&preview), loc(LocId::MarkdownPreviewUnavailableLineTooLong)); + assert_eq!( + preview.cache.as_ref().unwrap().unavailable, + Some(PreviewUnavailable::LineLength) + ); + assert_eq!(preview.parse_count, 0); + } + + #[test] + fn width_only_relayout_does_not_request_scroll_reset() { + let buffer = buffer_with("words that wrap across several preview lines"); + let mut preview = MarkdownPreview::default(); + + preview.prepare(&buffer, 10); + assert!(preview.take_scroll_reset()); + let parse_count = preview.parse_count; + let layout_count = preview.layout_count; + + preview.prepare(&buffer, 20); + + assert!(!preview.take_scroll_reset()); + assert_eq!(preview.parse_count, parse_count); + assert_eq!(preview.layout_count, layout_count + 1); + } +} diff --git a/crates/edit/src/bin/edit/markdown_preview/layout.rs b/crates/edit/src/bin/edit/markdown_preview/layout.rs new file mode 100644 index 00000000000..006367ce043 --- /dev/null +++ b/crates/edit/src/bin/edit/markdown_preview/layout.rs @@ -0,0 +1,176 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use edit::helpers::{CoordType, Point}; +use edit::unicode::MeasurementConfig; + +use super::parser::Block; +use super::{InlineSpan, InlineStyle, RenderedLine, RenderedLineKind, push_span}; + +pub(super) fn render_blocks(blocks: &[Block], width: CoordType) -> Vec { + let mut lines = Vec::new(); + + for (index, block) in blocks.iter().enumerate() { + if index != 0 { + lines.push(blank_line()); + } + + match block { + Block::Heading { level, spans } => { + wrap_spans(spans, RenderedLineKind::Heading(*level), width, &mut lines); + } + Block::Paragraph(spans) => { + wrap_spans(spans, RenderedLineKind::Text, width, &mut lines); + } + Block::ListItem { marker, spans } => { + let mut prefixed = + vec![InlineSpan { text: format!("{marker} "), style: InlineStyle::BOLD }]; + prefixed.extend_from_slice(spans); + wrap_spans(&prefixed, RenderedLineKind::Text, width, &mut lines); + } + Block::Quote(spans) => { + let mut prefixed = + vec![InlineSpan { text: "│ ".to_string(), style: InlineStyle::BOLD }]; + prefixed.extend_from_slice(spans); + wrap_spans(&prefixed, RenderedLineKind::Quote, width, &mut lines); + } + Block::Rule => { + lines.push(RenderedLine { + kind: RenderedLineKind::Rule, + spans: vec![InlineSpan { + text: "─".repeat(width as usize), + style: InlineStyle::BOLD, + }], + }); + } + Block::Code(code) => { + if code.is_empty() { + lines.push(RenderedLine { + kind: RenderedLineKind::Code, + spans: vec![InlineSpan { + text: " ".to_string(), + style: InlineStyle::CODE, + }], + }); + } else { + for code_line in code { + let spans = [InlineSpan { + text: format!(" {code_line}"), + style: InlineStyle::CODE, + }]; + wrap_spans(&spans, RenderedLineKind::Code, width, &mut lines); + } + } + } + } + } + + if lines.is_empty() { + lines.push(blank_line()); + } + lines +} + +pub(super) fn render_message(message: &str, width: CoordType) -> Vec { + let mut lines = Vec::new(); + let spans = [InlineSpan { text: message.to_string(), style: InlineStyle::BOLD }]; + wrap_spans(&spans, RenderedLineKind::Unavailable, width, &mut lines); + lines +} + +fn blank_line() -> RenderedLine { + RenderedLine { + kind: RenderedLineKind::Text, + spans: vec![InlineSpan { text: " ".to_string(), style: InlineStyle::default() }], + } +} + +fn wrap_spans( + spans: &[InlineSpan], + kind: RenderedLineKind, + width: CoordType, + output: &mut Vec, +) { + let text: String = spans.iter().map(|span| span.text.as_str()).collect(); + if text.is_empty() { + output.push(RenderedLine { + kind, + spans: vec![InlineSpan { text: " ".to_string(), style: InlineStyle::default() }], + }); + return; + } + + let bytes = text.as_bytes(); + let mut measurement = MeasurementConfig::new(&bytes).with_word_wrap_column(width); + let mut start = 0; + let mut row = 0; + + while start < text.len() { + let mut end = measurement.goto_visual(Point { x: CoordType::MAX, y: row }).offset; + if end <= start { + end = text[start..] + .char_indices() + .nth(1) + .map_or(text.len(), |(advance, _)| start + advance); + } + + output.push(RenderedLine { kind, spans: slice_spans(spans, start, end) }); + start = end; + row += 1; + } +} + +fn slice_spans(spans: &[InlineSpan], start: usize, end: usize) -> Vec { + let mut result = Vec::new(); + let mut span_start = 0; + + for span in spans { + let span_end = span_start + span.text.len(); + let overlap_start = start.max(span_start); + let overlap_end = end.min(span_end); + + if overlap_start < overlap_end { + push_span( + &mut result, + &span.text[overlap_start - span_start..overlap_end - span_start], + span.style, + ); + } + + span_start = span_end; + if span_start >= end { + break; + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::super::parser::parse_inline; + use super::*; + + #[test] + fn unicode_wrapping_preserves_text_and_style_boundaries() { + let spans = parse_inline("你好 **世界🙂 café** τέλος"); + let mut lines = Vec::new(); + wrap_spans(&spans, RenderedLineKind::Text, 7, &mut lines); + + let rendered: String = lines + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.text.as_str()) + .collect(); + assert_eq!(rendered, "你好 世界🙂 café τέλος"); + + let bold: String = lines + .iter() + .flat_map(|line| line.spans.iter()) + .filter(|span| span.style.is_bold()) + .map(|span| span.text.as_str()) + .collect(); + assert_eq!(bold, "世界🙂 café"); + assert!(lines.iter().flat_map(|line| &line.spans).all(|span| !span.text.is_empty())); + } +} diff --git a/crates/edit/src/bin/edit/markdown_preview/parser.rs b/crates/edit/src/bin/edit/markdown_preview/parser.rs new file mode 100644 index 00000000000..267b1469834 --- /dev/null +++ b/crates/edit/src/bin/edit/markdown_preview/parser.rs @@ -0,0 +1,615 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::{InlineSpan, InlineStyle, push_span}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Block { + Heading { level: u8, spans: Vec }, + Paragraph(Vec), + ListItem { marker: String, spans: Vec }, + Quote(Vec), + Rule, + Code(Vec), +} + +pub(super) fn parse_blocks(source: &str) -> Vec { + let lines: Vec<_> = source.lines().collect(); + let mut blocks = Vec::new(); + let mut paragraph = String::new(); + let mut index = 0; + + while index < lines.len() { + let line = lines[index]; + let trimmed = line.trim(); + let trimmed_start = line.trim_start(); + + if trimmed.is_empty() { + flush_paragraph(&mut blocks, &mut paragraph); + index += 1; + continue; + } + + if let Some((fence, count)) = fence_start(trimmed_start) { + flush_paragraph(&mut blocks, &mut paragraph); + let mut code = Vec::new(); + index += 1; + + while index < lines.len() { + let line = lines[index]; + if fence_end(line.trim_start(), fence, count) { + index += 1; + break; + } + code.push(line.to_string()); + index += 1; + } + + blocks.push(Block::Code(code)); + continue; + } + + if let Some((level, text)) = heading(trimmed_start) { + flush_paragraph(&mut blocks, &mut paragraph); + blocks.push(Block::Heading { + level, + spans: parse_inline_styled(text, InlineStyle::BOLD), + }); + } else if horizontal_rule(trimmed_start) { + flush_paragraph(&mut blocks, &mut paragraph); + blocks.push(Block::Rule); + } else if let Some((marker, text)) = list_item(trimmed_start) { + flush_paragraph(&mut blocks, &mut paragraph); + blocks.push(Block::ListItem { marker, spans: parse_inline(text) }); + } else if let Some(text) = trimmed_start.strip_prefix('>') { + flush_paragraph(&mut blocks, &mut paragraph); + blocks.push(Block::Quote(parse_inline_styled( + text.strip_prefix(' ').unwrap_or(text), + InlineStyle::ITALIC, + ))); + } else { + if !paragraph.is_empty() { + paragraph.push(' '); + } + paragraph.push_str(trimmed); + } + + index += 1; + } + + flush_paragraph(&mut blocks, &mut paragraph); + blocks +} + +fn flush_paragraph(blocks: &mut Vec, paragraph: &mut String) { + if paragraph.is_empty() { + return; + } + + blocks.push(Block::Paragraph(parse_inline(paragraph))); + paragraph.clear(); +} + +fn fence_start(line: &str) -> Option<(char, usize)> { + let fence = line.chars().next()?; + if !matches!(fence, '`' | '~') { + return None; + } + + let count = line.chars().take_while(|&ch| ch == fence).count(); + (count >= 3).then_some((fence, count)) +} + +fn fence_end(line: &str, fence: char, count: usize) -> bool { + let actual = line.chars().take_while(|&ch| ch == fence).count(); + actual >= count && line[actual..].trim().is_empty() +} + +fn heading(line: &str) -> Option<(u8, &str)> { + let level = line.bytes().take_while(|&byte| byte == b'#').count(); + if !(1..=6).contains(&level) { + return None; + } + + let rest = &line[level..]; + if !rest.is_empty() && !rest.starts_with(char::is_whitespace) { + return None; + } + + let mut text = rest.trim(); + if text.ends_with('#') { + text = text.trim_end_matches('#').trim_end(); + } + Some((level as u8, text)) +} + +fn horizontal_rule(line: &str) -> bool { + let mut marker = None; + let mut count = 0; + + for ch in line.chars().filter(|ch| !ch.is_whitespace()) { + if !matches!(ch, '-' | '_' | '*') { + return false; + } + if marker.is_some_and(|marker| marker != ch) { + return false; + } + marker = Some(ch); + count += 1; + } + + count >= 3 +} + +fn list_item(line: &str) -> Option<(String, &str)> { + let bytes = line.as_bytes(); + if bytes.len() >= 2 && matches!(bytes[0], b'-' | b'*' | b'+') && bytes[1].is_ascii_whitespace() + { + return Some(("•".to_string(), line[2..].trim_start())); + } + + let digits = bytes.iter().take_while(|byte| byte.is_ascii_digit()).count(); + if digits == 0 || digits + 1 >= bytes.len() { + return None; + } + if !matches!(bytes[digits], b'.' | b')') || !bytes[digits + 1].is_ascii_whitespace() { + return None; + } + + Some((line[..=digits].to_string(), line[digits + 2..].trim_start())) +} + +pub(super) fn parse_inline(text: &str) -> Vec { + parse_inline_styled(text, InlineStyle::default()) +} + +fn parse_inline_styled(text: &str, base_style: InlineStyle) -> Vec { + let mut metrics = InlineSearchMetrics::default(); + parse_inline_styled_with_metrics(text, base_style, &mut metrics) +} + +struct BacktickSearch { + max_run_from: Vec, +} + +impl BacktickSearch { + fn new(text: &str) -> Self { + let bytes = text.as_bytes(); + let mut max_run_from = vec![0; bytes.len() + 1]; + let mut run = 0; + + for index in (0..bytes.len()).rev() { + run = if bytes[index] == b'`' { run + 1 } else { 0 }; + max_run_from[index] = max_run_from[index + 1].max(run); + } + + Self { max_run_from } + } + + fn has_delimiter(&self, from: usize, count: usize) -> bool { + self.max_run_from[from] >= count + } +} + +#[derive(Default)] +struct LinkSearch { + delimiter: Option, + delimiter_search_from: usize, + delimiter_exhausted: bool, + end: Option<(usize, LinkEnd)>, +} + +#[derive(Clone, Copy)] +enum LinkEnd { + Close(usize), + NestedOpen, + Missing, +} + +struct LinkMatch { + label_start: usize, + label_end: usize, + url_start: usize, + url_end: usize, +} + +impl LinkSearch { + fn find( + &mut self, + text: &str, + open: usize, + metrics: &mut InlineSearchMetrics, + ) -> Option { + if matches!(self.end, Some((_, LinkEnd::Missing))) { + return None; + } + + let delimiter_min = open + 1; + if self.delimiter.is_some_and(|delimiter| delimiter < delimiter_min) { + self.delimiter = None; + self.end = None; + } + + let delimiter = if let Some(delimiter) = self.delimiter { + delimiter + } else { + if self.delimiter_exhausted { + return None; + } + + let search_from = self.delimiter_search_from.max(delimiter_min); + let remaining = &text[search_from..]; + let relative = remaining.find("]("); + metrics + .record_delimiter_search(relative.map_or(remaining.len(), |relative| relative + 2)); + + let Some(relative) = relative else { + self.delimiter_exhausted = true; + return None; + }; + + let delimiter = search_from + relative; + self.delimiter = Some(delimiter); + self.delimiter_search_from = delimiter + 2; + delimiter + }; + + let end = if let Some((cached_delimiter, end)) = self.end + && cached_delimiter == delimiter + { + end + } else { + let url_start = delimiter + 2; + let remaining = &text.as_bytes()[url_start..]; + let relative = remaining.iter().position(|byte| matches!(byte, b')' | b'[')); + metrics + .record_url_end_search(relative.map_or(remaining.len(), |relative| relative + 1)); + + let end = match relative { + Some(relative) if remaining[relative] == b')' => { + LinkEnd::Close(url_start + relative) + } + Some(_) => LinkEnd::NestedOpen, + None => LinkEnd::Missing, + }; + self.end = Some((delimiter, end)); + end + }; + + let LinkEnd::Close(url_end) = end else { + return None; + }; + + Some(LinkMatch { + label_start: open + 1, + label_end: delimiter, + url_start: delimiter + 2, + url_end, + }) + } +} + +#[derive(Default)] +struct InlineSearchMetrics { + #[cfg(test)] + inline_iterations: usize, + #[cfg(test)] + code_opening_runs: usize, + #[cfg(test)] + code_delimiter_searches: usize, + #[cfg(test)] + code_delimiter_bytes: usize, + #[cfg(test)] + delimiter_searches: usize, + #[cfg(test)] + delimiter_bytes: usize, + #[cfg(test)] + url_end_searches: usize, + #[cfg(test)] + url_end_bytes: usize, +} + +impl InlineSearchMetrics { + #[cfg(test)] + fn record_inline_iteration(&mut self) { + self.inline_iterations += 1; + } + + #[cfg(not(test))] + fn record_inline_iteration(&mut self) {} + + #[cfg(test)] + fn record_code_opening_run(&mut self) { + self.code_opening_runs += 1; + } + + #[cfg(not(test))] + fn record_code_opening_run(&mut self) {} + + #[cfg(test)] + fn record_code_delimiter_search(&mut self, bytes: usize) { + self.code_delimiter_searches += 1; + self.code_delimiter_bytes += bytes; + } + + #[cfg(not(test))] + fn record_code_delimiter_search(&mut self, _bytes: usize) {} + + #[cfg(test)] + fn record_delimiter_search(&mut self, bytes: usize) { + self.delimiter_searches += 1; + self.delimiter_bytes += bytes; + } + + #[cfg(not(test))] + fn record_delimiter_search(&mut self, _bytes: usize) {} + + #[cfg(test)] + fn record_url_end_search(&mut self, bytes: usize) { + self.url_end_searches += 1; + self.url_end_bytes += bytes; + } + + #[cfg(not(test))] + fn record_url_end_search(&mut self, _bytes: usize) {} +} + +fn parse_inline_styled_with_metrics( + text: &str, + base_style: InlineStyle, + metrics: &mut InlineSearchMetrics, +) -> Vec { + let mut spans = Vec::new(); + let mut backtick_search = None; + let mut link_search = LinkSearch::default(); + let mut offset = 0; + + while offset < text.len() { + metrics.record_inline_iteration(); + let remaining = &text[offset..]; + + if let Some(escaped) = remaining.strip_prefix('\\') + && let Some(ch) = escaped.chars().next() + { + let len = ch.len_utf8(); + push_span(&mut spans, &escaped[..len], base_style); + offset += 1 + len; + continue; + } + + if remaining.starts_with('`') { + let count = remaining.bytes().take_while(|&byte| byte == b'`').count(); + let delimiter = &remaining[..count]; + metrics.record_code_opening_run(); + + if backtick_search + .get_or_insert_with(|| BacktickSearch::new(text)) + .has_delimiter(offset + count, count) + { + let end = remaining[count..] + .find(delimiter) + .expect("backtick index must identify an existing delimiter"); + metrics.record_code_delimiter_search(end + count); + let end = count + end; + push_span(&mut spans, &remaining[count..end], base_style.with(InlineStyle::CODE)); + offset += end + count; + continue; + } + + push_span(&mut spans, delimiter, base_style); + offset += count; + continue; + } + + if let Some((delimiter, style)) = [ + ("**", InlineStyle::BOLD), + ("__", InlineStyle::BOLD), + ("~~", InlineStyle::STRIKETHROUGH), + ("*", InlineStyle::ITALIC), + ("_", InlineStyle::ITALIC), + ] + .into_iter() + .find(|(delimiter, _)| remaining.starts_with(delimiter)) + { + let start = delimiter.len(); + if let Some(end) = remaining[start..].find(delimiter) + && end != 0 + { + let end = start + end; + append_spans( + &mut spans, + parse_inline_styled_with_metrics( + &remaining[start..end], + base_style.with(style), + metrics, + ), + ); + offset += end + start; + continue; + } + } + + if remaining.starts_with('[') + && let Some(link) = link_search.find(text, offset, metrics) + { + append_spans( + &mut spans, + parse_inline_styled_with_metrics( + &text[link.label_start..link.label_end], + base_style.with(InlineStyle::LINK), + metrics, + ), + ); + push_span(&mut spans, " (", base_style.with(InlineStyle::LINK)); + push_span( + &mut spans, + &text[link.url_start..link.url_end], + base_style.with(InlineStyle::LINK), + ); + push_span(&mut spans, ")", base_style.with(InlineStyle::LINK)); + offset = link.url_end + 1; + continue; + } + + let ch = remaining.chars().next().unwrap(); + push_span(&mut spans, &remaining[..ch.len_utf8()], base_style); + offset += ch.len_utf8(); + } + + spans +} + +#[cfg(test)] +fn parse_inline_with_metrics(text: &str) -> (Vec, InlineSearchMetrics) { + let mut metrics = InlineSearchMetrics::default(); + let spans = parse_inline_styled_with_metrics(text, InlineStyle::default(), &mut metrics); + (spans, metrics) +} + +fn append_spans(destination: &mut Vec, source: Vec) { + for span in source { + push_span(destination, &span.text, span.style); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_supported_block_types() { + let blocks = parse_blocks( + "# Heading\n\nParagraph\n\n- bullet\n2. ordered\n> quote\n---\n```rust\nfn main() {}\n```\n", + ); + + assert!(matches!(blocks[0], Block::Heading { level: 1, .. })); + assert!(matches!(blocks[1], Block::Paragraph(_))); + assert!(matches!(&blocks[2], Block::ListItem { marker, .. } if marker == "•")); + assert!(matches!(&blocks[3], Block::ListItem { marker, .. } if marker == "2.")); + assert!(matches!(blocks[4], Block::Quote(_))); + assert!(matches!(blocks[5], Block::Rule)); + assert!(matches!(&blocks[6], Block::Code(lines) if lines == &["fn main() {}"])); + } + + #[test] + fn parses_supported_inline_styles() { + let spans = + parse_inline("plain **bold** *italic* ~~strike~~ `code` [link](https://example.com)"); + + assert!(spans.iter().any(|span| span.text == "bold" && span.style.is_bold())); + assert!(spans.iter().any(|span| span.text == "italic" && span.style.is_italic())); + assert!(spans.iter().any(|span| span.text == "strike" && span.style.is_strikethrough())); + assert!(spans.iter().any(|span| span.text == "code" && span.style.is_code())); + assert!(spans.iter().any(|span| span.text.contains("link") && span.style.is_link())); + assert!(spans.iter().any(|span| span.text.contains("https://example.com"))); + } + + #[test] + fn malformed_and_incomplete_markup_is_preserved_safely() { + let blocks = parse_blocks( + "Text **without a close and [broken](url\n\n> *unterminated\n\n```\nunterminated", + ); + + let Block::Paragraph(spans) = &blocks[0] else { + panic!("expected paragraph"); + }; + assert_eq!( + spans.iter().map(|span| span.text.as_str()).collect::(), + "Text **without a close and [broken](url" + ); + assert!(matches!(blocks[1], Block::Quote(_))); + assert!(matches!(&blocks[2], Block::Code(lines) if lines == &["unterminated"])); + } + + #[test] + fn bracket_heavy_links_have_bounded_searches_and_recover_after_malformed_input() { + const BRACKETS: usize = 256 * 1024; + + let no_delimiter = "[".repeat(BRACKETS); + let (spans, metrics) = parse_inline_with_metrics(&no_delimiter); + assert_eq!(spans.iter().map(|span| span.text.as_str()).collect::(), no_delimiter); + assert_eq!(metrics.delimiter_searches, 1); + assert_eq!(metrics.delimiter_bytes, BRACKETS - 1); + assert_eq!(metrics.url_end_searches, 0); + + let missing_close = format!("{}](", "[".repeat(BRACKETS)); + let (spans, metrics) = parse_inline_with_metrics(&missing_close); + assert_eq!(spans.iter().map(|span| span.text.as_str()).collect::(), missing_close); + assert_eq!(metrics.delimiter_searches, 1); + assert_eq!(metrics.url_end_searches, 1); + assert!(metrics.delimiter_bytes + metrics.url_end_bytes <= missing_close.len()); + + let recovers = format!("{}](unterminated [valid](ok)", "[".repeat(BRACKETS)); + let (spans, metrics) = parse_inline_with_metrics(&recovers); + assert!(spans.iter().any(|span| span.text == "valid (ok)" && span.style.is_link())); + assert_eq!(metrics.delimiter_searches, 2); + assert_eq!(metrics.url_end_searches, 2); + assert!(metrics.delimiter_bytes <= recovers.len()); + assert!(metrics.url_end_bytes <= recovers.len()); + } + + #[test] + fn unmatched_backtick_runs_have_bounded_work_and_recover_for_later_code() { + const BACKTICKS: usize = 32 * 1024; + + let spans = parse_inline("before ``code ` inside`` after"); + assert!(spans.iter().any(|span| span.text == "code ` inside" && span.style.is_code())); + + let unmatched = "`".repeat(BACKTICKS); + let (spans, metrics) = parse_inline_with_metrics(&unmatched); + assert_eq!(spans.iter().map(|span| span.text.as_str()).collect::(), unmatched); + assert_eq!(metrics.inline_iterations, 1); + assert_eq!(metrics.code_opening_runs, 1); + assert_eq!(metrics.code_delimiter_searches, 0); + assert_eq!(metrics.code_delimiter_bytes, 0); + + let recovers = "``malformed `valid` tail"; + let (spans, metrics) = parse_inline_with_metrics(recovers); + assert_eq!( + spans.iter().map(|span| span.text.as_str()).collect::(), + "``malformed valid tail" + ); + assert!(spans.iter().any(|span| span.text == "valid" && span.style.is_code())); + assert_eq!(metrics.code_opening_runs, 2); + assert_eq!(metrics.code_delimiter_searches, 1); + assert!(metrics.code_delimiter_bytes <= recovers.len()); + } + + #[test] + fn highlighting_fixture_exercises_representative_blocks_and_styles() { + let source = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../assets/highlighting-tests/markdown.md" + )); + let blocks = parse_blocks(source); + + for level in 1..=6 { + assert!( + blocks.iter().any( + |block| matches!(block, Block::Heading { level: actual, .. } if *actual == level) + ), + "missing heading level {level}" + ); + } + assert!(blocks.iter().filter(|block| matches!(block, Block::ListItem { .. })).count() >= 8); + assert!(blocks.iter().any(|block| matches!(block, Block::Quote(_)))); + assert!(blocks.iter().filter(|block| matches!(block, Block::Code(_))).count() >= 5); + assert!(blocks.iter().any( + |block| matches!(block, Block::Code(lines) if lines.iter().any(|line| line.contains("Hello, world"))) + )); + + let spans = blocks.iter().filter_map(|block| match block { + Block::Paragraph(spans) + | Block::ListItem { spans, .. } + | Block::Quote(spans) + | Block::Heading { spans, .. } => Some(spans.as_slice()), + Block::Rule | Block::Code(_) => None, + }); + let styles: Vec<_> = spans.flatten().map(|span| span.style).collect(); + assert!(styles.iter().any(|style| style.is_bold())); + assert!(styles.iter().any(|style| style.is_italic())); + assert!(styles.iter().any(|style| style.is_strikethrough())); + assert!(styles.iter().any(|style| style.is_code())); + assert!(styles.iter().any(|style| style.is_link())); + } +} diff --git a/crates/edit/src/bin/edit/state.rs b/crates/edit/src/bin/edit/state.rs index 13a1cefbbea..a37c9e387d1 100644 --- a/crates/edit/src/bin/edit/state.rs +++ b/crates/edit/src/bin/edit/state.rs @@ -241,6 +241,38 @@ impl State { self.error_log_count = self.error_log.len().min(self.error_log_count + 1); true } + + pub fn markdown_preview_available(&self) -> bool { + self.documents.active().is_some_and(|document| document.is_markdown()) + } + + pub fn markdown_preview_enabled(&self) -> bool { + self.documents.active().is_some_and(|document| document.markdown_preview_enabled()) + } + + pub fn toggle_markdown_preview(&mut self) { + let Some(document) = self.documents.active_mut() else { + return; + }; + if !document.is_markdown() { + document.markdown_preview.set_enabled(false); + return; + } + + let enabled = !document.markdown_preview.is_enabled(); + document.markdown_preview.set_enabled(enabled); + if enabled + && matches!(self.wants_search.kind, StateSearchKind::Search | StateSearchKind::Replace) + { + self.wants_search.kind = StateSearchKind::Hidden; + } + } + + pub fn disable_markdown_preview(&mut self) { + if let Some(document) = self.documents.active_mut() { + document.markdown_preview.set_enabled(false); + } + } } pub fn draw_add_untitled_document(ctx: &mut Context, state: &mut State) { diff --git a/crates/edit/src/tui.rs b/crates/edit/src/tui.rs index 9826c91cfc7..7ff465d7d31 100644 --- a/crates/edit/src/tui.rs +++ b/crates/edit/src/tui.rs @@ -2951,6 +2951,11 @@ impl<'a> Context<'a, '_> { && let Some(key) = self.input_keyboard { match key { + // Scrollareas already handle pagewise and boundary navigation here. Arrow + // keys are the linewise counterparts; handling them at this layer also keeps + // mouse, page, and arrow scrolling on the same persisted scroll offset. + vk::UP => sc.scroll_offset.y -= 1, + vk::DOWN => sc.scroll_offset.y += 1, vk::PRIOR => sc.scroll_offset.y -= prev_container.inner_clipped.height(), vk::NEXT => sc.scroll_offset.y += prev_container.inner_clipped.height(), vk::END => sc.scroll_offset.y = CoordType::MAX, diff --git a/i18n/edit.toml b/i18n/edit.toml index 11039636308..a40a021e2b1 100644 --- a/i18n/edit.toml +++ b/i18n/edit.toml @@ -999,6 +999,15 @@ vi = "Ngắt dòng tự động" zh-hans = "自动换行" zh-hant = "自動換行" +[ViewMarkdownPreview] +en = "Markdown Preview" + +[MarkdownPreviewUnavailableTooLarge] +en = "Markdown preview unavailable: file exceeds the 1 MiB preview limit." + +[MarkdownPreviewUnavailableLineTooLong] +en = "Markdown preview unavailable: a line exceeds the 32 KiB preview limit." + [ViewGoToFile] en = "Go to File…" ar = "الانتقال إلى ملف…"