From 54b363e00c828ec9351c857e4fcaf2c80ec64288 Mon Sep 17 00:00:00 2001 From: RivoLink Date: Tue, 30 Jun 2026 22:27:03 +0300 Subject: [PATCH] feat: copy code-block --- src/app/code_blocks.rs | 108 +++++++++++++++++++++++++++++++++++ src/app/content.rs | 4 ++ src/app/flash.rs | 15 +++++ src/app/mod.rs | 22 ++++++- src/app/navigation.rs | 9 +++ src/main.rs | 2 + src/markdown/mod.rs | 33 +++++++++++ src/render/content.rs | 44 ++++++++++++++ src/render/popup.rs | 19 ++++-- src/render/status.rs | 23 +++++++- src/runtime/keyboard.rs | 61 ++++++++++++++++++++ src/runtime/mod.rs | 8 +++ src/runtime/mouse.rs | 70 ++++++++++++++++++++++- src/tests/app_code_blocks.rs | 75 ++++++++++++++++++++++++ src/tests/markdown_blocks.rs | 76 ++++++++++++++++++++++++ src/tests/mod.rs | 1 + 16 files changed, 560 insertions(+), 10 deletions(-) create mode 100644 src/app/code_blocks.rs create mode 100644 src/tests/app_code_blocks.rs diff --git a/src/app/code_blocks.rs b/src/app/code_blocks.rs new file mode 100644 index 0000000..2dd7694 --- /dev/null +++ b/src/app/code_blocks.rs @@ -0,0 +1,108 @@ +use super::{App, CodeBlockFlash}; +use crate::{clipboard::copy_to_clipboard, markdown::CodeBlockInfo}; + +impl App { + pub(crate) fn set_code_blocks(&mut self, code_blocks: Vec) { + self.code_blocks = code_blocks; + } + + pub(crate) fn is_code_select_mode(&self) -> bool { + self.code_select.is_some() + } + + pub(crate) fn exit_code_select_mode(&mut self) -> bool { + let was_active = self.code_select.is_some(); + self.code_select = None; + was_active + } + + fn visible_code_block_indices(&self) -> Vec { + let top = self.scroll; + let bottom = self.visible_end(); + self.code_blocks + .iter() + .enumerate() + .filter(|(_, b)| b.rendered_end >= top && b.rendered_start < bottom) + .map(|(i, _)| i) + .collect() + } + + pub(crate) fn copy_first_visible_code_block(&mut self) { + let indices = self.visible_code_block_indices(); + match indices.first() { + Some(&idx) => self.copy_code_block_at(idx), + None => self.set_code_block_flash(CodeBlockFlash::NoneVisible), + } + } + + pub(crate) fn enter_code_select_mode(&mut self) { + let indices = self.visible_code_block_indices(); + match indices.first() { + Some(&idx) => self.code_select = Some(idx), + None => self.set_code_block_flash(CodeBlockFlash::NoneVisible), + } + } + + pub(crate) fn code_select_next(&mut self) { + let indices = self.visible_code_block_indices(); + if indices.is_empty() { + self.code_select = None; + return; + } + let current = self.code_select.unwrap_or(usize::MAX); + let next = indices + .iter() + .copied() + .find(|&i| i > current) + .unwrap_or(indices[0]); + self.code_select = Some(next); + } + + pub(crate) fn code_select_prev(&mut self) { + let indices = self.visible_code_block_indices(); + if indices.is_empty() { + self.code_select = None; + return; + } + let current = self.code_select.unwrap_or(0); + let prev = indices + .iter() + .rev() + .copied() + .find(|&i| i < current) + .unwrap_or_else(|| *indices.last().unwrap()); + self.code_select = Some(prev); + } + + pub(crate) fn copy_selected_code_block(&mut self) { + if let Some(idx) = self.code_select { + self.copy_code_block_at(idx); + } + self.code_select = None; + } + + pub(crate) fn copy_code_block_at(&mut self, idx: usize) { + let raw = match self.code_blocks.get(idx) { + Some(b) => b.raw_content.clone(), + None => { + self.set_code_block_flash(CodeBlockFlash::CopyFailed); + return; + } + }; + if copy_to_clipboard(&raw) { + self.set_code_block_flash(CodeBlockFlash::Copied); + } else { + self.set_code_block_flash(CodeBlockFlash::CopyFailed); + } + } + + pub(crate) fn code_block_at_line(&self, line_idx: usize) -> Option { + self.code_blocks + .iter() + .position(|b| line_idx >= b.rendered_start && line_idx <= b.rendered_end) + } + + pub(crate) fn highlighted_code_block(&self) -> Option { + self.code_select.or(self.hovered_code_block) + } +} diff --git a/src/app/content.rs b/src/app/content.rs index b639247..25c6c72 100644 --- a/src/app/content.rs +++ b/src/app/content.rs @@ -33,6 +33,7 @@ impl App { link_spans, line_number_map, source_line_map, + code_blocks, } = parsed; self.plain_lines = build_searchable_lines(&lines) @@ -45,6 +46,9 @@ impl App { self.toc_header_line = toc_header_line(); self.link_spans_by_line = super::links::link_spans_to_map(link_spans); self.hovered_link = None; + self.set_code_blocks(code_blocks); + self.code_select = None; + self.hovered_code_block = None; self.set_line_maps(line_number_map, source_line_map); self.refresh_static_caches(); } diff --git a/src/app/flash.rs b/src/app/flash.rs index 22fa968..74b587a 100644 --- a/src/app/flash.rs +++ b/src/app/flash.rs @@ -34,6 +34,13 @@ pub(crate) enum PathFlash { CopyFailed, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum CodeBlockFlash { + Copied, + CopyFailed, + NoneVisible, +} + impl App { pub(crate) fn set_editor_flash(&mut self, flash: EditorFlash) { self.editor_flash = Some((flash, Instant::now())); @@ -142,4 +149,12 @@ impl App { pub(crate) fn reload_flash_started(&self) -> Option { self.reload_flash } + + pub(crate) fn set_code_block_flash(&mut self, flash: CodeBlockFlash) { + self.code_block_flash = Some((flash, Instant::now())); + } + + pub(crate) fn code_block_flash(&self) -> Option<(&CodeBlockFlash, &Instant)> { + self.code_block_flash.as_ref().map(|(f, t)| (f, t)) + } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 26d6ed0..c211085 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2,7 +2,7 @@ use crate::{ markdown::{ build_searchable_lines, toc::{toc_levels, TocEntry}, - LinkSpan, + CodeBlockInfo, LinkSpan, }, render::{build_status_bar, build_toc_line_with_index, toc_header_line}, theme::{app_theme, current_theme_selection, theme_preset_index}, @@ -32,13 +32,17 @@ mod content; pub(crate) use content::{FileChange, FileState}; mod flash; -pub(crate) use flash::{EditorFlash, LinkFlash, PathFlash, WatchFlash, FLASH_DURATION_MS}; +pub(crate) use flash::{ + CodeBlockFlash, EditorFlash, LinkFlash, PathFlash, WatchFlash, FLASH_DURATION_MS, +}; mod popups; pub(crate) use popups::{EditorPickerState, PathKind}; mod links; +mod code_blocks; + mod io_picker; mod theme_picker; @@ -68,6 +72,7 @@ pub(crate) struct StatusCacheKey { config_flash_active: bool, link_flash_active: bool, path_flash_active: bool, + code_block_flash_active: bool, } pub(crate) struct AppConfig { @@ -128,6 +133,10 @@ pub(crate) struct App { config_flash: Option<(String, Instant)>, pub(crate) link_spans_by_line: HashMap>, pub(crate) hovered_link: Option<(usize, usize)>, + pub(crate) code_blocks: Vec, + pub(crate) code_select: Option, + pub(crate) hovered_code_block: Option, + code_block_flash: Option<(CodeBlockFlash, Instant)>, link_flash: Option<(LinkFlash, Instant)>, path_flash: Option<(PathFlash, Instant)>, pub(crate) last_click: Option<(u16, u16, Instant)>, @@ -278,6 +287,10 @@ impl App { config_flash: None, link_spans_by_line: HashMap::new(), hovered_link: None, + code_blocks: Vec::new(), + code_select: None, + hovered_code_block: None, + code_block_flash: None, link_flash: None, path_flash: None, last_click: None, @@ -551,6 +564,11 @@ impl App { .as_ref() .map(|(_, t)| t.elapsed() < Duration::from_millis(FLASH_DURATION_MS)) .unwrap_or(false), + code_block_flash_active: self + .code_block_flash + .as_ref() + .map(|(_, t)| t.elapsed() < Duration::from_millis(FLASH_DURATION_MS)) + .unwrap_or(false), }; if self.status_cache_key.as_ref() == Some(&cache_key) { diff --git a/src/app/navigation.rs b/src/app/navigation.rs index 6fb2cf9..d8c2fbc 100644 --- a/src/app/navigation.rs +++ b/src/app/navigation.rs @@ -16,6 +16,10 @@ impl App { .saturating_sub(self.content_area.height as usize) } + pub(crate) fn visible_end(&self) -> usize { + (self.scroll + self.content_area.height as usize).min(self.total()) + } + pub(crate) fn scroll_percent(&self) -> u16 { let max = self.max_scroll(); if max == 0 { @@ -36,26 +40,31 @@ impl App { pub(crate) fn scroll_down(&mut self, n: usize) { self.reset_numkey_state(); self.scroll = (self.scroll + n).min(self.max_scroll()); + self.hovered_code_block = None; } pub(crate) fn scroll_up(&mut self, n: usize) { self.reset_numkey_state(); self.scroll = self.scroll.saturating_sub(n); + self.hovered_code_block = None; } pub(crate) fn scroll_top(&mut self) { self.reset_numkey_state(); self.scroll = 0; + self.hovered_code_block = None; } pub(crate) fn scroll_bottom(&mut self) { self.reset_numkey_state(); self.scroll = self.max_scroll(); + self.hovered_code_block = None; } pub(crate) fn scroll_to(&mut self, position: usize) { self.reset_numkey_state(); self.scroll = position.min(self.max_scroll()); + self.hovered_code_block = None; } pub(crate) fn toggle_toc(&mut self) { diff --git a/src/main.rs b/src/main.rs index 4393b81..32cacd7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -343,6 +343,7 @@ fn main() -> Result<()> { link_spans, line_number_map, source_line_map, + code_blocks, } = parsed; let mut app = App::new_with_source( lines, @@ -357,6 +358,7 @@ fn main() -> Result<()> { }, ); app.set_link_spans(link_spans); + app.set_code_blocks(code_blocks); app.set_line_maps(line_number_map, source_line_map); app.set_last_content_hash(last_content_hash); app.set_watch_from_config(watch_from_config); diff --git a/src/markdown/mod.rs b/src/markdown/mod.rs index b865ade..7382542 100644 --- a/src/markdown/mod.rs +++ b/src/markdown/mod.rs @@ -220,12 +220,36 @@ fn push_text_event( push_custom_marker_spans(text, CUSTOM_MARKERS, fallback, theme, spans); } +#[derive(Clone, Debug)] +pub(crate) struct CodeBlockInfo { + pub(crate) rendered_start: usize, + pub(crate) rendered_end: usize, + pub(crate) raw_content: String, +} + +fn record_code_block( + code_blocks: &mut Vec, + raw_content: String, + rendered_start: usize, + lines_len_after: usize, +) { + let rendered_end = lines_len_after.saturating_sub(1); + if rendered_end >= rendered_start { + code_blocks.push(CodeBlockInfo { + rendered_start, + rendered_end, + raw_content, + }); + } +} + pub(crate) struct ParseResult { pub(crate) lines: Vec>, pub(crate) toc: Vec, pub(crate) link_spans: Vec, pub(crate) line_number_map: Vec, pub(crate) source_line_map: Vec, + pub(crate) code_blocks: Vec, } impl ParseResult { @@ -236,6 +260,7 @@ impl ParseResult { link_spans: Vec::new(), line_number_map: Vec::new(), source_line_map: Vec::new(), + code_blocks: Vec::new(), } } } @@ -297,6 +322,7 @@ pub(crate) fn parse_markdown_with_width( let mut in_code = false; let mut code_lang = String::new(); let mut code_buf = String::new(); + let mut code_blocks: Vec = Vec::new(); let mut blockquote_depth = 0usize; let mut inline = InlineStyleState::default(); let mut list_stack: Vec = Vec::new(); @@ -383,6 +409,8 @@ pub(crate) fn parse_markdown_with_width( } MdEvent::End(TagEnd::CodeBlock) => { in_code = false; + let raw_content = code_buf.clone(); + let rendered_start = lines.len(); if code_lang == "latex" || code_lang == "tex" { push_latex_block_lines( &mut lines, @@ -433,6 +461,7 @@ pub(crate) fn parse_markdown_with_width( ); wraps = true; } + record_code_block(&mut code_blocks, raw_content, rendered_start, lines.len()); last_block = LastBlock::Other; } MdEvent::Code(text) => { @@ -539,6 +568,8 @@ pub(crate) fn parse_markdown_with_width( lines.push(Line::from(std::mem::take(&mut spans))); } trim_paragraph_gap_before_block(&mut lines, last_block); + let raw_content = text.as_ref().trim().to_string(); + let rendered_start = lines.len(); push_latex_block_lines( &mut lines, text.as_ref(), @@ -551,6 +582,7 @@ pub(crate) fn parse_markdown_with_width( }, &mut item_stack, ); + record_code_block(&mut code_blocks, raw_content, rendered_start, lines.len()); wraps = true; last_block = LastBlock::Other; } @@ -583,6 +615,7 @@ pub(crate) fn parse_markdown_with_width( link_spans, line_number_map: state.line_number_map, source_line_map: state.source_line_map, + code_blocks, } } diff --git a/src/render/content.rs b/src/render/content.rs index dc020fd..f588895 100644 --- a/src/render/content.rs +++ b/src/render/content.rs @@ -52,6 +52,18 @@ pub(super) fn render_content_panel(f: &mut Frame, app: &mut App, area: Rect) { } } + if let Some(block_idx) = app.highlighted_code_block() { + if let Some(block) = app.code_blocks.get(block_idx) { + apply_code_block_highlight( + &mut visible_lines, + block, + scroll, + visible_end, + &theme.markdown, + ); + } + } + if app.is_line_number_visible() { let digit_width = app.line_number_total().max(1).to_string().len(); let gutter_style = Style::default().fg(theme.markdown.code_gutter); @@ -134,6 +146,38 @@ fn inner_content_area(area: Rect) -> Rect { } } +fn apply_code_block_highlight( + visible_lines: &mut [ratatui::text::Line<'static>], + block: &crate::markdown::CodeBlockInfo, + scroll: usize, + visible_end: usize, + theme: &crate::theme::MarkdownTheme, +) { + let start = block.rendered_start.max(scroll); + let end = block.rendered_end.min(visible_end.saturating_sub(1)); + if start > end { + return; + } + let footer_line = block.rendered_end.saturating_sub(1); + let focus = theme.link_hover; + for line_idx in start..=end { + let vis_idx = line_idx - scroll; + let Some(line) = visible_lines.get_mut(vis_idx) else { + continue; + }; + let target = if line_idx == block.rendered_start || line_idx == footer_line { + theme.code_frame + } else { + theme.code_gutter + }; + for span in &mut line.spans { + if span.style.fg == Some(target) { + span.style = span.style.fg(focus); + } + } + } +} + fn apply_hover_style( line: &mut ratatui::text::Line<'static>, start_col: usize, diff --git a/src/render/popup.rs b/src/render/popup.rs index 95b66d5..220b30d 100644 --- a/src/render/popup.rs +++ b/src/render/popup.rs @@ -77,7 +77,7 @@ pub(super) fn popup_footer_line(segments: &[&'static str], bg: Color) -> Line<'s pub(super) fn render_help_popup(f: &mut Frame, _app: &App) { let theme = app_theme(); - let area = centered_rect(55, 24, f.area()); + let area = centered_rect(53, 24, f.area()); let select_hint = crate::editor::selection_modifier_label(&crate::editor::detect_terminal_emulator()); @@ -108,25 +108,32 @@ pub(super) fn render_help_popup(f: &mut Frame, _app: &App) { Span::styled("scroll", text_style), Span::raw(" "), Span::styled("dbl-click ", key_style), - Span::styled("copy link", text_style), + Span::styled("copy code", text_style), ]), Line::from(vec![ Span::styled("u/d ", key_style), Span::styled("page up/down", text_style), Span::raw(" "), - Span::styled("ctrl+click ", key_style), - Span::styled("open link", text_style), + Span::styled("dbl-click ", key_style), + Span::styled("copy link", text_style), ]), Line::from(vec![ Span::styled("g/G ", key_style), Span::styled("top/bottom", text_style), Span::raw(" "), - Span::styled(format!("{select_hint:<12}"), key_style), - Span::styled("select text", text_style), + Span::styled("ctrl+click ", key_style), + Span::styled("open link", text_style), ]), Line::from(vec![ Span::styled("1-9/0+1-9 ", key_style), Span::styled("jump/reverse", text_style), + Span::raw(" "), + Span::styled(format!("{select_hint:<12}"), key_style), + Span::styled("slct text", text_style), + ]), + Line::from(vec![ + Span::styled("y/Y, c/C ", key_style), + Span::styled("focus code", text_style), ]), Line::from(""), Line::from(vec![ diff --git a/src/render/status.rs b/src/render/status.rs index 941918f..4a309b4 100644 --- a/src/render/status.rs +++ b/src/render/status.rs @@ -1,5 +1,5 @@ use crate::{ - app::{App, EditorFlash, LinkFlash, PathFlash, WatchFlash, FLASH_DURATION_MS}, + app::{App, CodeBlockFlash, EditorFlash, LinkFlash, PathFlash, WatchFlash, FLASH_DURATION_MS}, theme::app_theme, }; use ratatui::{ @@ -267,6 +267,21 @@ fn link_flash_section(app: &App) -> Option>> { Some(vec![Span::styled(text, Style::default().fg(fg).bg(bar_bg))]) } +fn code_block_flash_section(app: &App) -> Option>> { + let (flash, started) = app.code_block_flash()?; + if started.elapsed() >= std::time::Duration::from_millis(FLASH_DURATION_MS) { + return None; + } + let theme = app_theme(); + let bar_bg = status_bar_bg(); + let (text, fg) = match flash { + CodeBlockFlash::Copied => (" Code copied ", theme.ui.status_success_fg), + CodeBlockFlash::CopyFailed => (clipboard_hint(), theme.ui.status_error_fg), + CodeBlockFlash::NoneVisible => (" No code block in view ", theme.ui.status_error_fg), + }; + Some(vec![Span::styled(text, Style::default().fg(fg).bg(bar_bg))]) +} + fn path_flash_section(app: &App) -> Option>> { let (flash, started) = app.path_flash()?; if started.elapsed() >= std::time::Duration::from_millis(FLASH_DURATION_MS) { @@ -316,6 +331,12 @@ pub(crate) fn build_status_bar(app: &App, pct: u16) -> Vec> { return join_span_sections(vec![left], outer_separator); } + if let Some(flash_section) = code_block_flash_section(app) { + let mut left = status_brand_section(); + left.extend(flash_section); + return join_span_sections(vec![left], outer_separator); + } + if let Some(flash_section) = path_flash_section(app) { let mut left = status_brand_section(); left.extend(flash_section); diff --git a/src/runtime/keyboard.rs b/src/runtime/keyboard.rs index 01800a7..f8fbee6 100644 --- a/src/runtime/keyboard.rs +++ b/src/runtime/keyboard.rs @@ -229,6 +229,16 @@ pub(super) fn handle_key_event( _ => state_changed = false, } } else { + let mut mode_exited = false; + if app.is_code_select_mode() { + let mode_handled = handle_code_select_key(app, &key); + if mode_handled { + return Ok(HandleResult::Continue { redraw: true }); + } + mode_exited = app.exit_code_select_mode(); + } else if try_code_select_entry(app, &key) { + return Ok(HandleResult::Continue { redraw: true }); + } match key.code { KeyCode::Esc if app.has_active_goto_line() => app.clear_active_goto_line(), KeyCode::Esc if app.has_active_search() => app.clear_active_search(), @@ -331,9 +341,60 @@ pub(super) fn handle_key_event( } _ => state_changed = false, } + if mode_exited { + state_changed = true; + } } Ok(HandleResult::Continue { redraw: state_changed, }) } + +fn handle_code_select_key(app: &mut App, key: &KeyEvent) -> bool { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Char('c') if ctrl => { + app.exit_code_select_mode(); + true + } + KeyCode::Char('y') if ctrl => { + app.copy_selected_code_block(); + true + } + KeyCode::Char('c') | KeyCode::Char('y') if !ctrl => { + app.code_select_next(); + true + } + KeyCode::Char('C') | KeyCode::Char('Y') => { + app.code_select_prev(); + true + } + KeyCode::Enter => { + app.copy_selected_code_block(); + true + } + KeyCode::Esc => { + app.exit_code_select_mode(); + true + } + _ => false, + } +} + +fn try_code_select_entry(app: &mut App, key: &KeyEvent) -> bool { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Char('y') if ctrl => { + app.copy_first_visible_code_block(); + true + } + KeyCode::Char('c') | KeyCode::Char('y') | KeyCode::Char('C') | KeyCode::Char('Y') + if !ctrl => + { + app.enter_code_select_mode(); + true + } + _ => false, + } +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 766f2ab..57730be 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -22,6 +22,7 @@ const EDITOR_FLASH_DURATION: Duration = Duration::from_millis(FLASH_DURATION_MS) const WATCH_FLASH_DURATION: Duration = Duration::from_millis(FLASH_DURATION_MS); const CONFIG_FLASH_DURATION: Duration = Duration::from_millis(FLASH_DURATION_MS); const LINK_FLASH_DURATION: Duration = Duration::from_millis(FLASH_DURATION_MS); +const CODE_BLOCK_FLASH_DURATION: Duration = Duration::from_millis(FLASH_DURATION_MS); const PATH_FLASH_DURATION: Duration = Duration::from_millis(FLASH_DURATION_MS); const DOUBLE_CLICK_THRESHOLD: Duration = Duration::from_millis(400); const MOUSE_SCROLL_STEP: usize = 3; @@ -116,6 +117,9 @@ pub(crate) fn run( let link_flash_timeout = app .link_flash() .and_then(|(_, started)| LINK_FLASH_DURATION.checked_sub(started.elapsed())); + let code_block_flash_timeout = app + .code_block_flash() + .and_then(|(_, started)| CODE_BLOCK_FLASH_DURATION.checked_sub(started.elapsed())); let resize_timeout = pending_resize.and_then(|started| RESIZE_DEBOUNCE.checked_sub(started.elapsed())); let poll_timeout = [ @@ -134,6 +138,7 @@ pub(crate) fn run( watch_flash_timeout, config_flash_timeout, link_flash_timeout, + code_block_flash_timeout, app.path_flash() .and_then(|(_, started)| PATH_FLASH_DURATION.checked_sub(started.elapsed())), resize_timeout, @@ -196,6 +201,9 @@ pub(crate) fn run( } } Event::Resize(_, _) => { + if app.exit_code_select_mode() { + needs_redraw = true; + } pending_resize = Some(Instant::now()); } _ => {} diff --git a/src/runtime/mouse.rs b/src/runtime/mouse.rs index 988bebe..ad0bcab 100644 --- a/src/runtime/mouse.rs +++ b/src/runtime/mouse.rs @@ -2,6 +2,7 @@ use crate::{ app::{App, EditorFlash, LinkFlash, PathKind}, clipboard::{copy_to_clipboard, open_url}, editor::{self, classify, open_in_editor, split_editor_cmd, EditorResult}, + markdown::display_width, render::{CONTENT_HORIZONTAL_PADDING, SCROLLBAR_WIDTH}, }; use anyhow::Result; @@ -68,12 +69,14 @@ pub(super) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> bool { } else { match mouse.kind { MouseEventKind::ScrollUp => { + app.exit_code_select_mode(); app.scroll_up(super::MOUSE_SCROLL_STEP); app.hovered_link = None; app.hovered_toc_idx = None; true } MouseEventKind::ScrollDown => { + app.exit_code_select_mode(); app.scroll_down(super::MOUSE_SCROLL_STEP); app.hovered_link = None; app.hovered_toc_idx = None; @@ -154,9 +157,19 @@ pub(super) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> bool { false } } else if is_on_scrollbar(app.content_area, mouse.column, mouse.row) { + app.exit_code_select_mode(); app.scrollbar_dragging = true; scrollbar_scroll_to(app, mouse.row); true + } else if is_double_click { + if let Some(line_idx) = line_idx_at(app, mouse.column, mouse.row) { + if let Some(block_idx) = app.code_block_at_line(line_idx) { + app.copy_code_block_at(block_idx); + app.last_click = None; + return true; + } + } + false } else { false } @@ -164,11 +177,13 @@ pub(super) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> bool { MouseEventKind::Down(MouseButton::Middle | MouseButton::Right) if is_on_scrollbar(app.content_area, mouse.column, mouse.row) => { + app.exit_code_select_mode(); app.scrollbar_dragging = true; scrollbar_scroll_to(app, mouse.row); true } MouseEventKind::Drag(..) if app.scrollbar_dragging => { + app.exit_code_select_mode(); scrollbar_scroll_to(app, mouse.row); true } @@ -208,7 +223,21 @@ pub(super) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> bool { app.hovered_toc_idx = new_toc_hover; } - scrollbar_changed || hover_changed || toc_hover_changed + let new_code_block_hover = line_idx_at(app, mouse.column, mouse.row) + .and_then(|line_idx| app.code_block_at_line(line_idx)); + let code_block_hover_changed = app.hovered_code_block != new_code_block_hover; + if code_block_hover_changed { + app.hovered_code_block = new_code_block_hover; + } + if app.code_select.is_some() { + if let Some(new_idx) = new_code_block_hover { + if app.code_select != Some(new_idx) { + app.code_select = Some(new_idx); + } + } + } + + scrollbar_changed || hover_changed || toc_hover_changed || code_block_hover_changed } _ => false, } @@ -340,6 +369,45 @@ fn is_in_rect(rect: Rect, col: u16, row: u16) -> bool { col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height } +fn line_idx_at(app: &App, col: u16, row: u16) -> Option { + let area = app.content_area; + let gutter = app.line_number_gutter_width() as u16; + let inner_x = area.x + CONTENT_HORIZONTAL_PADDING + gutter; + let inner_w = area + .width + .saturating_sub(CONTENT_HORIZONTAL_PADDING * 2) + .saturating_sub(SCROLLBAR_WIDTH) + .saturating_sub(gutter); + if col < inner_x || col >= inner_x + inner_w || row < area.y || row >= area.y + area.height { + return None; + } + let rel_row = (row - area.y) as usize; + let content_width = inner_w.max(1) as usize; + let mut visual_row = 0usize; + let total = app.lines.len(); + for line_idx in app.scroll..total { + let line = &app.lines[line_idx]; + let line_width: usize = line + .spans + .iter() + .map(|s| display_width(s.content.as_ref())) + .sum(); + let wrapped_lines = if line_width == 0 { + 1 + } else { + line_width.div_ceil(content_width) + }; + if rel_row < visual_row + wrapped_lines { + return Some(line_idx); + } + visual_row += wrapped_lines; + if visual_row > area.height as usize { + break; + } + } + None +} + fn strip_unc_prefix(path: std::path::PathBuf) -> std::path::PathBuf { if cfg!(target_os = "windows") { let s = path.to_string_lossy(); diff --git a/src/tests/app_code_blocks.rs b/src/tests/app_code_blocks.rs new file mode 100644 index 0000000..f737657 --- /dev/null +++ b/src/tests/app_code_blocks.rs @@ -0,0 +1,75 @@ +use super::{test_assets, test_md_theme}; +use crate::app::App; +use crate::markdown::parse_markdown; +use ratatui::layout::Rect; + +fn build_app_with_mixed_blocks() -> App { + let src = "```rust\nfn main() {}\n```\n\n\ + ```mermaid\ngraph TD;\nA-->B;\n```\n\n\ + ```latex\nE = mc^2\n```\n\n\ + $$ F = ma $$\n"; + let (ss, theme) = test_assets(); + let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + let mut app = App::new( + parsed.lines, + parsed.toc, + "test".to_string(), + false, + false, + None, + None, + ); + app.set_code_blocks(parsed.code_blocks); + app.content_area = Rect::new(0, 0, 80, 200); + app +} + +#[test] +fn enter_code_select_mode_picks_first_visible_block() { + let mut app = build_app_with_mixed_blocks(); + app.enter_code_select_mode(); + assert_eq!(app.code_select, Some(0)); +} + +#[test] +fn code_select_next_cycles_through_all_blocks_including_mermaid_latex_and_display_math() { + let mut app = build_app_with_mixed_blocks(); + app.enter_code_select_mode(); + assert_eq!(app.code_select, Some(0)); + app.code_select_next(); + assert_eq!(app.code_select, Some(1)); + app.code_select_next(); + assert_eq!(app.code_select, Some(2)); + app.code_select_next(); + assert_eq!(app.code_select, Some(3)); + app.code_select_next(); + assert_eq!(app.code_select, Some(0)); +} + +#[test] +fn copy_selected_code_block_works_for_mermaid_latex_and_display_math() { + for idx in [1, 2, 3] { + let mut app = build_app_with_mixed_blocks(); + app.code_select = Some(idx); + app.copy_selected_code_block(); + assert!( + app.code_block_flash().is_some(), + "copy at idx={idx} should set a flash" + ); + assert_eq!(app.code_select, None); + } +} + +#[test] +fn code_block_at_line_resolves_mermaid_latex_and_display_math() { + let app = build_app_with_mixed_blocks(); + let mermaid = &app.code_blocks[1]; + let latex = &app.code_blocks[2]; + let display_math = &app.code_blocks[3]; + let mermaid_mid = (mermaid.rendered_start + mermaid.rendered_end) / 2; + let latex_mid = (latex.rendered_start + latex.rendered_end) / 2; + let display_math_mid = (display_math.rendered_start + display_math.rendered_end) / 2; + assert_eq!(app.code_block_at_line(mermaid_mid), Some(1)); + assert_eq!(app.code_block_at_line(latex_mid), Some(2)); + assert_eq!(app.code_block_at_line(display_math_mid), Some(3)); +} diff --git a/src/tests/markdown_blocks.rs b/src/tests/markdown_blocks.rs index 1bdc41c..f3ac5ce 100644 --- a/src/tests/markdown_blocks.rs +++ b/src/tests/markdown_blocks.rs @@ -174,3 +174,79 @@ fn source_line_map_padding_repeats_last_event_source_line() { let last = parsed.source_line_map[total - 1]; assert_eq!(last, 5); } + +#[test] +fn code_blocks_capture_raw_content_with_trailing_newline() { + let (ss, theme) = test_assets(); + let src = "```rust\nfn main() {\n println!(\"hi\");\n}\n```\n"; + let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + assert_eq!(parsed.code_blocks.len(), 1); + let block = &parsed.code_blocks[0]; + assert_eq!(block.raw_content, "fn main() {\n println!(\"hi\");\n}\n"); + assert!(block.rendered_start < block.rendered_end); + assert!(block.rendered_end < parsed.lines.len()); +} + +#[test] +fn code_blocks_capture_mermaid_raw_content() { + let (ss, theme) = test_assets(); + let src = "```mermaid\ngraph TD;\nA-->B;\n```\n"; + let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + assert_eq!(parsed.code_blocks.len(), 1); + let block = &parsed.code_blocks[0]; + assert_eq!(block.raw_content, "graph TD;\nA-->B;\n"); + assert!(block.rendered_end < parsed.lines.len()); +} + +#[test] +fn code_blocks_capture_latex_raw_content() { + let (ss, theme) = test_assets(); + let src = "```latex\nE = mc^2\n```\n"; + let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + assert_eq!(parsed.code_blocks.len(), 1); + let block = &parsed.code_blocks[0]; + assert_eq!(block.raw_content, "E = mc^2\n"); + assert!(block.rendered_end < parsed.lines.len()); +} + +#[test] +fn code_blocks_multiple_blocks_are_captured_in_order() { + let (ss, theme) = test_assets(); + let src = "```rust\nfirst\n```\n\nsome text\n\n```python\nsecond\n```\n"; + let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + assert_eq!(parsed.code_blocks.len(), 2); + assert_eq!(parsed.code_blocks[0].raw_content, "first\n"); + assert_eq!(parsed.code_blocks[1].raw_content, "second\n"); + assert!(parsed.code_blocks[0].rendered_end < parsed.code_blocks[1].rendered_start); +} + +#[test] +fn code_blocks_raw_content_excludes_fences() { + let (ss, theme) = test_assets(); + let src = "```\nplain code\n```\n"; + let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + assert_eq!(parsed.code_blocks.len(), 1); + let block = &parsed.code_blocks[0]; + assert!(!block.raw_content.contains("```")); + assert_eq!(block.raw_content, "plain code\n"); +} + +#[test] +fn code_blocks_capture_display_math_dollar_syntax() { + let (ss, theme) = test_assets(); + let src = "$$ E = mc^2 $$\n"; + let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + assert_eq!(parsed.code_blocks.len(), 1); + let block = &parsed.code_blocks[0]; + assert_eq!(block.raw_content, "E = mc^2"); + assert!(block.rendered_start <= block.rendered_end); + assert!(block.rendered_end < parsed.lines.len()); +} + +#[test] +fn code_blocks_ignores_inline_math_dollar_syntax() { + let (ss, theme) = test_assets(); + let src = "Voici une formule inline $a + b$ dans un paragraphe.\n"; + let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + assert_eq!(parsed.code_blocks.len(), 0); +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 83a9c4b..16925b2 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -12,6 +12,7 @@ use syntect::{ }; mod app; +mod app_code_blocks; mod completions; mod config; mod editor;