feat: support macOS modified backspace deletion
This commit is contained in:
@@ -119,13 +119,17 @@ For command-capable actions, canonical Ex names derive automatically from the ac
|
||||
Key notation follows [Herdr's readable style](https://herdr.dev/docs/configuration/#keybindings). Literal non-control Unicode characters are case-sensitive (`n`, `J`, `+`); named keys and modifiers are case-insensitive:
|
||||
|
||||
- Named keys: `esc`, `enter`, `backspace`, `delete`, `left`, `right`, `up`, `down`, `home`, `end`, `tab`, `space`.
|
||||
- Modifiers: `ctrl+a` through `ctrl+z`, `alt+a` through `alt+z` (`option+` is an alias for `alt+`), `ctrl+alt+a` through `ctrl+alt+z` (either modifier order), `shift+a` through `shift+z`, and `shift+tab`. `shift+j` and `J` address the same key.
|
||||
- Modifiers: `ctrl+a` through `ctrl+z`, `alt+a` through `alt+z` (`option+` is an alias for `alt+`), `ctrl+alt+a` through `ctrl+alt+z` (either modifier order), `shift+a` through `shift+z`, and `shift+tab`. `shift+j` and `J` address the same key. Modified Backspace accepts `alt+backspace` / `option+backspace` and `cmd+backspace` / `super+backspace`.
|
||||
- Named punctuation: `minus`, `comma`, `ampersand`, `plus`, `backtick`. Each addresses the same binding as its literal character.
|
||||
|
||||
`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. ASCII letters support a single modifier or combined Ctrl+Alt/Option; Shift+Tab is also supported. Cmd/Super, other combined modifiers, other modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
|
||||
`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. ASCII letters support a single modifier or combined Ctrl+Alt/Option; Shift+Tab and the modified Backspace keys above are also supported. Cmd/Super character keys, other combined modifiers, other modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
|
||||
|
||||
Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i`/`tab` to `config.core.jump_forward`. Insert-mode Tab inserts literal text outside snippets; inside one it moves to the next argument. Override or disable these keys with the ordinary per-mode binding interface; jump actions also appear in the command palette.
|
||||
|
||||
`config.core.backspace_word` and `config.core.backspace_line` are typed actions eligible in Insert, Command, and Search. Any platform can explicitly bind them to supported keys; only macOS defaults bind Option+Backspace and Command+Backspace respectively. For example, `config.bind "ctrl+w", config.core.backspace_word, mode: config.modes.insert`. Word deletion follows Unicode word boundaries after skipping trailing whitespace; line deletion removes the caret-to-logical-line-start prefix, not the suffix or newline. Active snippets confine both actions to their argument. macOS prompt and filter queries also recognize these modified Backspace keys.
|
||||
|
||||
Configuration accepts the Option/Alt and Command/Super Backspace names on every platform, but terminal modifier-event translation for these keys is macOS-only. The terminal must deliver Option or Super (Command in Kitty CSI-u); Meta is not Command. Some terminals intercept Command+Backspace or encode it as Ctrl+u instead, which does not invoke the default line-deletion action. Legacy ESC+DEL supports Option+Backspace without keyboard protocol negotiation.
|
||||
|
||||
Normal mode uses literal `_` and `|` for above/below and left/right splits, and `alt+h/j/k/l` for directional pane focus. Bind the produced punctuation rather than a physical Shift combination. Close actions have no default binding; for example, `config.bind "leader x", config.core.pane.close`. See [panes and splits](editing.md#panes-and-splits) for shared-buffer and unsaved-change policies.
|
||||
|
||||
Normal mode binds `ctrl+option+h/l` (`ctrl+alt+h/l`) to previous/next tab. Tab actions have no default open/close binding; for example, `config.bind "leader t", config.core.tab.new` and `config.bind "leader x", config.core.tab.close`. See [tabs](editing.md#tabs) for layout preservation and close guards.
|
||||
|
||||
@@ -38,11 +38,16 @@ Default controls and editing behavior. For custom bindings, see [configuration](
|
||||
| Insert, active snippet | Type to replace the highlighted argument and its mirrors; Tab / Shift+Tab moves next/previous |
|
||||
| Command | Enter executes; Backspace edits; Esc cancels |
|
||||
| Search | Type a literal query; Enter finds the next match; Backspace edits; Esc cancels |
|
||||
| Insert, Command, Search (macOS) | Option+Backspace deletes backward by word; Command+Backspace deletes backward to the logical line start |
|
||||
|
||||
`J` removes the next line's leading spaces/tabs and preserves existing trailing whitespace. It normally adds one space, two after `.`, `?`, or `!` (Vim's default), and none before `)` or when either side is empty. The cursor lands at the join point; the join is one undo action. On the last line, `J` does nothing.
|
||||
|
||||
`gt`/`gb` move every caret to the first nonblank grapheme on the first/last row, including the empty final row after a trailing newline. Duplicate carets and overlapping Visual selections normalize as usual. Both Visual shapes retain their anchors and extend to the destination. These motions do not edit text or add undo history; count prefixes are consumed rather than interpreted as line numbers. `g` waits for its second key, and Escape cancels only the pending prefix. In Insert and command/search prompts, `gt`/`gb` remain literal text.
|
||||
|
||||
On macOS, these modified Backspace defaults also edit prompt and filter queries, including file lists, the command palette, and revision selectors. Option+Backspace uses Unicode word boundaries, first skipping trailing whitespace; Command+Backspace removes only the prefix before the caret on the current logical line, preserving its suffix and newline. Buffer deletion runs at every caret, merges overlapping ranges, shares the Insert undo group, and dot repeats the boundary operation rather than the old deleted length. Within an active snippet, both operations stay confined to the current argument and update its mirrors. Other platforms retain their existing defaults.
|
||||
|
||||
The terminal must deliver the modifier: Option may use legacy ESC+DEL, and Kitty CSI-u represents Command as **Super**, not Meta. Some terminals intercept Command+Backspace or encode it as Ctrl+u; that is not the same key and does not invoke this default line deletion. Vamp does not negotiate an enhanced keyboard protocol or support generic Command-modified character keys.
|
||||
|
||||
## Manual LSP completion
|
||||
|
||||
With an enabled server that provides completion, Ctrl+n in Insert requests candidates at the **primary caret only**. The first candidate is inserted immediately and a small list appears near the caret; Ctrl+n or Ctrl+j cycles forward, Ctrl+k backward, with wraparound. There is no acceptance step. The **Original text** row restores the document and carets from before the result was shown, including any additional edits. Completion never broadcasts a candidate to secondary carets: they are preserved and translated through each chosen edit.
|
||||
|
||||
+52
-4
@@ -1,4 +1,4 @@
|
||||
use crate::editor::Input;
|
||||
use crate::editor::{BackspaceKind, Input};
|
||||
use crate::files::{FilePicker, FilePresentation, FileView};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
@@ -506,9 +506,11 @@ impl DiffState {
|
||||
Input::Home => self.selected = 0,
|
||||
Input::End => self.selected = self.visible.len().saturating_sub(1),
|
||||
Input::Enter if !self.loading => self.choose(),
|
||||
Input::Backspace => {
|
||||
if let Some((byte, _)) = self.query.grapheme_indices(true).next_back() {
|
||||
self.query.truncate(byte);
|
||||
input if BackspaceKind::from_input(&input).is_some() => {
|
||||
if BackspaceKind::from_input(&input)
|
||||
.expect("guard checked backspace input")
|
||||
.truncate(&mut self.query)
|
||||
{
|
||||
self.refilter();
|
||||
}
|
||||
}
|
||||
@@ -2113,6 +2115,52 @@ mod tests {
|
||||
assert_eq!(editor.diff().unwrap().selected, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_word_and_line_deletion_preserve_source_in_both_stages() {
|
||||
for after in [false, true] {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("source text".into())])
|
||||
.unwrap();
|
||||
let source = editor.context().text;
|
||||
let revision = editor.text_revision();
|
||||
let carets = editor.carets();
|
||||
open(&mut editor);
|
||||
if after {
|
||||
editor.handle(Input::Enter);
|
||||
}
|
||||
let phase = if after { Phase::After } else { Phase::Before };
|
||||
text(&mut editor, "FIX missing");
|
||||
assert_eq!(editor.diff().unwrap().entries().len(), 1);
|
||||
editor.handle(Input::AltBackspace);
|
||||
assert_eq!(editor.diff().unwrap().query, "FIX ");
|
||||
assert_eq!(editor.diff().unwrap().entries().len(), 2);
|
||||
editor.handle(Input::SuperBackspace);
|
||||
assert!(editor.diff().unwrap().query.is_empty());
|
||||
assert_eq!(
|
||||
editor
|
||||
.diff()
|
||||
.unwrap()
|
||||
.entries()
|
||||
.map(|entry| entry.label.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
"Working checkout",
|
||||
"abcdef123",
|
||||
"fedcba321",
|
||||
"Load history from origin"
|
||||
]
|
||||
);
|
||||
for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] {
|
||||
editor.handle(input);
|
||||
}
|
||||
assert_eq!(editor.diff().unwrap().phase, phase);
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_preserves_after_step_query_category_and_selection_then_compares() {
|
||||
let mut editor = editor();
|
||||
|
||||
+346
-40
@@ -34,6 +34,8 @@ pub enum Input {
|
||||
Escape,
|
||||
Enter,
|
||||
Backspace,
|
||||
AltBackspace,
|
||||
SuperBackspace,
|
||||
Delete,
|
||||
BackTab,
|
||||
Left,
|
||||
@@ -44,6 +46,46 @@ pub enum Input {
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum BackspaceKind {
|
||||
Grapheme,
|
||||
Word,
|
||||
Line,
|
||||
}
|
||||
|
||||
impl BackspaceKind {
|
||||
pub(crate) fn from_input(input: &Input) -> Option<Self> {
|
||||
match input {
|
||||
Input::Backspace => Some(Self::Grapheme),
|
||||
Input::AltBackspace => Some(Self::Word),
|
||||
Input::SuperBackspace => Some(Self::Line),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn start(self, prefix: &str) -> usize {
|
||||
match self {
|
||||
Self::Grapheme => prefix
|
||||
.grapheme_indices(true)
|
||||
.next_back()
|
||||
.map_or(0, |(byte, _)| byte),
|
||||
Self::Word => prefix
|
||||
.split_word_bound_indices()
|
||||
.rev()
|
||||
.find(|(_, word)| !word.chars().all(char::is_whitespace))
|
||||
.map_or(0, |(byte, _)| byte),
|
||||
Self::Line => prefix.rfind('\n').map_or(0, |byte| byte + 1),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn truncate(self, text: &mut String) -> bool {
|
||||
let start = self.start(text);
|
||||
let changed = start != text.len();
|
||||
text.truncate(start);
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum Mode {
|
||||
Normal,
|
||||
@@ -295,7 +337,7 @@ enum InsertStep {
|
||||
Bulk(String),
|
||||
Move(Action, usize),
|
||||
Delete(Vec<bool>),
|
||||
Backspace(Vec<bool>),
|
||||
Backspace(Vec<bool>, BackspaceKind),
|
||||
Completion {
|
||||
primary_edit: usize,
|
||||
edits: Vec<RelativeCompletionEdit>,
|
||||
@@ -583,6 +625,16 @@ core_actions![
|
||||
Action::Backspace,
|
||||
&[Mode::Insert, Mode::Command, Mode::Search],
|
||||
),
|
||||
(
|
||||
"core.backspace_word",
|
||||
Action::BackspaceWord,
|
||||
&[Mode::Insert, Mode::Command, Mode::Search],
|
||||
),
|
||||
(
|
||||
"core.backspace_line",
|
||||
Action::BackspaceLine,
|
||||
&[Mode::Insert, Mode::Command, Mode::Search],
|
||||
),
|
||||
(
|
||||
"core.submit",
|
||||
Action::Submit,
|
||||
@@ -785,10 +837,19 @@ impl Action {
|
||||
| Self::BufferDelete
|
||||
| Self::BufferForceDelete
|
||||
| Self::LspCompletion
|
||||
) || (self == Self::Backspace && mode == Mode::Insert)
|
||||
) || (self.backspace_kind().is_some() && mode == Mode::Insert)
|
||||
|| (self == Self::SnippetNext && mode == Mode::Insert)
|
||||
}
|
||||
|
||||
fn backspace_kind(self) -> Option<BackspaceKind> {
|
||||
match self {
|
||||
Self::Backspace => Some(BackspaceKind::Grapheme),
|
||||
Self::BackspaceWord => Some(BackspaceKind::Word),
|
||||
Self::BackspaceLine => Some(BackspaceKind::Line),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn named(mode: Mode, name: &str) -> Option<Self> {
|
||||
CORE_ACTIONS
|
||||
.iter()
|
||||
@@ -871,6 +932,16 @@ fn parse_key(key: &str) -> Result<Input, String> {
|
||||
(Some(modifier), Input::Char('\t')) if modifier.eq_ignore_ascii_case("shift") => {
|
||||
Ok(Input::BackTab)
|
||||
}
|
||||
(Some(modifier), Input::Backspace)
|
||||
if modifier.eq_ignore_ascii_case("alt") || modifier.eq_ignore_ascii_case("option") =>
|
||||
{
|
||||
Ok(Input::AltBackspace)
|
||||
}
|
||||
(Some(modifier), Input::Backspace)
|
||||
if modifier.eq_ignore_ascii_case("cmd") || modifier.eq_ignore_ascii_case("super") =>
|
||||
{
|
||||
Ok(Input::SuperBackspace)
|
||||
}
|
||||
(Some(modifier), Input::Char(ch)) if ch.is_ascii_alphabetic() => {
|
||||
if modifier.eq_ignore_ascii_case("ctrl") {
|
||||
Ok(control_key(ch))
|
||||
@@ -916,6 +987,8 @@ fn append_key(display: &mut String, input: &Input) {
|
||||
Input::Escape => display.push_str("Esc"),
|
||||
Input::Enter => display.push_str("Enter"),
|
||||
Input::Backspace => display.push_str("Backspace"),
|
||||
Input::AltBackspace => display.push_str("Alt+Backspace"),
|
||||
Input::SuperBackspace => display.push_str("Cmd+Backspace"),
|
||||
Input::Delete => display.push_str("Delete"),
|
||||
Input::Left => display.push_str("Left"),
|
||||
Input::Right => display.push_str("Right"),
|
||||
@@ -1088,6 +1161,16 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
|
||||
.or_default()
|
||||
.insert(key, Binding::Action(action));
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
for (key, action) in [
|
||||
(Input::AltBackspace, BackspaceWord),
|
||||
(Input::SuperBackspace, BackspaceLine),
|
||||
] {
|
||||
bindings
|
||||
.entry(mode)
|
||||
.or_default()
|
||||
.insert(key, Binding::Action(action));
|
||||
}
|
||||
}
|
||||
for mode in [
|
||||
Mode::Normal,
|
||||
@@ -3857,7 +3940,7 @@ impl Editor {
|
||||
}
|
||||
|
||||
// Only the primary argument and its declared mirrors participate in these edits.
|
||||
fn edit_snippet(&mut self, text: &str, deletion: Option<bool>) -> bool {
|
||||
fn edit_snippet(&mut self, text: &str, deletion: Option<Action>) -> bool {
|
||||
let Some(mut state) = self.snippet.take() else {
|
||||
return false;
|
||||
};
|
||||
@@ -3873,13 +3956,9 @@ impl Editor {
|
||||
let position = caret - argument.start;
|
||||
let (start, end) = if state.selected {
|
||||
(0, value.len())
|
||||
} else if let Some(backwards) = deletion {
|
||||
if backwards {
|
||||
let start = value[..position]
|
||||
.grapheme_indices(true)
|
||||
.next_back()
|
||||
.map_or(position, |(byte, _)| byte);
|
||||
(start, position)
|
||||
} else if let Some(action) = deletion {
|
||||
if let Some(kind) = action.backspace_kind() {
|
||||
(kind.start(&value[..position]), position)
|
||||
} else {
|
||||
let end = value[position..]
|
||||
.graphemes(true)
|
||||
@@ -4982,9 +5061,11 @@ impl Editor {
|
||||
}
|
||||
self.active.view.mode = Mode::Command;
|
||||
}
|
||||
Input::Backspace => {
|
||||
if let Some((byte, _)) = palette.query.grapheme_indices(true).next_back() {
|
||||
palette.query.truncate(byte);
|
||||
input if BackspaceKind::from_input(&input).is_some() => {
|
||||
if BackspaceKind::from_input(&input)
|
||||
.expect("backspace input")
|
||||
.truncate(&mut palette.query)
|
||||
{
|
||||
self.refresh_palette();
|
||||
}
|
||||
}
|
||||
@@ -5568,6 +5649,8 @@ impl Editor {
|
||||
| Action::SnippetPrevious
|
||||
| Action::Newline
|
||||
| Action::Backspace
|
||||
| Action::BackspaceWord
|
||||
| Action::BackspaceLine
|
||||
| Action::Delete
|
||||
)
|
||||
{
|
||||
@@ -5707,11 +5790,11 @@ impl Editor {
|
||||
);
|
||||
}
|
||||
Action::Delete => {
|
||||
if self.active.view.mode == Mode::Insert && self.edit_snippet("", Some(false)) {
|
||||
if self.active.view.mode == Mode::Insert && self.edit_snippet("", Some(action)) {
|
||||
return None;
|
||||
}
|
||||
let before = (self.active.view.mode != Mode::Insert).then(|| self.snapshot());
|
||||
let mask = self.deletion_mask(false);
|
||||
let mask = self.deletion_mask(None);
|
||||
self.delete_forward(&mask);
|
||||
if let Some(before) = before {
|
||||
self.record_native(
|
||||
@@ -5789,24 +5872,19 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
Action::Newline => self.type_native("\n"),
|
||||
Action::Backspace => {
|
||||
Action::Backspace | Action::BackspaceWord | Action::BackspaceLine => {
|
||||
let kind = action.backspace_kind().expect("backspace action");
|
||||
if self.active.view.mode == Mode::Insert {
|
||||
if self.edit_snippet("", Some(true)) {
|
||||
if self.edit_snippet("", Some(action)) {
|
||||
return None;
|
||||
}
|
||||
let mask = self.deletion_mask(true);
|
||||
self.backspace(&mask);
|
||||
let mask = self.deletion_mask(Some(kind));
|
||||
self.backspace(&mask, kind);
|
||||
if mask.iter().any(|active| *active) {
|
||||
self.push_insert_step(InsertStep::Backspace(mask));
|
||||
self.push_insert_step(InsertStep::Backspace(mask, kind));
|
||||
}
|
||||
} else if let Some((byte, _)) = self
|
||||
.active
|
||||
.view
|
||||
.command_line
|
||||
.grapheme_indices(true)
|
||||
.next_back()
|
||||
{
|
||||
self.active.view.command_line.truncate(byte);
|
||||
} else {
|
||||
kind.truncate(&mut self.active.view.command_line);
|
||||
}
|
||||
}
|
||||
Action::Submit => {
|
||||
@@ -7685,7 +7763,7 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
fn deletion_mask(&self, backwards: bool) -> Vec<bool> {
|
||||
fn deletion_mask(&self, backwards: Option<BackspaceKind>) -> Vec<bool> {
|
||||
let mut mask: Vec<_> = self
|
||||
.active
|
||||
.view
|
||||
@@ -7693,8 +7771,8 @@ impl Editor {
|
||||
.iter()
|
||||
.map(|target| {
|
||||
let cursor = target.active;
|
||||
if backwards {
|
||||
cursor.row != 0 || cursor.column != 0
|
||||
if let Some(kind) = backwards {
|
||||
cursor.column != 0 || (kind != BackspaceKind::Line && cursor.row != 0)
|
||||
} else {
|
||||
cursor.column < grapheme_count(&self.active.lines[cursor.row])
|
||||
|| (self.active.view.mode == Mode::Insert
|
||||
@@ -7886,16 +7964,16 @@ impl Editor {
|
||||
}
|
||||
InsertStep::Bulk(text) => self.insert_text(text),
|
||||
InsertStep::Move(action, count) => self.move_targets(*action, *count),
|
||||
InsertStep::Delete(mask) | InsertStep::Backspace(mask) => {
|
||||
InsertStep::Delete(mask) | InsertStep::Backspace(mask, _) => {
|
||||
// Earlier native steps may have merged carets. Refuse the entire transaction
|
||||
// rather than pairing a recorded partial deletion with different targets.
|
||||
if mask.len() != 1 && mask.len() != self.active.view.targets.len() {
|
||||
return Err("Repeat deletion count does not match target count");
|
||||
}
|
||||
if matches!(step, InsertStep::Delete(_)) {
|
||||
self.delete_forward(mask);
|
||||
if let InsertStep::Backspace(_, kind) = step {
|
||||
self.backspace(mask, *kind);
|
||||
} else {
|
||||
self.backspace(mask);
|
||||
self.delete_forward(mask);
|
||||
}
|
||||
}
|
||||
InsertStep::Completion {
|
||||
@@ -8555,9 +8633,25 @@ impl Editor {
|
||||
self.apply_edits(edits);
|
||||
}
|
||||
|
||||
fn backspace(&mut self, mask: &[bool]) {
|
||||
fn backspace_destination(&self, mut cursor: Cursor, kind: BackspaceKind) -> Cursor {
|
||||
if kind == BackspaceKind::Grapheme {
|
||||
return self.previous_boundary(cursor);
|
||||
}
|
||||
let mut prefix = &self.active.lines[cursor.row]
|
||||
[..grapheme_byte(&self.active.lines[cursor.row], cursor.column)];
|
||||
if kind == BackspaceKind::Word {
|
||||
while cursor.row > 0 && prefix.chars().all(char::is_whitespace) {
|
||||
cursor.row -= 1;
|
||||
prefix = &self.active.lines[cursor.row];
|
||||
}
|
||||
}
|
||||
cursor.column = prefix[..kind.start(prefix)].graphemes(true).count();
|
||||
cursor
|
||||
}
|
||||
|
||||
fn backspace(&mut self, mask: &[bool], kind: BackspaceKind) {
|
||||
let offsets = line_offsets(&self.active.lines);
|
||||
let edits = self
|
||||
let mut edits: Vec<_> = self
|
||||
.active
|
||||
.view
|
||||
.targets
|
||||
@@ -8568,11 +8662,11 @@ impl Editor {
|
||||
return None;
|
||||
}
|
||||
let cursor = item.active;
|
||||
if cursor.column == 0 && cursor.row == 0 {
|
||||
let destination = self.backspace_destination(cursor, kind);
|
||||
if destination == cursor {
|
||||
return None;
|
||||
}
|
||||
let start =
|
||||
cursor_offset(&self.active.lines, &offsets, self.previous_boundary(cursor));
|
||||
let start = cursor_offset(&self.active.lines, &offsets, destination);
|
||||
let end = cursor_offset(&self.active.lines, &offsets, cursor);
|
||||
Some(Edit {
|
||||
target: Some(target),
|
||||
@@ -8584,6 +8678,20 @@ impl Editor {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Backward ranges follow document-ordered carets; overlapping ranges delete once.
|
||||
let mut group = 0;
|
||||
for next in 1..=edits.len() {
|
||||
if next < edits.len() && edits[next].start < edits[next - 1].end {
|
||||
continue;
|
||||
}
|
||||
let start = edits[group].start;
|
||||
let end = edits[next - 1].end;
|
||||
for edit in &mut edits[group..next] {
|
||||
edit.start = start;
|
||||
edit.end = end;
|
||||
}
|
||||
group = next;
|
||||
}
|
||||
self.apply_edits(edits);
|
||||
}
|
||||
}
|
||||
@@ -16001,6 +16109,163 @@ mod tests {
|
||||
assert_eq!(editor.palette().unwrap().query, "cor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_backspace_preserves_suffixes_and_uses_unicode_boundaries() {
|
||||
for (key, action, prefix, suffix, expected) in [
|
||||
(
|
||||
"option+backspace",
|
||||
"core.backspace_word",
|
||||
"alpha βeta ",
|
||||
"SUFFIX\nlast",
|
||||
"alpha SUFFIX\nlast",
|
||||
),
|
||||
(
|
||||
"alt+backspace",
|
||||
"core.backspace_word",
|
||||
"foo don't",
|
||||
"!",
|
||||
"foo !",
|
||||
),
|
||||
(
|
||||
"alt+backspace",
|
||||
"core.backspace_word",
|
||||
"alpha e\u{301}",
|
||||
" tail",
|
||||
"alpha tail",
|
||||
),
|
||||
(
|
||||
"alt+backspace",
|
||||
"core.backspace_word",
|
||||
"alpha 👩🚀",
|
||||
" tail",
|
||||
"alpha tail",
|
||||
),
|
||||
(
|
||||
"alt+backspace",
|
||||
"core.backspace_word",
|
||||
"one word\n ",
|
||||
"tail",
|
||||
"one tail",
|
||||
),
|
||||
(
|
||||
"cmd+backspace",
|
||||
"core.backspace_line",
|
||||
"first\n βeta ",
|
||||
"SUFFIX\nlast",
|
||||
"first\nSUFFIX\nlast",
|
||||
),
|
||||
(
|
||||
"super+backspace",
|
||||
"core.backspace_line",
|
||||
"first\n",
|
||||
"tail",
|
||||
"first\ntail",
|
||||
),
|
||||
("alt+backspace", "core.backspace_word", "", "tail", "tail"),
|
||||
] {
|
||||
let mut editor = editor();
|
||||
editor.bind("insert", key, Some(action.into())).unwrap();
|
||||
let original = format!("{prefix}{suffix}");
|
||||
editor
|
||||
.apply(vec![Effect::Replace(original.clone())])
|
||||
.unwrap();
|
||||
let row = prefix.bytes().filter(|&byte| byte == b'\n').count();
|
||||
let column = prefix.rsplit('\n').next().unwrap().graphemes(true).count();
|
||||
set_carets(&mut editor, &[(row, column)], 0);
|
||||
let undo = editor.active.undo.len();
|
||||
let revision = editor.text_revision();
|
||||
editor.handle(Input::Char('i'));
|
||||
editor.handle(parse_key(key).unwrap());
|
||||
assert_eq!(editor.context().text, expected, "{prefix:?}");
|
||||
editor.handle(Input::Escape);
|
||||
if original == expected {
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
assert_eq!(editor.active.undo.len(), undo);
|
||||
} else {
|
||||
assert_eq!(editor.active.undo.len(), undo + 1);
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, original);
|
||||
assert_eq!(editor.cursor(), Cursor { row, column });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_backspace_merges_overlapping_carets_and_repeats_new_boundaries() {
|
||||
for (key, action, expected) in [
|
||||
(
|
||||
"alt+backspace",
|
||||
"core.backspace_word",
|
||||
"foo tail\nkeep row",
|
||||
),
|
||||
("cmd+backspace", "core.backspace_line", " tail\n row"),
|
||||
] {
|
||||
let mut editor = editor();
|
||||
editor.bind("insert", key, Some(action.into())).unwrap();
|
||||
let original = "foo elephant tail\nkeep second row";
|
||||
editor
|
||||
.apply(vec![Effect::Replace(original.into())])
|
||||
.unwrap();
|
||||
set_carets(&mut editor, &[(0, 5), (0, 12), (1, 11)], 1);
|
||||
let carets = editor.carets();
|
||||
editor.handle(Input::Char('i'));
|
||||
editor.handle(parse_key(key).unwrap());
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, expected);
|
||||
assert_eq!(editor.selection_count(), 2);
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, original);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
|
||||
let next = "foo hippopotamus tail\nkeep longer row";
|
||||
editor.apply(vec![Effect::Replace(next.into())]).unwrap();
|
||||
set_carets(&mut editor, &[(0, 5), (0, 16), (1, 11)], 1);
|
||||
editor.handle(Input::Char('.'));
|
||||
assert_eq!(editor.context().text, expected);
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, next);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_backspace_queries_never_edit_the_underlying_buffer() {
|
||||
for opening in [Input::Char(':'), Input::Char('/'), Input::Ctrl('p')] {
|
||||
let mut editor = editor();
|
||||
for mode in ["command", "search"] {
|
||||
editor
|
||||
.bind(mode, "alt+backspace", Some("core.backspace_word".into()))
|
||||
.unwrap();
|
||||
editor
|
||||
.bind(mode, "cmd+backspace", Some("core.backspace_line".into()))
|
||||
.unwrap();
|
||||
}
|
||||
editor
|
||||
.apply(vec![Effect::Replace("buffer text".into())])
|
||||
.unwrap();
|
||||
let revision = editor.text_revision();
|
||||
let carets = editor.carets();
|
||||
editor.handle(opening);
|
||||
type_text(&mut editor, "alpha βeta ");
|
||||
editor.handle(Input::AltBackspace);
|
||||
assert_eq!(
|
||||
editor
|
||||
.palette()
|
||||
.map_or(editor.command_line(), |palette| palette.query.as_str()),
|
||||
"alpha "
|
||||
);
|
||||
editor.handle(Input::SuperBackspace);
|
||||
assert_eq!(
|
||||
editor
|
||||
.palette()
|
||||
.map_or(editor.command_line(), |palette| palette.query.as_str()),
|
||||
""
|
||||
);
|
||||
assert_eq!(editor.context().text, "buffer text");
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_leaf_search_opens_direct_match_and_directory_search_enters_browse() {
|
||||
let directory = Directory::new();
|
||||
@@ -21291,6 +21556,47 @@ mod tests {
|
||||
assert!(editor.snippet_selections().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_backspace_snippets_stay_inside_arguments_and_update_mirrors() {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.bind(
|
||||
"insert",
|
||||
"alt+backspace",
|
||||
Some("core.backspace_word".into()),
|
||||
)
|
||||
.unwrap();
|
||||
editor
|
||||
.bind(
|
||||
"insert",
|
||||
"cmd+backspace",
|
||||
Some("core.backspace_line".into()),
|
||||
)
|
||||
.unwrap();
|
||||
editor.handle(Input::Char('i'));
|
||||
editor
|
||||
.show_completion(vec![snippet_item(
|
||||
(0, 0),
|
||||
(0, 0),
|
||||
"f(one, one)",
|
||||
vec![(1, vec![2..5, 7..10]), (0, vec![11..11])],
|
||||
)])
|
||||
.unwrap();
|
||||
type_text(&mut editor, "alpha βeta");
|
||||
editor.handle(Input::AltBackspace);
|
||||
assert_eq!(editor.context().text, "f(alpha , alpha )");
|
||||
editor.handle(Input::SuperBackspace);
|
||||
assert_eq!(editor.context().text, "f(, )");
|
||||
editor.handle(Input::SuperBackspace);
|
||||
editor.handle(Input::AltBackspace);
|
||||
assert_eq!(editor.context().text, "f(, )");
|
||||
type_text(&mut editor, "keep");
|
||||
assert_eq!(editor.context().text, "f(keep, keep)");
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippet_nested_mirrors_update_parents_and_replacing_parent_retires_children() {
|
||||
let mut editor = editor();
|
||||
|
||||
+52
-14
@@ -3,7 +3,6 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum FilePresentation {
|
||||
@@ -385,10 +384,20 @@ impl FilePicker {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_filter_input(&mut self, input: &crate::editor::Input) -> bool {
|
||||
use crate::editor::Input;
|
||||
use crate::editor::{BackspaceKind, Input};
|
||||
match input {
|
||||
Input::Ctrl('u') => self.clear_filter(),
|
||||
Input::Backspace if self.view == FileView::List || self.searching => self.backspace(),
|
||||
input
|
||||
if BackspaceKind::from_input(input).is_some()
|
||||
&& (self.view == FileView::List || self.searching) =>
|
||||
{
|
||||
if BackspaceKind::from_input(input)
|
||||
.expect("guard checked backspace input")
|
||||
.truncate(&mut self.query)
|
||||
{
|
||||
self.refilter();
|
||||
}
|
||||
}
|
||||
Input::Char('i') if self.view == FileView::Tree && !self.searching => {
|
||||
self.start_search()
|
||||
}
|
||||
@@ -604,13 +613,6 @@ impl FilePicker {
|
||||
self.clear_error();
|
||||
}
|
||||
|
||||
pub(crate) fn backspace(&mut self) {
|
||||
if let Some((byte, _)) = self.query.grapheme_indices(true).next_back() {
|
||||
self.query.truncate(byte);
|
||||
self.refilter();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn navigate(&mut self, forward: bool) {
|
||||
let count = self.rows.len();
|
||||
self.selected = if count == 0 {
|
||||
@@ -932,11 +934,11 @@ mod tests {
|
||||
picker.refilter();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("a space 界[e\u{301}].txt")]);
|
||||
picker.query = "e\u{301}👩💻".into();
|
||||
picker.backspace();
|
||||
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||
assert_eq!(picker.query, "e\u{301}");
|
||||
picker.backspace();
|
||||
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||
assert_eq!(picker.query, "");
|
||||
picker.backspace();
|
||||
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||
assert_eq!(picker.entries().len(), 3);
|
||||
picker.query = "no match".into();
|
||||
picker.refilter();
|
||||
@@ -947,12 +949,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_and_line_deletion_refilter_only_editable_queries() {
|
||||
use crate::editor::Input;
|
||||
|
||||
for view in [FileView::List, FileView::Tree] {
|
||||
let mut picker = FilePicker::from_paths(
|
||||
PathBuf::new(),
|
||||
["alpha one.txt", "alpha two.txt", "beta.txt"]
|
||||
.map(PathBuf::from)
|
||||
.into_iter()
|
||||
.enumerate(),
|
||||
);
|
||||
picker.set_view(view);
|
||||
picker.query = "alpha missing".into();
|
||||
picker.refilter();
|
||||
assert_eq!(picker.entries().len(), 0);
|
||||
if view == FileView::Tree {
|
||||
for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] {
|
||||
assert!(!picker.handle_filter_input(&input));
|
||||
assert_eq!(picker.query, "alpha missing");
|
||||
}
|
||||
picker.start_search();
|
||||
}
|
||||
assert!(picker.handle_filter_input(&Input::AltBackspace));
|
||||
assert_eq!(picker.query, "alpha ");
|
||||
assert_eq!(picker.entries().len(), 2);
|
||||
assert!(picker.handle_filter_input(&Input::SuperBackspace));
|
||||
assert_eq!(picker.query, "");
|
||||
assert_eq!(picker.entries().len(), 3);
|
||||
for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] {
|
||||
assert!(picker.handle_filter_input(&input));
|
||||
assert_eq!(picker.entries().len(), 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_scan_refresh_and_failure_preserve_reachable_state() {
|
||||
let directory = Directory::new();
|
||||
let mut picker = directory.scan();
|
||||
picker.navigate(false);
|
||||
picker.backspace();
|
||||
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||
assert_eq!(picker.entries().len(), 0);
|
||||
directory.put("new.txt", "text");
|
||||
picker.query = "new".into();
|
||||
|
||||
+61
@@ -882,6 +882,14 @@ fn typing_ready(
|
||||
}
|
||||
|
||||
fn translate(key: KeyEvent) -> Option<Input> {
|
||||
#[cfg(target_os = "macos")]
|
||||
if key.code == KeyCode::Backspace {
|
||||
match key.modifiers {
|
||||
KeyModifiers::ALT => return Some(Input::AltBackspace),
|
||||
KeyModifiers::SUPER => return Some(Input::SuperBackspace),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META)
|
||||
@@ -10025,6 +10033,59 @@ end
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_modified_backspace_edits_words_and_line_prefix_from_terminal_events() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.file("input.txt", "before\nalpha βeta SUFFIX\nlast");
|
||||
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Home);
|
||||
session.handle(Input::Char('i'));
|
||||
for _ in 0..13 {
|
||||
session.handle(Input::Right);
|
||||
}
|
||||
session.handle(translate(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)).unwrap());
|
||||
assert_eq!(session.editor().lines(), ["before", "alpha SUFFIX", "last"]);
|
||||
session.handle(translate(KeyEvent::new(KeyCode::Backspace, KeyModifiers::SUPER)).unwrap());
|
||||
assert_eq!(session.editor().lines(), ["before", "SUFFIX", "last"]);
|
||||
|
||||
for modifiers in [
|
||||
KeyModifiers::META,
|
||||
KeyModifiers::HYPER,
|
||||
KeyModifiers::ALT | KeyModifiers::CONTROL,
|
||||
KeyModifiers::ALT | KeyModifiers::SHIFT,
|
||||
KeyModifiers::SUPER | KeyModifiers::CONTROL,
|
||||
KeyModifiers::SUPER | KeyModifiers::SHIFT,
|
||||
KeyModifiers::SUPER | KeyModifiers::ALT,
|
||||
KeyModifiers::SUPER | KeyModifiers::META,
|
||||
] {
|
||||
assert_eq!(
|
||||
translate(KeyEvent::new(KeyCode::Backspace, modifiers)),
|
||||
None
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
translate(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn modified_backspace_keeps_non_macos_terminal_defaults() {
|
||||
for modifiers in [KeyModifiers::ALT, KeyModifiers::SUPER] {
|
||||
assert_eq!(
|
||||
translate(KeyEvent::new(KeyCode::Backspace, modifiers)),
|
||||
None
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
translate(KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL)),
|
||||
Some(Input::Backspace)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_routes_to_floating_and_unfocused_docked_files_without_changing_selection() {
|
||||
let directory = Directory::new();
|
||||
|
||||
Reference in New Issue
Block a user