Skip to content

Commit

Permalink
Text area undo redo (#4124)
Browse files Browse the repository at this point in the history
* Initial undo related machinery added to TextArea

* Initial undo implementation

* Basic undo and redo

* Some more fleshing out of undo and redo

* Skeleton code for managing TextArea history

* Initial implementation of undo & redo checkpointing in TextArea

* Increase checkpoint characters

* Restoring the selection in the TextArea and then restoring it on undo

* Adding docstrings to undo_batch and redo_batch in the TextArea

* Batching edits of the same type

* Batching edits of the same type

* Keeping edits containing newlines in their own batch

* Checking for newline characters in insertion or replacement during undo checkpoint creation. Updating docstrings in history.py

* Fix mypy warning

* Performance improvement

* Add history checkpoint on cursor movement

* Fixing merge conflict in Edit class

* Fixing error in merge conflict resolution

* Remove unused test file

* Remove unused test file

* Initial testing of undo and redo

* Testing for undo redo

* Updating lockfile

* Add an extra test

* Fix: setting the `text` property programmatically should invalidate the edit history

* Improving docstrings

* Rename EditHistory.reset() to EditHistory.clear()

* Add docstring to an exception

* Add a pause after focus/blur in a test

* Forcing CI colour

* Update focus checkpoint test

* Try to force color in pytest by setting --color=yes in PYTEST_ADDOPTS in env var on Github Actions

* Add extra assertion in a test

* Toggle text_area has focus to trigger checkpoint in history

* Apply grammar/wording suggestions from code review

Co-authored-by: Rodrigo Girão Serrão <[email protected]>

* Making max checkpoints configurable in TextArea history

* Improve a docstring

* Update changelog

* Spelling fixes

* More spelling fixes

* Americanize spelling of tab_behaviour (->tab_behavior)

* Update CHANGELOG regarding `tab_behaviour`->`tab_behavior`

---------

Co-authored-by: Rodrigo Girão Serrão <[email protected]>
  • Loading branch information
darrenburns and rodrigogiraoserrao authored Feb 14, 2024
1 parent b736c93 commit fa4f75f
Show file tree
Hide file tree
Showing 14 changed files with 1,180 additions and 570 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/pythonpackage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ on:
- "**.lock"
- "Makefile"

env:
PYTEST_ADDOPTS: "--color=yes"

jobs:
build:
runs-on: ${{ matrix.os }}
Expand Down
8 changes: 4 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,20 @@ repos:
- id: end-of-file-fixer # ensures that a file is either empty, or ends with one newline
- id: mixed-line-ending # replaces or checks mixed line ending
- repo: https://github.com/pycqa/isort
rev: 5.12.0
rev: '5.12.0'
hooks:
- id: isort
name: isort (python)
language_version: '3.11'
args: ["--profile", "black", "--filter-files"]
args: ['--profile', 'black', '--filter-files']
- repo: https://github.com/psf/black
rev: 24.1.1
rev: '24.1.1'
hooks:
- id: black
- repo: https://github.com/hadialqattan/pycln # removes unused imports
rev: v2.3.0
hooks:
- id: pycln
language_version: "3.11"
language_version: '3.11'
args: [--all]
exclude: ^tests/snapshot_tests
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

- TextArea now has `read_only` mode https://github.com/Textualize/textual/pull/4151
- Add some syntax highlighting to TextArea default theme https://github.com/Textualize/textual/pull/4149
- Add undo and redo to TextArea https://github.com/Textualize/textual/pull/4124

### Changed

- Renamed `TextArea.tab_behaviour` to `TextArea.tab_behavior` https://github.com/Textualize/textual/pull/4124

## [0.51.1] - 2024-02-09

Expand Down
2 changes: 1 addition & 1 deletion docs/widgets/text_area.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ This immediately updates the appearance of the `TextArea`:
Pressing the ++tab++ key will shift focus to the next widget in your application by default.
This matches how other widgets work in Textual.

To have ++tab++ insert a `\t` character, set the `tab_behaviour` attribute to the string value `"indent"`.
To have ++tab++ insert a `\t` character, set the `tab_behavior` attribute to the string value `"indent"`.
While in this mode, you can shift focus by pressing the ++escape++ key.

### Indentation
Expand Down
758 changes: 355 additions & 403 deletions poetry.lock

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ mkdocstrings-python = "0.10.1"
mkdocs-material = "^9.0.11"
mkdocs-exclude = "^1.0.2"
pre-commit = "^2.13.0"
time-machine = "^2.6.0"
mkdocs-rss-plugin = "^1.5.0"
httpx = "^0.23.1"
types-setuptools = "^67.2.0.1"
Expand Down
2 changes: 1 addition & 1 deletion src/textual/document/_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def _detect_newline_style(text: str) -> Newline:
text: The text to inspect.
Returns:
The NewlineStyle used in the file.
The Newline used in the file.
"""
if "\r\n" in text: # Windows newline
return "\r\n"
Expand Down
143 changes: 143 additions & 0 deletions src/textual/document/_edit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from textual.document._document import EditResult, Location, Selection

if TYPE_CHECKING:
from textual.widgets import TextArea


@dataclass
class Edit:
"""Implements the Undoable protocol to replace text at some range within a document."""

text: str
"""The text to insert. An empty string is equivalent to deletion."""

from_location: Location
"""The start location of the insert."""

to_location: Location
"""The end location of the insert"""

maintain_selection_offset: bool
"""If True, the selection will maintain its offset to the replacement range."""

_original_selection: Selection | None = field(init=False, default=None)
"""The Selection when the edit was originally performed, to be restored on undo."""

_updated_selection: Selection | None = field(init=False, default=None)
"""Where the selection should move to after the replace happens."""

_edit_result: EditResult | None = field(init=False, default=None)
"""The result of doing the edit."""

def do(self, text_area: TextArea, record_selection: bool = True) -> EditResult:
"""Perform the edit operation.
Args:
text_area: The `TextArea` to perform the edit on.
record_selection: If True, record the current selection in the TextArea
so that it may be restored if this Edit is undone in the future.
Returns:
An `EditResult` containing information about the replace operation.
"""
if record_selection:
self._original_selection = text_area.selection

text = self.text

# This code is mostly handling how we adjust TextArea.selection
# when an edit is made to the document programmatically.
# We want a user who is typing away to maintain their relative
# position in the document even if an insert happens before
# their cursor position.

edit_bottom_row, edit_bottom_column = self.bottom

selection_start, selection_end = text_area.selection
selection_start_row, selection_start_column = selection_start
selection_end_row, selection_end_column = selection_end

edit_result = text_area.document.replace_range(self.top, self.bottom, text)

new_edit_to_row, new_edit_to_column = edit_result.end_location

column_offset = new_edit_to_column - edit_bottom_column
target_selection_start_column = (
selection_start_column + column_offset
if edit_bottom_row == selection_start_row
and edit_bottom_column <= selection_start_column
else selection_start_column
)
target_selection_end_column = (
selection_end_column + column_offset
if edit_bottom_row == selection_end_row
and edit_bottom_column <= selection_end_column
else selection_end_column
)

row_offset = new_edit_to_row - edit_bottom_row
target_selection_start_row = selection_start_row + row_offset
target_selection_end_row = selection_end_row + row_offset

if self.maintain_selection_offset:
self._updated_selection = Selection(
start=(target_selection_start_row, target_selection_start_column),
end=(target_selection_end_row, target_selection_end_column),
)
else:
self._updated_selection = Selection.cursor(edit_result.end_location)

self._edit_result = edit_result
return edit_result

def undo(self, text_area: TextArea) -> EditResult:
"""Undo the edit operation.
Looks at the data stored in the edit, and performs the inverse operation of `Edit.do`.
Args:
text_area: The `TextArea` to undo the insert operation on.
Returns:
An `EditResult` containing information about the replace operation.
"""
replaced_text = self._edit_result.replaced_text
edit_end = self._edit_result.end_location

# Replace the span of the edit with the text that was originally there.
undo_edit_result = text_area.document.replace_range(
self.top, edit_end, replaced_text
)
self._updated_selection = self._original_selection

return undo_edit_result

def after(self, text_area: TextArea) -> None:
"""Hook for running code after an Edit has been performed via `Edit.do` *and*
side effects such as re-wrapping the document and refreshing the display
have completed.
For example, we can't record cursor visual offset until we know where the cursor will
land *after* wrapping has been performed, so we must wait until here to do it.
Args:
text_area: The `TextArea` this operation was performed on.
"""
if self._updated_selection is not None:
text_area.selection = self._updated_selection
text_area.record_cursor_width()

@property
def top(self) -> Location:
"""The Location impacted by this edit that is nearest the start of the document."""
return min([self.from_location, self.to_location])

@property
def bottom(self) -> Location:
"""The Location impacted by this edit that is nearest the end of the document."""
return max([self.from_location, self.to_location])
Loading

0 comments on commit fa4f75f

Please sign in to comment.