diff --git a/Cargo.lock b/Cargo.lock index 30396769..1f58e840 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -634,12 +634,44 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "edtui" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4941adefb4c21c4a8a5bece220a24dc28d1cbb5d1e5875be4a575d5d1da172a" +dependencies = [ + "crossterm", + "edtui-jagged", + "enum_dispatch", + "ratatui-core", + "ratatui-widgets", + "unicode-width", +] + +[[package]] +name = "edtui-jagged" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6818b2d6b8b3da52f7491b6331e27d45ae34e5baaffeb1edfde43911fe63dd6" + [[package]] name = "either" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2706,6 +2738,7 @@ dependencies = [ "clap", "crossterm", "directories", + "edtui", "git2", "ignore", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index 8be39c00..21109cee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,11 @@ categories = ["command-line-utilities", "development-tools"] ratatui = { version = "0.30", features = ["unstable-rendered-line-info"] } crossterm = "0.29" +# Vim-style modal editing engine for the review comment box (opt-in via +# `comment_vim`). Used as the editing model + input engine only; we keep our +# own rendering, so its widget/clipboard/syntax features are disabled. +edtui = { version = "0.11", default-features = false } + # Git operations git2 = { version = "0.20", default-features = false } diff --git a/README.md b/README.md index 21d176e7..626a4670 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,7 @@ ignore_whitespace = false # ignore all whitespace in local VCS diffs appearance = "system" # or "dark" / "light" mouse = true leader = ";" # configurable prefix for leader shortcuts +comment_vim = false # vim modal editing in the review comment box review_watch_interval_ms = 1000 # set to 0 to disable persisted-review polling [[comment_types]] diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 9d41595b..8504fff2 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -29,6 +29,8 @@ ignore_whitespace = false show_file_list = true mouse = true leader = "," +comment_vim = false +comment_tab_width = 4 wrap = false cursor_line = true transparent_background = true @@ -63,6 +65,8 @@ comment_type_prefix = true | `show_file_list` | `true` | Whether the file list panel is visible on startup. Toggle with `e`. | | `mouse` | `true` | Wheel scrolling, clicks, and drag-to-select. | | `leader` | `;` | Single-character prefix for panel focus, sidebar toggles, and review-comment shortcuts. Invalid multi-character values are ignored with a startup warning. | +| `comment_vim` | `false` | Vim modal editing in the comment box; toggle at runtime with `:vim`. When off, default emacs/readline bindings. | +| `comment_tab_width` | `4` | Spaces inserted by Tab while typing in the vim comment box (Insert mode). | | `wrap` | `false` | Line wrap in the diff view. Toggle with `:set wrap!`. | | `cursor_line` | `true` | Highlight the current cursor line and visual selection. | | `transparent_background` | `true` | Let the terminal background show through panels. `false` paints the theme's `panel_bg`. | diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index 43b3ff13..e0c53b25 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -68,7 +68,8 @@ Shown below the file tree when local comments or visible remote PR threads exist | `c` | Add review comment | | `v` / `V` | Enter visual mode for range comments | | `dd` | Delete comment at cursor | -| `i` | Edit comment at cursor | +| `i` | Edit comment at cursor (vim: text cursor at start) | +| `A` | Edit comment at cursor with text cursor at end (vim mode only) | | `y` | Copy review to clipboard | ## Visual mode @@ -91,6 +92,15 @@ Shown below the file tree when local comments or visible remote PR threads exist | `Ctrl-u` | Clear line | | `Esc` / `Ctrl-c` | Cancel | +With `comment_vim = true` the box uses [`edtui`](https://github.com/preiter93/edtui) +modal editing (Normal/Insert/Visual: `hjkl`, `w`/`b`/`e`, `dd`/`D`/`ciw`/`x`, +`u`/`Ctrl-r`, visual `v`+`y`/`d`/`p`). From Normal mode `:w` (or `Enter` twice) +saves and `:q` (or `Esc`/`q` twice) cancels — the first press arms the action +and the header shows a confirm hint. `Tab` cycles the comment type in Normal +mode and inserts `comment_tab_width` spaces (default 4) in Insert mode; `Ctrl-s` +also saves. Operator+motion combos like +`dw`/`cw` aren't supported (edtui limitation). + ## Commands In command mode, @@ -105,6 +115,7 @@ In command mode, | `:edit` | Open focused file in `$EDITOR` | | `:clip` (`:export`) | Copy review to clipboard | | `:diff` | Toggle diff view (unified / side-by-side) | +| `:vim` / `:novim` (`:set vim` / `:set novim`) | Enable/toggle/disable vim modal editing in the comment box (overrides `comment_vim`) | | `:commits` | Select commits to review | | `:submit` | Open submit picker (Comment / Approve / Request changes / Draft) | | `:submit comment` | Submit a Comment review | diff --git a/src/app.rs b/src/app.rs index 0d3e0a4a..94b7dd75 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,6 +5,7 @@ use std::time::{Duration, Instant, SystemTime}; use chrono::Utc; use ratatui::style::Color; +use crate::comment_vim::CommentVimEditor; use crate::config::CommentTypeConfig; use crate::editor::EditorTarget; use crate::error::{Result, TuicrError}; @@ -937,6 +938,17 @@ enum StoredCommentLocation { Line { path: PathBuf, line: u32 }, } +/// Pending "press again to confirm" state for the vim comment box. A first +/// plain `Enter`/`Esc` in Normal mode arms `Save`/`Cancel` and shows a header +/// hint; a second consecutive press performs it. Any other key resets to `None`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CommentVimPending { + #[default] + None, + Save, + Cancel, +} + pub struct App { pub theme: Theme, pub vcs: Box, @@ -966,6 +978,21 @@ pub struct App { pub last_search_pattern: Option, pub comment_buffer: String, pub comment_cursor: usize, + /// Config `comment_vim`: vim modal editing in the comment box. + pub comment_vim_enabled: bool, + /// Spaces inserted by Tab while typing in the vim comment box (config + /// `comment_tab_width`, default 4). + pub comment_tab_width: usize, + /// Active vim overlay (only while in comment mode with vim on); synced into + /// `comment_buffer`/`comment_cursor`, which stay canonical for rendering. + pub comment_vim_editor: Option, + /// In-progress `:` command-line in vim Normal mode (without the leading + /// `:`); `Some("")` right after `:` is pressed. `:w` saves, `:q` cancels. + pub comment_vim_command: Option, + /// Pending double-press confirm in vim Normal mode: a first plain + /// `Enter`/`Esc` arms Save/Cancel (with a header hint), the second performs + /// it (`:w`/`:q`). Any other key resets it. + pub comment_vim_pending: CommentVimPending, pub comment_type: CommentType, pub comment_types: Vec, pub comment_is_review_level: bool, @@ -1831,6 +1858,11 @@ impl App { last_search_pattern: None, comment_buffer: String::new(), comment_cursor: 0, + comment_vim_enabled: false, + comment_tab_width: 4, + comment_vim_editor: None, + comment_vim_command: None, + comment_vim_pending: CommentVimPending::None, comment_type: default_comment_type, comment_types, comment_is_review_level: false, @@ -6538,10 +6570,109 @@ impl App { self.set_message(msg); } - /// Enter edit mode for the comment at the current cursor position - /// Returns true if a comment was found and edit mode entered - pub fn enter_edit_mode(&mut self) -> bool { + /// True if two annotation rows belong to the same rendered comment. + /// `AnnotatedLine` is not `Eq`, so compare the identifying fields. + fn same_comment(a: &AnnotatedLine, b: &AnnotatedLine) -> bool { + use AnnotatedLine::{FileComment, LineComment, ReviewComment}; + match (a, b) { + (ReviewComment { comment_idx: x }, ReviewComment { comment_idx: y }) => x == y, + ( + FileComment { + file_idx: f1, + comment_idx: c1, + }, + FileComment { + file_idx: f2, + comment_idx: c2, + }, + ) => f1 == f2 && c1 == c2, + ( + LineComment { + file_idx: f1, + line: l1, + side: s1, + comment_idx: c1, + }, + LineComment { + file_idx: f2, + line: l2, + side: s2, + comment_idx: c2, + }, + ) => f1 == f2 && l1 == l2 && s1 == s2 && c1 == c2, + _ => false, + } + } + + /// First annotation row of the comment rendered at `cursor_line` (or + /// `cursor_line` itself when it isn't on a comment). + fn comment_block_start(&self, cursor_line: usize) -> usize { + let Some(cur) = self.line_annotations.get(cursor_line) else { + return cursor_line; + }; + let mut start = cursor_line; + while start > 0 + && self + .line_annotations + .get(start - 1) + .is_some_and(|prev| Self::same_comment(prev, cur)) + { + start -= 1; + } + start + } + + /// Byte offset in the loaded `comment_buffer` for the start + /// (`cursor_at_end == false`) or end of the comment line the diff cursor is + /// on. The comment's block begins at annotation row `block_start` (row 0 = + /// top border, then one row per wrapped segment, then the bottom border). + fn comment_current_line_cursor(&self, block_start: usize, cursor_at_end: bool) -> usize { + let content = &self.comment_buffer; + let content_area = self.diff_state.viewport_width.saturating_sub(10); + // Visual content row under the cursor (skip the top border at row 0). + let visual_target = self + .diff_state + .cursor_line + .saturating_sub(block_start) + .saturating_sub(1); + + let mut visual = 0usize; + let mut byte = 0usize; + let mut line_start = 0usize; + let mut line_len = 0usize; + for line in content.split('\n') { + line_start = byte; + line_len = line.len(); + let segs = crate::ui::comment_panel::wrap_segments(line, content_area) + .len() + .max(1); + if visual_target < visual + segs { + return if cursor_at_end { + line_start + line_len + } else { + line_start + }; + } + visual += segs; + byte += line.len() + 1; + } + // Cursor on the bottom border or past the content: use the last line. + if cursor_at_end { + line_start + line_len + } else { + line_start + } + } + + /// Enter edit mode for the comment at the current cursor position. + /// `cursor_at_end` places the text cursor at the end of the current comment + /// line (vim `A` / the default non-vim behavior); otherwise at its start + /// (vim `i`). Returns true if a comment was found and edit mode entered. + pub fn enter_edit_mode(&mut self, cursor_at_end: bool) -> bool { let location = self.find_comment_at_cursor(); + // First annotation row of the comment under the cursor, so we can place + // the text cursor on the line the diff cursor is actually pointing at. + let block_start = self.comment_block_start(self.diff_state.cursor_line); match location { Some(CommentLocation::Review { index }) => { @@ -6549,7 +6680,8 @@ impl App { self.input_mode = InputMode::Comment; self.diff_state.scroll_x = 0; self.comment_buffer = comment.content.clone(); - self.comment_cursor = self.comment_buffer.len(); + self.comment_cursor = + self.comment_current_line_cursor(block_start, cursor_at_end); self.comment_type = comment.comment_type.clone(); self.comment_is_review_level = true; self.comment_is_file_level = false; @@ -6565,7 +6697,8 @@ impl App { self.input_mode = InputMode::Comment; self.diff_state.scroll_x = 0; self.comment_buffer = comment.content.clone(); - self.comment_cursor = self.comment_buffer.len(); + self.comment_cursor = + self.comment_current_line_cursor(block_start, cursor_at_end); self.comment_type = comment.comment_type.clone(); self.comment_is_review_level = false; self.comment_is_file_level = true; @@ -6592,7 +6725,8 @@ impl App { self.input_mode = InputMode::Comment; self.diff_state.scroll_x = 0; self.comment_buffer = comment.content.clone(); - self.comment_cursor = self.comment_buffer.len(); + self.comment_cursor = + self.comment_current_line_cursor(block_start, cursor_at_end); self.comment_type = comment.comment_type.clone(); self.comment_is_review_level = false; self.comment_is_file_level = false; @@ -6663,11 +6797,185 @@ impl App { self.input_mode = InputMode::Normal; self.comment_buffer.clear(); self.comment_cursor = 0; + self.comment_vim_editor = None; + self.comment_vim_command = None; + self.comment_vim_pending = CommentVimPending::None; self.comment_is_review_level = false; self.editing_comment_id = None; self.comment_line_range = None; } + /// Lazily build the vim overlay from the buffer on first key in comment mode + /// (no-op unless `comment_vim` is on). Seeding from the buffer covers both + /// fresh and existing-comment edits. + pub fn ensure_comment_vim_editor(&mut self) { + if !self.comment_vim_enabled || self.input_mode != InputMode::Comment { + return; + } + if self.comment_vim_editor.is_none() { + self.comment_vim_editor = Some(CommentVimEditor::from_buffer( + &self.comment_buffer, + self.comment_cursor, + )); + } + } + + /// Header indicator for the comment box as `(text, warn)`, or `None` when + /// vim is off. `warn` is true for the cancel-confirm hint so the renderer + /// can paint it red. Shows the in-progress `:` command-line when active, + /// otherwise the pending-confirm hint, otherwise the mode label. + pub fn comment_vim_mode_label(&self) -> Option<(String, bool)> { + if !self.comment_vim_enabled || self.input_mode != InputMode::Comment { + return None; + } + if let Some(cmd) = &self.comment_vim_command { + return Some((format!(":{cmd}"), false)); + } + match self.comment_vim_pending { + CommentVimPending::Save => return Some(("Enter again to save".to_string(), false)), + CommentVimPending::Cancel => { + return Some(("Esc/q again to cancel".to_string(), true)); + } + CommentVimPending::None => {} + } + Some(( + self.comment_vim_editor + .as_ref() + .map_or("INSERT", CommentVimEditor::label) + .to_string(), + false, + )) + } + + /// True while a `:` command-line is being entered in the comment box. + pub fn comment_vim_command_active(&self) -> bool { + self.comment_vim_command.is_some() + } + + /// Plain Enter in vim Normal mode: arm Save, or save on the second + /// consecutive press (like `:w`). + pub fn comment_vim_enter_normal(&mut self) { + if self.comment_vim_pending == CommentVimPending::Save { + self.comment_vim_pending = CommentVimPending::None; + self.save_comment(); + } else { + self.comment_vim_pending = CommentVimPending::Save; + } + } + + /// Esc in vim Normal mode: arm Cancel, or cancel on the second consecutive + /// press (like `:q`). A lone Esc does nothing but show the hint. + pub fn comment_vim_esc_normal(&mut self) { + if self.comment_vim_pending == CommentVimPending::Cancel { + self.comment_vim_pending = CommentVimPending::None; + self.exit_comment_mode(); + } else { + self.comment_vim_pending = CommentVimPending::Cancel; + } + } + + /// Reset the pending double-press state; called when any other key + /// interrupts the sequence. + pub fn comment_vim_reset_pending(&mut self) { + self.comment_vim_pending = CommentVimPending::None; + } + + /// Open the `:` command-line (vim Normal mode). + pub fn start_comment_vim_command(&mut self) { + self.comment_vim_command = Some(String::new()); + } + + /// Append a typed character to the `:` command-line. + pub fn comment_vim_command_push(&mut self, c: char) { + if let Some(cmd) = self.comment_vim_command.as_mut() { + cmd.push(c); + } + } + + /// Backspace in the `:` command-line; backspacing past `:` closes it. + pub fn comment_vim_command_backspace(&mut self) { + if let Some(cmd) = self.comment_vim_command.as_mut() { + if cmd.is_empty() { + self.comment_vim_command = None; + } else { + cmd.pop(); + } + } + } + + /// Abandon the `:` command-line without running anything. + pub fn comment_vim_command_cancel(&mut self) { + self.comment_vim_command = None; + } + + /// Execute the typed `:` command: `w`/`wq`/`x` save, `q`/`q!` cancel. + pub fn run_comment_vim_command(&mut self) { + let cmd = self.comment_vim_command.take().unwrap_or_default(); + match cmd.trim() { + "w" | "wq" | "x" => self.save_comment(), + "q" | "q!" => self.exit_comment_mode(), + "" => {} + other => self.set_warning(format!("Not a comment command: :{other}")), + } + } + + /// True when the comment vim overlay exists and is in Normal mode. + pub fn comment_vim_in_normal_mode(&self) -> bool { + self.comment_vim_editor + .as_ref() + .is_some_and(CommentVimEditor::is_normal_mode) + } + + /// Feed a key to the vim overlay and sync the result into the canonical + /// `comment_buffer`/`comment_cursor`. + pub fn comment_vim_feed_key(&mut self, key: crossterm::event::KeyEvent) { + if let Some(editor) = self.comment_vim_editor.as_mut() { + let (text, cursor) = editor.feed_key(key); + self.comment_buffer = text; + self.comment_cursor = cursor; + } + } + + /// Insert a soft tab (`comment_tab_width` spaces) into the vim overlay, + /// used for Tab while typing in Insert mode. + pub fn comment_vim_insert_soft_tab(&mut self) { + let space = crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char(' '), + crossterm::event::KeyModifiers::NONE, + ); + for _ in 0..self.comment_tab_width { + self.comment_vim_feed_key(space); + } + } + + /// Feed a bracketed-paste payload to the vim overlay and sync the result. + pub fn comment_vim_feed_paste(&mut self, text: String) { + if let Some(editor) = self.comment_vim_editor.as_mut() { + let (text, cursor) = editor.feed_paste(text); + self.comment_buffer = text; + self.comment_cursor = cursor; + } + } + + /// Enable/disable vim editing at runtime (e.g. `:vim`); takes effect on the + /// next comment session. + pub fn set_comment_vim(&mut self, enabled: bool) { + self.comment_vim_enabled = enabled; + if !enabled { + self.comment_vim_editor = None; + } + self.set_message(if enabled { + "Vim mode enabled for the comment box" + } else { + "Vim mode disabled for the comment box" + }); + } + + /// Toggle vim modal editing for the comment box. + pub fn toggle_comment_vim(&mut self) { + self.set_comment_vim(!self.comment_vim_enabled); + } + pub fn enter_visual_mode_at_cursor(&mut self) { let idx = self.diff_state.cursor_line; let side = self @@ -10432,6 +10740,146 @@ mod target_selector_tests { build_app_with_commits(Vec::new()) } + #[test] + fn comment_vim_command_line_q_cancels_w_saves() { + let mut app = build_app(); + app.comment_vim_enabled = true; + + // `:q` exits the comment box. + app.enter_review_comment_mode(); + assert_eq!(app.input_mode, InputMode::Comment); + app.start_comment_vim_command(); + assert!(app.comment_vim_command_active()); + app.comment_vim_command_push('q'); + assert_eq!( + app.comment_vim_mode_label(), + Some((":q".to_string(), false)) + ); + app.run_comment_vim_command(); + assert_eq!(app.input_mode, InputMode::Normal); + assert!(!app.comment_vim_command_active()); + + // `:w` reaches save_comment; on an empty buffer save rejects it and the + // box stays open — proving the mapping without touching disk. + app.enter_review_comment_mode(); + app.start_comment_vim_command(); + app.comment_vim_command_push('w'); + app.run_comment_vim_command(); + assert_eq!(app.input_mode, InputMode::Comment); + assert!(!app.comment_vim_command_active()); + } + + #[test] + fn comment_vim_double_enter_saves() { + let mut app = build_app(); + app.comment_vim_enabled = true; + app.enter_review_comment_mode(); + + // First Enter arms (header would show the hint); second routes to + // save_comment (empty buffer rejected, box stays open) — double-Enter == :w. + app.comment_vim_enter_normal(); + assert_eq!(app.comment_vim_pending, CommentVimPending::Save); + assert_eq!( + app.comment_vim_mode_label(), + Some(("Enter again to save".to_string(), false)) + ); + app.comment_vim_enter_normal(); + assert_eq!(app.comment_vim_pending, CommentVimPending::None); + assert_eq!(app.input_mode, InputMode::Comment); + + // A non-Enter key between the two presses breaks the sequence. + app.comment_vim_enter_normal(); + app.comment_vim_reset_pending(); + assert_eq!(app.comment_vim_pending, CommentVimPending::None); + } + + #[test] + fn comment_vim_double_esc_cancels() { + let mut app = build_app(); + app.comment_vim_enabled = true; + app.enter_review_comment_mode(); + assert_eq!(app.input_mode, InputMode::Comment); + + // First Esc arms cancel + header hint; second exits the comment box. + app.comment_vim_esc_normal(); + assert_eq!(app.comment_vim_pending, CommentVimPending::Cancel); + assert_eq!( + app.comment_vim_mode_label(), + Some(("Esc/q again to cancel".to_string(), true)) + ); + app.comment_vim_esc_normal(); + assert_eq!(app.input_mode, InputMode::Normal); + assert_eq!(app.comment_vim_pending, CommentVimPending::None); + } + + #[test] + fn comment_vim_soft_tab_inserts_configured_spaces() { + let mut app = build_app(); + app.comment_vim_enabled = true; + app.comment_tab_width = 2; + app.enter_review_comment_mode(); + app.ensure_comment_vim_editor(); // Insert mode, empty buffer + app.comment_vim_insert_soft_tab(); + assert_eq!(app.comment_buffer, " "); + assert_eq!(app.comment_cursor, 2); + } + + #[test] + fn comment_block_start_finds_first_row_of_comment() { + let mut app = build_app(); + app.line_annotations = vec![ + AnnotatedLine::ReviewComment { comment_idx: 0 }, + AnnotatedLine::ReviewComment { comment_idx: 1 }, + AnnotatedLine::ReviewComment { comment_idx: 1 }, + AnnotatedLine::ReviewComment { comment_idx: 1 }, + AnnotatedLine::ReviewComment { comment_idx: 2 }, + ]; + assert_eq!(app.comment_block_start(3), 1); + assert_eq!(app.comment_block_start(1), 1); + assert_eq!(app.comment_block_start(0), 0); + assert_eq!(app.comment_block_start(4), 4); + } + + #[test] + fn comment_current_line_cursor_targets_the_cursor_line() { + let mut app = build_app(); + app.comment_buffer = "alpha\nbravo\ncharlie".to_string(); + app.diff_state.viewport_width = 200; // wide => no wrapping + let block_start = 10; + + // 2nd content line "bravo" (block_start + top-border + 1): start 6, end 11. + app.diff_state.cursor_line = block_start + 2; + assert_eq!(app.comment_current_line_cursor(block_start, false), 6); + assert_eq!(app.comment_current_line_cursor(block_start, true), 11); + + // 1st content line "alpha": start 0, end 5. + app.diff_state.cursor_line = block_start + 1; + assert_eq!(app.comment_current_line_cursor(block_start, false), 0); + assert_eq!(app.comment_current_line_cursor(block_start, true), 5); + + // Top border row maps to the first line. + app.diff_state.cursor_line = block_start; + assert_eq!(app.comment_current_line_cursor(block_start, false), 0); + + // Bottom border / beyond maps to the last line "charlie": start 12, end 19. + app.diff_state.cursor_line = block_start + 99; + assert_eq!(app.comment_current_line_cursor(block_start, false), 12); + assert_eq!(app.comment_current_line_cursor(block_start, true), 19); + } + + #[test] + fn comment_vim_command_backspace_past_colon_closes() { + let mut app = build_app(); + app.comment_vim_enabled = true; + app.enter_review_comment_mode(); + app.start_comment_vim_command(); + app.comment_vim_command_push('q'); + app.comment_vim_command_backspace(); // -> ":" + assert!(app.comment_vim_command_active()); + app.comment_vim_command_backspace(); // past ':' -> closed + assert!(!app.comment_vim_command_active()); + } + fn build_app_with_commits(commits: Vec) -> App { let vcs_info = VcsInfo { root_path: PathBuf::from("/tmp"), diff --git a/src/comment_vim.rs b/src/comment_vim.rs new file mode 100644 index 00000000..7532482e --- /dev/null +++ b/src/comment_vim.rs @@ -0,0 +1,206 @@ +//! Vim-style modal editing for the comment box, backed by `edtui`. +//! +//! An overlay: `App::comment_buffer`/`comment_cursor` stay canonical (rendering +//! reads them); the editor syncs back into them after each event. Cursor +//! conversions bridge tuicr's UTF-8 byte offset and edtui's `Index2` (row + +//! char-col), both UTF-8 aware. + +use crossterm::event::{Event, KeyEvent}; +use edtui::{EditorEventHandler, EditorMode, EditorState, Index2, Lines}; + +/// Active modal editor for the comment box. Present only while in comment mode +/// with `comment_vim` enabled. +pub struct CommentVimEditor { + state: EditorState, + events: EditorEventHandler, +} + +impl CommentVimEditor { + /// Build an editor seeded from the buffer text + byte cursor, starting in + /// Insert mode (typing works immediately; `Esc` drops to Normal). + pub fn from_buffer(text: &str, cursor_byte: usize) -> Self { + let mut state = EditorState::new(Lines::from(text)); + state.mode = EditorMode::Insert; + state.cursor = byte_to_index2(text, cursor_byte); + Self { + state, + events: EditorEventHandler::default(), + } + } + + /// True when the editor is in Normal mode. Used to decide whether `Esc` + /// cancels the comment and whether `Enter` submits. + pub fn is_normal_mode(&self) -> bool { + self.state.mode == EditorMode::Normal + } + + /// Short status label for the current mode. The catch-all tolerates any + /// extra edtui modes (e.g. search). + pub fn label(&self) -> &'static str { + match self.state.mode { + EditorMode::Normal => "NORMAL", + EditorMode::Insert => "INSERT", + EditorMode::Visual => "VISUAL", + _ => "NORMAL", + } + } + + /// Feed a key event to the editor and return the resulting + /// `(text, byte_cursor)` for the caller to sync into the canonical buffer. + pub fn feed_key(&mut self, key: KeyEvent) -> (String, usize) { + self.events.on_key_event(key, &mut self.state); + self.text_and_cursor() + } + + /// Feed a bracketed-paste payload, returning the synced `(text, byte_cursor)`. + pub fn feed_paste(&mut self, text: String) -> (String, usize) { + self.events.on_event(Event::Paste(text), &mut self.state); + self.text_and_cursor() + } + + /// Extract the full buffer text and the byte-offset cursor. + pub fn text_and_cursor(&self) -> (String, usize) { + let text = self.state.lines.to_string(); + let cursor = index2_to_byte(&text, self.state.cursor); + (text, cursor) + } +} + +/// Convert an edtui `(row, col)` index into a UTF-8 byte offset into `text`. +/// `col` counts characters within the row; values past the end of a line clamp +/// to the line end, and rows past the end clamp to the buffer end. +pub fn index2_to_byte(text: &str, idx: Index2) -> usize { + let mut offset = 0usize; + for (row, line) in text.split('\n').enumerate() { + if row == idx.row { + // Walk `col` characters into this line. + for (chars, (byte, _)) in line.char_indices().enumerate() { + if chars == idx.col { + return offset + byte; + } + } + // col at or beyond the last char: clamp to end of line. + return offset + line.len(); + } + offset += line.len() + 1; // + 1 for the '\n' separator + } + text.len() +} + +/// Convert a UTF-8 byte offset into an edtui `(row, col)` index, where `col` is +/// a character index within the row. +pub fn byte_to_index2(text: &str, byte: usize) -> Index2 { + let byte = byte.min(text.len()); + let mut row = 0usize; + let mut line_start = 0usize; + for (i, ch) in text.char_indices() { + if i >= byte { + break; + } + if ch == '\n' { + row += 1; + line_start = i + 1; + } + } + let col = text[line_start..byte].chars().count(); + Index2::new(row, col) +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn key(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + } + + fn special(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + #[test] + fn insert_mode_typing_syncs_buffer() { + let mut editor = CommentVimEditor::from_buffer("", 0); + assert!(!editor.is_normal_mode()); + let mut last = (String::new(), 0); + for c in "hi".chars() { + last = editor.feed_key(key(c)); + } + assert_eq!(last, ("hi".to_string(), 2)); + assert_eq!(editor.label(), "INSERT"); + } + + #[test] + fn esc_switches_to_normal_mode() { + let mut editor = CommentVimEditor::from_buffer("hello", 5); + editor.feed_key(special(KeyCode::Esc)); + assert!(editor.is_normal_mode()); + assert_eq!(editor.label(), "NORMAL"); + } + + #[test] + fn normal_mode_x_deletes_char_under_cursor() { + // Seed "abc" with cursor at start, drop to normal, delete first char. + let mut editor = CommentVimEditor::from_buffer("abc", 0); + editor.feed_key(special(KeyCode::Esc)); + let (text, _) = editor.feed_key(key('x')); + assert_eq!(text, "bc"); + } + + #[test] + fn normal_mode_dd_deletes_line() { + let mut editor = CommentVimEditor::from_buffer("line1\nline2", 0); + editor.feed_key(special(KeyCode::Esc)); + editor.feed_key(key('d')); + let (text, _) = editor.feed_key(key('d')); + assert_eq!(text, "line2"); + } + + fn roundtrip(text: &str, byte: usize) { + let idx = byte_to_index2(text, byte); + assert_eq!( + index2_to_byte(text, idx), + byte, + "roundtrip failed for {text:?} @ {byte}" + ); + } + + #[test] + fn roundtrip_ascii_multiline() { + let text = "hello\nworld\nfoo"; + for byte in 0..=text.len() { + if text.is_char_boundary(byte) { + roundtrip(text, byte); + } + } + } + + #[test] + fn roundtrip_multibyte() { + // CJK + emoji across lines. + let text = "héllo\n世界\n👋🏽 bye"; + for byte in 0..=text.len() { + if text.is_char_boundary(byte) { + roundtrip(text, byte); + } + } + } + + #[test] + fn index2_clamps_past_line_and_buffer() { + let text = "ab\ncd"; + // col past end of row 0 clamps to end of "ab" (byte 2). + assert_eq!(index2_to_byte(text, Index2::new(0, 99)), 2); + // row past end clamps to buffer end. + assert_eq!(index2_to_byte(text, Index2::new(99, 0)), text.len()); + } + + #[test] + fn byte_to_index2_on_second_line() { + let text = "ab\ncde"; + // byte 4 = 'd' on row 1, col 1. + let idx = byte_to_index2(text, 4); + assert_eq!((idx.row, idx.col), (1, 1)); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 004dd521..bd4acf2d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -50,6 +50,12 @@ pub struct AppConfig { pub export_legend: Option, pub cursor_line: Option, pub mouse: Option, + /// Enable vim-style modal editing in the review comment text box. When + /// unset/false the comment box uses the default emacs/readline bindings. + pub comment_vim: Option, + /// Number of spaces inserted by Tab while typing in the vim comment box. + /// Defaults to 4 (matching diff tab expansion). + pub comment_tab_width: Option, pub leader: Option, pub transparent_background: Option, pub scroll_offset: Option, @@ -83,6 +89,8 @@ const KNOWN_KEYS: &[&str] = &[ "export_legend", "cursor_line", "mouse", + "comment_vim", + "comment_tab_width", "leader", "transparent_background", "scroll_offset", @@ -292,6 +300,8 @@ fn load_config_from_path(path: &Path) -> Result { export_legend: read_bool(table, "export_legend", &mut warnings), cursor_line: read_bool(table, "cursor_line", &mut warnings), mouse: read_bool(table, "mouse", &mut warnings), + comment_vim: read_bool(table, "comment_vim", &mut warnings), + comment_tab_width: read_usize(table, "comment_tab_width", &mut warnings), leader: read_leader(table, &mut warnings), transparent_background: read_bool(table, "transparent_background", &mut warnings), scroll_offset: read_usize(table, "scroll_offset", &mut warnings), diff --git a/src/handler.rs b/src/handler.rs index 3a2d0b47..ed448804 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -39,6 +39,9 @@ const COMMAND_SPECS: &[CommandSpec] = &[ CommandSpec::new(&["set wrap"], CommandKind::SetWrap), CommandSpec::new(&["set wrap!"], CommandKind::ToggleWrap), CommandSpec::new(&["wrap"], CommandKind::ToggleWrap), + CommandSpec::new(&["vim", "set vim!"], CommandKind::ToggleVim), + CommandSpec::new(&["set vim"], CommandKind::SetVim(true)), + CommandSpec::new(&["novim", "set novim"], CommandKind::SetVim(false)), CommandSpec::new(&["set commits"], CommandKind::SetCommitsVisible(true)), CommandSpec::new(&["set nocommits"], CommandKind::SetCommitsVisible(false)), CommandSpec::new(&["set commits!"], CommandKind::ToggleCommits), @@ -110,6 +113,8 @@ enum CommandKind { Update, SetWrap, ToggleWrap, + ToggleVim, + SetVim(bool), SetCommitsVisible(bool), ToggleCommits, Diff, @@ -773,6 +778,14 @@ fn dispatch_command(app: &mut App, kind: CommandKind) -> CommandAfterDispatch { app.toggle_diff_wrap(); CommandAfterDispatch::ExitCommandMode } + CommandKind::ToggleVim => { + app.toggle_comment_vim(); + CommandAfterDispatch::ExitCommandMode + } + CommandKind::SetVim(enabled) => { + app.set_comment_vim(enabled); + CommandAfterDispatch::ExitCommandMode + } CommandKind::SetCommitsVisible(visible) => { set_commit_selector_visible(app, visible); CommandAfterDispatch::ExitCommandMode @@ -1303,6 +1316,21 @@ pub fn handle_comment_navigator_action(app: &mut App, action: Action) { } } +/// Enter edit mode for the comment under the cursor, placing the text cursor at +/// the end (vim `A` / non-vim default) or beginning (vim `i`). Surfaces the +/// right message when the comment is read-only or absent. +fn edit_comment_at_cursor(app: &mut App, cursor_at_end: bool) { + if app.cursor_on_locked_comment() { + app.set_message("Comment already pushed to GitHub — read only in tuicr"); + } else if !app.enter_edit_mode(cursor_at_end) { + if app.cursor_on_remote_thread() { + app.set_message("GitHub comment — read only in tuicr"); + } else { + app.set_message("No comment at cursor"); + } + } +} + /// Handle actions when diff panel is focused pub fn handle_diff_action(app: &mut App, action: Action) { match action { @@ -1452,16 +1480,11 @@ fn handle_shared_normal_action(app: &mut App, action: Action) { } } Action::AddFileComment => app.enter_comment_mode(true, None), - Action::EditComment if app.cursor_on_locked_comment() => { - app.set_message("Comment already pushed to GitHub — read only in tuicr"); - } - Action::EditComment if !app.enter_edit_mode() => { - if app.cursor_on_remote_thread() { - app.set_message("GitHub comment — read only in tuicr"); - } else { - app.set_message("No comment at cursor"); - } - } + // `i` edits the comment at cursor. In vim mode the text cursor starts at + // the beginning; otherwise (and for `A`) it starts at the end. + Action::EditComment => edit_comment_at_cursor(app, !app.comment_vim_enabled), + // `A` (vim only) edits with the text cursor at end-of-line. + Action::EditCommentAtEnd if app.comment_vim_enabled => edit_comment_at_cursor(app, true), Action::ExportToClipboard => handle_export(app), Action::SearchNext => { app.search_next_in_diff(); diff --git a/src/input/keybindings.rs b/src/input/keybindings.rs index 95acbd43..71f96b4d 100644 --- a/src/input/keybindings.rs +++ b/src/input/keybindings.rs @@ -39,6 +39,8 @@ pub enum Action { AddLineComment, AddFileComment, EditComment, + /// Edit the comment at cursor with the text cursor at end (vim `A`). + EditCommentAtEnd, PendingDCommand, SearchNext, SearchPrev, @@ -196,6 +198,7 @@ fn map_normal_mode(key: KeyEvent, leader_key: char) -> Action { (KeyCode::Char('c'), KeyModifiers::NONE) => Action::AddLineComment, (KeyCode::Char('C'), _) => Action::AddFileComment, (KeyCode::Char('i'), KeyModifiers::NONE) => Action::EditComment, + (KeyCode::Char('A'), _) => Action::EditCommentAtEnd, (KeyCode::Char('d'), KeyModifiers::NONE) => Action::PendingDCommand, (KeyCode::Char('v') | KeyCode::Char('V'), _) => Action::EnterVisualMode, (KeyCode::Char('y'), KeyModifiers::NONE) => Action::ExportToClipboard, @@ -456,6 +459,18 @@ mod tests { } } + #[test] + fn should_map_i_and_shift_a_to_edit_comment_actions() { + assert_eq!( + map_normal_mode(key(KeyCode::Char('i')), DEFAULT_LEADER_KEY), + Action::EditComment + ); + assert_eq!( + map_normal_mode(key_shift('A'), DEFAULT_LEADER_KEY), + Action::EditCommentAtEnd + ); + } + #[test] fn should_map_uppercase_g_to_go_to_bottom_in_normal_mode() { let action = map_normal_mode(key_shift('G'), DEFAULT_LEADER_KEY); diff --git a/src/lib.rs b/src/lib.rs index 053cf97e..94483a9b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod app; pub mod cli; +pub mod comment_vim; pub mod config; pub mod editor; pub mod error; diff --git a/src/main.rs b/src/main.rs index 6e076049..6ae8e4bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -181,6 +181,10 @@ fn main() -> anyhow::Result<()> { if let Some(leader) = cfg.leader { app.leader_key = leader; } + app.comment_vim_enabled = cfg.comment_vim.unwrap_or(false); + if let Some(w) = cfg.comment_tab_width { + app.comment_tab_width = w; + } if let Some(username) = cfg .username .as_deref() @@ -529,6 +533,15 @@ fn main() -> anyhow::Result<()> { // Otherwise fall through to normal handling } + // Vim modal editing: route comment-box keys to the edtui + // overlay (app-level keys handled inside). + if app.input_mode == InputMode::Comment && app.comment_vim_enabled { + app.ensure_comment_vim_editor(); + if handle_comment_vim_key(&mut app, key) { + continue; + } + } + // Editing the PR-tab filter is a sub-state of CommitSelect; // route through the filter-specific key map so typed // characters update the filter buffer rather than driving @@ -653,6 +666,12 @@ fn main() -> anyhow::Result<()> { Event::Paste(text) => { // Bracketed-paste payload — route to whichever handler is // currently accepting text input. Other modes ignore. + // Vim comment box: feed the overlay so the buffer stays synced. + if app.input_mode == InputMode::Comment && app.comment_vim_enabled { + app.ensure_comment_vim_editor(); + app.comment_vim_feed_paste(text); + continue; + } let action = Action::Paste(text); match app.input_mode { InputMode::Comment => handle_comment_action(&mut app, action), @@ -711,6 +730,64 @@ fn dispatch_action(app: &mut App, action: Action) { } } +/// Handle a comment-mode key while vim is active: app-level keys keep their +/// semantics, everything else feeds the overlay. Always returns `true`. +fn handle_comment_vim_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool { + use crossterm::event::{KeyCode, KeyModifiers}; + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + + // An open `:` command-line captures all input until Enter/Esc. + if app.comment_vim_command_active() { + match key.code { + KeyCode::Enter => app.run_comment_vim_command(), + KeyCode::Esc => app.comment_vim_command_cancel(), + KeyCode::Backspace => app.comment_vim_command_backspace(), + KeyCode::Char(c) if !ctrl && !key.modifiers.contains(KeyModifiers::ALT) => { + app.comment_vim_command_push(c) + } + _ => {} + } + return true; + } + + let normal = app.comment_vim_in_normal_mode(); + + // In Normal mode a first plain Enter/Esc arms a confirm (header shows the + // hint); a second consecutive press saves (`:w`) / cancels (`:q`). + if normal && key.modifiers.is_empty() { + match key.code { + KeyCode::Enter => { + app.comment_vim_enter_normal(); + return true; + } + // `q` behaves exactly like Esc here (arm/confirm cancel). + KeyCode::Esc | KeyCode::Char('q') => { + app.comment_vim_esc_normal(); + return true; + } + _ => {} + } + } + // Any other key breaks a pending double-press sequence. + app.comment_vim_reset_pending(); + + match key.code { + // Save shortcut in any mode (Ctrl-C cancel is handled earlier). + KeyCode::Char('s') if ctrl => app.save_comment(), + KeyCode::Enter if ctrl => app.save_comment(), + // Tab cycles the comment type in Normal mode; in Insert it inserts a + // soft tab (`comment_tab_width` spaces). + KeyCode::Tab | KeyCode::Char('\t') if normal => app.cycle_comment_type(), + KeyCode::Tab | KeyCode::Char('\t') => app.comment_vim_insert_soft_tab(), + KeyCode::BackTab if normal => app.cycle_comment_type_reverse(), + // `:` opens the command-line in Normal mode (`:w` saves, `:q` cancels). + // Esc/Enter in Normal fall through to edtui (Esc is a no-op there). + KeyCode::Char(':') if normal => app.start_comment_vim_command(), + _ => app.comment_vim_feed_key(key), + } + true +} + fn run_editor_from_tui( terminal: &mut TerminalSession, target: &EditorTarget, diff --git a/src/syntax/mod.rs b/src/syntax/mod.rs index b2a0e55b..dee3bc99 100644 --- a/src/syntax/mod.rs +++ b/src/syntax/mod.rs @@ -100,8 +100,6 @@ impl SyntaxHighlighter { file_path: &Path, lines: &[String], ) -> Option { - use syntect::easy::HighlightLines; - // Get syntax definition let syntax = self.get_syntax(file_path).or_else(|| { lines @@ -109,10 +107,32 @@ impl SyntaxHighlighter { .and_then(|line| self.syntax_set.find_syntax_by_first_line(line)) })?; - // Create highlighter + Some(self.highlight_lines_with(syntax, lines)) + } + + /// Highlight `lines` as Markdown, for the in-progress review comment box. + /// Colors come from the active syntect theme, matching code highlighting. + pub(crate) fn highlight_markdown_lines(&self, lines: &[String]) -> HighlightedLines { + let syntax = self + .syntax_set + .find_syntax_by_extension("md") + .or_else(|| self.syntax_set.find_syntax_by_name("Markdown")) + .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text()); + self.highlight_lines_with(syntax, lines) + } + + /// Run syntect line-by-line against a resolved syntax, converting to + /// ratatui spans. Shared by file and markdown highlighting. + fn highlight_lines_with( + &self, + syntax: &syntect::parsing::SyntaxReference, + lines: &[String], + ) -> HighlightedLines { + use syntect::easy::HighlightLines; + let mut highlighter = HighlightLines::new(syntax, &self.theme); - Some(Self::collect_line_highlights(lines, |line| { + Self::collect_line_highlights(lines, |line| { // Highlight failures are scoped to the single line; other lines still keep highlighting. highlighter .highlight_line(&format!("{}\n", line), &self.syntax_set) @@ -137,7 +157,7 @@ impl SyntaxHighlighter { } spans }) - })) + }) } fn collect_line_highlights(lines: &[String], mut highlight_line: F) -> HighlightedLines diff --git a/src/ui/comment_panel.rs b/src/ui/comment_panel.rs index 12f030ec..0ec24a22 100644 --- a/src/ui/comment_panel.rs +++ b/src/ui/comment_panel.rs @@ -49,24 +49,40 @@ pub(crate) fn wrap_segments(text: &str, content_area: usize) -> Vec<&str> { segments } -/// Push spans for a text segment containing the cursor. The cursor is rendered -/// as a styled span on the character at the split point, or a trailing space -/// when the cursor is at end-of-segment. -fn push_cursor_spans( - spans: &mut Vec>, - before: &str, - after: &str, - cursor_style: Style, -) { - spans.push(Span::raw(before.to_string())); - // Cursor at end-of-segment: no trailing space so the line stays within - // bounds. The terminal cursor (set_cursor_position) handles the visible - // position without needing an extra styled space. - let mut chars = after.chars(); - if let Some(cursor_char) = chars.next() { - spans.push(Span::styled(cursor_char.to_string(), cursor_style)); - spans.push(Span::raw(chars.as_str().to_string())); +/// Emit spans covering `line_text[start..end)` using the per-line markdown +/// highlight `runs` (concatenation of run text equals `line_text`). Falls back +/// to a single unstyled span when highlighting is unavailable. Offsets are byte +/// positions; callers pass char-boundary offsets (segment/cursor boundaries). +fn highlighted_window_spans( + runs: Option<&[(Style, String)]>, + line_text: &str, + start: usize, + end: usize, +) -> Vec> { + if start >= end { + return Vec::new(); } + let Some(runs) = runs else { + return vec![Span::raw(line_text[start..end].to_string())]; + }; + let mut out = Vec::new(); + let mut run_start = 0usize; + for (style, text) in runs { + let run_end = run_start + text.len(); + let lo = start.max(run_start); + let hi = end.min(run_end); + if lo < hi { + out.push(Span::styled( + text[lo - run_start..hi - run_start].to_string(), + *style, + )); + } + run_start = run_end; + if run_start >= end { + break; + } + } + out } /// Information about where the cursor should be positioned within comment input @@ -101,6 +117,7 @@ pub fn format_comment_input_lines( line_range: Option, is_editing: bool, width: usize, + vim_mode: Option<(&str, bool)>, ) -> (Vec>, CommentCursorInfo) { let type_style = styles::comment_type_style(theme, comment_type.color); let border_style = styles::comment_border_style(theme, comment_type.color); @@ -132,20 +149,31 @@ pub fn format_comment_input_lines( let top_corner = if line_range.is_some() { '├' } else { '╭' }; let top_prefix = format!(" {top_corner}── "); - // Top border with type label and hints - result.push(Line::from(vec![ + // Top border with type label and hints. In vim mode the hints describe the + // modal bindings and a `[MODE]` tag is shown after the type label. + let hint = match vim_mode { + Some(_) => "(Tab/S-Tab:type i:insert Esc:normal :w save :q cancel)".to_string(), + None => format!("(Tab/S-Tab:type Enter:save {newline_hint}:newline Esc:cancel)"), + }; + let mut header_spans = vec![ Span::styled(top_prefix, border_style), - Span::styled(format!("{} ", action), styles::dim_style(theme)), + Span::styled(format!("{action} "), styles::dim_style(theme)), Span::styled(format!("[{}] ", comment_type.label), type_style), - Span::styled(line_info, styles::dim_style(theme)), - Span::styled( - format!( - "(Tab/S-Tab:type Enter:save {}:newline Esc:cancel)", - newline_hint - ), - styles::dim_style(theme), - ), - ])); + ]; + if let Some((mode, warn)) = vim_mode { + // The cancel-confirm hint is painted red to flag the destructive action. + let mode_style = if warn { + Style::default() + .fg(theme.comment_issue) + .add_modifier(Modifier::BOLD) + } else { + type_style + }; + header_spans.push(Span::styled(format!("[{mode}] "), mode_style)); + } + header_spans.push(Span::styled(line_info, styles::dim_style(theme))); + header_spans.push(Span::styled(hint, styles::dim_style(theme))); + result.push(Line::from(header_spans)); // Content lines with cursor if buffer.is_empty() { @@ -159,6 +187,12 @@ pub fn format_comment_input_lines( // cursor_column is already BORDER_PREFIX_WIDTH (cursor at start of content) } else { let buffer_lines: Vec<&str> = buffer.split('\n').collect(); + // Markdown-highlight the in-progress text; colors come from the active + // syntect theme (same engine/theme as diff code highlighting). + let owned_lines: Vec = buffer_lines.iter().map(|s| s.to_string()).collect(); + let highlighted = theme + .syntax_highlighter() + .highlight_markdown_lines(&owned_lines); let mut byte_offset = 0; // Tracks how many visual lines have been pushed so far (not counting the header). let mut total_visual_lines: usize = 0; @@ -186,17 +220,44 @@ pub fn format_comment_input_lines( && (cursor_pos < seg_end || is_last_seg); let mut line_spans = vec![Span::styled(BORDER_PREFIX, border_style)]; + let line_runs = highlighted.get(line_idx).and_then(|o| o.as_deref()); + let seg_end_in_line = seg_byte_start + seg.len(); if cursor_in_seg { let cursor_pos_in_seg = (cursor_pos - seg_start).min(seg.len()); let (before, after) = seg.split_at(cursor_pos_in_seg); + let cursor_in_line = seg_byte_start + cursor_pos_in_seg; // Track cursor position for IME cursor_line_offset = 1 + total_visual_lines; cursor_column = BORDER_PREFIX_WIDTH as u16 + before.width() as u16; - push_cursor_spans(&mut line_spans, before, after, cursor_style); + + // before-cursor text, highlighted + line_spans.extend(highlighted_window_spans( + line_runs, + text, + seg_byte_start, + cursor_in_line, + )); + // Cursor char gets the cursor style; the rest stays highlighted. + // No trailing cell at end-of-segment — the terminal cursor + // (set_cursor_position) handles that position. + if let Some(cursor_char) = after.chars().next() { + line_spans.push(Span::styled(cursor_char.to_string(), cursor_style)); + line_spans.extend(highlighted_window_spans( + line_runs, + text, + cursor_in_line + cursor_char.len_utf8(), + seg_end_in_line, + )); + } } else { - line_spans.push(Span::raw(seg.to_string())); + line_spans.extend(highlighted_window_spans( + line_runs, + text, + seg_byte_start, + seg_end_in_line, + )); } result.push(Line::from(line_spans)); @@ -357,6 +418,36 @@ pub fn format_remote_review_summary_lines( /// resulting badge reads `[TYPE @name]`, mirroring the `[github @author]` /// format used for remote PR threads. `None` keeps the existing neutral /// `[TYPE]` badge and theme border. +/// Render `content` as markdown-highlighted, border-prefixed, pre-wrapped lines +/// (no cursor). Colors come from the active syntect theme. Used for displayed +/// comment bodies; the editor box does its own variant with cursor handling. +fn markdown_body_lines( + theme: &Theme, + content: &str, + content_area: usize, + border_style: Style, +) -> Vec> { + let lines: Vec<&str> = content.split('\n').collect(); + let owned: Vec = lines.iter().map(|s| s.to_string()).collect(); + // Highlight all lines together so multi-line constructs (e.g. fenced code) + // carry state across lines. + let highlighted = theme.syntax_highlighter().highlight_markdown_lines(&owned); + + let mut out = Vec::new(); + for (idx, text) in lines.iter().enumerate() { + let runs = highlighted.get(idx).and_then(|o| o.as_deref()); + let mut seg_start = 0usize; + for seg in wrap_segments(text, content_area) { + let seg_end = seg_start + seg.len(); + let mut spans = vec![Span::styled(BORDER_PREFIX, border_style)]; + spans.extend(highlighted_window_spans(runs, text, seg_start, seg_end)); + out.push(Line::from(spans)); + seg_start = seg_end; + } + } + out +} + pub fn format_comment_lines( theme: &Theme, comment_type: CommentTypePresentation, @@ -390,8 +481,6 @@ pub fn format_comment_lines( // one so the terminal cursor at end-of-segment stays clear of the border. let content_area = width.saturating_sub(BORDER_PREFIX_WIDTH + 2); - let content_lines: Vec<&str> = content.split('\n').collect(); - let mut result = Vec::new(); let top_corner = if line_range.is_some() { '├' } else { '╭' }; @@ -408,15 +497,13 @@ pub fn format_comment_lines( Span::styled("─".repeat(top_fill), border_style), ])); - // Content lines — pre-wrap at content_area so ratatui never wraps them - for line in &content_lines { - for seg in wrap_segments(line, content_area) { - result.push(Line::from(vec![ - Span::styled(BORDER_PREFIX, border_style), - Span::raw(seg.to_string()), - ])); - } - } + // Content lines — markdown-highlighted, pre-wrapped at content_area. + result.extend(markdown_body_lines( + theme, + content, + content_area, + border_style, + )); // Bottom border — " ╰" = 5 chars, fill to width result.push(Line::from(vec![Span::styled( @@ -595,6 +682,7 @@ mod tests { None, false, 80, + None, ); // then @@ -622,6 +710,7 @@ mod tests { None, false, 80, + None, ); // then @@ -648,6 +737,7 @@ mod tests { None, false, 80, + None, ); // then @@ -675,6 +765,7 @@ mod tests { None, false, 80, + None, ); // then @@ -701,6 +792,7 @@ mod tests { None, false, 80, + None, ); // then @@ -728,6 +820,7 @@ mod tests { None, false, 80, + None, ); // then @@ -735,4 +828,99 @@ mod tests { // "a" = 1 display width, "좋" = 2 display width, total = 3 assert_eq!(cursor_info.column, 7 + 3); } + + // -- markdown highlighting tests -- + + /// Reconstruct the buffer text from the rendered content lines: drop the + /// header (first) and footer (last) lines, skip each line's BORDER_PREFIX + /// span, concatenate the rest, and join visual lines with '\n'. With a wide + /// width (no wrapping) each logical line is one visual line, so this must + /// equal the original buffer regardless of how content is split into spans. + fn reconstruct(lines: &[Line<'static>]) -> String { + lines[1..lines.len() - 1] + .iter() + .map(|line| { + line.spans + .iter() + .skip(1) // BORDER_PREFIX + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") + } + + fn render_md(buffer: &str, cursor: usize) -> Vec> { + let theme = test_theme(); + format_comment_input_lines( + &theme, + CommentTypePresentation { + label: "NOTE".to_string(), + color: Color::Blue, + }, + buffer, + cursor, + None, + false, + 80, + None, + ) + .0 + } + + #[test] + fn markdown_render_preserves_all_text() { + let buffer = "# Title\n**bold** and `code`\n- item"; + // cursor in the middle of the bold span + let lines = render_md(buffer, 11); + assert_eq!(reconstruct(&lines), buffer); + } + + #[test] + fn markdown_render_preserves_multibyte_text() { + let buffer = "# 世界\n**bold** 좋아"; + for cursor in [0, buffer.find('世').unwrap(), buffer.len()] { + let lines = render_md(buffer, cursor); + assert_eq!(reconstruct(&lines), buffer, "cursor={cursor}"); + } + } + + #[test] + fn displayed_comment_is_markdown_highlighted_and_preserves_text() { + let theme = test_theme(); + let content = "# Heading\n**bold** and `code`\n- item"; + let lines = format_comment_lines( + &theme, + CommentTypePresentation { + label: "NOTE".to_string(), + color: Color::Blue, + }, + content, + None, + 80, + None, + ); + // Header + footer wrap the body; reconstruct must round-trip the text. + assert_eq!(reconstruct(&lines), content); + // The inline-code line should be split into multiple styled runs. + let code_line = &lines[2]; // header, heading, **this** + assert!( + code_line.spans.len() - 1 > 1, + "expected displayed markdown to be highlighted into multiple spans" + ); + } + + #[test] + fn markdown_highlighting_splits_line_into_runs() { + // An inline-code line should yield multiple styled content spans (proof + // the markdown grammar resolved and coloring is applied), not one raw + // span. Cursor at end so no cursor cell splits the line artificially. + let buffer = "plain `code` plain"; + let lines = render_md(buffer, buffer.len()); + let content_spans = lines[1].spans.len() - 1; // minus BORDER_PREFIX + assert!( + content_spans > 1, + "expected markdown highlighting to split the line, got {content_spans} span(s)" + ); + } } diff --git a/src/ui/diff_side_by_side.rs b/src/ui/diff_side_by_side.rs index 311739e0..274cf699 100644 --- a/src/ui/diff_side_by_side.rs +++ b/src/ui/diff_side_by_side.rs @@ -174,6 +174,9 @@ pub(super) fn render_side_by_side_diff(frame: &mut Frame, app: &mut App, area: R None, true, ctx.panel_width.saturating_sub(1), + app.comment_vim_mode_label() + .as_ref() + .map(|(t, w)| (t.as_str(), *w)), ); comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset); comment_cursor_column = 1 + cursor_info.column; @@ -249,6 +252,9 @@ pub(super) fn render_side_by_side_diff(frame: &mut Frame, app: &mut App, area: R None, false, ctx.panel_width.saturating_sub(1), + app.comment_vim_mode_label() + .as_ref() + .map(|(t, w)| (t.as_str(), *w)), ); comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset); comment_cursor_column = 1 + cursor_info.column; @@ -337,6 +343,9 @@ pub(super) fn render_side_by_side_diff(frame: &mut Frame, app: &mut App, area: R None, true, ctx.panel_width.saturating_sub(1), + app.comment_vim_mode_label() + .as_ref() + .map(|(t, w)| (t.as_str(), *w)), ); comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset); comment_cursor_column = 1 + cursor_info.column; @@ -393,6 +402,9 @@ pub(super) fn render_side_by_side_diff(frame: &mut Frame, app: &mut App, area: R None, false, ctx.panel_width.saturating_sub(1), + app.comment_vim_mode_label() + .as_ref() + .map(|(t, w)| (t.as_str(), *w)), ); comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset); comment_cursor_column = 1 + cursor_info.column; @@ -1418,6 +1430,10 @@ fn add_comments_to_line( line_range, true, ctx.panel_width.saturating_sub(1), + ctx.app + .comment_vim_mode_label() + .as_ref() + .map(|(t, w)| (t.as_str(), *w)), ); let box_top_row = line_idx; let box_end = line_idx + input_lines.len().saturating_sub(1); @@ -1495,6 +1511,10 @@ fn add_comments_to_line( line_range, false, ctx.panel_width.saturating_sub(1), + ctx.app + .comment_vim_mode_label() + .as_ref() + .map(|(t, w)| (t.as_str(), *w)), ); let box_top_row = line_idx; let box_end = line_idx + input_lines.len().saturating_sub(1); diff --git a/src/ui/diff_unified.rs b/src/ui/diff_unified.rs index ed121a41..71fd89d9 100644 --- a/src/ui/diff_unified.rs +++ b/src/ui/diff_unified.rs @@ -124,6 +124,9 @@ pub(super) fn render_unified_diff(frame: &mut Frame, app: &mut App, area: Rect) None, true, comment_width, + app.comment_vim_mode_label() + .as_ref() + .map(|(t, w)| (t.as_str(), *w)), ); comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset); comment_cursor_column = 1 + cursor_info.column; @@ -200,6 +203,9 @@ pub(super) fn render_unified_diff(frame: &mut Frame, app: &mut App, area: Rect) None, false, comment_width, + app.comment_vim_mode_label() + .as_ref() + .map(|(t, w)| (t.as_str(), *w)), ); comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset); comment_cursor_column = 1 + cursor_info.column; @@ -296,6 +302,9 @@ pub(super) fn render_unified_diff(frame: &mut Frame, app: &mut App, area: Rect) None, true, comment_width, + app.comment_vim_mode_label() + .as_ref() + .map(|(t, w)| (t.as_str(), *w)), ); // Track cursor position: logical line = current line_idx + cursor offset within input comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset); @@ -355,6 +364,9 @@ pub(super) fn render_unified_diff(frame: &mut Frame, app: &mut App, area: Rect) None, false, comment_width, + app.comment_vim_mode_label() + .as_ref() + .map(|(t, w)| (t.as_str(), *w)), ); // Track cursor position comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset); @@ -650,6 +662,9 @@ pub(super) fn render_unified_diff(frame: &mut Frame, app: &mut App, area: Rect) line_range, true, comment_width, + app.comment_vim_mode_label() + .as_ref() + .map(|(t, w)| (t.as_str(), *w)), ); comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset); @@ -755,6 +770,9 @@ pub(super) fn render_unified_diff(frame: &mut Frame, app: &mut App, area: Rect) line_range, false, comment_width, + app.comment_vim_mode_label() + .as_ref() + .map(|(t, w)| (t.as_str(), *w)), ); comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset); comment_cursor_column = 1 + cursor_info.column; @@ -809,6 +827,9 @@ pub(super) fn render_unified_diff(frame: &mut Frame, app: &mut App, area: Rect) line_range, true, comment_width, + app.comment_vim_mode_label() + .as_ref() + .map(|(t, w)| (t.as_str(), *w)), ); comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset); @@ -914,6 +935,9 @@ pub(super) fn render_unified_diff(frame: &mut Frame, app: &mut App, area: Rect) line_range, false, comment_width, + app.comment_vim_mode_label() + .as_ref() + .map(|(t, w)| (t.as_str(), *w)), ); comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset); comment_cursor_column = 1 + cursor_info.column; diff --git a/src/ui/help_popup.rs b/src/ui/help_popup.rs index 90b73f35..49415b17 100644 --- a/src/ui/help_popup.rs +++ b/src/ui/help_popup.rs @@ -516,6 +516,17 @@ pub fn render_help(frame: &mut Frame, app: &mut App) { ), Span::raw("Cancel"), ]), + Line::from(vec![ + Span::styled( + " comment_vim", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(if app.comment_vim_enabled { + "Vim modal editing ON (Esc:normal i/a:insert hjkl dd/ciw/x u; :w save :q cancel)" + } else { + "Set comment_vim=true (or :vim) for vim modal editing" + }), + ]), Line::from(""), Line::from(Span::styled( "Commands",