diff --git a/CHANGELOG.md b/CHANGELOG.md index e01c89c..f6d2ab5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- add Visual Line mode (`V`) to select whole lines - show the hovered link's URL in the status bar - add triple-click to select the whole line - add `--maximized` and `--fullscreen` flags to start the window in that mode diff --git a/README.md b/README.md index c219819..3412c0f 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,7 @@ visual_bell = true # default: false | Binding | Action | |---|---| | `v` | Enter Visual mode | +| `V` | Enter Visual LINE mode (linewise selection) | | `i` / `Escape` | Return to Insert mode | | `j` / `k` | Scroll down / up | | `/` | Open search | @@ -318,13 +319,16 @@ visual_bell = true # default: false Navigate freely to position the cursor, press `v` to set the selection anchor, then move to the end and copy. +`V` enters Visual LINE mode, where the selection always spans whole lines: `j`/`k` extend it line by line and `y` copies every selected line. `v` returns to characterwise selection. The status bar badge reads `V-LINE`. Triple-clicking a line also leaves you in Visual LINE, so the selection can be extended from there. + | Binding | Action | |---|---| | `h/j/k/l` or arrows | Move cursor (scrolls viewport at boundaries) | | `w` / `b` / `e` | Forward word / backward word / end of word | | `0` / `$` | Start / end of line | | `g` / `G` | Top / bottom of viewport | -| `v` | Set selection anchor at cursor (starts highlighting) | +| `v` | Set selection anchor at cursor (starts highlighting; returns to charwise) | +| `V` | Visual LINE: anchor a whole-line selection at the cursor | | `o` | Swap anchor and cursor | | `y` / `Ctrl+C` | Copy selection and exit | | `Y` | Yank (copy) the entire line at cursor | diff --git a/doc/LLMs.md b/doc/LLMs.md index 530d534..aa4b916 100644 --- a/doc/LLMs.md +++ b/doc/LLMs.md @@ -38,10 +38,18 @@ Constants in `src/ui/layout.rs`: `TAB_BAR_H = 22`, `STATUS_BAR_H = 22`. ## Visual Mode Selection (implemented) -- `InputMode::Visual { start_col, start_row, cur_col, cur_row, anchored: bool }` +- `InputMode::Visual { start_col, start_row, cur_col, cur_row, anchored: bool, linewise: bool }` - `anchored: false` — cursor navigates freely, no selection highlight shown - `anchored: true` — selection highlighted from `(start_col, start_row)` to `(cur_col, cur_row)` -- `v` in Visual mode → `Action::VisualAnchor` → sets `start = cur, anchored = true` +- `v` in Visual mode → `Action::VisualAnchor` → sets `start = cur, anchored = true, linewise = false` +- `V` (Normal or Visual) → `Action::VisualLineAnchor` → `set_visual_line_anchor()` sets `start_col = 0, start_row = cur_row, anchored = true, linewise = true` +- `V` must NOT travel via `Action::SetMode(Visual{..})`: `do_set_mode` discards keybinding-supplied fields on a Normal→Visual transition, silently dropping `linewise` +- `linewise: true` — the selection spans whole rows; only two computations are linewise-aware: + 1. `selection_range` in `src/renderer/text.rs` normalizes to `(0, start_row, grid.cols-1, cur_row)` — nothing downstream changes + 2. `do_visual_copy` passes `sc = 0, ec = grid.cols-1` to `grid.selected_text` (its `row_col_range` already makes interior rows full-width) +- Every reconstruction of the `Visual` variant (motions, scroll, boundary scroll, swap anchor) must carry `linewise` through unchanged +- Status bar badge: `V-LINE` when `linewise`, else `VISUAL` (`mode_style` in `src/renderer/draw_fns.rs`) +- Triple-click (`select_line_at` in `src/input/mouse_ops.rs`) enters `linewise: true`, so the picked line can be extended with `j`/`k` - `o` → `Action::VisualSwapAnchor` → swaps start ↔ cur - `w`/`b`/`e` → `Action::VisualWordForward/Backward/End` → handled in `main.rs` via `motion::word_*` - `y` → `Action::Copy`; `Y` → `Action::VisualYankLine` (copies `cur_row`, exits to Insert) diff --git a/doc/SPEC.md b/doc/SPEC.md index e3f10a6..d501015 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -533,6 +533,7 @@ writing a file. |---|---| | `i` / `Escape` | Return to Insert mode | | `v` | Enter Visual mode | +| `V` | Enter Visual LINE mode | | `/` | Enter Search mode | | `n` | Next search match | | `N` | Previous search match | @@ -555,13 +556,21 @@ selection by moving the cursor. `k`/↑ at the top row and `j`/↓ at the bottom row scroll the viewport, making the entire scrollback buffer reachable. Scroll actions shift the anchor coordinates so the selected content stays stable. +`V` switches to **Visual LINE**, a linewise variant: the selection always spans +whole rows regardless of the cursor column, `j`/`k` extend it a line at a time, +and `y` copies every selected line (trailing spaces trimmed, rows joined with +`\n`). `v` returns to characterwise selection. The status bar badge reads +`V-LINE`. Triple-click also enters Visual LINE, so a mouse-picked line can be +extended with `j`/`k`. + | Key | Action | |---|---| | `h/j/k/l` or arrows | Move cursor (scrolls viewport when at boundary) | | `w` / `b` / `e` | Start of next word / start of prev word / end of word | | `0` / `$` | Start / end of line | | `g` / `G` | Top / bottom of viewport | -| `v` | Set selection anchor at cursor (activates highlight) | +| `v` | Set selection anchor at cursor (activates highlight; charwise) | +| `V` | Visual LINE: anchor a whole-line selection at the cursor | | `o` | Swap anchor and cursor (extend from either end) | | `y` / `Ctrl+C` | Copy selection to clipboard, return to Insert mode | | `Y` | Yank (copy) the entire line at the cursor, return to Insert mode | diff --git a/src/app_state.rs b/src/app_state.rs index 9872f18..b127609 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -338,6 +338,7 @@ impl AppState { cur_col, cur_row, anchored, + linewise, } = self.mode().clone() else { return; @@ -356,6 +357,7 @@ impl AppState { cur_col: nc, cur_row: nr, anchored, + linewise, }; } @@ -382,6 +384,7 @@ impl AppState { cur_col, cur_row, anchored, + linewise, } = self.mode().clone() { self.tab_mut().mode = InputMode::Visual { @@ -390,6 +393,7 @@ impl AppState { cur_col, cur_row: (cur_row + n).min(grid_rows.saturating_sub(1)), anchored, + linewise, }; } } @@ -401,6 +405,7 @@ impl AppState { cur_col, cur_row, anchored, + linewise, } = self.mode().clone() { self.tab_mut().mode = InputMode::Visual { @@ -409,6 +414,7 @@ impl AppState { cur_col, cur_row: cur_row.saturating_sub(n), anchored, + linewise, }; } } @@ -439,17 +445,17 @@ impl AppState { cur_col, cur_row, anchored: true, + linewise, } = self.mode().clone() { let text = self.active_entry().and_then(|entry| { entry.pane.grid_read().map(|g| { - g.selected_text( - start_col, - start_row, - cur_col, - cur_row, - entry.pane.scroll_offset, - ) + let (sc, ec) = if linewise { + (0, g.cols.saturating_sub(1)) + } else { + (start_col, cur_col) + }; + g.selected_text(sc, start_row, ec, cur_row, entry.pane.scroll_offset) }) }); if let Some(text) = text { @@ -481,6 +487,7 @@ impl AppState { cur_col, cur_row, anchored, + linewise, } = self.mode().clone() { let grid_rows = self.active_grid_rows(); @@ -490,6 +497,7 @@ impl AppState { cur_col: start_col, cur_row: start_row.min(grid_rows.saturating_sub(1)), anchored, + linewise, }; } } @@ -505,15 +513,36 @@ impl AppState { cur_col, cur_row, anchored: true, + linewise: false, }; } } + /// `V` — enter (or switch to) Visual LINE: anchor a whole-row selection at + /// the current row. + fn set_visual_line_anchor(&mut self) { + let (cur_col, row) = match self.mode().clone() { + InputMode::Visual { + cur_col, cur_row, .. + } => (cur_col, cur_row), + _ => self.visual_start_pos(), + }; + self.tab_mut().mode = InputMode::Visual { + start_col: 0, + start_row: row, + cur_col, + cur_row: row, + anchored: true, + linewise: true, + }; + } + fn dispatch_visual_action(&mut self, action: Action) -> Vec { match action { Action::Copy => self.do_visual_copy(), Action::VisualSwapAnchor => self.swap_visual_anchor(), Action::VisualAnchor => self.set_visual_anchor(), + Action::VisualLineAnchor => self.set_visual_line_anchor(), Action::VisualWordForward => { self.move_visual_cursor(crate::input::motion::word_forward) } @@ -536,6 +565,7 @@ impl AppState { start_row, cur_col, anchored, + linewise, .. } = self.mode().clone() { @@ -545,6 +575,7 @@ impl AppState { cur_col, cur_row: 0, anchored, + linewise, }; } } @@ -559,6 +590,7 @@ impl AppState { start_row, cur_col, anchored, + linewise, .. } = self.mode().clone() { @@ -568,6 +600,7 @@ impl AppState { cur_col, cur_row: grid_rows.saturating_sub(1), anchored, + linewise, }; } } @@ -688,6 +721,7 @@ impl AppState { Action::Copy | Action::VisualSwapAnchor | Action::VisualAnchor + | Action::VisualLineAnchor | Action::VisualWordForward | Action::VisualWordBackward | Action::VisualWordEnd @@ -858,6 +892,7 @@ impl AppState { cur_col: col, cur_row: row, anchored: false, + linewise: false, } } } else { diff --git a/src/app_state_test.rs b/src/app_state_test.rs index a8fff89..7c4bef9 100644 --- a/src/app_state_test.rs +++ b/src/app_state_test.rs @@ -51,6 +51,7 @@ fn visual_mode_does_not_leak_to_other_tab() { cur_col: 2, cur_row: 1, anchored: true, + linewise: false, }); assert!(matches!(s.mode(), InputMode::Visual { .. })); s.next_tab(); @@ -421,6 +422,7 @@ fn dispatch_visual_anchor_sets_anchored() { cur_col: 3, cur_row: 1, anchored: false, + linewise: false, }; s.dispatch_action(Action::VisualAnchor); if let &InputMode::Visual { @@ -448,6 +450,7 @@ fn dispatch_visual_swap_anchor_swaps_start_and_cur() { cur_col: 5, cur_row: 3, anchored: true, + linewise: false, }; s.dispatch_action(Action::VisualSwapAnchor); if let &InputMode::Visual { @@ -703,6 +706,7 @@ fn dispatch_visual_word_forward_with_pane_does_not_panic() { cur_col: 0, cur_row: 0, anchored: false, + linewise: false, }; s.dispatch_action(Action::VisualWordForward); if let &InputMode::Visual { cur_col, .. } = s.mode() { @@ -725,6 +729,7 @@ fn dispatch_visual_yank_line_exits_visual_mode() { cur_col: 4, cur_row: 0, anchored: true, + linewise: false, }; s.dispatch_action(Action::VisualYankLine); assert!(matches!(s.mode(), InputMode::Insert)); @@ -745,6 +750,7 @@ fn dispatch_copy_with_anchored_selection_exits_visual() { cur_col: 4, cur_row: 0, anchored: true, + linewise: false, }; s.dispatch_action(Action::Copy); assert!(matches!(s.mode(), InputMode::Insert)); @@ -773,6 +779,7 @@ fn visual_boundary_up_start_row_grows_beyond_one_page() { cur_col: 0, cur_row: 0, anchored: true, + linewise: false, }; // Scroll up two full pages via boundary scroll s.dispatch_action(Action::VisualBoundaryUp(grid_rows)); @@ -808,6 +815,7 @@ fn dispatch_visual_boundary_up_scrolls_pane() { cur_col: 0, cur_row: 0, anchored: true, + linewise: false, }; s.dispatch_action(Action::VisualBoundaryUp(2)); let off = s @@ -844,6 +852,7 @@ fn dispatch_visual_word_backward_moves_cursor() { cur_col: 6, cur_row: 0, anchored: false, + linewise: false, }; s.dispatch_action(Action::VisualWordBackward); if let &InputMode::Visual { @@ -874,6 +883,7 @@ fn dispatch_visual_word_end_moves_cursor_forward() { cur_col: 0, cur_row: 0, anchored: false, + linewise: false, }; s.dispatch_action(Action::VisualWordEnd); if let &InputMode::Visual { cur_col, .. } = s.mode() { @@ -906,6 +916,7 @@ fn dispatch_visual_boundary_down_scrolls_pane() { cur_col: 0, cur_row: 5, anchored: true, + linewise: false, }; s.dispatch_action(Action::VisualBoundaryDown(2)); let off = s @@ -941,6 +952,7 @@ fn dispatch_scroll_up_adjusts_visual_coords() { cur_col: 0, cur_row: 3, anchored: true, + linewise: false, }; s.dispatch_action(Action::ScrollUp(2)); if let &InputMode::Visual { @@ -970,6 +982,7 @@ fn dispatch_scroll_down_adjusts_visual_coords() { cur_col: 0, cur_row: 6, anchored: true, + linewise: false, }; s.dispatch_action(Action::ScrollDown(2)); if let &InputMode::Visual { @@ -1000,6 +1013,7 @@ fn viewport_scroll_up_adjusts_visual_selection() { cur_col: 3, cur_row: 4, anchored: true, + linewise: false, }; s.viewport_scroll(3.0); // positive = scroll up let &InputMode::Visual { @@ -1028,6 +1042,7 @@ fn viewport_scroll_down_adjusts_visual_selection() { cur_col: 3, cur_row: 8, anchored: true, + linewise: false, }; s.viewport_scroll(-2.0); // negative = scroll down let &InputMode::Visual { @@ -1523,3 +1538,211 @@ fn click_count_resets_after_window_expires() { "a slow second click is a new single" ); } + +// ── Visual LINE mode ────────────────────────────────────────────────────── + +#[test] +fn dispatch_visual_line_anchor_from_visual_sets_linewise() { + let mut s = make_state_with_pane(); + s.tab_mut().mode = InputMode::Visual { + start_col: 5, + start_row: 2, + cur_col: 3, + cur_row: 4, + anchored: false, + linewise: false, + }; + s.dispatch_action(Action::VisualLineAnchor); + if let &InputMode::Visual { + start_col, + start_row, + cur_row, + anchored, + linewise, + .. + } = s.mode() + { + assert!(anchored); + assert!(linewise); + assert_eq!(start_col, 0); + assert_eq!(start_row, 4); + assert_eq!(cur_row, 4); + } else { + panic!("expected Visual mode"); + } +} + +#[test] +fn dispatch_visual_line_anchor_from_normal_enters_linewise() { + let mut s = make_state_with_pane(); + s.tab_mut().mode = InputMode::Normal; + s.dispatch_action(Action::VisualLineAnchor); + assert!(matches!( + s.mode(), + InputMode::Visual { + anchored: true, + linewise: true, + .. + } + )); +} + +#[test] +fn dispatch_visual_anchor_clears_linewise() { + let mut s = make_state_with_pane(); + s.tab_mut().mode = InputMode::Visual { + start_col: 0, + start_row: 1, + cur_col: 2, + cur_row: 3, + anchored: true, + linewise: true, + }; + s.dispatch_action(Action::VisualAnchor); + assert!(matches!( + s.mode(), + InputMode::Visual { + anchored: true, + linewise: false, + .. + } + )); +} + +/// Read back what a linewise Copy would place on the clipboard, without +/// touching the real system clipboard. +fn linewise_selection_text(s: &crate::app_state::AppState) -> String { + let active = s.tab().active; + let e = s.tab().panes.get(&active).unwrap(); + let g = e.pane.grid.read().unwrap(); + match s.mode() { + &InputMode::Visual { + start_row, cur_row, .. + } => g.selected_text( + 0, + start_row, + g.cols.saturating_sub(1), + cur_row, + e.pane.scroll_offset, + ), + _ => panic!("expected Visual mode"), + } +} + +#[test] +fn linewise_copy_takes_whole_rows_not_columns() { + let mut s = make_state_with_pane(); + let active = s.tab().active; + if let Some(e) = s.tab_mut().panes.get_mut(&active) { + let mut g = e.pane.grid.write().unwrap(); + for c in "hello".chars() { + g.write_char(c); + } + g.cursor_col = 0; + g.cursor_row = 1; + for c in "world".chars() { + g.write_char(c); + } + } + // A narrow charwise span, but linewise must still yield both full rows. + s.tab_mut().mode = InputMode::Visual { + start_col: 2, + start_row: 0, + cur_col: 1, + cur_row: 1, + anchored: true, + linewise: true, + }; + assert_eq!(linewise_selection_text(&s), "hello\nworld"); + s.dispatch_action(Action::Copy); + assert!(matches!(s.mode(), InputMode::Insert)); +} + +#[test] +fn linewise_single_row_matches_triple_click_text() { + let mut s = make_state_with_pane(); + let active = s.tab().active; + if let Some(e) = s.tab_mut().panes.get_mut(&active) { + for c in "hello".chars() { + e.pane.grid.write().unwrap().write_char(c); + } + } + s.tab_mut().mode = InputMode::Visual { + start_col: 0, + start_row: 0, + cur_col: 0, + cur_row: 0, + anchored: true, + linewise: true, + }; + let via_v = linewise_selection_text(&s); + // Triple-click builds exactly (0, row) → (cols-1, row). + let via_triple_click = { + let e = s.tab().panes.get(&active).unwrap(); + let g = e.pane.grid.read().unwrap(); + g.selected_text(0, 0, g.cols.saturating_sub(1), 0, e.pane.scroll_offset) + }; + assert_eq!(via_v, via_triple_click); + assert_eq!(via_v, "hello"); +} + +#[test] +fn visual_boundary_scroll_preserves_linewise() { + let mut s = make_state_with_pane(); + let active = s.tab().active; + let grid_rows = s + .tab() + .panes + .get(&active) + .map(|e| e.pane.grid.read().unwrap().rows) + .unwrap_or(1); + if let Some(e) = s.tab_mut().panes.get_mut(&active) { + for _ in 0..(grid_rows * 2) { + e.pane.grid.write().unwrap().scroll_up(1); + } + } + s.tab_mut().mode = InputMode::Visual { + start_col: 0, + start_row: 0, + cur_col: 0, + cur_row: 0, + anchored: true, + linewise: true, + }; + s.dispatch_action(Action::VisualBoundaryUp(1)); + assert!(matches!(s.mode(), InputMode::Visual { linewise: true, .. })); + s.dispatch_action(Action::VisualBoundaryDown(1)); + assert!(matches!(s.mode(), InputMode::Visual { linewise: true, .. })); +} + +#[test] +fn visual_scroll_preserves_linewise() { + let mut s = make_state_with_pane(); + s.tab_mut().mode = InputMode::Visual { + start_col: 0, + start_row: 1, + cur_col: 0, + cur_row: 2, + anchored: true, + linewise: true, + }; + s.viewport_scroll(1.0); + assert!(matches!(s.mode(), InputMode::Visual { linewise: true, .. })); + s.viewport_scroll(-1.0); + assert!(matches!(s.mode(), InputMode::Visual { linewise: true, .. })); +} + +#[test] +fn visual_swap_anchor_preserves_linewise() { + let mut s = make_state_with_pane(); + s.tab_mut().mode = InputMode::Visual { + start_col: 0, + start_row: 1, + cur_col: 4, + cur_row: 3, + anchored: true, + linewise: true, + }; + s.dispatch_action(Action::VisualSwapAnchor); + assert!(matches!(s.mode(), InputMode::Visual { linewise: true, .. })); +} diff --git a/src/input/keybindings.rs b/src/input/keybindings.rs index 5bb1dd3..b0f3e86 100644 --- a/src/input/keybindings.rs +++ b/src/input/keybindings.rs @@ -43,6 +43,7 @@ pub enum Action { SearchPrev, VisualSwapAnchor, VisualAnchor, + VisualLineAnchor, VisualBoundaryUp(usize), VisualBoundaryDown(usize), VisualWordForward, @@ -126,11 +127,13 @@ pub(crate) fn handle_key_inner( cur_col, cur_row, anchored, + linewise, } => handle_visual( key, (*start_col, *start_row), (*cur_col, *cur_row), *anchored, + *linewise, grid_cols, grid_rows, ), @@ -217,6 +220,7 @@ fn visual_mode_init() -> InputMode { cur_col: 0, cur_row: 0, anchored: false, + linewise: false, } } @@ -440,6 +444,7 @@ fn handle_normal(key: &Key, grid_rows: usize) -> Action { Key::Character(s) => match s.as_str() { "i" => Action::SetMode(InputMode::Insert), "v" => Action::SetMode(visual_mode_init()), + "V" => Action::VisualLineAnchor, "q" => Action::ClosePane, "/" => Action::SearchOpen, "n" => Action::SearchNext, @@ -501,6 +506,7 @@ fn visual_char_action( "Y" => Action::VisualYankLine, "o" => Action::VisualSwapAnchor, "v" => Action::VisualAnchor, + "V" => Action::VisualLineAnchor, "q" => Action::SetMode(InputMode::Insert), _ => Action::None, } @@ -511,6 +517,7 @@ fn handle_visual( (start_col, start_row): (usize, usize), (cur_col, cur_row): (usize, usize), anchored: bool, + linewise: bool, cols: usize, rows: usize, ) -> Action { @@ -524,6 +531,7 @@ fn handle_visual( cur_col: nc.min(cols), cur_row: nr.min(rows), anchored, + linewise, }) }; diff --git a/src/input/keybindings_test.rs b/src/input/keybindings_test.rs index cd48e61..fcd0399 100644 --- a/src/input/keybindings_test.rs +++ b/src/input/keybindings_test.rs @@ -144,6 +144,7 @@ fn visual() -> InputMode { cur_col: 0, cur_row: 0, anchored: false, + linewise: false, } } @@ -1036,6 +1037,7 @@ fn visual_at(sc: usize, sr: usize, cc: usize, cr: usize) -> InputMode { cur_col: cc, cur_row: cr, anchored: false, + linewise: false, } } @@ -1558,6 +1560,7 @@ fn visual_page_up_with_anchored_selection() { cur_col: 3, cur_row: 10, anchored: true, + linewise: false, }; let a = handle_key_inner( &named(NamedKey::PageUp), @@ -1580,6 +1583,7 @@ fn visual_page_down_with_anchored_selection() { cur_col: 3, cur_row: 10, anchored: true, + linewise: false, }; let a = handle_key_inner( &named(NamedKey::PageDown), @@ -2047,6 +2051,7 @@ fn handle_ctrl_only_c_in_visual_returns_copy() { cur_col: 0, cur_row: 0, anchored: true, + linewise: false, }; assert!(matches!( handle_ctrl_only(&char_key("c"), false, &mode), @@ -2129,6 +2134,7 @@ fn visual_char_h_moves_left() { cur_col: c, cur_row: r, anchored: false, + linewise: false, }) }; let result = visual_char_action("h", 5, 3, 80, 24, &move_to); @@ -2443,3 +2449,127 @@ fn ctrl_alt_b_also_toggles_passthrough() { let a = handle_key_inner(&char_key("b"), true, false, true, &insert(), 80, 24, false); assert!(matches!(a, Action::TogglePassthrough)); } + +// ── Visual LINE mode (V) ───────────────────────────────────────────────────── + +fn visual_line_at(cc: usize, cr: usize) -> InputMode { + InputMode::Visual { + start_col: 0, + start_row: cr, + cur_col: cc, + cur_row: cr, + anchored: true, + linewise: true, + } +} + +fn vis_linewise(a: Action) -> bool { + match a { + Action::SetMode(InputMode::Visual { linewise, .. }) => linewise, + _ => panic!("expected Visual SetMode"), + } +} + +#[test] +fn shift_v_in_visual_returns_visual_line_anchor() { + let mode = visual_at(0, 0, 3, 2); + let a = handle_key_inner(&char_key("V"), false, true, false, &mode, 80, 24, false); + assert!(matches!(a, Action::VisualLineAnchor)); +} + +#[test] +fn shift_v_in_normal_returns_visual_line_anchor() { + let a = handle_key_inner( + &char_key("V"), + false, + true, + false, + &InputMode::Normal, + 80, + 24, + false, + ); + assert!(matches!(a, Action::VisualLineAnchor)); +} + +#[test] +fn lowercase_v_in_visual_returns_charwise_anchor() { + let mode = visual_line_at(3, 2); + let a = handle_key_inner(&char_key("v"), false, false, false, &mode, 80, 24, false); + assert!(matches!(a, Action::VisualAnchor)); +} + +#[test] +fn visual_line_motions_preserve_linewise() { + let mode = visual_line_at(3, 2); + for k in ["h", "l", "j", "k", "0", "$", "g", "G"] { + let a = handle_key_inner(&char_key(k), false, false, false, &mode, 80, 24, false); + assert!(vis_linewise(a), "{k} lost the linewise flag"); + } + for k in [ + NamedKey::ArrowLeft, + NamedKey::ArrowRight, + NamedKey::ArrowUp, + NamedKey::ArrowDown, + NamedKey::Home, + NamedKey::End, + ] { + let a = handle_key_inner(&named(k), false, false, false, &mode, 80, 24, false); + assert!(vis_linewise(a), "{k:?} lost the linewise flag"); + } +} + +#[test] +fn visual_charwise_motions_stay_charwise() { + let mode = visual_at(0, 0, 3, 2); + let a = handle_key_inner(&char_key("j"), false, false, false, &mode, 80, 24, false); + assert!(!vis_linewise(a)); +} + +#[test] +fn ctrl_v_in_visual_is_not_visual_line_anchor() { + let mode = visual_at(0, 0, 3, 2); + let a = handle_key_inner(&char_key("V"), true, true, false, &mode, 80, 24, false); + assert!(!matches!(a, Action::VisualLineAnchor)); +} + +#[test] +fn alt_v_in_visual_behaves_like_plain_v() { + // Alt is not consumed for character keys in Visual mode — every Visual + // binding falls through the same way, so Alt+V matches plain V. + let mode = visual_at(0, 0, 3, 2); + let a = handle_key_inner(&char_key("V"), false, true, true, &mode, 80, 24, false); + assert!(matches!(a, Action::VisualLineAnchor)); + let lower = handle_key_inner(&char_key("v"), false, false, true, &mode, 80, 24, false); + assert!(matches!(lower, Action::VisualAnchor)); +} + +#[test] +fn ctrl_v_in_normal_is_not_visual_line_anchor() { + let a = handle_key_inner( + &char_key("V"), + true, + true, + false, + &InputMode::Normal, + 80, + 24, + false, + ); + assert!(!matches!(a, Action::VisualLineAnchor)); +} + +#[test] +fn shift_v_in_insert_is_not_visual_line_anchor() { + let a = handle_key_inner( + &char_key("V"), + false, + true, + false, + &InputMode::Insert, + 80, + 24, + false, + ); + assert!(!matches!(a, Action::VisualLineAnchor)); +} diff --git a/src/input/mode.rs b/src/input/mode.rs index e0c94d4..fbdefdf 100644 --- a/src/input/mode.rs +++ b/src/input/mode.rs @@ -10,6 +10,8 @@ pub enum InputMode { cur_row: usize, /// False while navigating (no highlight); true after the user presses `v` to set the anchor. anchored: bool, + /// True in Visual LINE: the selection spans whole rows. + linewise: bool, }, /// Inline tab-rename: buf holds the name being typed RenameTab { diff --git a/src/input/mouse_ops.rs b/src/input/mouse_ops.rs index f12138f..5ab11e4 100644 --- a/src/input/mouse_ops.rs +++ b/src/input/mouse_ops.rs @@ -93,6 +93,7 @@ impl App { cur_col: col, cur_row: row, anchored: true, + linewise: false, }; self.state.mouse_selecting = true; if let Some(w) = &self.window { @@ -137,6 +138,7 @@ impl App { cur_col: end, cur_row: row, anchored: true, + linewise: false, }; self.copy_selection_to_clipboard(start, row, end, row); } @@ -173,6 +175,7 @@ impl App { cur_col: end, cur_row: row, anchored: true, + linewise: true, }; self.copy_selection_to_clipboard(0, row, end, row); self.state.mouse_selecting = false; @@ -185,6 +188,7 @@ impl App { if let InputMode::Visual { start_col, start_row, + linewise, .. } = self.state.mode().clone() { @@ -196,6 +200,7 @@ impl App { cur_col: col, cur_row: row, anchored: true, + linewise, }; } } diff --git a/src/input/mouse_ops_test.rs b/src/input/mouse_ops_test.rs index 688ee53..108b34f 100644 --- a/src/input/mouse_ops_test.rs +++ b/src/input/mouse_ops_test.rs @@ -107,12 +107,14 @@ fn select_line_at_anchors_full_row_visual() { cur_col, cur_row, anchored, + linewise, } => { assert_eq!(*start_col, 0, "line selection starts at column 0"); assert_eq!(*cur_col, 79, "line selection ends at the last column"); assert_eq!(*start_row, 0); assert_eq!(*cur_row, 0); assert!(*anchored, "line selection is anchored/highlighted"); + assert!(*linewise, "triple-click enters Visual LINE"); } other => panic!("expected anchored Visual selection, got {other:?}"), } diff --git a/src/renderer/draw_fns.rs b/src/renderer/draw_fns.rs index c24998b..b6eccff 100644 --- a/src/renderer/draw_fns.rs +++ b/src/renderer/draw_fns.rs @@ -58,7 +58,10 @@ pub(super) fn mode_style( let label = if passthrough { "INSERT PASS" } else { "INSERT" }; (label, color_u32(theme.palette[2])) } - InputMode::Visual { .. } => ("VISUAL", color_u32(theme.palette[5])), + InputMode::Visual { linewise, .. } => { + let label = if *linewise { "V-LINE" } else { "VISUAL" }; + (label, color_u32(theme.palette[5])) + } InputMode::RenameTab { .. } => ("RENAME", color_u32(theme.palette[3])), InputMode::Search { .. } => ("SEARCH", color_u32(theme.palette[3])), InputMode::CommandPalette { .. } => ("PALETTE", color_u32(theme.palette[6])), diff --git a/src/renderer/text.rs b/src/renderer/text.rs index d712105..6d25c24 100644 --- a/src/renderer/text.rs +++ b/src/renderer/text.rs @@ -258,7 +258,16 @@ impl Renderer { cur_col, cur_row, anchored: true, - } => Some((*start_col, *start_row, *cur_col, *cur_row)), + linewise, + } => { + if *linewise { + // Linewise: normalize to whole rows so the rest of the + // pipeline stays characterwise-only. + Some((0, *start_row, grid.cols.saturating_sub(1), *cur_row)) + } else { + Some((*start_col, *start_row, *cur_col, *cur_row)) + } + } _ => None, } } else { diff --git a/src/renderer/text_test.rs b/src/renderer/text_test.rs index 54a232d..a835686 100644 --- a/src/renderer/text_test.rs +++ b/src/renderer/text_test.rs @@ -146,6 +146,7 @@ fn mode_style_returns_badge_for_each_mode() { cur_col: 0, cur_row: 0, anchored: false, + linewise: false, }, false, &theme, @@ -766,6 +767,7 @@ fn draw_pane_visual_selection_does_not_panic() { cur_col: 2, cur_row: 0, anchored: true, + linewise: false, }; do_draw(&mut r, &[pane], &mode); } @@ -1061,6 +1063,7 @@ fn draw_pane_backwards_visual_selection_normalises_range() { cur_col: 0, cur_row: 0, anchored: true, + linewise: false, }; do_draw(&mut r, &[pane], &mode); } @@ -1303,6 +1306,7 @@ fn draw_pane_visual_mode_shows_cursor_at_cur_position() { cur_col: 2, cur_row: 0, anchored: false, + linewise: false, }; let pane = PaneView { grid: &grid, @@ -1334,6 +1338,7 @@ fn draw_pane_visual_mode_inactive_pane_no_cursor() { cur_col: 3, cur_row: 0, anchored: false, + linewise: false, }; let pane = PaneView { grid: &grid, @@ -1540,3 +1545,70 @@ fn draw_pane_sixel_transparent_pixels_preserved() { let pane = make_pane(&grid, &m); do_draw(&mut r, &[pane], &InputMode::Insert); } + +#[test] +fn draw_pane_linewise_visual_selection_does_not_panic() { + let mut r = make_renderer(); + let m = r.make_metrics(Physical(16.0)); + let (cols, rows) = m.grid_size_for(800, 600u32.saturating_sub(44)); + let mut grid = make_grid(cols, rows); + for c in "AB".chars() { + grid.write_char(c); + } + grid.cursor_col = 0; + grid.cursor_row = 1; + for c in "CD".chars() { + grid.write_char(c); + } + let pane = PaneView { + grid: &grid, + rect: [0, 22, 800, 600 - 44], + scroll_offset: 0, + is_active: true, + show_cursor: false, + blink_visible: true, + search_matches: &[], + search_current: None, + hovered_url: None, + cursor_shape: CursorShape::Block, + metrics: &m, + }; + // Narrow columns, but linewise must highlight the full rows. + let mode = InputMode::Visual { + start_col: 1, + start_row: 0, + cur_col: 0, + cur_row: 1, + anchored: true, + linewise: true, + }; + do_draw(&mut r, &[pane], &mode); +} + +#[test] +fn mode_style_reports_v_line_for_linewise_visual() { + let theme = default_theme(); + let charwise = InputMode::Visual { + start_col: 0, + start_row: 0, + cur_col: 0, + cur_row: 0, + anchored: true, + linewise: false, + }; + let linewise = InputMode::Visual { + start_col: 0, + start_row: 0, + cur_col: 0, + cur_row: 0, + anchored: true, + linewise: true, + }; + let (charwise_label, charwise_color) = + crate::renderer::draw_fns::mode_style(&charwise, false, &theme); + let (linewise_label, linewise_color) = + crate::renderer::draw_fns::mode_style(&linewise, false, &theme); + assert_eq!(charwise_label, "VISUAL"); + assert_eq!(linewise_label, "V-LINE"); + assert_eq!(charwise_color, linewise_color); +}