diff --git a/README.md b/README.md index 1994bf4..4a558ec 100644 --- a/README.md +++ b/README.md @@ -135,12 +135,21 @@ It includes shared prompts for commands and buffer navigation, a directory picke | `C-x C-r` | Reload a clean buffer from disk | | `C-x C-s` | Save the file | | `C-x C-c` | Quit | +| Tab / Shift-Tab | Indent or outdent the active region; otherwise insert to a tab stop or outdent the current line | | `M-x` | Open the named command prompt | If any open buffer is dirty, `C-x C-c` asks whether to quit without saving. Press `y` to confirm. Press `n` or Escape to cancel. +Tab inserts spaces to the next four-column stop. +Enter carries leading spaces and tabs from the current line, limited to indentation before point. +Newlines retain the current line's LF or CRLF style; an unterminated final line uses the previous line's style, and a new file uses LF. +With a region active, Tab adds four spaces to each selected line and Shift-Tab removes up to four columns of leading indentation. +Each region change is one undo step and keeps the region active for another indentation command. +A selection ending at the start of a line excludes that line. +Shift-Tab without a region outdents the current line and preserves any remaining tabs. + ## Named commands Press `M-x`, type a command name, and press Enter. @@ -164,7 +173,8 @@ Escape cancels the prompt; unknown names and invalid arguments report an error w | `forward-char`, `backward-char` | Move one grapheme | | | `next-line`, `previous-line` | Move one line | | | `beginning-of-line`, `end-of-line` | Move to a line boundary | | -| `newline` | Insert a newline | | +| `newline` | Insert a newline carrying leading indentation | | +| `indent`, `outdent` | Apply Tab or Shift-Tab behavior | | | `delete-char`, `delete-backward-char` | Delete one grapheme | | | `set-mark`, `kill-region`, `kill-line`, `yank` | Select, cut, or insert cut text | | | `self-insert-command ` | Insert one printable character | | diff --git a/docs/issues/164-plan.md b/docs/issues/164-plan.md new file mode 100644 index 0000000..92ab221 --- /dev/null +++ b/docs/issues/164-plan.md @@ -0,0 +1,35 @@ +# Issue 164: Practical indentation + +## Task and acceptance + +Tab inserts spaces to the next four-column display stop when no region is active. +Enter copies the current line's leading spaces and tabs, limited to whitespace before point. +It uses the current line's LF or CRLF ending, falling back to the previous line at an unterminated final line, then LF for a new file. +Tab and Shift-Tab change all selected lines as one undo step and preserve the selected range for repeated indentation. +A region ending at the start of a line excludes that line. +Shift-Tab without a region removes up to four columns of leading indentation from the current line. +Do not split graphemes or convert existing tabs while carrying or removing indentation. + +## Implementation + +Keep line and Rope access in Buffer and point in View. +Add bounded leading-whitespace and line-ending queries to Buffer. +Reuse one contiguous replacement for a selected block so existing history records the entire operation as one edit. +End the replacement at the last changed prefix, so single-line operations retain only indentation bytes in history. +Map point and mark through inserted or removed prefixes; leave an unchanged outdent as a no-op. +Named indent and outdent commands share their keybinding behavior. + +## Checks + +Cover tab stops after wide characters and tabs, indentation split points, CRLF and mixed line endings, region boundaries and direction, blank lines, mixed whitespace, graphemes, and undo/redo point restoration. +Run focused command/app/input tests, the full suite, formatting, all-target Clippy, and release build. +Use a real PTY to type nested code, indent and outdent a region, undo, save, and verify terminal restoration and a usable shell. + +## Verification + +The final local suite passed 352 unit tests and eight terminal integration tests. +The two redirected-input tests remain restricted by the local sandbox; require their unchanged CI coverage before merge. +Formatting, all-target Clippy, and the release build passed. +A real release PTY flow typed nested Rust using Tab, Enter, and Shift-Tab, saved it, indented and outdented a region ending at a line boundary, exercised one-step undo/redo across saves, and restored a usable shell. +Independent review approved after replacing whole-line indentation history with prefix-only changes. +A one-million-character regression proves that single-line indent/outdent retains only the four changed bytes in history. diff --git a/docs/roadmap.md b/docs/roadmap.md index e502716..2c15076 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -111,10 +111,10 @@ Shipped: - Multiple buffers. - Find file. - Switch buffer. +- Command registry and `M-x` with prefix completion. Planned: - Incremental search. -- Command registry and `M-x`. - Kill ring and yank-pop. Tracking issues: @@ -122,7 +122,7 @@ Tracking issues: - [closed] [#28 Add minibuffer foundation](https://github.com/owainlewis/cortex/issues/28) - [open] [#29 Add incremental search](https://github.com/owainlewis/cortex/issues/29) - [closed] [#30 Add multiple buffers, find-file, and switch-buffer](https://github.com/owainlewis/cortex/issues/30) -- [open] [#46 Add command registry and M-x](https://github.com/owainlewis/cortex/issues/46) +- [closed] [#46 Add command registry and M-x](https://github.com/owainlewis/cortex/issues/46) - [open] [#47 Replace cut slot with a real kill ring](https://github.com/owainlewis/cortex/issues/47) Release notes should focus on editing safety, search, and buffer navigation. @@ -137,7 +137,7 @@ Delivery order: - [closed] [#161 Align the product direction](https://github.com/owainlewis/cortex/issues/161) - [closed] [#162 Correct TypeScript and fenced Markdown colours](https://github.com/owainlewis/cortex/issues/162) - [closed] [#175 Fix macOS PTY disconnect monitoring](https://github.com/owainlewis/cortex/issues/175) -- [open] [#46 Add command registry and M-x](https://github.com/owainlewis/cortex/issues/46) +- [closed] [#46 Add command registry and M-x](https://github.com/owainlewis/cortex/issues/46) - [open] [#164 Add typing and region indentation](https://github.com/owainlewis/cortex/issues/164) - [open] [#165 Handle literal terminal paste](https://github.com/owainlewis/cortex/issues/165) - [open] [#166 Group typing and deletion undo steps](https://github.com/owainlewis/cortex/issues/166) diff --git a/src/app.rs b/src/app.rs index e61c2b5..6c0d11e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -548,6 +548,22 @@ impl AppState { use commands::Command; match command { Command::OpenCommandLine => self.start_command_line(), + Command::Indent | Command::Outdent => { + if let Some(region) = self.active_region(buffer, view) { + let point_at_start = view.point() == region.start; + let region = + commands::indent_region(buffer, view, region, command == Command::Outdent); + self.mark = Some(if point_at_start { + region.end + } else { + region.start + }); + self.clear_status(); + AppAction::Continue + } else { + self.dispatch_command(command, buffer, view) + } + } Command::SetMark => self.set_mark(view), Command::KillRegion => self.kill_region(buffer, view), Command::KillLine => self.kill_line(buffer, view), @@ -781,6 +797,7 @@ fn keycast_text(key: crate::input::Key) -> Option { crate::input::Key::Command(ch) => Some(format!("Cmd-{ch}")), crate::input::Key::Enter => Some("Enter".to_string()), crate::input::Key::Tab => Some("Tab".to_string()), + crate::input::Key::BackTab => Some("Shift-Tab".to_string()), crate::input::Key::Escape => Some("Esc".to_string()), crate::input::Key::Backspace => Some("Backspace".to_string()), crate::input::Key::Delete => Some("Delete".to_string()), @@ -812,6 +829,8 @@ fn command_clears_mark(command: commands::Command) -> bool { command, commands::Command::Insert(_) | commands::Command::InsertNewline + | commands::Command::Indent + | commands::Command::Outdent | commands::Command::DeleteBackward | commands::Command::DeleteForward | commands::Command::ReloadBuffer @@ -1793,6 +1812,74 @@ mod tests { ); } + #[test] + fn tab_and_backtab_preserve_forward_and_reverse_selected_regions() { + for reverse in [false, true] { + let mut app = AppState::default(); + let mut keymap = Keymap::new(); + let source = "pre\n\talpha\n beta\n"; + let mut buffer = buffer_with_text("region.txt", source); + let mut view = View::new(); + let start = buffer.line_start_char(1); + let end = buffer.len_chars(); + let point = if reverse { start } else { end }; + app.mark = Some(if reverse { end } else { start }); + view.set_point(point, &buffer); + app.handle_key(Key::Tab, &mut keymap, &mut buffer, &mut view); + assert_eq!(buffer.text(), "pre\n \talpha\n beta\n"); + assert_eq!(app.active_region(&buffer, &view), Some(start..end + 8)); + assert_eq!(view.point(), if reverse { start } else { end + 8 }); + app.handle_key(Key::BackTab, &mut keymap, &mut buffer, &mut view); + assert_eq!(buffer.text(), source); + assert_eq!(app.active_region(&buffer, &view), Some(start..end)); + assert_eq!(view.point(), point); + run_slash_command("undo", &mut app, &mut keymap, &mut buffer, &mut view); + assert_eq!(buffer.text(), "pre\n \talpha\n beta\n"); + assert!(app.mark.is_none()); + run_slash_command("undo", &mut app, &mut keymap, &mut buffer, &mut view); + assert_eq!(buffer.text(), source); + assert_eq!(view.point(), point); + assert_eq!(buffer.undo(), None); + } + } + + #[test] + fn region_indentation_keeps_blank_lines_and_graphemes_intact() { + let mut app = AppState::default(); + let mut keymap = Keymap::new(); + let source = " \u{301}a\n\n\t界\r\nlast"; + let mut buffer = buffer_with_text("region.txt", source); + let mut view = View::new(); + let end = buffer.line_start_char(3); + app.mark = Some(0); + view.set_point(end, &buffer); + app.handle_key(Key::Tab, &mut keymap, &mut buffer, &mut view); + assert_eq!(buffer.text(), " \u{301}a\n \n \t界\r\nlast"); + assert_eq!(app.active_region(&buffer, &view), Some(0..end + 12)); + app.handle_key(Key::BackTab, &mut keymap, &mut buffer, &mut view); + assert_eq!(buffer.text(), source); + assert_eq!(view.point(), end); + assert_eq!(app.mark, Some(0)); + } + + #[test] + fn named_indentation_preserves_partial_line_region_endpoints() { + let mut app = AppState::default(); + let mut keymap = Keymap::new(); + let mut buffer = buffer_with_text("region.txt", " a\n b\nc"); + let mut view = View::new(); + app.mark = Some(1); + view.set_point(6, &buffer); + run_slash_command("indent", &mut app, &mut keymap, &mut buffer, &mut view); + assert_eq!(buffer.text(), " a\n b\nc"); + assert_eq!(app.mark, Some(5)); + assert_eq!(view.point(), 14); + run_slash_command("outdent", &mut app, &mut keymap, &mut buffer, &mut view); + assert_eq!(buffer.text(), " a\n b\nc"); + assert_eq!(app.mark, Some(1)); + assert_eq!(view.point(), 6); + } + #[test] fn named_commands_match_editing_and_movement_keys() { for (name, key) in [ diff --git a/src/buffer.rs b/src/buffer.rs index 93cfc8e..d857930 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -388,6 +388,36 @@ impl Buffer { self.line_start_char(line_idx) + line_content_len_chars(line) } + pub(crate) fn leading_indentation(&self, line_idx: usize, max_chars: usize) -> String { + let line_idx = self.clamp_line_idx(line_idx); + let start = self.line_start_char(line_idx); + let count = self + .text + .line(line_idx) + .chars() + .take(max_chars) + .take_while(|ch| matches!(ch, ' ' | '\t')) + .count(); + let end = self.grapheme_boundary_at_or_before(start + count); + self.text_range(start..end) + } + + pub(crate) fn newline_at(&self, line_idx: usize) -> &'static str { + let line_idx = self.clamp_line_idx(line_idx); + for index in [line_idx, line_idx.saturating_sub(1)] { + let line = self.text.line(index); + let len = line.len_chars(); + if len > 0 && line.char(len - 1) == '\n' { + return if len > 1 && line.char(len - 2) == '\r' { + "\r\n" + } else { + "\n" + }; + } + } + "\n" + } + pub fn line_prefix_text(&self, line_idx: usize, max_chars: usize) -> String { if max_chars == 0 { return String::new(); @@ -823,7 +853,7 @@ impl Buffer { line_idx.min(self.len_lines().saturating_sub(1)) } - fn replace_with_points( + pub(crate) fn replace_with_points( &mut self, char_range: Range, inserted: &str, @@ -2213,6 +2243,41 @@ mod tests { ("paragraph-separator", "\u{2029}"), ]; + #[test] + fn single_line_indentation_history_retains_only_the_changed_prefix() { + use crate::{ + commands::{self, Command}, + view::View, + }; + let dir = test_dir("indentation-history"); + let path = dir.join("long.txt"); + let source = format!(" {}", "x".repeat(1_000_000)); + fs::write(&path, &source).unwrap(); + for selected in [false, true] { + let mut buffer = Buffer::open(&path).unwrap(); + let mut view = View::new(); + let end = buffer.len_chars(); + view.set_point(end, &buffer); + if selected { + commands::indent_region(&mut buffer, &mut view, 0..end, true); + } else { + commands::dispatch(Command::Outdent, &mut buffer, &mut view); + } + let edit = buffer.undo_stack.last().unwrap(); + assert_eq!(edit.deleted, " "); + assert!(edit.inserted.is_empty()); + assert_eq!(view.point(), end - 4); + commands::dispatch(Command::Undo, &mut buffer, &mut view); + assert_eq!(view.point(), end); + assert_eq!(buffer.text(), source); + commands::indent_region(&mut buffer, &mut view, 0..end, false); + let edit = buffer.undo_stack.last().unwrap(); + assert!(edit.deleted.is_empty()); + assert_eq!(edit.inserted, " "); + } + remove_dir(dir); + } + #[test] fn loads_existing_files_into_the_buffer() { let dir = test_dir("loads-existing-files"); diff --git a/src/command_registry.rs b/src/command_registry.rs index ccc7bd7..d60c1e9 100644 --- a/src/command_registry.rs +++ b/src/command_registry.rs @@ -125,11 +125,25 @@ pub const COMMANDS: &[CommandSpec] = &[ ), command( "newline", - "Insert a newline at point", + "Insert a newline carrying the current indentation", &[], Command::InsertNewline, None, ), + command( + "indent", + "Insert spaces to a tab stop or indent selected lines", + &[], + Command::Indent, + None, + ), + command( + "outdent", + "Remove one indentation level from current or selected lines", + &[], + Command::Outdent, + None, + ), command( "delete-backward-char", "Delete the previous grapheme", @@ -439,6 +453,8 @@ mod tests { }) .chain([ Key::Enter, + Key::Tab, + Key::BackTab, Key::Backspace, Key::Delete, Key::Left, diff --git a/src/commands.rs b/src/commands.rs index 5a35b21..42d959e 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -7,6 +7,8 @@ use crate::{ pub enum Command { Insert(char), InsertNewline, + Indent, + Outdent, DeleteBackward, DeleteForward, OpenCommandLine, @@ -52,10 +54,25 @@ pub fn dispatch(command: Command, buffer: &mut Buffer, view: &mut View) -> Comma } Command::InsertNewline => { let point = view.point(); - let point_after = buffer.insert(point, "\n"); + let line = buffer.line_for_char(point); + let indentation: String = + buffer.leading_indentation(line, point - buffer.line_start_char(line)); + let inserted = format!("{}{indentation}", buffer.newline_at(line)); + let point_after = buffer.insert(point, &inserted); view.set_point(point_after, buffer); CommandOutcome::default() } + Command::Indent => { + let point = view.point(); + let spaces = " ".repeat(4 - buffer.display_column(point) % 4); + let point_after = buffer.insert(point, &spaces); + view.set_point(point_after, buffer); + CommandOutcome::default() + } + Command::Outdent => { + indent_region(buffer, view, view.point()..view.point(), true); + CommandOutcome::default() + } Command::DeleteBackward => { let point = view.point(); if point > 0 { @@ -145,6 +162,81 @@ pub fn dispatch(command: Command, buffer: &mut Buffer, view: &mut View) -> Comma } } +pub fn indent_region( + buffer: &mut Buffer, + view: &mut View, + region: std::ops::Range, + outdent: bool, +) -> std::ops::Range { + let first = buffer.line_for_char(region.start); + let mut last = buffer.line_for_char(region.end); + if !region.is_empty() && region.end == buffer.line_start_char(last) { + last = last.saturating_sub(1); + } + let removals: Vec<_> = (first..=last) + .map(|line| { + if outdent { + outdent_chars(&buffer.leading_indentation(line, 4)) + } else { + 0 + } + }) + .collect(); + if outdent && removals.iter().all(|count| *count == 0) { + return region; + } + // The last line's body is unchanged. End at its edited prefix so a + // single-line operation never copies that body into text or history. + let replaced = + buffer.line_start_char(first)..buffer.line_start_char(last) + removals[last - first]; + let mut inserted = String::new(); + let mut mapped = region.clone(); + for (line, removed) in (first..=last).zip(removals) { + let start = buffer.line_start_char(line); + let end = if line == last { + replaced.end + } else { + buffer.line_start_char(line + 1) + }; + let added = if outdent { 0 } else { 4 }; + if !outdent { + inserted.push_str(" "); + } + inserted.push_str(&buffer.text_range(start + removed..end)); + for (before, after) in [ + (region.start, &mut mapped.start), + (region.end, &mut mapped.end), + ] { + if before > start { + *after = after.saturating_sub(removed.min(before - start)) + added; + } + } + } + let point_after = if view.point() == region.start { + mapped.start + } else { + mapped.end + }; + let point_after = buffer.replace_with_points(replaced, &inserted, view.point(), point_after); + view.set_point(point_after, buffer); + mapped.start = buffer.grapheme_boundary_at_or_before(mapped.start); + mapped.end = buffer.grapheme_boundary_at_or_before(mapped.end); + mapped +} + +fn outdent_chars(indentation: &str) -> usize { + let mut columns = 0; + let mut chars = 0; + for ch in indentation.chars() { + if columns >= 4 { + break; + } + columns += if ch == '\t' { 4 - columns % 4 } else { 1 }; + chars += 1; + } + chars +} + fn reload_buffer(buffer: &mut Buffer, view: &mut View) -> CommandOutcome { let line = buffer.line_for_char(view.point()); let column = buffer.display_column(view.point()); @@ -209,6 +301,113 @@ mod tests { assert!(buffer.is_dirty()); } + #[test] + fn tab_inserts_spaces_to_the_next_display_stop() { + for (source, point, expected, after) in [ + ("", 0, " ", 4), + ("a", 1, "a ", 4), + ("界", 1, "界 ", 3), + ("a\tb", 2, "a\t b", 6), + ("e\u{301}", 2, "e\u{301} ", 5), + (" ", 4, " ", 8), + ] { + let mut buffer = buffer_with_text("tab.txt", source); + let mut view = View::new(); + view.set_point(point, &buffer); + dispatch(Command::Indent, &mut buffer, &mut view); + assert_eq!(buffer.text(), expected); + assert_eq!(view.point(), after); + dispatch(Command::Undo, &mut buffer, &mut view); + assert_eq!(buffer.text(), source); + assert_eq!(view.point(), point); + assert!(!buffer.is_dirty()); + } + } + + #[test] + fn newline_carries_only_indentation_before_point_and_preserves_line_endings() { + for (source, point, expected, after) in [ + (" ab", 6, " ab\n ", 11), + ("\t ab", 3, "\t \n\t ab", 7), + (" code", 2, " \n code", 5), + (" a\r\n b", 3, " a\r\n \r\n b", 7), + ("a\r\n b", 6, "a\r\n b\r\n ", 10), + ("a\r\n b\n", 6, "a\r\n b\n \n", 9), + (" \u{301}ab", 4, " \u{301}ab\n", 5), + (" \u{301}ab", 0, "\n \u{301}ab", 1), + ] { + let mut buffer = buffer_with_text("newline.txt", source); + let mut view = View::new(); + view.set_point(point, &buffer); + dispatch(Command::InsertNewline, &mut buffer, &mut view); + assert_eq!(buffer.text(), expected, "{source:?} at {point}"); + assert_eq!(view.point(), after); + dispatch(Command::Undo, &mut buffer, &mut view); + assert_eq!(buffer.text(), source); + assert_eq!(view.point(), point); + dispatch(Command::Redo, &mut buffer, &mut view); + assert_eq!(buffer.text(), expected); + assert_eq!(view.point(), after); + } + } + + #[test] + fn outdent_removes_one_display_level_without_converting_remaining_tabs() { + for (source, point, expected, after) in [ + ("\t abc", 6, " abc", 5), + (" \tabc", 6, "abc", 3), + (" \tabc", 8, "\tabc", 4), + (" abc", 1, "abc", 0), + (" abc", 0, "abc", 0), + ] { + let mut buffer = buffer_with_text("outdent.txt", source); + let mut view = View::new(); + view.set_point(point, &buffer); + dispatch(Command::Outdent, &mut buffer, &mut view); + assert_eq!(buffer.text(), expected); + assert_eq!(view.point(), after); + dispatch(Command::Undo, &mut buffer, &mut view); + assert_eq!(buffer.text(), source); + assert_eq!(view.point(), point); + } + } + + #[test] + fn outdent_noop_preserves_graphemes_clean_state_and_history() { + for source in ["abc", " \u{301}abc", "\u{3000}abc", ""] { + let mut buffer = buffer_with_text("unchanged.txt", source); + let mut view = View::new(); + dispatch(Command::Outdent, &mut buffer, &mut view); + assert_eq!(buffer.text(), source); + assert!(!buffer.is_dirty()); + assert_eq!(buffer.undo(), None); + } + } + + #[test] + fn region_indent_excludes_end_at_line_start_and_is_one_undo_step() { + for ending in ["\n", "\r\n"] { + let source = format!("a{ending}b{ending}c{ending}"); + let mut buffer = buffer_with_text("region.txt", &source); + let mut view = View::new(); + let end = buffer.line_start_char(2); + view.set_point(end, &buffer); + let region = super::indent_region(&mut buffer, &mut view, 0..end, false); + let expected = format!(" a{ending} b{ending}c{ending}"); + assert_eq!(buffer.text(), expected); + assert_eq!(region, 0..end + 8); + assert_eq!(view.point(), end + 8); + dispatch(Command::Undo, &mut buffer, &mut view); + assert_eq!(buffer.text(), source); + assert_eq!(view.point(), end); + assert!(!buffer.is_dirty()); + assert_eq!(buffer.undo(), None); + dispatch(Command::Redo, &mut buffer, &mut view); + assert_eq!(buffer.text(), expected); + assert_eq!(view.point(), end + 8); + } + } + #[test] fn sequential_unicode_input_keeps_point_after_the_complete_grapheme() { let mut buffer = buffer_with_text("notes.txt", ""); diff --git a/src/input.rs b/src/input.rs index 23334d3..d492a7a 100644 --- a/src/input.rs +++ b/src/input.rs @@ -8,6 +8,7 @@ pub enum Key { Command(char), Enter, Tab, + BackTab, Escape, Backspace, Delete, @@ -43,6 +44,9 @@ pub fn key_from_event(event: KeyEvent) -> Key { KeyCode::Char(ch) if printable_char(ch, event.modifiers) => Key::Char(ch), KeyCode::Enter => Key::Enter, KeyCode::Tab if event.modifiers.is_empty() => Key::Tab, + KeyCode::BackTab if event.modifiers.difference(KeyModifiers::SHIFT).is_empty() => { + Key::BackTab + } KeyCode::Esc => Key::Escape, KeyCode::Backspace => Key::Backspace, KeyCode::Delete => Key::Delete, @@ -176,6 +180,23 @@ mod tests { ); } + #[test] + fn maps_shift_tab_without_accepting_unrelated_modifiers() { + for modifiers in [KeyModifiers::NONE, KeyModifiers::SHIFT] { + assert_eq!( + key_from_event(KeyEvent::new(KeyCode::BackTab, modifiers)), + Key::BackTab + ); + } + assert_eq!( + key_from_event(KeyEvent::new( + KeyCode::BackTab, + KeyModifiers::CONTROL | KeyModifiers::SHIFT + )), + Key::Unhandled + ); + } + #[test] fn maps_escape() { assert_eq!( diff --git a/src/keymap.rs b/src/keymap.rs index fd719d5..df9c66f 100644 --- a/src/keymap.rs +++ b/src/keymap.rs @@ -34,6 +34,8 @@ impl Keymap { } Key::Char(ch) => KeymapResult::Command(Command::Insert(ch)), Key::Enter => KeymapResult::Command(Command::InsertNewline), + Key::Tab => KeymapResult::Command(Command::Indent), + Key::BackTab => KeymapResult::Command(Command::Outdent), Key::Backspace => KeymapResult::Command(Command::DeleteBackward), Key::Delete | Key::Ctrl('d') => KeymapResult::Command(Command::DeleteForward), Key::Ctrl('k') => KeymapResult::Command(Command::KillLine),