Skip to content

feat: undo and redo any input #78

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,27 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Features

- Undo any input with <kbd>ctrl+z</kbd>; redo with <kbd>ctrl+y</kbd> ([#12](https://github.com/tconbeer/textual-textarea/issues/12)).

## [0.4.2] - 2023-08-03

### Bug Fixes

- No longer clears selection for more keystrokes (e.g,. <kbd>ctrl+j</kbd>)
- Better-maintains selection and cursor position when bulk commenting or uncommenting with <kbd>ctrl+/</kbd>

## [0.4.1] - 2023-08-03

### Features

- Adds a parameter to PathInput to allow <kbd>tab</kbd> to advance the focus.

## [0.4.0] - 2023-08-03

### Features

- Adds a suggester to autocomplete paths for the save and open file inputs.
- Adds a validator to validate paths for the save and open file inputs.
- `textual-textarea` now requires `textual` >=0.27.0
Expand Down
84 changes: 80 additions & 4 deletions src/textual_textarea/textarea.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from collections import deque
from dataclasses import dataclass
from math import ceil, floor
from os.path import expanduser
from typing import List, Tuple, Union
from typing import Deque, List, Tuple, Union

import pyperclip
from rich.console import RenderableType
Expand Down Expand Up @@ -29,6 +31,14 @@
}
CLOSERS = {'"': '"', "'": "'", **BRACKETS}
TAB_SIZE = 4
UNDO_SIZE = 25


@dataclass
class InputState:
lines: List[str]
cursor: Cursor
selection_anchor: Union[Cursor, None]


class TextInput(Static, can_focus=True):
Expand All @@ -45,7 +55,6 @@ class TextInput(Static, can_focus=True):
selection_anchor: reactive[Union[Cursor, None]] = reactive(None)
clipboard: List[str] = list()
cursor_visible: reactive[bool] = reactive(True)
use_system_clipboard: bool = True
language: reactive[Union[str, None]] = reactive(None)

def __init__(
Expand All @@ -60,23 +69,36 @@ def __init__(
self.language = language
self.theme = theme
self.use_system_clipboard = use_system_clipboard
self.undo_stack: Deque[InputState] = deque(maxlen=UNDO_SIZE)
self.redo_stack: Deque[InputState] = deque(maxlen=UNDO_SIZE)

def on_mount(self) -> None:
self.blink_timer = self.set_interval(
0.5,
self._toggle_cursor,
interval=0.5,
callback=self._toggle_cursor,
name="blink_timer",
pause=not self.has_focus,
)
self.undo_timer = self.set_interval(
interval=0.3,
callback=self._create_undo_snapshot,
name="undo_timer",
pause=not self.has_focus,
)
self._create_undo_snapshot()

def on_focus(self) -> None:
self.cursor_visible = True
self.blink_timer.reset()
self.undo_timer.reset()
self._scroll_to_cursor()
self.update(self._content)

def on_blur(self) -> None:
self.blink_timer.pause()
self.cursor_visible = False
self.undo_timer.pause()
self._create_undo_snapshot()
self.update(self._content)

def on_mouse_down(self, event: events.MouseDown) -> None:
Expand All @@ -86,6 +108,7 @@ def on_mouse_down(self, event: events.MouseDown) -> None:
event.stop()
self.cursor_visible = True
self.blink_timer.reset()
self.undo_timer.reset()
self.selection_anchor = Cursor.from_mouse_event(event)
self.move_cursor(event.x - 1, event.y)
self.focus()
Expand All @@ -96,6 +119,9 @@ def on_mouse_move(self, event: events.MouseMove) -> None:
is moving.
"""
if event.button == 1:
self.cursor_visible = True
self.blink_timer.reset()
self.undo_timer.reset()
self.move_cursor(event.x - 1, event.y)

def on_mouse_up(self, event: events.MouseUp) -> None:
Expand All @@ -105,11 +131,13 @@ def on_mouse_up(self, event: events.MouseUp) -> None:
event.stop()
self.cursor_visible = True
self.blink_timer.reset()
self.undo_timer.reset()
if self.selection_anchor == Cursor.from_mouse_event(event):
# simple click
self.selection_anchor = None
else:
self.move_cursor(event.x - 1, event.y)
self._create_undo_snapshot()
self.focus()

def on_click(self, event: events.Click) -> None:
Expand All @@ -129,15 +157,30 @@ def on_paste(self, event: events.Paste) -> None:
event.stop()
self.cursor_visible = True
self.blink_timer.reset()
self.undo_timer.reset()
self._create_undo_snapshot()
self._insert_clipboard_at_selection(self.selection_anchor, self.cursor)
self.selection_anchor = None
self.update(self._content)

def on_key(self, event: events.Key) -> None:
self.cursor_visible = True
self.blink_timer.reset()
self.undo_timer.reset()
selection_before = self.selection_anchor

# for large actions, take an undo snapshot first.
if event.key in (
"ctrl+underscore",
"ctrl+v",
"ctrl+u",
"ctrl+x",
"ctrl+z",
"tab",
"shift+tab",
):
self._create_undo_snapshot()

# set selection_anchor if it's unset
if event.key == "shift+delete":
pass # todo: shift+delete should delete the whole line
Expand All @@ -152,6 +195,8 @@ def on_key(self, event: events.Key) -> None:
"ctrl+enter",
"ctrl+j",
"ctrl+e",
"ctrl+y",
"ctrl+z",
"f1",
"f2",
"f3",
Expand Down Expand Up @@ -365,6 +410,25 @@ def on_key(self, event: events.Key) -> None:
anchor = selection_before
cursor = self.cursor
self._delete_selection(anchor, cursor)
elif event.key == "ctrl+z":
event.stop()
if len(self.undo_stack) > 1:
# we just took a snapshot, so the current state is
# on the stack.
current_state = self.undo_stack.pop()
self.redo_stack.append(current_state)
prev_state = self.undo_stack[-1]
self.lines = prev_state.lines.copy()
self.cursor = prev_state.cursor
self.selection_anchor = prev_state.selection_anchor
elif event.key == "ctrl+y":
event.stop()
if self.redo_stack:
state = self.redo_stack.pop()
self.undo_stack.append(state)
self.lines = state.lines.copy()
self.cursor = state.cursor
self.selection_anchor = state.selection_anchor

elif event.is_printable:
event.stop()
Expand Down Expand Up @@ -424,6 +488,18 @@ def _toggle_cursor(self) -> None:
self.cursor_visible = not self.cursor_visible
self.update(self._content)

def _create_undo_snapshot(self) -> None:
new_snapshot = InputState(
lines=self.lines.copy(),
cursor=self.cursor,
selection_anchor=self.selection_anchor,
)
if self.undo_stack and self.undo_stack[-1] == new_snapshot:
return
self.undo_stack.append(new_snapshot)
if self.redo_stack:
self.redo_stack = deque(maxlen=UNDO_SIZE)

def _get_selected_lines(
self,
maybe_anchor: Union[Cursor, None],
Expand Down
69 changes: 69 additions & 0 deletions tests/functional_tests/test_textarea.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,72 @@ async def test_text_property(app: App) -> None:
ta.cursor = Cursor(3, 0)
assert ta.selection_anchor == Cursor(0, 1)
assert ta.selected_text == "\n\nb\n"


@pytest.mark.asyncio
async def test_undo_redo(app: App) -> None:
async with app.run_test() as pilot:
ta = app.query_one(TextArea)
ti = ta.text_input
assert ti
assert ti.has_focus
assert ti.undo_stack
assert len(ti.undo_stack) == 1
assert ti.undo_stack[0].cursor == Cursor(0, 0)
assert not ti.redo_stack

for char in "foo":
await pilot.press(char)
await pilot.pause(0.6)
assert ti.undo_stack
assert len(ti.undo_stack) == 2
assert ti.undo_stack[-1].lines == ["foo "]
assert ti.undo_stack[-1].cursor == Cursor(0, 3)
assert ti.undo_stack[-1].selection_anchor is None

await pilot.press("enter")
for char in "bar":
await pilot.press(char)
await pilot.pause(0.6)
assert ti.undo_stack
assert len(ti.undo_stack) == 3
assert ti.undo_stack[-1].lines == ["foo ", "bar "]
assert ti.undo_stack[-1].cursor == Cursor(1, 3)
assert ti.undo_stack[-1].selection_anchor is None

await pilot.press("ctrl+z")
assert ti.undo_stack
assert len(ti.undo_stack) == 2
assert ti.undo_stack[-1].lines == ["foo "]
assert ti.lines == ["foo "]
assert ti.cursor == Cursor(0, 3)
assert ti.redo_stack
assert len(ti.redo_stack) == 1
assert ti.redo_stack[-1].lines == ["foo ", "bar "]

await pilot.press("ctrl+z")
assert ti.undo_stack
assert len(ti.undo_stack) == 1
assert ti.undo_stack[-1].lines == [" "]
assert ti.lines == [" "]
assert ti.cursor == Cursor(0, 0)
assert ti.redo_stack
assert len(ti.redo_stack) == 2
assert ti.redo_stack[-1].lines == ["foo "]

await pilot.press("ctrl+y")
assert ti.undo_stack
assert len(ti.undo_stack) == 2
assert ti.undo_stack[-1].lines == ["foo "]
assert ti.lines == ["foo "]
assert ti.cursor == Cursor(0, 3)
assert ti.redo_stack
assert len(ti.redo_stack) == 1
assert ti.redo_stack[-1].lines == ["foo ", "bar "]

await pilot.press("z")
await pilot.pause(0.6)
assert len(ti.undo_stack) == 3
assert ti.undo_stack[-1].lines == ["fooz "]
assert ti.lines == ["fooz "]
assert not ti.redo_stack