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
This commit is contained in:
Paulo Aboim Pinto
2026-05-17 20:57:23 +02:00
committed by Hunter Bown
parent 0ce41505bc
commit f77a07e207
3 changed files with 160 additions and 2 deletions
+86
View File
@@ -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<OsString>,
+2 -2
View File
@@ -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();
+72
View File
@@ -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 {