diff --git a/src/app/mod.rs b/src/app/mod.rs index c211085..a17af6b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -73,6 +73,7 @@ pub(crate) struct StatusCacheKey { link_flash_active: bool, path_flash_active: bool, code_block_flash_active: bool, + mouse_capture: bool, } pub(crate) struct AppConfig { @@ -149,6 +150,7 @@ pub(crate) struct App { pub(super) file_mode: bool, pub(super) code_line_numbers: bool, max_width: Option, + mouse_capture: bool, } impl App { @@ -303,6 +305,7 @@ impl App { file_mode: false, code_line_numbers: true, max_width: None, + mouse_capture: true, }; app.store_current_theme_preview(); app.refresh_static_caches(); @@ -325,6 +328,21 @@ impl App { self.max_width = max_width; } + pub(crate) fn is_mouse_capture_enabled(&self) -> bool { + self.mouse_capture + } + + pub(crate) fn toggle_mouse_capture(&mut self) -> bool { + self.mouse_capture = !self.mouse_capture; + if !self.mouse_capture { + self.hovered_link = None; + self.hovered_code_block = None; + self.hovered_toc_idx = None; + self.scrollbar_dragging = false; + } + self.mouse_capture + } + pub(crate) fn max_width(&self) -> Option { self.max_width } @@ -569,6 +587,7 @@ impl App { .as_ref() .map(|(_, t)| t.elapsed() < Duration::from_millis(FLASH_DURATION_MS)) .unwrap_or(false), + mouse_capture: self.mouse_capture, }; if self.status_cache_key.as_ref() == Some(&cache_key) { diff --git a/src/render/popup.rs b/src/render/popup.rs index 220b30d..5b0fe6f 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(53, 24, f.area()); + let area = centered_rect(53, 25, f.area()); let select_hint = crate::editor::selection_modifier_label(&crate::editor::detect_terminal_emulator()); @@ -107,33 +107,36 @@ pub(super) fn render_help_popup(f: &mut Frame, _app: &App) { Span::styled("j/k, ↑/↓ ", key_style), Span::styled("scroll", text_style), Span::raw(" "), - Span::styled("dbl-click ", key_style), - Span::styled("copy code", text_style), + Span::styled("shift+m ", key_style), + Span::styled("capture", text_style), ]), Line::from(vec![ Span::styled("u/d ", key_style), Span::styled("page up/down", 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("g/G ", key_style), Span::styled("top/bottom", 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("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), + Span::styled("ctrl+click ", key_style), + Span::styled("open link", text_style), ]), Line::from(vec![ Span::styled("y/Y, c/C ", key_style), Span::styled("focus code", text_style), + Span::raw(" "), + Span::styled(format!("{select_hint:<12}"), key_style), + Span::styled("slct text", text_style), ]), Line::from(""), Line::from(vec![ diff --git a/src/render/status.rs b/src/render/status.rs index 4a309b4..da1321a 100644 --- a/src/render/status.rs +++ b/src/render/status.rs @@ -307,6 +307,16 @@ pub(crate) fn build_status_bar(app: &App, pct: u16) -> Vec> { let bar_bg = status_bar_bg(); let outer_separator = Span::raw(" "); + if !app.is_mouse_capture_enabled() { + let theme = app_theme(); + let mut left = status_brand_section(); + left.push(Span::styled( + " Mouse capture disabled ", + Style::default().fg(theme.ui.status_warning_fg).bg(bar_bg), + )); + return join_span_sections(vec![left], outer_separator); + } + if let Some(flash_section) = editor_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 f8fbee6..0601d26 100644 --- a/src/runtime/keyboard.rs +++ b/src/runtime/keyboard.rs @@ -1,5 +1,6 @@ use crate::app::{App, WatchFlash}; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent, KeyModifiers}; +use crossterm::execute; use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; @@ -18,6 +19,23 @@ pub(super) fn handle_key_event( ss: &SyntaxSet, themes: &ThemeSet, ) -> anyhow::Result { + if matches!(key.code, KeyCode::Char('m') | KeyCode::Char('M')) + && !key.modifiers.contains(KeyModifiers::CONTROL) + { + let in_text_input = app.is_search_mode() + || app.is_goto_line_mode() + || (app.is_file_picker_open() && app.is_fuzzy_file_picker()); + if !in_text_input { + let now_enabled = app.toggle_mouse_capture(); + if now_enabled { + execute!(terminal.backend_mut(), EnableMouseCapture)?; + } else { + execute!(terminal.backend_mut(), DisableMouseCapture)?; + } + return Ok(HandleResult::Continue { redraw: true }); + } + } + let mut state_changed = true; if app.is_help_open() { match key.code { diff --git a/src/tests/app.rs b/src/tests/app.rs index f113214..bdfac80 100644 --- a/src/tests/app.rs +++ b/src/tests/app.rs @@ -38,6 +38,36 @@ fn key_release_events_are_ignored() { assert!(!should_handle_key(KeyEventKind::Release)); } +#[test] +fn mouse_capture_is_enabled_by_default_and_toggles() { + let (ss, theme) = test_assets(); + let (lines, toc, _, _) = + parse_markdown("body", &ss, &theme, &test_md_theme(), false, true).into(); + let mut app = App::new(lines, toc, "test".to_string(), false, false, None, None); + assert!(app.is_mouse_capture_enabled()); + assert!(!app.toggle_mouse_capture()); + assert!(!app.is_mouse_capture_enabled()); + assert!(app.toggle_mouse_capture()); + assert!(app.is_mouse_capture_enabled()); +} + +#[test] +fn toggle_mouse_capture_off_resets_hover_and_drag_state() { + let (ss, theme) = test_assets(); + let (lines, toc, _, _) = + parse_markdown("body", &ss, &theme, &test_md_theme(), false, true).into(); + let mut app = App::new(lines, toc, "test".to_string(), false, false, None, None); + app.hovered_link = Some((3, 0)); + app.hovered_code_block = Some(0); + app.hovered_toc_idx = Some(2); + app.scrollbar_dragging = true; + app.toggle_mouse_capture(); + assert_eq!(app.hovered_link, None); + assert_eq!(app.hovered_code_block, None); + assert_eq!(app.hovered_toc_idx, None); + assert!(!app.scrollbar_dragging); +} + #[test] fn stdin_read_is_rejected_when_over_limit() { let mut cursor = std::io::Cursor::new(vec![b'a'; 5]);