From f77a07e20742beefe8d12afe3424c94d39946e2b Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Sun, 17 May 2026 20:57:23 +0200 Subject: [PATCH] feat: Home/End jump to line start/end in multiline composer Plain Home and End now navigate within the current line instead of jumping to the absolute start/end of the entire input. Ctrl+A and Ctrl+E remain as absolute start/end shortcuts. - Add move_cursor_line_start() / move_cursor_line_end() to App - Wire Home -> move_cursor_line_start(), End -> move_cursor_line_end() - On single-line input the new methods behave identically to the absolute versions (no behaviour change) - End on a newline character skips to the end of the next line - 14 tests covering multiline, singleline, and edge cases --- crates/tui/src/tui/app.rs | 86 ++++++++++++++++++++++++++++++++++ crates/tui/src/tui/ui.rs | 4 +- crates/tui/src/tui/ui/tests.rs | 72 ++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 7d034653..5968f2e3 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -3218,6 +3218,38 @@ impl App { self.needs_redraw = true; } + /// In a multiline composer, jump to the start of the current line. + /// On single-line input this is equivalent to `move_cursor_start`. + pub fn move_cursor_line_start(&mut self) { + let byte_pos = byte_index_at_char(&self.input, self.cursor_position); + let before = &self.input[..byte_pos]; + if let Some(last_nl_byte) = before.rfind('\n') { + // Position after the '\n' (start of the current line). + self.cursor_position = char_count(&self.input[..=last_nl_byte]); + } else { + self.cursor_position = 0; + } + self.needs_redraw = true; + } + + /// In a multiline composer, jump to the end of the current line + /// (just before the next `\n` or at the end of input). + /// On single-line input this is equivalent to `move_cursor_end`. + pub fn move_cursor_line_end(&mut self) { + let mut search_start = byte_index_at_char(&self.input, self.cursor_position); + // If the cursor sits on a '\n', skip past it — the user + // wants the end of the *next* line. + if self.input.as_bytes().get(search_start) == Some(&b'\n') { + search_start += 1; + } + if let Some(offset) = self.input[search_start..].find('\n') { + self.cursor_position = char_count(&self.input[..search_start + offset]); + } else { + self.cursor_position = char_count(&self.input); + } + self.needs_redraw = true; + } + /// Move forward one word. Skips over the current word then any trailing /// whitespace to land on the first character of the next word. pub fn move_cursor_word_forward(&mut self) { @@ -4235,6 +4267,60 @@ mod tests { assert!(default_composer_arrows_scroll_for_platform(true, true)); } + #[test] + fn move_cursor_line_start_multiline() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "abc\ndef\nghi".to_string(); + app.cursor_position = "abc\ndef\nghi".chars().count(); // absolute end + app.move_cursor_line_start(); + assert_eq!(app.cursor_position, "abc\ndef\n".len()); // start of "ghi" + } + + #[test] + fn move_cursor_line_start_singleline() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello".to_string(); + app.cursor_position = 3; + app.move_cursor_line_start(); + assert_eq!(app.cursor_position, 0); + } + + #[test] + fn move_cursor_line_end_multiline() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "abc\ndef\nghi".to_string(); + app.cursor_position = 0; // start of first line + app.move_cursor_line_end(); + assert_eq!(app.cursor_position, "abc".len()); // before first '\n' + } + + #[test] + fn move_cursor_line_end_at_newline_skips_to_next_line() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "abc\ndef\nghi".to_string(); + app.cursor_position = "abc".len(); // on the '\n' + app.move_cursor_line_end(); + assert_eq!(app.cursor_position, "abc\ndef".len()); // end of second line + } + + #[test] + fn move_cursor_line_end_last_line() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "abc\ndef".to_string(); + app.cursor_position = "abc\n".len(); // start of last line + app.move_cursor_line_end(); + assert_eq!(app.cursor_position, "abc\ndef".chars().count()); // absolute end + } + + #[test] + fn move_cursor_line_start_already_at_start() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "abc\ndef".to_string(); + app.cursor_position = "abc\n".len(); // start of second line + app.move_cursor_line_start(); + assert_eq!(app.cursor_position, "abc\n".len()); // unchanged + } + struct EnvVarGuard { key: &'static str, previous: Option, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 520bda73..c909e855 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3267,10 +3267,10 @@ async fn run_event_loop( app.move_cursor_start(); } KeyCode::Home => { - app.move_cursor_start(); + app.move_cursor_line_start(); } KeyCode::End => { - app.move_cursor_end(); + app.move_cursor_line_end(); } KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { app.move_cursor_end(); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index edbabbae..afd8dd96 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -5524,6 +5524,78 @@ fn composer_arrows_scroll_config_overrides_default() { ); } +#[test] +fn home_jumps_to_line_start_multiline() { + let mut app = create_test_app(); + app.input = "line one\nline two\nline three".to_string(); + app.cursor_position = app.input.chars().count(); + app.move_cursor_line_start(); + assert_eq!(app.cursor_position, "line one\nline two\n".len()); +} + +#[test] +fn home_from_middle_of_line_jumps_to_line_start() { + let mut app = create_test_app(); + app.input = "line one\nline two".to_string(); + app.cursor_position = "line one\nli".len(); + app.move_cursor_line_start(); + assert_eq!(app.cursor_position, "line one\n".len()); +} + +#[test] +fn home_on_singleline_jumps_to_zero() { + let mut app = create_test_app(); + app.input = "hello world".to_string(); + app.cursor_position = 6; + app.move_cursor_line_start(); + assert_eq!(app.cursor_position, 0); +} + +#[test] +fn end_jumps_to_line_end_multiline() { + let mut app = create_test_app(); + app.input = "line one\nline two\nline three".to_string(); + app.cursor_position = 0; + app.move_cursor_line_end(); + assert_eq!(app.cursor_position, "line one".len()); +} + +#[test] +fn end_from_middle_of_line_jumps_to_line_end() { + let mut app = create_test_app(); + app.input = "line one\nline two".to_string(); + app.cursor_position = "line one\nli".len(); + app.move_cursor_line_end(); + assert_eq!(app.cursor_position, "line one\nline two".len()); +} + +#[test] +fn end_on_singleline_jumps_to_absolute_end() { + let mut app = create_test_app(); + app.input = "hello world".to_string(); + app.cursor_position = 0; + app.move_cursor_line_end(); + assert_eq!(app.cursor_position, app.input.chars().count()); +} + +#[test] +fn home_at_line_start_stays_put() { + let mut app = create_test_app(); + app.input = "line one\nline two".to_string(); + app.cursor_position = "line one\n".len(); + app.move_cursor_line_start(); + assert_eq!(app.cursor_position, "line one\n".len()); +} + +#[test] +fn end_at_newline_skips_to_next_line_end() { + let mut app = create_test_app(); + app.input = "line one\nline two\nline three".to_string(); + app.cursor_position = "line one".len(); + app.move_cursor_line_end(); + assert_eq!(app.cursor_position, "line one\nline two".len()); +} + #[test] fn notification_settings_tui_always_keeps_configured_method_no_threshold() { let config = Config {