shift + movement = selection
This commit is contained in:
217
babi.py
217
babi.py
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -16,5 +16,6 @@ def test_position_repr():
|
||||
' sha256=None,\n'
|
||||
' undo_stack=[],\n'
|
||||
' redo_stack=[],\n'
|
||||
' select_start=None,\n'
|
||||
')'
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user