feat: support macOS modified backspace deletion
This commit is contained in:
+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