From 0f5ae36c5a16f50168cad26d99881e4608e7d07a Mon Sep 17 00:00:00 2001 From: barkure Date: Thu, 20 Aug 2026 04:54:19 +0800 Subject: [PATCH] Add Ctrl+Shift+Home/End selection and document Windows Terminal workaround Fixes #687 The VT sequences CSI 1;5 H/F (Ctrl+Home/End) and CSI 1;6 H/F (Ctrl+Shift+Home/End) were already correctly parsed in input.rs:parse_modifiers and handled in tui.rs HOME/END. Add regression tests for the VT parsing and the selection behavior, and document the required Windows Terminal keybinding unbind in README.md. --- README.md | 12 ++++++ crates/edit/src/input.rs | 44 +++++++++++++++++++++ crates/edit/src/tui.rs | 84 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+) diff --git a/README.md b/README.md index b62a023e5d4..b36ef00995b 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,18 @@ You can install the latest version with Homebrew: brew install msedit ``` +## Keyboard Shortcuts + +Common shortcuts such as `Ctrl+S` (save), `Ctrl+Z`/`Ctrl+Y` (undo/redo), and `Ctrl+Home`/`Ctrl+End` (go to file start/end) work out of the box. `Shift` adds selection, e.g. `Shift+Home` selects to line start and `Ctrl+Shift+Home`/`Ctrl+Shift+End` select to file start/end. + +> **Windows Terminal:** `Ctrl+Shift+Home` and `Ctrl+Shift+End` are intercepted by the terminal by default. To enable them in `edit`, add the following to your Windows Terminal `settings.json`: +> ```json +> "keybindings": [ +> { "id": null, "keys": "ctrl+shift+home" }, +> { "id": null, "keys": "ctrl+shift+end" } +> ] +> ``` + ## Build Instructions * [Install Rust](https://www.rust-lang.org/tools/install) diff --git a/crates/edit/src/input.rs b/crates/edit/src/input.rs index 09144fb7b64..01730ffa820 100644 --- a/crates/edit/src/input.rs +++ b/crates/edit/src/input.rs @@ -597,3 +597,47 @@ impl<'input> Stream<'_, '_, 'input> { Some(Input::Mouse(mouse)) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::vt; + + fn parse_single(input: &str) -> Option> { + let mut vt_parser = vt::Parser::new(); + let mut parser = Parser::new(); + let mut stream = parser.parse(vt_parser.parse(input)); + stream.next() + } + + #[test] + fn test_ctrl_shift_home_end_vt_parsing() { + // Ctrl+Home -> ESC[1;5H, Ctrl+Shift+Home -> ESC[1;6H + // Ctrl+End -> ESC[1;5F, Ctrl+Shift+End -> ESC[1;6F + let cases = [ + ("\x1b[1;5H", vk::HOME, kbmod::CTRL), + ("\x1b[1;6H", vk::HOME, kbmod::CTRL_SHIFT), + ("\x1b[1;5F", vk::END, kbmod::CTRL), + ("\x1b[1;6F", vk::END, kbmod::CTRL_SHIFT), + ("\x1b[H", vk::HOME, kbmod::NONE), + ("\x1b[F", vk::END, kbmod::NONE), + ]; + + for (seq, expected_key, expected_mod) in cases { + let input = parse_single(seq).expect("should parse"); + match input { + Input::Keyboard(key) => { + let expected = expected_mod | expected_key; + assert_eq!( + key.value(), + expected.value(), + "mismatch for {seq:?}: got {:#010x}, expected {:#010x}", + key.value(), + expected.value() + ); + } + _ => panic!("expected Keyboard for {seq:?}"), + } + } + } +} diff --git a/crates/edit/src/tui.rs b/crates/edit/src/tui.rs index 9826c91cfc7..63151cb0167 100644 --- a/crates/edit/src/tui.rs +++ b/crates/edit/src/tui.rs @@ -4110,3 +4110,87 @@ impl<'a> Node<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::buffer::TextBuffer; + + fn handle_key(tui: &mut Tui, buffer: &TextBufferCell, key: InputKey) { + let mut content = TextareaContent { + buffer, + scroll_offset: Point::default(), + scroll_offset_y_drag_start: CoordType::MIN, + scroll_offset_x_max: 0, + thumb_height: 0, + preferred_column: 0, + single_line: false, + has_focus: true, + }; + let node = Node::default(); + let mut ctx = tui.create_context(Some(Input::Keyboard(key))); + assert!(ctx.textarea_handle_input(&mut content, &node, false)); + } + + fn selection_logical(tb: &TextBuffer) -> (Point, Point) { + let (beg, end) = tb.selection_range().expect("should have selection"); + (beg.logical_pos, end.logical_pos) + } + + #[test] + fn ctrl_shift_home_end_selects_to_file_bounds() -> io::Result<()> { + // Ctrl+Shift+Home/End should select to file start/end, not merely line start/end. + let mut tui = Tui::new()?; + let buffer = TextBuffer::new_rc(true)?; + { + let mut tb = buffer.borrow_mut(); + tb.set_crlf(false); + tb.write_canon(b"a\nb\nc\nd\n"); + } + + let file_end = { + let mut tb = buffer.borrow_mut(); + tb.cursor_move_to_logical(Point::MAX); + tb.cursor_logical_pos() + }; + let mid = Point { x: 0, y: 2 }; + + { + let mut tb = buffer.borrow_mut(); + tb.cursor_move_to_logical(mid); + } + handle_key(&mut tui, &buffer, kbmod::CTRL_SHIFT | vk::HOME); + { + let tb = buffer.borrow(); + assert_eq!(selection_logical(&tb), (Point { x: 0, y: 0 }, mid)); + } + + { + let mut tb = buffer.borrow_mut(); + tb.clear_selection(); + tb.cursor_move_to_logical(mid); + } + // Shift+End without Ctrl only covers the current line. + handle_key(&mut tui, &buffer, kbmod::SHIFT | vk::END); + { + let tb = buffer.borrow(); + let (beg, end) = selection_logical(&tb); + assert_eq!(beg, mid); + assert_eq!(end.y, mid.y); + assert_ne!(end, file_end); + } + + { + let mut tb = buffer.borrow_mut(); + tb.clear_selection(); + tb.cursor_move_to_logical(mid); + } + handle_key(&mut tui, &buffer, kbmod::CTRL_SHIFT | vk::END); + { + let tb = buffer.borrow(); + assert_eq!(selection_logical(&tb), (mid, file_end)); + } + + Ok(()) + } +}