diff --git a/Cargo.lock b/Cargo.lock index eda4f78..441e2fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1123,6 +1123,7 @@ dependencies = [ "base64", "crossterm", "getrandom 0.2.17", + "pulldown-cmark", "ratatui", "serde", "serde_json", @@ -1316,6 +1317,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.0", + "memchr", + "unicase", +] + [[package]] name = "quote" version = "1.0.46" @@ -2040,6 +2052,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index bb1cfbc..faecd7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,3 +25,4 @@ sha2 = "0.11.0" thiserror = "2.0.18" ureq = { version = "3.3.0", features = ["json", "platform-verifier"] } url = "2.5.8" +pulldown-cmark = { version = "0.13.4", default-features = false } diff --git a/src/lib.rs b/src/lib.rs index e5186c2..85345c3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -662,6 +662,7 @@ impl App { self.note_input.clear(); self.notes.clear(); + self.editor_description_scroll = 0; self.notes_session_id = self.notes_session_id.wrapping_add(1); self.note_submit_pending = None; self.notes_issue_iid = Some(iid); @@ -865,7 +866,7 @@ impl App { pub fn scroll_editor_description(&mut self, delta: i16) { if !matches!( self.overlay, - Some(Overlay::Details | Overlay::EditIssue | Overlay::NewIssue) + Some(Overlay::Details | Overlay::EditIssue | Overlay::NewIssue | Overlay::Notes) ) { return; } @@ -881,6 +882,12 @@ impl App { } fn editor_description_scroll_max(&self) -> usize { + if self.overlay == Some(Overlay::Notes) { + let notes_lines = self.notes.len().clamp(1, 5); + let input_lines = self.note_input.lines().count().max(1); + return (notes_lines + input_lines + 6).saturating_sub(1); + } + // The read-only details view always renders the markdown preview. let preview = self.editor_preview || self.overlay == Some(Overlay::Details); crate::ui::markdown::description_lines( @@ -936,7 +943,13 @@ impl App { if self.overlay != Some(Overlay::GenerateStories) { return; } - let max_scroll = self.story_source.lines().count().saturating_sub(1); + let max_scroll = if self.story_preview { + crate::ui::markdown::markdown_preview_lines(&self.story_source) + .len() + .saturating_sub(1) + } else { + self.story_source.lines().count().saturating_sub(1) + }; if delta > 0 { self.story_source_scroll = (self.story_source_scroll + delta as usize).min(max_scroll); } else { @@ -1271,6 +1284,39 @@ impl App { self.story_selected = self.story_selected.saturating_sub(1); } + pub fn scroll_palette(&mut self, delta: i16) { + if self.overlay != Some(Overlay::Palette) { + return; + } + let len = self.commands().len(); + if len == 0 { + return; + } + if delta > 0 { + self.palette_selected = (self.palette_selected + delta as usize).min(len - 1); + } else { + self.palette_selected = self + .palette_selected + .saturating_sub(delta.unsigned_abs() as usize); + } + } + + pub fn scroll_settings(&mut self, delta: i16) { + let Some(form) = self.settings_form.as_mut() else { + return; + }; + if self.overlay != Some(Overlay::Settings) { + return; + } + for _ in 0..delta.unsigned_abs() { + if delta > 0 { + form.focus_next(); + } else { + form.focus_prev(); + } + } + } + pub fn toggle_selected_story(&mut self) { if self.overlay != Some(Overlay::GenerateStories) || !matches!( @@ -1862,12 +1908,32 @@ impl App { self.scroll_editor_description(delta); return; } + Some(Overlay::Notes) => { + self.scroll_editor_description(delta); + return; + } Some(Overlay::Help) => { self.scroll_help(delta); return; } Some(Overlay::GenerateStories) => { - self.scroll_story_source(delta); + if self.story_tab() == StoryTab::Generated { + if delta > 0 { + self.move_story_selection_down(); + } else { + self.move_story_selection_up(); + } + } else { + self.scroll_story_source(delta); + } + return; + } + Some(Overlay::Palette) => { + self.scroll_palette(delta); + return; + } + Some(Overlay::Settings) => { + self.scroll_settings(delta); return; } _ => {} diff --git a/src/main.rs b/src/main.rs index d5aeb4d..3d2b657 100644 --- a/src/main.rs +++ b/src/main.rs @@ -111,6 +111,7 @@ fn run(terminal: &mut Terminal>, app: &mut App) -> let mut config = load_config(); prepare_startup(&config, app); let mut job_rx: Option> = None; + let mut mouse_capture = true; loop { if app.config_dirty { @@ -132,7 +133,20 @@ fn run(terminal: &mut Terminal>, app: &mut App) -> } if event::poll(Duration::from_millis(40))? { - handle_event(event::read()?, app); + let event = event::read()?; + if mouse_capture_toggle_requested(&event) { + mouse_capture = !mouse_capture; + set_mouse_capture(terminal, mouse_capture)?; + report_mouse_capture(app, mouse_capture); + continue; + } + if mouse_capture && terminal_mouse_requested(&event) { + mouse_capture = false; + set_mouse_capture(terminal, false)?; + report_mouse_capture(app, false); + continue; + } + handle_event(event, app); } if let Some(text) = app.clipboard_request.take() { let _ = copy_to_clipboard(&text); @@ -140,6 +154,50 @@ fn run(terminal: &mut Terminal>, app: &mut App) -> } } +fn set_mouse_capture( + terminal: &mut Terminal>, + enabled: bool, +) -> io::Result<()> { + if enabled { + execute!(terminal.backend_mut(), EnableMouseCapture)?; + } else { + execute!(terminal.backend_mut(), DisableMouseCapture)?; + } + Ok(()) +} + +fn mouse_capture_toggle_requested(event: &Event) -> bool { + matches!( + event, + Event::Key(key) + if (key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat) + && matches!(key.code, KeyCode::Char('m') | KeyCode::Char('M')) + && key.modifiers.contains(KeyModifiers::ALT) + ) +} + +fn terminal_mouse_requested(event: &Event) -> bool { + matches!( + event, + Event::Mouse(mouse) + if matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Right) | MouseEventKind::Up(MouseButton::Right) + ) + ) +} + +fn report_mouse_capture(app: &mut App, enabled: bool) { + if enabled { + app.set_status(StatusKind::Info, "App mouse enabled"); + } else { + app.set_status( + StatusKind::Info, + "Terminal select/copy/paste enabled. Alt+M restores app mouse.", + ); + } +} + fn prepare_startup(config: &Result, app: &mut App) { app.settings = lane_ratatui_premium::settings::load(); match config { @@ -827,6 +885,28 @@ mod tests { assert_eq!(app.hover, Some(ClickTarget::Help)); } + #[test] + fn alt_m_toggles_terminal_mouse_mode() { + let event = Event::Key(crossterm::event::KeyEvent::new( + KeyCode::Char('m'), + KeyModifiers::ALT, + )); + + assert!(mouse_capture_toggle_requested(&event)); + } + + #[test] + fn right_click_requests_terminal_mouse_mode() { + let event = Event::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: 5, + row: 5, + modifiers: KeyModifiers::NONE, + }); + + assert!(terminal_mouse_requested(&event)); + } + #[test] fn startup_requests_connect_when_config_is_valid() { let config = AppConfig::from_env_values(EnvConfig { diff --git a/src/ui/help.rs b/src/ui/help.rs index c95d940..9302077 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -70,7 +70,8 @@ fn help_columns() -> (Vec>, Vec>) { ("c", "Connect"), ("s / r", "Sync"), ("Ctrl+C", "Copy field"), - ("Drag", "Text select"), + ("Alt+M", "Mouse mode"), + ("Right click", "Terminal select/paste"), ("q", "Quit"), ], ); diff --git a/src/ui/markdown.rs b/src/ui/markdown.rs index 4652c98..ab2ba02 100644 --- a/src/ui/markdown.rs +++ b/src/ui/markdown.rs @@ -1,11 +1,7 @@ -//! Hand-rolled markdown rendering for card/issue descriptions. -//! -//! Supports a small, TUI-friendly subset of markdown: headings (levels 1-3), -//! task list items, bullets, blockquotes, ordered lists, fenced code blocks, -//! and inline bold/code/link/italic spans. There is no external markdown -//! dependency; everything here is a manual scanner over `&str`. +//! Markdown rendering for card/issue descriptions. use crate::theme; +use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use ratatui::prelude::*; /// Renders a description as either raw source (edit mode) or a styled @@ -45,250 +41,253 @@ pub fn markdown_preview_lines(markdown: &str) -> Vec> { )])]; } - let mut lines = Vec::new(); - let mut in_code = false; - for raw_line in markdown.lines() { - let line = raw_line.trim_end(); - let trimmed = line.trim_start(); + let mut options = Options::empty(); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TASKLISTS); + options.insert(Options::ENABLE_GFM); - if trimmed.starts_with("```") { - in_code = !in_code; - continue; - } + let mut renderer = MarkdownRenderer::default(); + for event in Parser::new_ext(markdown, options) { + renderer.push_event(event); + } + renderer.finish() +} - if in_code { - lines.push(Line::from(vec![Span::styled( - format!(" {line}"), - theme::code_style(), - )])); - continue; - } +#[derive(Default)] +struct MarkdownRenderer { + lines: Vec>, + current: Vec>, + styles: Vec