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 = "الانتقال إلى ملف…"