diff --git a/src/apps/tui/markdown.rs b/src/apps/tui/markdown.rs index 4a44df5..7fd9f56 100644 --- a/src/apps/tui/markdown.rs +++ b/src/apps/tui/markdown.rs @@ -19,8 +19,8 @@ use crate::apps::config::{GUTTER_GLYPH, PREVIEW_FRAME_OVERHEAD}; use crate::apps::theme::Theme; use crate::runner::CodeId; use upmd_parser::nodes::{ - inline_text, Alignment, Code, DepsToken, FrontmatterStyle, InlineSpan, InlineStyle, Table, - TableCell, + inline_text, Alignment, Code, DepsToken, FrontmatterStyle, InlineSpan, InlineStyle, Node, + Table, TableCell, }; use upmd_parser::Codes; @@ -40,8 +40,8 @@ pub enum RenderMode { Markup, } -/// Render-time context passed to [`LogicalLine::render`]. -pub struct RenderContext<'a> { +/// Per-frame inputs used to render a [`LogicalLine`]. +pub struct LineRenderContext<'a> { pub theme: &'a Theme, pub active_code_id: Option, /// When set, this block's task status color overrides active gutter color. @@ -210,13 +210,6 @@ impl FrontmatterBlock { } } -#[derive(Debug, Clone)] -pub struct CodeInfoLine { - left: Vec<(String, Style)>, - right: String, - style: Style, -} - #[derive(Debug)] struct TableRenderCache { viewport_width: usize, @@ -275,7 +268,11 @@ pub enum LogicalLineSource { level: u8, text: LazyText, }, - CodeInfo(CodeInfoLine), + CodeInfo { + left: Vec<(String, Style)>, + right: String, + style: Style, + }, CodeBody(LazyText), Output(Text<'static>), /// One row of a raw HTML block highlighted and cached as a complete block. @@ -327,6 +324,35 @@ impl LogicalLineSource { } } +/// Source byte-offset anchor for a rendered line, used to remap the +/// viewport/selection when toggling between Visual and Markup render modes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SourcePosition { + At(usize), + Before(usize), +} + +impl Default for SourcePosition { + fn default() -> Self { + Self::At(0) + } +} + +impl SourcePosition { + pub(crate) fn offset(self) -> usize { + match self { + Self::At(offset) | Self::Before(offset) => offset, + } + } + + pub(crate) fn same_kind(self, other: Self) -> bool { + matches!( + (self, other), + (Self::At(_), Self::At(_)) | (Self::Before(_), Self::Before(_)) + ) + } +} + /// Renderable content and metadata for one semantic preview line. /// /// A logical line is width-independent; preview layout maps it to one or more @@ -353,8 +379,8 @@ pub struct LogicalLine { /// Optional foreground color override for the gutter indicator (used by /// output lines to reflect task status). pub gutter_fg: Option, - /// AST node index used to preserve the viewport across render modes. - pub node_idx: Option, + /// Stable source position used to preserve the viewport across render modes. + pub(crate) source_position: SourcePosition, } impl LogicalLine { @@ -366,6 +392,11 @@ impl LogicalLine { } } + pub(crate) fn with_source_position(mut self, position: SourcePosition) -> Self { + self.source_position = position; + self + } + /// Creates a text line from inline markdown spans. pub fn text_lazy_spans(spans: Vec, source: &str, is_block_start: bool) -> Self { Self { @@ -425,7 +456,7 @@ impl LogicalLine { is_running: bool, ) -> Self { Self { - source: LogicalLineSource::CodeInfo(CodeInfoLine { left, right, style }), + source: LogicalLineSource::CodeInfo { left, right, style }, code_id: Some(code_id), is_block_start: is_start, is_code_start: is_start, @@ -564,7 +595,7 @@ impl LogicalLine { #[inline] pub fn is_code_info(&self) -> bool { - matches!(self.source, LogicalLineSource::CodeInfo(_)) + matches!(self.source, LogicalLineSource::CodeInfo { .. }) } #[inline] @@ -586,7 +617,7 @@ impl LogicalLine { pub fn has_code_gutter(&self) -> bool { matches!( self.source, - LogicalLineSource::CodeInfo(_) + LogicalLineSource::CodeInfo { .. } | LogicalLineSource::CodeBody(_) | LogicalLineSource::Output(_) ) @@ -627,9 +658,9 @@ impl LogicalLine { | LogicalLineSource::ListItem(text) | LogicalLineSource::CodeBody(text) | LogicalLineSource::Heading { text, .. } => text.text.clone(), - LogicalLineSource::CodeInfo(info) => { - let left: String = info.left.iter().map(|(text, _)| text.as_str()).collect(); - format!("{left} {}", info.right.trim_end()) + LogicalLineSource::CodeInfo { left, right, .. } => { + let left: String = left.iter().map(|(text, _)| text.as_str()).collect(); + format!("{left} {}", right.trim_end()) } LogicalLineSource::Output(text) => text.to_string(), LogicalLineSource::Html { block, row_idx } => block.raw_line(*row_idx).to_owned(), @@ -653,7 +684,7 @@ impl LogicalLine { /// 3. `apply_chrome`: display prefixes and the code gutter. /// /// Called once per frame by the preview pane. - pub fn render(&self, ctx: &RenderContext<'_>) -> Line<'static> { + pub fn render(&self, ctx: &LineRenderContext<'_>) -> Line<'static> { self.render_with(ctx, true) } @@ -661,12 +692,12 @@ impl LogicalLine { /// /// Layout and painted output must contain the same characters in the same /// order. Styles may differ, but width and wrapping must not. - pub fn render_plain(&self, ctx: &RenderContext<'_>) -> Line<'static> { + pub fn render_plain(&self, ctx: &LineRenderContext<'_>) -> Line<'static> { self.render_with(ctx, false) } /// Populates the content cache without applying viewport-dependent paint. - pub fn ensure_rendered(&self, ctx: &RenderContext<'_>) -> bool { + pub fn ensure_rendered(&self, ctx: &LineRenderContext<'_>) -> bool { if let Some(text) = self.lazy_text() { if text.cached.borrow().is_none() { drop(self.render_content(ctx)); @@ -676,7 +707,7 @@ impl LogicalLine { false } - fn render_with(&self, ctx: &RenderContext<'_>, highlight: bool) -> Line<'static> { + fn render_with(&self, ctx: &LineRenderContext<'_>, highlight: bool) -> Line<'static> { if let Some(mut line) = self.render_synthetic(ctx) { self.apply_prefixes(&mut line); return line; @@ -699,7 +730,7 @@ impl LogicalLine { &self, line: &mut Line<'static>, is_active: bool, - ctx: &RenderContext<'_>, + ctx: &LineRenderContext<'_>, ) { if self.is_code_body() { let bg = ctx.theme.code_style(); @@ -723,14 +754,14 @@ impl LogicalLine { line.spans = spans; } - fn apply_chrome(&self, line: &mut Line<'static>, is_active: bool, ctx: &RenderContext<'_>) { + fn apply_chrome(&self, line: &mut Line<'static>, is_active: bool, ctx: &LineRenderContext<'_>) { self.add_gutter(line, is_active, ctx); self.apply_prefixes(line); } /// Handles synthetic line types (thematic break, table) that bypass content /// rendering; `render` adds display prefixes afterward. - fn render_synthetic(&self, ctx: &RenderContext<'_>) -> Option> { + fn render_synthetic(&self, ctx: &LineRenderContext<'_>) -> Option> { match &self.source { LogicalLineSource::ThematicBreak => { let width = ctx @@ -754,7 +785,7 @@ impl LogicalLine { } /// Renders text-identical content without syntax highlighting. - fn render_plain_content(&self, ctx: &RenderContext<'_>) -> Line<'static> { + fn render_plain_content(&self, ctx: &LineRenderContext<'_>) -> Line<'static> { match &self.source { LogicalLineSource::Html { block, row_idx } => { expand_tabs_in_line(Line::raw(block.raw_line(*row_idx).to_owned())) @@ -771,7 +802,7 @@ impl LogicalLine { } /// Renders the main line content, caching semantic Markdown or syntax-highlighted code. - fn render_content(&self, ctx: &RenderContext<'_>) -> Line<'static> { + fn render_content(&self, ctx: &LineRenderContext<'_>) -> Line<'static> { match &self.source { LogicalLineSource::Text(text) | LogicalLineSource::ListItem(text) @@ -815,14 +846,13 @@ impl LogicalLine { } LogicalLineSource::Html { block, row_idx } => block.line(*row_idx, ctx.theme), LogicalLineSource::Frontmatter { block, row_idx } => block.line(*row_idx, ctx.theme), - LogicalLineSource::CodeInfo(info) => { + LogicalLineSource::CodeInfo { left, right, style } => { let prefix_width = self.prefix_width(); let wrap_width = ctx .viewport_width .saturating_sub(crate::apps::config::PREVIEW_CODE_WRAP_OVERHEAD + prefix_width) .max(1); - let mut spans: Vec> = info - .left + let mut spans: Vec> = left .iter() .map(|(text, style)| Span::styled(text.clone(), *style)) .collect(); @@ -833,11 +863,11 @@ impl LogicalLine { )); } let left_chars: usize = spans.iter().map(|span| span.content.chars().count()).sum(); - let right_chars = info.right.chars().count(); + let right_chars = right.chars().count(); let gap = wrap_width.saturating_sub(left_chars + right_chars).max(1); - spans.push(Span::styled(" ".repeat(gap), info.style)); - spans.push(Span::styled(info.right.clone(), info.style)); - Line::from(spans).style(info.style) + spans.push(Span::styled(" ".repeat(gap), *style)); + spans.push(Span::styled(right.clone(), *style)); + Line::from(spans).style(*style) } LogicalLineSource::Output(text) => { text.lines.first().cloned().unwrap_or_else(|| Line::raw("")) @@ -851,7 +881,7 @@ impl LogicalLine { } /// Adds a gutter indicator for highlightable lines. - fn add_gutter(&self, line: &mut Line<'static>, is_active: bool, ctx: &RenderContext<'_>) { + fn add_gutter(&self, line: &mut Line<'static>, is_active: bool, ctx: &LineRenderContext<'_>) { if !self.has_code_gutter() { return; } @@ -1085,52 +1115,28 @@ fn render_table_cell( spans } -/// Render-time context for code-block snap-to-heading/paragraph. -#[derive(Default)] -struct SnapContext { - /// Index of the heading LogicalLine that precedes the next code block. - title_line: Option, - /// Index of the first paragraph LogicalLine that precedes the next code block. - description_line: Option, -} - -impl SnapContext { - /// Consumes the best snap target. Prefers title, falls back to description. - /// Clears both after returning, so adjacent code blocks don't reuse context. - fn take_target(&mut self) -> Option { - let target = self.title_line.or(self.description_line); - self.title_line = None; - self.description_line = None; - target - } -} - #[derive(Default)] struct RenderState { - /// Snap targets are scoped separately from visual nesting. - snap: SnapContext, + /// Default source anchor inherited by lines emitted for the current node. + source_position: SourcePosition, + /// Heading and paragraph candidates for the next code block's snap target. + snap_title_line: Option, + snap_description_line: Option, /// Current blockquote nesting depth. Each level adds a display-only gutter. quote_depth: usize, /// Ordered display prefixes contributed by the active render mode. prefixes: Vec>, /// Extra display width before code content, keyed by code block. code_prefix_overhead: HashMap, - /// Traversal index allocated to the next AST node. - next_node_idx: usize, - /// Index of the node currently being rendered. - node_idx: usize, } impl RenderState { - fn begin_node(&mut self) -> usize { - let parent = self.node_idx; - self.next_node_idx += 1; - self.node_idx = self.next_node_idx; - parent - } - - fn end_node(&mut self, parent: usize) { - self.node_idx = parent; + /// Consumes the best snap target and clears both candidates. + fn take_snap_target(&mut self) -> Option { + let target = self.snap_title_line.or(self.snap_description_line); + self.snap_title_line = None; + self.snap_description_line = None; + target } fn prefix_width(&self) -> usize { @@ -1146,6 +1152,10 @@ pub struct RenderedMarkdown { pub code_prefix_overhead: HashMap, } +trait ModeRenderer { + fn render(&self, nodes: &[Node], lines: &mut Vec, state: &mut RenderState); +} + /// From AST nodes to ratatui `Text` lines. pub struct MarkdownRenderer<'a> { theme: &'a Theme, @@ -1182,12 +1192,12 @@ impl<'a> MarkdownRenderer<'a> { self } - pub fn render(&self, nodes: &[upmd_parser::nodes::Node]) -> RenderedMarkdown { + pub fn render(&self, nodes: &[Node]) -> RenderedMarkdown { let mut lines = Vec::new(); let mut state = RenderState::default(); match self.mode { - RenderMode::Visual => self.render_visual(nodes, &mut lines, &mut state), - RenderMode::Markup => self.render_markup(nodes, &mut lines, &mut state), + RenderMode::Visual => visual::Visual::new(self).render(nodes, &mut lines, &mut state), + RenderMode::Markup => markup::Markup::new(self).render(nodes, &mut lines, &mut state), } RenderedMarkdown { lines, @@ -1195,12 +1205,19 @@ impl<'a> MarkdownRenderer<'a> { } } + fn source_line_start(&self, offset: usize) -> usize { + let offset = offset.min(self.source.len()); + self.source[..offset] + .rfind('\n') + .map_or(0, |newline| newline + 1) + } + fn render_code(&self, code_id: CodeId, lines: &mut Vec, state: &mut RenderState) { let code = self .codes .by_id(code_id) .expect("CodeId must resolve to a Code in Document.codes"); - let is_start = match state.snap.take_target() { + let is_start = match state.take_snap_target() { Some(idx) => { if let Some(line) = lines.get_mut(idx) { line.code_id = Some(code.id); @@ -1234,7 +1251,18 @@ impl<'a> MarkdownRenderer<'a> { self.render_code_output(code, lines, state, gutter_fg, is_running); } - fn push_line(&self, lines: &mut Vec, mut line: LogicalLine, state: &RenderState) { + fn push_line(&self, lines: &mut Vec, line: LogicalLine, state: &RenderState) { + self.push_line_at(lines, line, state.source_position, state); + } + + fn push_line_at( + &self, + lines: &mut Vec, + mut line: LogicalLine, + source_position: SourcePosition, + state: &RenderState, + ) { + line.source_position = source_position; let mut wrap_prefixes = state.prefixes.clone(); if let Some(line_prefixes) = line.wrap_prefixes.take() { wrap_prefixes.extend(line_prefixes); @@ -1245,16 +1273,6 @@ impl<'a> MarkdownRenderer<'a> { .sum(); line.wrap_prefixes = (!wrap_prefixes.is_empty()).then_some(wrap_prefixes); line.prefixes.splice(0..0, state.prefixes.iter().cloned()); - self.push_unquoted_line(lines, line, state); - } - - fn push_unquoted_line( - &self, - lines: &mut Vec, - mut line: LogicalLine, - state: &RenderState, - ) { - line.node_idx = Some(state.node_idx); lines.push(line); } @@ -1657,8 +1675,8 @@ mod tests { Theme::new("base16-ocean.dark", false) } - fn test_ctx(theme: &Theme, width: usize) -> RenderContext<'_> { - RenderContext { + fn test_ctx(theme: &Theme, width: usize) -> LineRenderContext<'_> { + LineRenderContext { theme, active_code_id: None, prefer_status_gutter: None, @@ -1687,7 +1705,7 @@ mod tests { LogicalLineSource::Text(_) => "Text".to_string(), LogicalLineSource::ListItem(_) => "ListItem".to_string(), LogicalLineSource::Heading { level, .. } => format!("Heading({level})"), - LogicalLineSource::CodeInfo(_) => "CodeInfo".to_string(), + LogicalLineSource::CodeInfo { .. } => "CodeInfo".to_string(), LogicalLineSource::CodeBody(_) => "CodeBody".to_string(), LogicalLineSource::Output(_) => "Output".to_string(), LogicalLineSource::Html { .. } => "Html".to_string(), @@ -1932,7 +1950,7 @@ mod tests { #[test] fn running_code_body_does_not_append_spinner() { let theme = test_theme(); - let ctx = RenderContext { + let ctx = LineRenderContext { theme: &theme, active_code_id: Some(1), prefer_status_gutter: None, @@ -1962,7 +1980,7 @@ mod tests { .into_iter() .find(|line| line.text_content().trim_start_matches("- ") == "after") .expect("following list item") - .node_idx + .source_position }; assert_eq!(identity(RenderMode::Visual), identity(RenderMode::Markup)); } diff --git a/src/apps/tui/markdown/markup.rs b/src/apps/tui/markdown/markup.rs index a4b4242..08815d7 100644 --- a/src/apps/tui/markdown/markup.rs +++ b/src/apps/tui/markdown/markup.rs @@ -1,28 +1,45 @@ //! Source-preserving Markdown rendering for the preview's Markup mode. use ratatui::{style::Style, text::Span}; -use std::ops::Range; +use std::{ops::Deref, ops::Range}; use upmd_parser::nodes::{Node, NodeKind}; -use super::{LogicalLine, LogicalLineSource, MarkdownRenderer, RenderState}; +use super::{ + LogicalLine, LogicalLineSource, MarkdownRenderer, ModeRenderer, RenderState, SourcePosition, +}; -impl MarkdownRenderer<'_> { - pub(super) fn render_markup( - &self, - nodes: &[Node], - lines: &mut Vec, - state: &mut RenderState, - ) { +pub(super) struct Markup<'renderer, 'document> { + renderer: &'renderer MarkdownRenderer<'document>, +} + +impl<'renderer, 'document> Markup<'renderer, 'document> { + pub(super) fn new(renderer: &'renderer MarkdownRenderer<'document>) -> Self { + Self { renderer } + } +} + +impl<'document> Deref for Markup<'_, 'document> { + type Target = MarkdownRenderer<'document>; + + fn deref(&self) -> &Self::Target { + self.renderer + } +} + +impl ModeRenderer for Markup<'_, '_> { + fn render(&self, nodes: &[Node], lines: &mut Vec, state: &mut RenderState) { let mut cursor = 0; for node in nodes { - self.render_markup_gap(cursor..node.range.start, lines, state); + self.render_markup_gap(cursor..node.range.start, lines); self.render_markup_node(node, lines, state); cursor = cursor.max(node.range.end); } - self.render_markup_gap(cursor..self.source.len(), lines, state); + self.render_markup_gap(cursor..self.source.len(), lines); } +} +impl Markup<'_, '_> { fn markup_code_prefix(&self, content: String) -> Span<'static> { Span::styled( content, @@ -38,8 +55,11 @@ impl MarkdownRenderer<'_> { lines: &mut Vec, state: &mut RenderState, ) { - let parent_identity = state.begin_node(); let start_line = lines.len(); + let parent_position = std::mem::replace( + &mut state.source_position, + SourcePosition::At(self.source_line_start(node.range.start)), + ); match &node.kind { NodeKind::Code(code_id) => self.render_markup_code(*code_id, lines, state), @@ -55,15 +75,15 @@ impl MarkdownRenderer<'_> { } self.render_markup_code_ranges(node.range.clone(), &codes, lines, state); } - _ => self.render_markup_source(node.range.clone(), lines, state), + _ => self.render_markup_source(node.range.clone(), lines), } match node.kind { - NodeKind::Heading { .. } => state.snap.title_line = Some(start_line), - NodeKind::Paragraph(_) => state.snap.description_line = Some(start_line), + NodeKind::Heading { .. } => state.snap_title_line = Some(start_line), + NodeKind::Paragraph(_) => state.snap_description_line = Some(start_line), _ => {} } - state.end_node(parent_identity); + state.source_position = parent_position; } fn render_markup_code_ranges( @@ -76,7 +96,7 @@ impl MarkdownRenderer<'_> { let mut cursor = range.start; for (code, quote_depth) in codes { let raw_end = self.code_prefix_start(cursor, code.range.start, *quote_depth); - self.render_markup_source(cursor..raw_end, lines, state); + self.render_markup_source(cursor..raw_end, lines); let parent_depth = std::mem::replace(&mut state.quote_depth, *quote_depth); let prefix = self.source[raw_end..code.range.start].to_owned(); @@ -91,7 +111,7 @@ impl MarkdownRenderer<'_> { state.quote_depth = parent_depth; cursor = self.after_line_ending(cursor.max(code.range.end)); } - self.render_markup_source(cursor..range.end, lines, state); + self.render_markup_source(cursor..range.end, lines); } fn render_markup_code( @@ -133,26 +153,22 @@ impl MarkdownRenderer<'_> { } } - fn render_markup_source( - &self, - range: Range, - lines: &mut Vec, - state: &mut RenderState, - ) { - let Some(source) = self.source.get(range) else { + fn render_markup_source(&self, range: Range, lines: &mut Vec) { + let Some(source) = self.source.get(range.clone()) else { return; }; - for (index, line) in source.lines().enumerate() { - self.push_unquoted_line(lines, LogicalLine::markup_text(line, index == 0), state); + let mut offset = range.start; + // Keep line endings so each rendered row retains its exact source byte range. + for (index, raw_line) in source.split_inclusive('\n').enumerate() { + let start = offset; + offset += raw_line.len(); + let line = raw_line.trim_end_matches(['\r', '\n']); + let position = source_position_for_line(line, start, offset, range.end); + lines.push(LogicalLine::markup_text(line, index == 0).with_source_position(position)); } } - fn render_markup_gap( - &self, - range: Range, - lines: &mut Vec, - state: &mut RenderState, - ) { + fn render_markup_gap(&self, range: Range, lines: &mut Vec) { let Some(gap) = self.source.get(range.clone()) else { return; }; @@ -168,11 +184,30 @@ impl MarkdownRenderer<'_> { newline_count.saturating_sub(1) }; for _ in 0..blank_lines { - self.push_unquoted_line(lines, LogicalLine::newline(), state); + lines.push( + LogicalLine::newline().with_source_position(SourcePosition::Before( + self.source_line_start(range.end), + )), + ); } } } +/// Returns `Before` for trailing blank lines, otherwise `At`. +fn source_position_for_line( + line: &str, + line_start: usize, + line_end: usize, + range_end: usize, +) -> SourcePosition { + let blank = line.chars().all(|c| c.is_whitespace() || c == '>'); + if blank && line_end < range_end { + SourcePosition::Before(line_end) + } else { + SourcePosition::At(line_start) + } +} + fn collect_codes<'a>(nodes: &'a [Node], quote_depth: usize, codes: &mut Vec<(&'a Node, usize)>) { for node in nodes { match &node.kind { @@ -210,7 +245,7 @@ mod tests { fn markup_text(lines: &[LogicalLine]) -> String { let theme = test_theme(); - let ctx = RenderContext { + let ctx = LineRenderContext { theme: &theme, active_code_id: None, prefer_status_gutter: None, diff --git a/src/apps/tui/markdown/visual.rs b/src/apps/tui/markdown/visual.rs index 7cd431c..436f47d 100644 --- a/src/apps/tui/markdown/visual.rs +++ b/src/apps/tui/markdown/visual.rs @@ -1,6 +1,6 @@ //! Semantic terminal rendering for the preview's Visual mode. -use std::rc::Rc; +use std::{ops::Deref, rc::Rc}; use ratatui::{style::Style, text::Span}; use upmd_parser::nodes::{InlineSpan, ListItem, ListKind, Node, NodeKind, TaskStatus}; @@ -9,22 +9,38 @@ use crate::apps::config::{GUTTER_GLYPH, PREVIEW_FRAME_OVERHEAD}; use super::{ owned_table, render_table, split_span_lines, FrontmatterBlock, LogicalLine, LogicalLineSource, - MarkdownHtml, MarkdownRenderer, MarkdownTable, RenderState, MAX_BLOCKQUOTE_MARKER_INDENT, + MarkdownHtml, MarkdownRenderer, MarkdownTable, ModeRenderer, RenderState, SourcePosition, + MAX_BLOCKQUOTE_MARKER_INDENT, }; -impl MarkdownRenderer<'_> { - pub(super) fn render_visual( - &self, - nodes: &[upmd_parser::nodes::Node], - lines: &mut Vec, - state: &mut RenderState, - ) { +pub(super) struct Visual<'renderer, 'document> { + renderer: &'renderer MarkdownRenderer<'document>, +} + +impl<'renderer, 'document> Visual<'renderer, 'document> { + pub(super) fn new(renderer: &'renderer MarkdownRenderer<'document>) -> Self { + Self { renderer } + } +} + +impl<'document> Deref for Visual<'_, 'document> { + type Target = MarkdownRenderer<'document>; + + fn deref(&self) -> &Self::Target { + self.renderer + } +} + +impl ModeRenderer for Visual<'_, '_> { + fn render(&self, nodes: &[Node], lines: &mut Vec, state: &mut RenderState) { self.render_nodes(nodes, lines, state, Self::render_node); if lines.last().is_some_and(LogicalLine::is_newline) { lines.pop(); } } +} +impl Visual<'_, '_> { /// Whether this heading renders its own visual separator. fn has_heading_rule(node: &Node) -> bool { matches!( @@ -42,13 +58,17 @@ impl MarkdownRenderer<'_> { state: &mut RenderState, mut render: impl FnMut(&Self, &Node, &mut Vec, &mut RenderState), ) { - for node in nodes { + for (index, node) in nodes.iter().enumerate() { render(self, node, lines, state); if lines.last().is_some_and(LogicalLine::is_newline) { lines.pop(); } if !Self::has_heading_rule(node) { - self.push_line(lines, LogicalLine::newline(), state); + let position = nodes.get(index + 1).map_or_else( + || SourcePosition::At(self.source_line_start(node.range.end)), + |next| SourcePosition::Before(self.source_line_start(next.range.start)), + ); + self.push_line_at(lines, LogicalLine::newline(), position, state); } } } @@ -69,7 +89,10 @@ impl MarkdownRenderer<'_> { state: &mut RenderState, ) { use upmd_parser::nodes::NodeKind; - let parent_identity = state.begin_node(); + let parent_position = std::mem::replace( + &mut state.source_position, + SourcePosition::At(self.source_line_start(node.range.start)), + ); match &node.kind { NodeKind::HtmlBlock => { let html = self.source[node.range.clone()].to_owned(); @@ -100,7 +123,7 @@ impl MarkdownRenderer<'_> { } NodeKind::Paragraph(t) => { if let Some(idx) = self.render_highlighted_lines(t, lines, true, state) { - state.snap.description_line = Some(idx); + state.snap_description_line = Some(idx); } } NodeKind::BlockQuote(children) => { @@ -108,11 +131,14 @@ impl MarkdownRenderer<'_> { // context is scoped: a quoted paragraph should not become the // snap target for a following non-quoted code block, and an // outer paragraph should not snap to quoted code. - let parent_snap = std::mem::take(&mut state.snap); + let parent_snap = ( + state.snap_title_line.take(), + state.snap_description_line.take(), + ); state.prefixes.push(self.visual_quote_prefix()); self.render_nodes(children, lines, state, Self::render_node); state.prefixes.pop(); - state.snap = parent_snap; + (state.snap_title_line, state.snap_description_line) = parent_snap; } NodeKind::Heading { text: t, level } => { let line_idx = lines.len(); @@ -140,7 +166,7 @@ impl MarkdownRenderer<'_> { if Self::has_heading_rule(node) { self.push_line(lines, LogicalLine::heading_rule(), state); } - state.snap.title_line = Some(line_idx); + state.snap_title_line = Some(line_idx); } NodeKind::List(items) => self.render_list(items, lines, state), NodeKind::Code(code_id) => self.render_code(*code_id, lines, state), @@ -178,7 +204,7 @@ impl MarkdownRenderer<'_> { self.push_line(lines, line, state); } } - state.end_node(parent_identity); + state.source_position = parent_position; } fn render_highlighted_lines( @@ -287,7 +313,7 @@ impl MarkdownRenderer<'_> { } else { Span::raw(continuation.clone()) }; - self.push_line( + self.push_line_at( lines, LogicalLine::list_item_spans( line, @@ -296,6 +322,7 @@ impl MarkdownRenderer<'_> { Span::raw(continuation.clone()), is_list_start && line_index == 0, ), + SourcePosition::At(self.source_line_start(item.range.start)), state, ); } @@ -344,8 +371,8 @@ mod tests { Theme::new("base16-ocean.dark", false) } - fn test_ctx(theme: &Theme, width: usize) -> RenderContext<'_> { - RenderContext { + fn test_ctx(theme: &Theme, width: usize) -> LineRenderContext<'_> { + LineRenderContext { theme, active_code_id: None, prefer_status_gutter: None, @@ -371,7 +398,7 @@ mod tests { LogicalLineSource::Text(_) => "Text".to_string(), LogicalLineSource::ListItem(_) => "ListItem".to_string(), LogicalLineSource::Heading { level, .. } => format!("Heading({level})"), - LogicalLineSource::CodeInfo(_) => "CodeInfo".to_string(), + LogicalLineSource::CodeInfo { .. } => "CodeInfo".to_string(), LogicalLineSource::CodeBody(_) => "CodeBody".to_string(), LogicalLineSource::Output(_) => "Output".to_string(), LogicalLineSource::Html { .. } => "Html".to_string(), diff --git a/src/apps/tui/preview/layout_lines.rs b/src/apps/tui/preview/layout_lines.rs index 7d8ea0c..61c5df8 100644 --- a/src/apps/tui/preview/layout_lines.rs +++ b/src/apps/tui/preview/layout_lines.rs @@ -5,7 +5,7 @@ use crate::apps::config::{PREVIEW_CODE_WRAP_OVERHEAD, PREVIEW_FRAME_OVERHEAD}; use crate::apps::theme::Theme; use crate::runner::CodeId; -use crate::apps::tui::markdown::{apply_gutter, LogicalLine, RenderContext}; +use crate::apps::tui::markdown::{apply_gutter, LineRenderContext, LogicalLine}; use crate::apps::tui::wrap::{slice_line, wrap_ranges}; /// Width-dependent slice of a logical line occupying one terminal row. @@ -42,7 +42,7 @@ impl LayoutLine { pub fn render_plain( &self, logical_line: &LogicalLine, - ctx: &RenderContext<'_>, + ctx: &LineRenderContext<'_>, ) -> ratatui::text::Line<'static> { if logical_line.is_image() && self.is_continuation() { return ratatui::text::Line::raw(""); @@ -63,7 +63,7 @@ impl LayoutLine { &self, logical_line: &LogicalLine, rendered_line: &ratatui::text::Line<'static>, - ctx: &RenderContext<'_>, + ctx: &LineRenderContext<'_>, ) -> ratatui::text::Line<'static> { if logical_line.is_image() && self.is_continuation() { return ratatui::text::Line::raw(""); @@ -145,7 +145,7 @@ impl LayoutLines { return None; } self.last_width.set(width); - let ctx = RenderContext { + let ctx = LineRenderContext { theme, active_code_id: None, prefer_status_gutter: None, diff --git a/src/apps/tui/preview/mod.rs b/src/apps/tui/preview/mod.rs index ac32b1b..3d94c92 100644 --- a/src/apps/tui/preview/mod.rs +++ b/src/apps/tui/preview/mod.rs @@ -40,7 +40,8 @@ use upmd_parser::nodes::Node; use upmd_parser::{Codes, Document}; use super::markdown::{ - highlight_line, LogicalLine, MarkdownHtml, MarkdownRenderer, RenderContext, RenderMode, + highlight_line, LineRenderContext, LogicalLine, MarkdownHtml, MarkdownRenderer, RenderMode, + SourcePosition, }; use super::selection::SelectionState; use super::wrap::CopyLine; @@ -76,8 +77,7 @@ enum LayoutLineIdentity { wrap_idx: usize, }, Document { - node_idx: Option, - logical_idx: usize, + source_position: SourcePosition, wrap_idx: usize, }, } @@ -473,8 +473,7 @@ impl Preview { }) } None => Some(LayoutLineIdentity::Document { - node_idx: logical_lines[line.logical_idx].node_idx, - logical_idx: line.logical_idx, + source_position: logical_lines[line.logical_idx].source_position, wrap_idx: line.wrap_idx, }), } @@ -488,10 +487,9 @@ impl Preview { wrap_idx, } => self.layout_idx_for_code_identity(id, line_idx, wrap_idx), LayoutLineIdentity::Document { - node_idx, - logical_idx, + source_position, wrap_idx, - } => self.layout_idx_for_document_identity(node_idx, logical_idx, wrap_idx), + } => self.layout_idx_for_document_identity(source_position, wrap_idx), } } @@ -520,38 +518,22 @@ impl Preview { fn layout_idx_for_document_identity( &self, - node_idx: Option, - logical_idx: usize, + source_position: SourcePosition, wrap_idx: usize, ) -> Option { - let layout_lines = self.layout_lines.borrow(); + self.layout_lines + .borrow() + .iter() + .enumerate() + .min_by_key(|(_, layout)| { + let candidate = self.logical_lines[layout.logical_idx].source_position; + let different_kind = !candidate.same_kind(source_position); + let source_distance = candidate.offset().abs_diff(source_position.offset()); + let different_wrap = layout.wrap_idx != wrap_idx; - // Prefer the same wrapped row within the AST node, then the first row - // from that node before using the previous logical index. - node_idx - .and_then(|node_idx| { - layout_lines - .iter() - .position(|line| { - self.logical_lines[line.logical_idx].node_idx == Some(node_idx) - && line.wrap_idx == wrap_idx - }) - .or_else(|| { - layout_lines.iter().position(|line| { - self.logical_lines[line.logical_idx].node_idx == Some(node_idx) - }) - }) - }) - .or_else(|| { - layout_lines - .iter() - .position(|line| line.logical_idx == logical_idx && line.wrap_idx == wrap_idx) - .or_else(|| { - layout_lines - .iter() - .position(|line| line.logical_idx == logical_idx) - }) + (different_kind, source_distance, different_wrap) }) + .map(|(index, _)| index) } fn layout_extent_for_code( @@ -753,7 +735,7 @@ impl Preview { /// Builds a [`CopyLine`] from a layout line. fn copy_line_at(&self, line_idx: usize) -> Option { let line = self.layout_lines.get(line_idx)?; - let ctx = RenderContext { + let ctx = LineRenderContext { theme: &self.theme, active_code_id: None, prefer_status_gutter: None, @@ -1013,7 +995,7 @@ impl Preview { ) -> Option<(usize, usize)> { let layout_lines = self.layout_lines.borrow(); let offset = self.state.borrow().offset(); - let ctx = RenderContext { + let ctx = LineRenderContext { theme: &self.theme, active_code_id: None, prefer_status_gutter: None, @@ -1159,7 +1141,7 @@ impl Output for Preview { None => None, }; - let ctx = RenderContext { + let ctx = LineRenderContext { theme: &self.theme, active_code_id, prefer_status_gutter, @@ -1233,7 +1215,7 @@ impl Preview { layout_lines: &[LayoutLine], offset: usize, viewport: usize, - ctx: &RenderContext<'_>, + ctx: &LineRenderContext<'_>, ) { let start = offset.saturating_sub(viewport); let end = offset @@ -1421,6 +1403,67 @@ mod tests { } } + #[test] + fn mode_toggle_preserves_nested_source_boundary() { + let markdown = "\ +> Outer\n\ +>\n\ +> > Inner\n\ +> >\n\ +> > | A | B |\n\ +> > |---|---|\n\ +> > | x | y |\n\ +>\n\ +> Back\n"; + let mut preview = preview_from_markdown(markdown); + preview.rebuild_layout_lines(60); + let back = preview + .logical_lines + .iter() + .position(|line| line.text_content() == "Back") + .expect("outer quote paragraph"); + let separator = back - 1; + let source_position = preview.logical_lines[separator].source_position; + let back_source_start = markdown.find("> Back").expect("Back source line"); + assert_eq!( + source_position, + SourcePosition::Before(back_source_start), + "separator must anchor immediately before Back" + ); + let layout_idx = preview + .layout_lines + .borrow() + .iter() + .position(|line| line.logical_idx == separator) + .expect("separator layout line"); + { + let mut state = preview.state.borrow_mut(); + state.select(Some(layout_idx)); + *state.offset_mut() = layout_idx; + } + + for _ in 0..2 { + preview.toggle_mode(); + preview.rebuild_view(&HashMap::new()); + let selected = preview + .selected_logical_line() + .expect("source boundary selection"); + assert_eq!( + preview.logical_lines[selected].source_position, + source_position + ); + assert!( + preview + .logical_lines + .get(selected + 1) + .is_some_and(|line| line.text_content().ends_with("Back")), + "mode {:?} selected the wrong source boundary", + preview.mode.get() + ); + assert_eq!(preview.state.borrow().offset(), preview.selected_idx()); + } + } + #[test] fn mode_toggle_maps_heading_rule_to_heading() { let mut preview = preview_from_markdown("# One\n\nbody\n"); @@ -1472,7 +1515,7 @@ mod tests { assert!(row .render_plain( &preview.logical_lines[0], - &RenderContext { + &LineRenderContext { theme: &preview.theme, active_code_id: None, prefer_status_gutter: None, @@ -1487,7 +1530,7 @@ mod tests { fn render_layout_line( preview: &Preview, layout_line: &LayoutLine, - ctx: &RenderContext<'_>, + ctx: &LineRenderContext<'_>, ) -> Line<'static> { let logical_line = &preview.logical_lines[layout_line.logical_idx]; let rendered_line = logical_line.render(ctx); @@ -1496,7 +1539,7 @@ mod tests { /// Renders every layout row to its final text, joined with newlines. fn full_preview_text(preview: &Preview) -> String { - let ctx = RenderContext { + let ctx = LineRenderContext { theme: &preview.theme, active_code_id: None, prefer_status_gutter: None, @@ -1680,7 +1723,7 @@ mod tests { "start **[abcdefghijklmnopqrstuvwxyz](https://example.com)** end", ); preview.rebuild_layout_lines(12); - let ctx = RenderContext { + let ctx = LineRenderContext { theme: &preview.theme, active_code_id: None, prefer_status_gutter: None, @@ -1754,7 +1797,7 @@ mod tests { .clone(); drop(layout_lines); - let ctx = RenderContext { + let ctx = LineRenderContext { theme: &preview.theme, active_code_id: Some(1), prefer_status_gutter: None, @@ -1805,7 +1848,7 @@ mod tests { .expect("expected a code body layout line") .clone(); - let active_ctx = RenderContext { + let active_ctx = LineRenderContext { theme: &preview.theme, active_code_id: Some(1), prefer_status_gutter: None, @@ -1824,7 +1867,7 @@ mod tests { Some(preview.theme.active) ); - let status_ctx = RenderContext { + let status_ctx = LineRenderContext { theme: &preview.theme, active_code_id: Some(1), prefer_status_gutter: Some(1), @@ -1852,7 +1895,7 @@ mod tests { .iter() .find(|line| line.is_code_info()) .expect("expected a code info logical line"); - let ctx = RenderContext { + let ctx = LineRenderContext { theme: &preview.theme, active_code_id: Some(1), prefer_status_gutter: None, @@ -1890,7 +1933,7 @@ mod tests { .clone(); drop(layout_lines); - let ctx = RenderContext { + let ctx = LineRenderContext { theme: &preview.theme, active_code_id: None, prefer_status_gutter: None, @@ -1922,7 +1965,7 @@ mod tests { let display_len = line .render_plain( &preview.logical_lines[line.logical_idx], - &RenderContext { + &LineRenderContext { theme: &preview.theme, active_code_id: None, prefer_status_gutter: None,