shift + movement = selection

This commit is contained in:
Anthony Sottile
2019-12-31 22:15:57 -08:00
parent 6137fac556
commit a893bf0b93
3 changed files with 272 additions and 22 deletions

217
babi.py
View File

@@ -508,8 +508,7 @@ class Action:
def action(func: TCallable) -> TCallable:
@functools.wraps(func)
def action_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
assert not isinstance(self.lines, ListSpy), 'nested edit/movement'
self.mark_previous_action_as_final()
self.finalize_previous_action()
return func(self, *args, **kwargs)
return cast(TCallable, action_inner)
@@ -528,6 +527,23 @@ def edit_action(
return edit_action_decorator
def keep_selection(func: TCallable) -> TCallable:
@functools.wraps(func)
def keep_selection_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
with self.select():
return func(self, *args, **kwargs)
return cast(TCallable, keep_selection_inner)
def clear_selection(func: TCallable) -> TCallable:
@functools.wraps(func)
def clear_selection_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
ret = func(self, *args, **kwargs)
self.select_start = None
return ret
return cast(TCallable, clear_selection_inner)
class Found(NamedTuple):
y: int
match: Match[str]
@@ -600,6 +616,7 @@ class File:
self.sha256: Optional[str] = None
self.undo_stack: List[Action] = []
self.redo_stack: List[Action] = []
self.select_start: Optional[Tuple[int, int]] = None
def ensure_loaded(self, status: Status) -> None:
if self.lines:
@@ -795,18 +812,22 @@ class File:
self.x = self.x_hint = match.start()
self.scroll_screen_if_needed(margin)
@clear_selection
def replace(
self,
screen: 'Screen',
reg: Pattern[str],
replace: str,
) -> None:
self.mark_previous_action_as_final()
self.finalize_previous_action()
def highlight() -> None:
y = screen.file.rendered_y(screen.margin)
x = screen.file.rendered_x()
screen.stdscr.chgat(y, x, len(match[0]), curses.A_REVERSE)
self.highlight(
screen.stdscr, screen.margin,
y=self.y, x=self.x, n=len(match[0]),
color=curses.A_REVERSE,
include_edge=True,
)
count = 0
res: Union[str, PromptResult] = ''
@@ -863,6 +884,7 @@ class File:
# editing
@edit_action('backspace text', final=False)
@clear_selection
def backspace(self, margin: Margin) -> None:
# backspace at the beginning of the file does nothing
if self.y == 0 and self.x == 0:
@@ -885,6 +907,7 @@ class File:
self.x = self.x_hint = self.x - 1
@edit_action('delete text', final=False)
@clear_selection
def delete(self, margin: Margin) -> None:
# noop at end of the file
if self.y == len(self.lines) - 1:
@@ -898,6 +921,7 @@ class File:
self.lines[self.y] = s[:self.x] + s[self.x + 1:]
@edit_action('line break', final=False)
@clear_selection
def enter(self, margin: Margin) -> None:
s = self.lines[self.y]
self.lines[self.y] = s[:self.x]
@@ -905,13 +929,31 @@ class File:
self._increment_y(margin)
self.x = self.x_hint = 0
@edit_action('cut selection', final=True)
@clear_selection
def cut_selection(self, margin: Margin) -> Tuple[str, ...]:
ret = []
(s_y, s_x), (e_y, e_x) = self._get_selection()
if s_y == e_y:
ret.append(self.lines[s_y][s_x:e_x])
self.lines[s_y] = self.lines[s_y][:s_x] + self.lines[s_y][e_x:]
else:
ret.append(self.lines[s_y][s_x:])
for l_y in range(s_y + 1, e_y):
ret.append(self.lines[l_y])
ret.append(self.lines[e_y][:e_x])
self.lines[s_y] = self.lines[s_y][:s_x] + self.lines[e_y][e_x:]
for _ in range(s_y + 1, e_y + 1):
self.lines.pop(s_y + 1)
self.y = s_y
self.x = self.x_hint = s_x
self.scroll_screen_if_needed(margin)
return tuple(ret)
def cut(self, cut_buffer: Tuple[str, ...]) -> Tuple[str, ...]:
# only continue a cut if the last action is a non-final cut
if (
not self.undo_stack or
self.undo_stack[-1].name != 'cut' or
self.undo_stack[-1].final
):
if not self._continue_last_action('cut'):
cut_buffer = ()
with self.edit_action_context('cut', final=False):
@@ -922,8 +964,7 @@ class File:
self.x = self.x_hint = 0
return cut_buffer + (victim,)
@edit_action('uncut', final=True)
def uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None:
def _uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None:
for cut_line in cut_buffer:
line = self.lines[self.y]
before, after = line[:self.x], line[self.x:]
@@ -932,6 +973,22 @@ class File:
self._increment_y(margin)
self.x = self.x_hint = 0
@edit_action('uncut', final=True)
@clear_selection
def uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None:
self._uncut(cut_buffer, margin)
@edit_action('uncut selection', final=True)
@clear_selection
def uncut_selection(
self,
cut_buffer: Tuple[str, ...], margin: Margin,
) -> None:
self._uncut(cut_buffer, margin)
self._decrement_y(margin)
self.x = self.x_hint = len(self.lines[self.y])
self.lines[self.y] += self.lines.pop(self.y + 1)
DISPATCH = {
# movement
b'KEY_UP': up,
@@ -956,30 +1013,49 @@ class File:
b'KEY_BACKSPACE': backspace,
b'KEY_DC': delete,
b'^M': enter,
# selection (shift + movement)
b'KEY_SR': keep_selection(up),
b'KEY_SF': keep_selection(down),
b'KEY_SLEFT': keep_selection(left),
b'KEY_SRIGHT': keep_selection(right),
b'KEY_SHOME': keep_selection(home),
b'KEY_SEND': keep_selection(end),
b'KEY_SPREVIOUS': keep_selection(page_up),
b'KEY_SNEXT': keep_selection(page_down),
b'kRIT6': keep_selection(ctrl_right),
b'kLFT6': keep_selection(ctrl_left),
b'kHOM6': keep_selection(ctrl_home),
b'kEND6': keep_selection(ctrl_end),
}
@edit_action('text', final=False)
@clear_selection
def c(self, wch: str, margin: Margin) -> None:
s = self.lines[self.y]
self.lines[self.y] = s[:self.x] + wch + s[self.x:]
self.x = self.x_hint = self.x + 1
_restore_lines_eof_invariant(self.lines)
def mark_previous_action_as_final(self) -> None:
def finalize_previous_action(self) -> None:
assert not isinstance(self.lines, ListSpy), 'nested edit/movement'
self.select_start = None
if self.undo_stack:
self.undo_stack[-1].final = True
def _continue_last_action(self, name: str) -> bool:
return (
bool(self.undo_stack) and
self.undo_stack[-1].name == name and
not self.undo_stack[-1].final
)
@contextlib.contextmanager
def edit_action_context(
self, name: str,
*,
final: bool,
) -> Generator[None, None, None]:
continue_last = (
self.undo_stack and
self.undo_stack[-1].name == name and
not self.undo_stack[-1].final
)
continue_last = self._continue_last_action(name)
if continue_last:
spy = self.undo_stack[-1].spy
else:
@@ -1011,6 +1087,17 @@ class File:
)
self.undo_stack.append(action)
@contextlib.contextmanager
def select(self) -> Generator[None, None, None]:
if self.select_start is None:
select_start = (self.y, self.x)
else:
select_start = self.select_start
try:
yield
finally:
self.select_start = select_start
# positioning
def rendered_y(self, margin: Margin) -> int:
@@ -1026,6 +1113,14 @@ class File:
) -> None:
stdscr.move(self.rendered_y(margin), self.rendered_x())
def _get_selection(self) -> Tuple[Tuple[int, int], Tuple[int, int]]:
assert self.select_start is not None
select_end = (self.y, self.x)
if select_end < self.select_start:
return select_end, self.select_start
else:
return self.select_start, select_end
def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None:
to_display = min(len(self.lines) - self.file_y, margin.body_lines)
for i in range(to_display):
@@ -1038,6 +1133,63 @@ class File:
for i in range(to_display, margin.body_lines):
stdscr.insstr(i + margin.header, 0, blankline)
if self.select_start is not None:
(s_y, s_x), (e_y, e_x) = self._get_selection()
if s_y == e_y:
self.highlight(
stdscr, margin,
y=s_y, x=s_x, n=e_x - s_x, color=curses.A_REVERSE,
include_edge=True,
)
else:
self.highlight(
stdscr, margin,
y=s_y, x=s_x, n=-1, color=curses.A_REVERSE,
include_edge=True,
)
for l_y in range(s_y + 1, e_y):
self.highlight(
stdscr, margin,
y=l_y, x=0, n=-1, color=curses.A_REVERSE,
include_edge=True,
)
self.highlight(
stdscr, margin,
y=e_y, x=0, n=e_x, color=curses.A_REVERSE,
include_edge=True,
)
def highlight(
self,
stdscr: 'curses._CursesWindow', margin: Margin,
*,
y: int, x: int, n: int, color: int,
include_edge: bool,
) -> None:
h_y = y - self.file_y + margin.header
if y == self.y:
line_x = _line_x(self.x, curses.COLS)
if x < line_x:
h_x = 0
n -= line_x - x
else:
h_x = x - line_x
else:
line_x = 0
h_x = x
if not include_edge and len(self.lines[y]) > line_x + curses.COLS:
raise NotImplementedError('h_n = min(curses.COLS - h_x - 1, n)')
else:
h_n = n
if (
h_y < margin.header or
h_y > margin.header + margin.body_lines or
h_x >= curses.COLS
):
return
stdscr.chgat(h_y, h_x, h_n, color)
class Screen:
def __init__(
@@ -1052,6 +1204,7 @@ class Screen:
self.status = Status()
self.margin = Margin.from_current_screen()
self.cut_buffer: Tuple[str, ...] = ()
self.cut_selection = False
self._resize_cb: Optional[Callable[[], None]] = None
@property
@@ -1169,10 +1322,18 @@ class Screen:
self.status.update(f'{line}, {col} (of {line_count} {lines_word})')
def cut(self) -> None:
self.cut_buffer = self.file.cut(self.cut_buffer)
if self.file.select_start:
self.cut_buffer = self.file.cut_selection(self.margin)
self.cut_selection = True
else:
self.cut_buffer = self.file.cut(self.cut_buffer)
self.cut_selection = False
def uncut(self) -> None:
self.file.uncut(self.cut_buffer, self.margin)
if self.cut_selection:
self.file.uncut_selection(self.cut_buffer, self.margin)
else:
self.file.uncut(self.cut_buffer, self.margin)
def _get_search_re(self, prompt: str) -> Union[Pattern[str], PromptResult]:
response = self.prompt(prompt, history='search', default_prev=True)
@@ -1232,7 +1393,7 @@ class Screen:
return None
def save(self) -> Optional[PromptResult]:
self.file.mark_previous_action_as_final()
self.file.finalize_previous_action()
# TODO: make directories if they don't exist
# TODO: maybe use mtime / stat as a shortcut for hashing below
@@ -1355,6 +1516,14 @@ def _color_test(stdscr: 'curses._CursesWindow') -> None:
SEQUENCE_KEYNAME = {
'\x1bOH': b'KEY_HOME',
'\x1bOF': b'KEY_END',
'\x1b[1;2A': b'KEY_SR',
'\x1b[1;2B': b'KEY_SF',
'\x1b[1;2C': b'KEY_SRIGHT',
'\x1b[1;2D': b'KEY_SLEFT',
'\x1b[1;2H': b'KEY_SHOME',
'\x1b[1;2F': b'KEY_SEND',
'\x1b[5;2~': b'KEY_SPREVIOUS',
'\x1b[6;2~': b'KEY_SNEXT',
'\x1b[1;3A': b'kUP3', # M-Up
'\x1b[1;3B': b'kDN3', # M-Down
'\x1b[1;3C': b'kRIT3', # M-Right
@@ -1365,6 +1534,10 @@ SEQUENCE_KEYNAME = {
'\x1b[1;5D': b'kLFT5', # ^Left
'\x1b[1;5H': b'kHOM5', # ^Home
'\x1b[1;5F': b'kEND5', # ^End
'\x1b[1;6C': b'kRIT6', # Shift + ^Right
'\x1b[1;6D': b'kLFT6', # Shift + ^Left
'\x1b[1;6H': b'kHOM6', # Shift + ^Home
'\x1b[1;6F': b'kEND6', # Shift + ^End
}

View File

@@ -48,3 +48,79 @@ def test_cut_uncut_multiple_file_buffers(tmpdir):
h.await_text_missing('world')
h.press('^U')
h.await_text('hello\ngood\nbye\n')
def test_selection_cut_uncut(ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('Right')
h.press('S-Right')
h.press('S-Down')
h.press('^K')
h.await_cursor_position(x=1, y=1)
h.await_text('lne_1\n')
h.await_text_missing('line_0')
h.await_text(' *')
h.press('^U')
h.await_cursor_position(x=2, y=2)
h.await_text('line_0\nline_1')
def test_selection_cut_uncut_backwards_select(ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
for _ in range(3):
h.press('Down')
h.press('Right')
h.press('S-Up')
h.press('S-Up')
h.press('S-Right')
h.press('^K')
h.await_text('line_0\nliine_3\nline_4\n')
h.await_cursor_position(x=2, y=2)
h.await_text(' *')
h.press('^U')
h.await_text('line_0\nline_1\nline_2\nline_3\nline_4\n')
h.await_cursor_position(x=1, y=4)
def test_selection_cut_uncut_within_line(ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('Right')
h.press('S-Right')
h.press('S-Right')
h.press('^K')
h.await_text('le_0\n')
h.await_cursor_position(x=1, y=1)
h.await_text(' *')
h.press('^U')
h.await_text('line_0\n')
h.await_cursor_position(x=3, y=1)
def test_selection_cut_uncut_selection_offscreen_y(ten_lines):
with run(str(ten_lines), height=4) as h, and_exit(h):
for _ in range(3):
h.press('S-Down')
h.await_text_missing('line_0')
h.await_text('line_3')
h.press('^K')
h.await_text_missing('line_2')
h.await_cursor_position(x=0, y=1)
def test_selection_cut_uncut_selection_offscreen_x():
with run() as h, and_exit(h):
h.press(f'hello{"o" * 100}')
h.await_text_missing('hello')
h.press('Home')
h.await_text('hello')
for _ in range(5):
h.press('Right')
h.press('S-End')
h.await_text_missing('hello')
h.press('^K')
h.await_text('hello\n')

View File

@@ -16,5 +16,6 @@ def test_position_repr():
' sha256=None,\n'
' undo_stack=[],\n'
' redo_stack=[],\n'
' select_start=None,\n'
')'
)