diff --git a/babi/file.py b/babi/file.py index 7a0d353..fcf1901 100644 --- a/babi/file.py +++ b/babi/file.py @@ -608,6 +608,46 @@ def backspace(self, dim: Dim) -> None: self.buf[self.buf.y] = s[:self.buf.x - 1] + s[self.buf.x:] self.buf.left(dim) + @edit_action('backword text', final=False) + @clear_selection + def backword(self, dim: Dim) -> None: + line = self.buf[self.buf.y] + if self.buf.x == 0 and self.buf.y == 0: + pass + # backword at the end of the file does not change the contents + elif ( + self.buf.y == len(self.buf) - 1 and + # still allow backword if there are 2+ blank lines + self.buf[self.buf.y - 1] != '' + ): + self.buf.left(dim) + elif self.buf.x == 0: + y, victim = self.buf.y, self.buf.pop(self.buf.y) + self.buf.left(dim) + self.buf[y - 1] += victim + elif self.buf.x > 0 and line[:self.buf.x].isspace(): + while self.buf.x > 0: + s = self.buf[self.buf.y] + self.buf[self.buf.y] = s[:self.buf.x - 1] + s[self.buf.x:] + self.buf.left(dim) + else: + tp = line[self.buf.x - 1].isalnum() + # we keep track of whether we're deleting spaces + are_spaces = line[self.buf.x - 1].isspace() + while self.buf.x > 0 and tp == line[self.buf.x - 1].isalnum(): + s = self.buf[self.buf.y] + self.buf[self.buf.y] = s[:self.buf.x - 1] + s[self.buf.x:] + self.buf.left(dim) + + # if we were deleting spaces, this means we haven't deleted the + # word yet + tp = line[self.buf.x - 1].isalnum() + if are_spaces: + while self.buf.x > 0 and line[self.buf.x - 1].isalnum(): + s = self.buf[self.buf.y] + self.buf[self.buf.y] = s[:self.buf.x - 1] + s[self.buf.x:] + self.buf.left(dim) + @edit_action('delete text', final=False) @clear_selection def delete(self, dim: Dim) -> None: @@ -901,6 +941,7 @@ def reload(self, status: Status, dim: Dim) -> None: b'kDN3': alt_down, # editing b'KEY_BACKSPACE': backspace, + b'KEY_BACKWORD': backword, b'KEY_DC': delete, b'^M': enter, b'^I': tab, diff --git a/babi/screen.py b/babi/screen.py index e26c702..00e35dd 100644 --- a/babi/screen.py +++ b/babi/screen.py @@ -67,6 +67,7 @@ '\x1b[1;6H': b'kHOM6', # Shift + ^Home '\x1b[1;6F': b'kEND6', # Shift + ^End '\x1b[~': b'KEY_BTAB', # Shift + Tab + '\x1b(263)': b'KEY_BACKWORD', # M-Backspace } KEYNAME_REWRITE = { # windows-curses: numeric pad arrow keys diff --git a/tests/features/conftest.py b/tests/features/conftest.py index d1b0851..a6e54e4 100644 --- a/tests/features/conftest.py +++ b/tests/features/conftest.py @@ -268,6 +268,7 @@ def value(self) -> int: Key('^End', b'kEND5', 530), Key('^S-Up', b'kUP6', 567), Key('^S-Down', b'kDN6', 526), + Key('M-BSpace', b'KEY_BACKWORD', 504), Key('M-Up', b'kUP3', 564), Key('M-Down', b'kDN3', 523), Key('M-Right', b'kRIT3', 558), diff --git a/tests/features/text_editing_test.py b/tests/features/text_editing_test.py index dd571df..1f33623 100644 --- a/tests/features/text_editing_test.py +++ b/tests/features/text_editing_test.py @@ -79,6 +79,85 @@ def test_backspace_deletes_text(run, tmpdir, key): h.await_cursor_position(x=2, y=1) +def test_backword_at_beginning_of_file(run): + # same behavior as backspace + with run() as h, and_exit(h): + h.press('M-BSpace') + h.await_text_missing('unknown key') + h.assert_cursor_line_equal('') + h.await_text_missing('*') + + +def test_backword_joins_lines(run, tmpdir): + # same behavior as backspace + f = tmpdir.join('f') + f.write('foo\nbar\nbaz\n') + + with run(str(f)) as h, and_exit(h): + h.await_text('foo') + h.press('Down') + h.press('M-BSpace') + h.await_text('foobar') + h.await_text('f *') + h.await_cursor_position(x=3, y=1) + # pressing down should retain the X position + h.press('Down') + h.await_cursor_position(x=3, y=2) + + +def test_backword_at_end_of_file_still_allows_scrolling_down(run, tmpdir): + f = tmpdir.join('f') + f.write('hello world') + + with run(str(f)) as h, and_exit(h): + h.await_text('hello world') + h.press('Down') + h.press('M-BSpace') + h.press('Down') + h.await_cursor_position(x=0, y=2) + h.await_text_missing('*') + + +def test_backword_deletes_newline_at_end_of_file(run, tmpdir): + f = tmpdir.join('f') + f.write('foo\n\n') + + with run(str(f)) as h, and_exit(h): + h.press('^End') + h.press('M-BSpace') + h.press('^S') + + assert f.read() == 'foo\n' + + +def test_backword_deletes_text(run, tmpdir): + f = tmpdir.join('f') + f.write('ohai there') + + with run(str(f)) as h, and_exit(h): + h.await_text('ohai there') + for _ in range(3): + h.press('Right') + h.press('M-BSpace') + h.await_text('i') + h.await_text('f *') + h.await_cursor_position(x=0, y=1) + + +def test_backword_deletes_text_with_space_after(run, tmpdir): + f = tmpdir.join('f') + f.write('ohai there ') + + with run(str(f)) as h, and_exit(h): + h.await_text('ohai there') + for _ in range(13): + h.press('Right') + h.press('M-BSpace') + h.await_text('ohai') + h.await_text('f *') + h.await_cursor_position(x=5, y=1) + + def test_delete_at_end_of_file(run, tmpdir): with run() as h, and_exit(h): h.press('DC')