From 0212c053c60b9ea111d406fd6631ac228799f4e6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:32:31 -0400 Subject: [PATCH] Restore external editing capabilities over current main --- src/textual/_clipboard.py | 22 +- src/textual/app.py | 2 + src/textual/binding.py | 15 + src/textual/document/_document.py | 24 + src/textual/document/_document_navigator.py | 62 -- src/textual/document/_edit.py | 21 + src/textual/document/_editing.py | 760 ++---------------- src/textual/document/_wrapped_document.py | 2 +- src/textual/dom.py | 9 - src/textual/keys.py | 7 + src/textual/widget.py | 4 + src/textual/widgets/_text_area.py | 196 +++-- src/textual/widgets/text_area.py | 5 +- .../test_application_command_mode.py | 88 +- tests/text_area/test_cursor_shape.py | 10 +- 15 files changed, 291 insertions(+), 936 deletions(-) diff --git a/src/textual/_clipboard.py b/src/textual/_clipboard.py index d1661a8c593..f6f2d24b099 100644 --- a/src/textual/_clipboard.py +++ b/src/textual/_clipboard.py @@ -1,4 +1,4 @@ -"""The local clipboard payload, including editing semantics lost by plain text.""" +"""The application's local clipboard payload.""" from __future__ import annotations from dataclasses import dataclass @@ -11,23 +11,3 @@ class ClipboardContent: @classmethod def of(cls, value: str | ClipboardContent) -> ClipboardContent: return value if isinstance(value, ClipboardContent) else cls(value) - - def insertion(self, editor, before, count): - return editor.navigator.get_location_column_offset(editor.cursor_location, 0 if before else 1), self.text * count - - def paste(self, editor, before, count): - location, text = self.insertion(editor, before, count) - mode = editor.editing_mode - mode.suspend(editor) - editor.replace(text, location, location, maintain_selection_offset=False) - editor.move_cursor(location) - mode.resume(editor) - - -class LinewiseClipboard(ClipboardContent): - def insertion(self, editor, before, count): - row = editor.cursor_location[0] + (0 if before else 1) - text = self.text * count - if row < editor.document.line_count: - return (row, 0), text - return editor.document.end, editor.document.newline + text.removesuffix(editor.document.newline) diff --git a/src/textual/app.py b/src/textual/app.py index f822d7083ed..e6c5350b977 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -4265,6 +4265,8 @@ async def on_event(self, event: events.Event) -> None: self.screen.minimize() return if self.focused: + if not await self.focused.prepare_key(): + return try: self.screen._clear_tooltip() except NoScreen: diff --git a/src/textual/binding.py b/src/textual/binding.py index 985f0af4fee..ffb1d974c70 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -22,6 +22,7 @@ NamedTuple, Self, TypeVar, + overload, ) import rich.repr @@ -38,6 +39,7 @@ from textual.dom import DOMNode from textual.events import Key from textual.widget import Widget + from textual.widgets import TextArea BindingType: TypeAlias = "Binding | tuple[str, str] | tuple[str, str, str]" """The possible types of a binding found in the `BINDINGS` class variable.""" @@ -181,6 +183,10 @@ def fallback_bindings(self) -> tuple[Binding, ...]: """Ordinary keys stay on the focused native ancestor chain.""" return () + def engine_boundary(self, editor: TextArea) -> bool: + """Native declarations may offer an application edge to an external editor.""" + return False + @classmethod def make_bindings(cls, bindings: Iterable[BindingType]) -> Iterable[Binding]: """Convert a list of BindingType (the types that can be specified in BINDINGS) @@ -335,6 +341,12 @@ def declared(method: Callable[..., object]) -> MethodBinding: tooltip=method.__doc__ or "", show=False, selecting=selecting) return declared + @overload + def __get__(self, instance: None, owner: type) -> FunctionType: ... + + @overload + def __get__(self, instance: object, owner: type) -> Callable[..., object]: ... + def __get__(self, instance: object, owner: type) -> Callable[..., object]: return self.action.__get__(instance, owner) @@ -350,6 +362,9 @@ def __set_name__(self, owner: type[DOMNode], name: str) -> None: def arguments(self) -> tuple[object, ...]: return () + def engine_boundary(self, editor: TextArea) -> bool: + return editor.before_engine_input(self.action) + def call(self, node: DOMNode) -> object: return invoke_sync(getattr(node, self.action.__name__), *self.arguments()) diff --git a/src/textual/document/_document.py b/src/textual/document/_document.py index 92d5a12a991..ee967ee2f26 100644 --- a/src/textual/document/_document.py +++ b/src/textual/document/_document.py @@ -29,6 +29,14 @@ class EditResult: """The text that was replaced.""" +@dataclass +class RangedEditResult(EditResult): + """A projected edit retains the original range needed by its view.""" + + start: Location + old_end: Location + + @lru_cache(maxsize=1024) def _utf8_encode(text: str) -> bytes: """Encode the input text as utf-8 bytes. @@ -80,6 +88,22 @@ def replace_range(self, start: Location, end: Location, text: str) -> EditResult The new end location after the edit is complete. """ + def replace_lines(self, first: int, last: int, lines: tuple[str, ...]) -> RangedEditResult: + """Project an end-exclusive line change into this same document.""" + count = self.line_count + end = (last, 0) if last < count else self.end + start = (first, 0) if first < count else self.end + text = self.newline.join(lines) + if last < count: + if lines: + text += self.newline + elif first > 0: + start = first - 1, len(self.get_line(first - 1)) + if lines: + text = self.newline + text + result = self.replace_range(start, end, text) + return RangedEditResult(result.end_location, result.replaced_text, start, end) + @property @abstractmethod def text(self) -> str: diff --git a/src/textual/document/_document_navigator.py b/src/textual/document/_document_navigator.py index 027a466bbf5..1507b94f64a 100644 --- a/src/textual/document/_document_navigator.py +++ b/src/textual/document/_document_navigator.py @@ -8,37 +8,6 @@ from textual.geometry import Offset, clamp -class WordBoundary: - """Vim word boundaries in document coordinates, independent of the cursor.""" - - direction = 1 - pattern = re.compile(r"\w+|[^\w\s]+") - - def matches(self, line: str): - return self.pattern.finditer(line) - - def position(self, match) -> int: - return match.start() - - def accepts(self, column: int, origin: int) -> bool: - return column > origin - - -class PreviousWordBoundary(WordBoundary): - direction = -1 - - def matches(self, line: str): - return reversed(tuple(super().matches(line))) - - def accepts(self, column: int, origin: int) -> bool: - return column < origin - - -class EndWordBoundary(WordBoundary): - def position(self, match) -> int: - return match.end() - 1 - - class DocumentNavigator: """Cursor navigation in the TextArea is "wrapping-aware". @@ -102,37 +71,6 @@ def __init__(self, wrapped_document: WrappedDocument) -> None: """Remembers the last x offset (cell width) the cursor was moved horizontally to, so that it can be restored on vertical movement where possible.""" - def get_location_column_offset(self, location: Location, columns: int) -> Location: - """Move within a raw document line without crossing its newline.""" - row, column = location - return row, clamp(column + columns, 0, len(self._document[row])) - - def get_location_line_offset(self, location: Location, rows: int) -> Location: - """Move by logical lines, as opposed to wrapped visual sections.""" - row = clamp(location[0] + rows, 0, self._document.line_count - 1) - return row, min(location[1], len(self._document[row])) - - def get_location_first_nonblank(self, location: Location) -> Location: - line = self._document[location[0]] - return location[0], len(line) - len(line.lstrip()) - - def get_location_document_line_end(self, location: Location, lines: int = 1) -> Location: - row = min(self._document.line_count - 1, location[0] + lines - 1) - return row, len(self._document[row]) - - def get_location_word_boundary(self, location: Location, count: int, boundary: WordBoundary) -> Location: - """Find counted raw-word boundaries without moving an editor selection.""" - row, column = location - stop = self._document.line_count if boundary.direction > 0 else -1 - for next_row in range(row, stop, boundary.direction): - for match in boundary.matches(self._document[next_row]): - next_column = boundary.position(match) - if next_row != row or boundary.accepts(next_column, column): - count -= 1 - if count == 0: - return next_row, next_column - return self._document.end if boundary.direction > 0 else self._document.start - def get_location_word_left(self, location: Location) -> Location: """The native word-left boundary, including its whitespace semantics.""" row, column = location diff --git a/src/textual/document/_edit.py b/src/textual/document/_edit.py index dc9c04aeba8..18954104bd8 100644 --- a/src/textual/document/_edit.py +++ b/src/textual/document/_edit.py @@ -9,6 +9,27 @@ from textual.widgets import TextArea +class InsertionPoint: + """An omitted position follows the executing editor's cursor, not its view.""" + + def resolve(self, cursor: Location) -> Location: + return cursor + + def engine_location(self) -> object: + return None + + +@dataclass(frozen=True) +class InsertAt(InsertionPoint): + location: Location + + def resolve(self, cursor: Location) -> Location: + return self.location + + def engine_location(self) -> object: + return self.location + + @dataclass class Edit: """Implements the Undoable protocol to replace text at some range within a document.""" diff --git a/src/textual/document/_editing.py b/src/textual/document/_editing.py index 1f7780213e9..7cf77d91686 100644 --- a/src/textual/document/_editing.py +++ b/src/textual/document/_editing.py @@ -1,33 +1,34 @@ -"""Native editor modes and commands, sharing TextArea's document and history. - -Commands declare native bindings; modes own how their behavior participates -in an unfinished operation. Dispatch uses the application's remapped table. -""" +"""TextArea editing capabilities. Engines own edits; the widget owns presentation.""" from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import dataclass, replace -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING -from textual.binding import Binding, CommandBinding, MethodBinding -from textual.document._document import Location, Selection -from textual._clipboard import ClipboardContent, LinewiseClipboard -from textual.keys import KeyDeclaration -from textual.cursor_shape import BlinkingBar, CellCursor, CursorShape, SteadyBlock -from textual.document._document_navigator import WordBoundary, PreviousWordBoundary, EndWordBoundary +from textual.binding import Binding, MethodBinding +from textual.cursor_shape import CellCursor, CursorShape, SteadyBlock +from textual.document._document import EditResult, Location, Selection +from textual.document._edit import Edit, InsertionPoint if TYPE_CHECKING: from textual.events import Key - from textual.dom import DOMNode from textual.widgets import TextArea +class EditingFailure(RuntimeError): + """The engine failed; preserve its displayed draft and never replay input.""" + + class EditingMode: - """Ordinary text entry; subclasses declare additional editing behavior.""" + """Native text entry, with overridable document and input ownership.""" modal = False - accepts_text = True - cursor_shape: ClassVar[CursorShape] = CellCursor() + @property + def accepts_text(self) -> bool: + return True + + @property + def cursor_shape(self) -> CursorShape: + return CellCursor() @property def label(self) -> str: @@ -35,731 +36,122 @@ def label(self) -> str: @property def allows_application_commands(self) -> bool: - """Whether idle modal command entry permits application priority bindings.""" return False @property def allows_history_navigation(self) -> bool: - """Text entry can offer an application's prompt-history boundary.""" return self.accepts_text @classmethod def fallback_bindings(cls, binding: Binding) -> tuple[Binding, ...]: - """Ordinary editor keys do not escape their focused owner.""" return () - def handle_key(self, editor: TextArea, event: Key) -> bool: + async def handle_key(self, editor: TextArea, event: Key) -> bool: return editor.edit_key(event) def apply_native_binding(self, editor: TextArea, binding: MethodBinding, event: Key) -> bool: - try: - binding.call(editor) - return True - finally: - editor.editing_mode = editor.editing_mode.settled() - - def command_bindings(self) -> tuple[Binding, ...]: - return () - - @classmethod - def editor_bindings(cls, command: type[EditorCommand]) -> tuple[Binding, ...]: - """Ordinary entry has no modal editor commands.""" - return () + binding.call(editor) + return True @property def binding_mode(self) -> type[EditingMode]: - """The stable mode identity, independent of an application's profile subclass.""" return EditingMode - def apply_command(self, editor: TextArea, command: type[EditorCommand]) -> bool: - return False + def enter_text(self, editor: TextArea) -> None: + """An application may focus an existing editor in text-entry mode.""" def settled(self) -> EditingMode: return self - def pasted(self) -> EditingMode: - return self - def document_changed(self) -> EditingMode: return self.settled() + def attach(self, editor: TextArea) -> None: + self.resume(editor) + def resume(self, editor: TextArea) -> None: editor.history.checkpoint() def suspend(self, editor: TextArea) -> None: editor.history.end_group() - def selection_range(self, editor: TextArea) -> Selection: - return editor.selection - - -class VimMode(EditingMode, ABC): - modal = True - cursor_shape: ClassVar[CursorShape] = SteadyBlock() - - @classmethod - def fallback_bindings(cls, binding: Binding) -> tuple[Binding, ...]: - """Modal navigation also belongs to its screen's selected resource.""" - return (binding,) - - def handle_key(self, editor: TextArea, event: Key) -> bool: - editor.history.checkpoint() - try: - return self._handle_key(editor, event) - finally: - editor.history.checkpoint() - - @abstractmethod - def _handle_key(self, editor: TextArea, event: Key) -> bool: - raise NotImplementedError + def capture(self, editor: TextArea) -> EditingMode: + return self.settled() - def pasted(self) -> EditingMode: - return VimInsert() + async def unmount(self, editor: TextArea) -> None: + await self.close() - def command_bindings(self) -> tuple[Binding, ...]: - return tuple(binding for command in EditorCommand.declarations() - for binding in self.declared_command_bindings(command)) + async def close(self) -> None: + pass - def declared_command_bindings(self, command: type[EditorCommand]) -> tuple[Binding, ...]: - return command.bindings_for(self.binding_mode) + async def finish(self, editor: TextArea) -> bool: + return await self.flush(editor) - def apply_command(self, editor: TextArea, command: type[EditorCommand]) -> bool: - command.apply_mode(editor, self) + async def flush(self, editor: TextArea) -> bool: + """Acknowledge input, or explicitly refuse transfer while input is pending.""" return True - def escape(self, editor: TextArea) -> None: - editor.editing_mode = self.settled() - - -class VimInsert(VimMode): - label = "INSERT" - cursor_shape = BlinkingBar() - - @property - def binding_mode(self) -> type[VimInsert]: - return VimInsert + async def acknowledge(self, editor: TextArea) -> None: + """Read actual engine state without waiting for a modal command to finish.""" - def resume(self, editor: TextArea) -> None: - editor.history.begin_group() - - def _handle_key(self, editor: TextArea, event: Key) -> bool: - return editor.edit_key(event) - - @classmethod - def editor_bindings(cls, command: type[EditorCommand]) -> tuple[Binding, ...]: - return command.insert_bindings(cls) - - def escape(self, editor: TextArea) -> None: - editor.editing_mode = VimNormal() - - -@dataclass(frozen=True) -class VimNormal(VimMode): - count: int = 0 - accepts_text = False - label = "NORMAL" - - @property - def allows_application_commands(self) -> bool: - """An idle command mode can yield to application-level priority bindings.""" - return self.count == 0 - - @property - def allows_history_navigation(self) -> bool: - """A counted motion stays a motion, not an application history request.""" - return self.count == 0 - - def settled(self) -> EditingMode: - return replace(self, count=0) - - @property - def binding_mode(self) -> type[VimNormal]: - return VimNormal - - @classmethod - def editor_bindings(cls, command: type[EditorCommand]) -> tuple[Binding, ...]: - return command.native_bindings(cls) - - def _handle_key(self, editor: TextArea, event: Key) -> bool: - if event.key.isdecimal() and (self.count or event.key != "0"): - editor.editing_mode = replace(self, count=self.count * 10 + int(event.key)) - return True - if editor.edit_key(event): - return True - # Unknown normal-mode text must never reach TextArea's insertion path. - if event.is_printable or event.key == "enter": - editor.editing_mode = self.settled() - return True - editor.editing_mode = self.settled() - return False - - def apply_command(self, editor: TextArea, command: type[EditorCommand]) -> bool: - editor.editing_mode = self.settled() - command.apply(editor, self, self.count or 1) + async def prepare_input(self, editor: TextArea) -> bool: + """Asynchronous engines may fence earlier widget input before binding lookup.""" return True - def move_to(self, editor: TextArea, location: Location) -> None: - editor.move_cursor(location) - - def motion(self, editor: TextArea, motion: type[Motion], count: int) -> None: - self.move_to(editor, motion.destination(editor, editor.cursor_location, count)) - - def character_range(self, editor: TextArea, count: int) -> EditorRange: - return EditorRange(editor.cursor_location, - Right.destination(editor, editor.cursor_location, count)) - - def characters(self, editor: TextArea, operator: type[Operator], count: int) -> None: - operator.replace(editor, self.character_range(editor, count)) - - def visual(self, editor: TextArea) -> None: - editor.editing_mode = VimVisual() - - def operate(self, editor: TextArea, operator: type[Operator], count: int) -> None: - editor.editing_mode = VimOperator(operator=operator, operator_count=count) + def resize(self, editor: TextArea) -> None: + pass - def escape(self, editor: TextArea) -> None: - editor.leave_modal_editor() - - -@dataclass(frozen=True) -class VimVisual(VimNormal): - label = "VISUAL" - - @property - def allows_history_navigation(self) -> bool: - return False - - @property - def binding_mode(self) -> type[VimVisual]: - return VimVisual - - @property - def allows_application_commands(self) -> bool: - return False - - def document_changed(self) -> EditingMode: - return VimNormal() - - def move_to(self, editor: TextArea, location: Location) -> None: - editor.move_cursor(location, select=True) - - def character_range(self, editor: TextArea, count: int) -> EditorRange: - return EditorRange(*self.selection_range(editor)) - - def visual(self, editor: TextArea) -> None: - self.escape(editor) + def request_selection(self, editor: TextArea, selection: Selection) -> Selection: + return selection def selection_range(self, editor: TextArea) -> Selection: - start, end = sorted(editor.selection) - return Selection(start, editor.navigator.get_location_right(end)) - - def operate(self, editor: TextArea, operator: type[Operator], count: int) -> None: - self.characters(editor, operator, count) - - def escape(self, editor: TextArea) -> None: - editor.move_cursor(editor.cursor_location) - editor.editing_mode = VimNormal() + return editor.selection + def selection_for_line(self, editor: TextArea, row: int) -> Selection: + return self.selection_range(editor) -class PendingMode(EditingMode): - """An unfinished command owns printable operands and suppresses history.""" + def selected_text(self, editor: TextArea) -> str: + return editor.get_text_range(*self.selection_range(editor)) - @property - def allows_history_navigation(self) -> bool: - return False - - @property - def allows_application_commands(self) -> bool: - return False + def load_text(self, editor: TextArea, text: str) -> None: + editor._load_text(text) - def apply_native_binding(self, editor: TextArea, binding: MethodBinding, event: Key) -> bool: - if event.is_printable: - return False - return super().apply_native_binding(editor, binding, event) + def edit(self, editor: TextArea, edit: Edit) -> EditResult | None: + return editor._edit(edit) + def insert(self, editor: TextArea, text: str, point: InsertionPoint, maintain_selection_offset: bool) -> EditResult | None: + location = point.resolve(editor.cursor_location) + return self.edit(editor, Edit(text, location, location, maintain_selection_offset)) -@dataclass(frozen=True, kw_only=True) -class VimOperator(PendingMode, VimNormal): - operator: type[Operator] - operator_count: int = 1 + def clear(self, editor: TextArea) -> EditResult | None: + return editor.delete((0, 0), editor.document.end, maintain_selection_offset=False) - def settled(self) -> EditingMode: - return VimNormal() + def move_to_end(self, editor: TextArea) -> None: + editor.move_cursor(editor.document.end) - def apply_command(self, editor: TextArea, command: type[EditorCommand]) -> bool: - editor.editing_mode = VimNormal() - command.pending(editor, self, self.operator_count * (self.count or 1)) - return True + def undo(self, editor: TextArea) -> None: + editor._undo() - def escape(self, editor: TextArea) -> None: - editor.editing_mode = VimNormal() + def redo(self, editor: TextArea) -> None: + editor._redo() + def paste(self, editor: TextArea, text: str) -> None: + if result := editor._replace_via_keyboard(text, *editor.selection): + editor.move_cursor(result.end_location) -@dataclass(frozen=True) -class AwaitKey(PendingMode, VimMode): - command: type[CharacterCommand] - previous: VimNormal - count: int - accepts_text = False - @property - def label(self) -> str: - return self.previous.label +class VimMode(EditingMode, ABC): + """Modal binding capability, not a second implementation of Vim.""" + modal = True @property - def binding_mode(self) -> type[EditingMode]: - return self.previous.binding_mode - - def settled(self) -> EditingMode: - return self.previous.settled() - - def document_changed(self) -> EditingMode: - return self.previous.document_changed() - - def selection_range(self, editor: TextArea) -> Selection: - return self.previous.selection_range(editor) - - def declared_command_bindings(self, command: type[EditorCommand]) -> tuple[Binding, ...]: - return command.pending_bindings(self.binding_mode) - - def _handle_key(self, editor: TextArea, event: Key) -> bool: - if editor.edit_key(event): - return True - editor.editing_mode = self.settled() - if not event.is_printable: - return False - self.command.character(editor, self.previous, event.character, self.count) - return True - - -class EditorBinding(CommandBinding["TextArea"]): - """The native synchronous command participates before text insertion.""" - - def edit(self, editor: DOMNode, event: Key) -> bool: - if not self.state(editor.app, editor): - return False - return editor.apply_editor_command(self.action) - - -class EditorCommand(KeyDeclaration["TextArea"]): - keys: ClassVar[tuple[str, ...]] = () - _declared: ClassVar[list[type[EditorCommand]]] = [] - - def __init_subclass__(cls, **kwargs) -> None: - super().__init_subclass__(**kwargs) - # An explicit key declaration includes an intentionally unbound command. - # Abstract intermediates inherit the empty root and declare no key set. - if "keys" in cls.__dict__: - EditorCommand._declared.append(cls) - - @classmethod - def declarations(cls): - return (member for member in EditorCommand._declared if issubclass(member, cls)) - - @classmethod - def bindings_for(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: - return mode.editor_bindings(cls) - - @classmethod - def native_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: - return cls.mode_bindings(mode, mode) - - @classmethod - def mode_bindings(cls, mode: type[EditingMode], capability: type[EditingMode]) -> tuple[Binding, ...]: - return tuple(EditorBinding(key=key, action=cls(), description=cls.title, mode=capability, - id=cls.binding_id(mode), tooltip=cls.summary(), show=False, - available=cls.available) - for key in cls.keys) - - @classmethod - def insert_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: - return () - - @classmethod - def pending_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: - return () - - @staticmethod - def available(editor: TextArea) -> bool: - return not editor.read_only - - @classmethod - def invoke(cls, editor: TextArea) -> None: - editor.editing_mode.apply_command(editor, cls) - - def __call__(self, editor: TextArea) -> None: - self.invoke(editor) - - @classmethod - def apply(cls, editor: TextArea, mode: VimNormal, count: int) -> None: - cls.apply_mode(editor, mode) - - @classmethod - def apply_mode(cls, editor: TextArea, mode: VimMode) -> None: - """A control shared by modal states; counted commands override ``apply``.""" - raise NotImplementedError - - @classmethod - def pending(cls, editor: TextArea, mode: VimOperator, count: int) -> None: - """An unrelated command cancels the unfinished operator without editing.""" - - -class Motion(EditorCommand): - inclusive = False - - @classmethod - def destination(cls, editor: TextArea, origin: Location, count: int) -> Location: - raise NotImplementedError - - @classmethod - def apply(cls, editor: TextArea, mode: VimNormal, count: int) -> None: - mode.motion(editor, cls, count) - - @classmethod - def pending(cls, editor: TextArea, mode: VimOperator, count: int) -> None: - start = editor.cursor_location - end = cls.destination(editor, start, count) - start, end = sorted((start, end)) - if cls.inclusive: - end = editor.navigator.get_location_right(end) - mode.operator.replace(editor, EditorRange(start, end)) - - -class Left(Motion): - keys = ("h",) - - @classmethod - def destination(cls, editor, origin, count): - return editor.navigator.get_location_column_offset(origin, -count) - - -class Right(Motion): - keys = ("l",) - - @classmethod - def destination(cls, editor, origin, count): - return editor.navigator.get_location_column_offset(origin, count) - - -class LineMotion(Motion): - direction: ClassVar[int] - - @classmethod - def apply(cls, editor: TextArea, mode: VimNormal, count: int) -> None: - editor.move_line_motion(cls, mode, count) - - @classmethod - def destination(cls, editor, origin, count): - return editor.navigator.get_location_line_offset(origin, cls.direction * count) - - @classmethod - def pending(cls, editor, mode, count): - top, bottom = sorted((editor.cursor_location[0], cls.destination(editor, editor.cursor_location, count)[0])) - mode.operator.lines(editor, top, bottom - top + 1) - - -class Down(LineMotion): - keys = ("j",) - direction = 1 - - -class Up(LineMotion): - keys = ("k",) - direction = -1 - - -class LineStart(Motion): - keys = ("0",) - - @classmethod - def destination(cls, editor, origin, count): - return origin[0], 0 - - -class FirstNonblank(Motion): - keys = ("circumflex",) - - @classmethod - def destination(cls, editor, origin, count): - return editor.navigator.get_location_first_nonblank(origin) - - -class LineEnd(Motion): - keys = ("dollar",) - - @classmethod - def destination(cls, editor, origin, count): - return editor.navigator.get_location_document_line_end(origin, count) - - -class Word(Motion): - keys = ("w",) - boundary = WordBoundary() - - @classmethod - def destination(cls, editor, origin, count): - return editor.navigator.get_location_word_boundary(origin, count, cls.boundary) - - -class BackWord(Word): - keys = ("b",) - boundary = PreviousWordBoundary() - - -class EndWord(Word): - keys = ("e",) - boundary = EndWordBoundary() - inclusive = True - - -class DocumentEnd(Motion): - keys = ("G",) - - @classmethod - def destination(cls, editor, origin, count): - return editor.document.end - - -@dataclass(frozen=True) -class EditorRange: - start: Location - end: Location - - def text(self, editor: TextArea) -> str: - return editor.document.get_text_range(self.start, self.end) - - def clipboard_content(self, editor: TextArea) -> ClipboardContent: - return ClipboardContent(self.text(editor)) - - def replace_characters(self, editor: TextArea, character: str) -> None: - text = "".join(symbol if symbol in "\r\n" else character for symbol in self.text(editor)) - editor.replace(text, self.start, self.end, maintain_selection_offset=False) - editor.move_cursor(self.start) - - def deletion_start(self, editor: TextArea) -> Location: - return self.start - - def change_text(self, editor: TextArea) -> str: - return "" - - -class LineRange(EditorRange): - def clipboard_content(self, editor: TextArea) -> ClipboardContent: - return LinewiseClipboard(self.text(editor)) - - def change_text(self, editor: TextArea) -> str: - return editor.document.newline - - def text(self, editor: TextArea) -> str: - text = super().text(editor) - return text if text.endswith(editor.document.newline) else text + editor.document.newline - - -class FinalLineRange(LineRange): - def change_text(self, editor: TextArea) -> str: - return "" - - def deletion_start(self, editor: TextArea) -> Location: - if self.start[0]: - row = self.start[0] - 1 - return row, len(editor.document[row]) - return self.start - - -class Operator(EditorCommand): - @classmethod - def apply(cls, editor, mode, count): - mode.operate(editor, cls, count) - - @classmethod - def pending(cls, editor, mode, count): - if cls is mode.operator: - cls.lines(editor, editor.cursor_location[0], count) - - @classmethod - def lines(cls, editor, row, count): - end_row = min(editor.document.line_count, row + count) - if end_row < editor.document.line_count: - selection = LineRange((row, 0), (end_row, 0)) - else: - selection = FinalLineRange((row, 0), editor.document.end) - cls.replace(editor, selection) - - @classmethod - def replace(cls, editor, selection: EditorRange): - raise NotImplementedError - - -class Yank(Operator): - keys = ("y",) + def cursor_shape(self) -> CursorShape: + return SteadyBlock() @classmethod - def replace(cls, editor, selection: EditorRange): - editor.app.copy_to_clipboard(selection.clipboard_content(editor)) - editor.move_cursor(selection.start) - editor.editing_mode = VimNormal() - - -class Delete(Yank): - keys = ("d",) - following_mode: ClassVar[type[EditingMode]] = VimNormal - - @classmethod - def deletion_start(cls, editor, selection): - return selection.deletion_start(editor) - - @classmethod - def inserted_text(cls, editor, selection): - return "" - - @classmethod - def replace(cls, editor, selection: EditorRange): - super().replace(editor, selection) - editor.editing_mode = cls.following_mode() - editor.replace(cls.inserted_text(editor, selection), cls.deletion_start(editor, selection), - selection.end, maintain_selection_offset=False) - - -class Change(Delete): - keys = ("c",) - following_mode = VimInsert - - @classmethod - def deletion_start(cls, editor, selection): - return selection.start - - @classmethod - def inserted_text(cls, editor, selection): - return selection.change_text(editor) - - -class Insert(EditorCommand): - keys = ("i",) - - @classmethod - def apply(cls, editor, mode, count): - editor.editing_mode = VimInsert() - - -class Append(Insert): - keys = ("a",) - - @classmethod - def apply(cls, editor, mode, count): - editor.move_cursor(Right.destination(editor, editor.cursor_location, 1)) - super().apply(editor, mode, count) - - -class Visual(EditorCommand): - keys = ("v",) - - @classmethod - def apply(cls, editor, mode, count): - mode.visual(editor) - - -class Escape(EditorCommand): - keys = ("escape",) - - @staticmethod - def available(editor: TextArea) -> bool: - """Leaving a mode never edits the read-only document.""" - return True - - @classmethod - def insert_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: - return cls.native_bindings(mode) - - @classmethod - def pending_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: - return cls.mode_bindings(mode, VimMode) - - @classmethod - def apply_mode(cls, editor: TextArea, mode: VimMode) -> None: - mode.escape(editor) - - -class DeleteCharacter(EditorCommand): - keys = ("x",) - - @classmethod - def apply(cls, editor, mode, count): - mode.characters(editor, Delete, count) - - -class Undo(EditorCommand): - keys = ("u",) - - @classmethod - def apply(cls, editor, mode, count): - for _ in range(min(count, len(editor.history.undo_stack))): - editor.undo() - - -class Redo(Undo): - keys = ("ctrl+r",) - - @classmethod - def apply(cls, editor, mode, count): - for _ in range(min(count, len(editor.history.redo_stack))): - editor.redo() - - -class Paste(EditorCommand): - keys = ("p",) - before = False - - @classmethod - def apply(cls, editor, mode, count): - editor.paste_editor_text(before=cls.before, count=count) - - -class PasteBefore(Paste): - keys = ("P",) - before = True - - -class CharacterCommand(EditorCommand): - @classmethod - def apply(cls, editor, mode, count): - editor.editing_mode = AwaitKey(cls, mode, count) + def fallback_bindings(cls, binding: Binding) -> tuple[Binding, ...]: + return (binding,) - @classmethod - def character(cls, editor, mode, character, count): + @abstractmethod + async def handle_key(self, editor: TextArea, event: Key) -> bool: raise NotImplementedError - - -class GoToStart(CharacterCommand): - keys = ("g",) - - @classmethod - def character(cls, editor, mode, character, count): - if character == "g": - mode.move_to(editor, (min(count - 1, editor.document.line_count - 1), 0)) - - -class FindCharacter(CharacterCommand): - keys = ("f",) - - @classmethod - def character(cls, editor, mode, character, count): - if character is None: - return - origin = editor.cursor_location - line = editor.document[origin[0]] - column = origin[1] - for _ in range(count): - column = line.find(character, column + 1) - if column < 0: - return - mode.move_to(editor, (origin[0], column)) - - -class ReplaceCharacter(CharacterCommand): - keys = ("r",) - - @classmethod - def character(cls, editor, mode, character, count): - if character is not None and character.isprintable(): - mode.character_range(editor, count).replace_characters(editor, character) - editor.editing_mode = VimNormal() diff --git a/src/textual/document/_wrapped_document.py b/src/textual/document/_wrapped_document.py index 68846bedab8..5ab67f97789 100644 --- a/src/textual/document/_wrapped_document.py +++ b/src/textual/document/_wrapped_document.py @@ -151,7 +151,7 @@ def lines(self) -> list[list[str]]: @property def height(self) -> int: """The height of the wrapped document.""" - return sum(len(offsets) + 1 for offsets in self._wrap_offsets) + return len(self._offset_to_line_info) def wrap_range( self, diff --git a/src/textual/dom.py b/src/textual/dom.py index 2e0778f673f..6db781391cb 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -552,11 +552,6 @@ def declared_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: for binding in base.__dict__.get("_declared_key_bindings", ()) for projected in binding.for_mode(mode))) - @classmethod - def editor_command_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: - """Only native document editors declare modal document commands.""" - return () - @classmethod def binding_defaults(cls, mode: type[EditingMode]) -> BindingsMap: """Catalogs see the same raw and nominal native defaults as dispatch.""" @@ -578,10 +573,6 @@ def apply_editor_binding(self, binding: MethodBinding, event: Key) -> bool: binding.call(self) return True - def apply_editor_command(self, command: KeyDeclaration[TextArea]) -> bool: - """A generic node does not own a modal document editor.""" - return False - @cached_property def _query_one_cache(self) -> LRUCache[QueryOneCacheKey, DOMNode]: return LRUCache(1024) diff --git a/src/textual/keys.py b/src/textual/keys.py index ca7b509b739..a285239b4eb 100644 --- a/src/textual/keys.py +++ b/src/textual/keys.py @@ -340,6 +340,10 @@ def invocations(self, defaults: tuple[KeyInvocation, ...]) -> tuple[KeyInvocatio def preempts(self, binding: Binding, event: Key, keymap: Mapping[str, str]) -> bool: return False + def input_bindings(self, spelling: str) -> tuple[tuple[str, str], ...]: + """Native input aliases for an external editing engine.""" + return () + class UndecodedKey(DeclaredKey): """Only Binding.make_bindings sees an external declaration's key spelling.""" @@ -383,6 +387,9 @@ def preempts(self, binding: Binding, event: Key, keymap: Mapping[str, str]) -> b def key_identity(self) -> str: return self.qualified(type(self)) + def input_bindings(self, spelling: str) -> tuple[tuple[str, str], ...]: + return ((spelling, self.key.value),) + def key_title(self) -> str: return f"Cursor {self.key.value}" diff --git a/src/textual/widget.py b/src/textual/widget.py index 42036292cfb..78932ebc314 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -4588,6 +4588,10 @@ async def _on_click(self, event: events.Click) -> None: await self.broker_event("click", event) + async def prepare_key(self) -> bool: + """Allow an asynchronous editor to settle previous input before key lookup.""" + return True + async def _on_key(self, event: events.Key) -> None: await self.handle_key(event) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index a980d7bafd6..4916b3f3d57 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from functools import lru_cache from pathlib import Path +from types import FunctionType from typing import TYPE_CHECKING, ClassVar, Iterable, Optional, Sequence, Tuple from rich.console import RenderableType @@ -28,13 +29,14 @@ DocumentBase, EditResult, Location, + RangedEditResult, Selection, _utf8_encode, ) from textual.document._document_navigator import DocumentNavigator -from textual.document._edit import Edit +from textual.document._edit import Edit, InsertAt, InsertionPoint from textual.document._history import EditHistory -from textual.document._editing import EditingMode, LineMotion, VimNormal +from textual.document._editing import EditingFailure, EditingMode from textual.document._syntax_aware_document import ( SyntaxAwareDocument, SyntaxAwareDocumentError, @@ -46,11 +48,10 @@ if TYPE_CHECKING: from tree_sitter import Language, Query - from textual.keys import KeyDeclaration from textual import events, log from textual._cells import cell_len, cell_width_to_column_index -from textual.binding import Binding, BindingsMap, MethodBinding +from textual.binding import Binding, MethodBinding from textual.events import Message, MouseEvent from textual.geometry import Offset, Region, Size, Spacing, clamp from textual.reactive import Reactive, reactive @@ -154,25 +155,37 @@ def key_mode_label(self) -> str: def binding_mode(self) -> type[EditingMode]: return self.editing_mode.binding_mode - def binding_map(self, mode: type[EditingMode]) -> BindingsMap: - return BindingsMap.merge(( - BindingsMap(self.editing_mode.command_bindings()), super().binding_map(mode), - )) - - @classmethod - def editor_command_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: - return mode().command_bindings() - def apply_editor_binding(self, binding: MethodBinding, event: events.Key) -> bool: return self.editing_mode.apply_native_binding(self, binding, event) - def apply_editor_command(self, command: KeyDeclaration[TextArea]) -> bool: - command(self) - return True + def before_engine_input(self, action: FunctionType) -> bool: + """Offer a declared application boundary without interpreting engine commands.""" + return False - def move_line_motion(self, motion: type[LineMotion], mode: VimNormal, count: int) -> None: - """Offer an application's line boundary before the original modal geometry.""" - mode.motion(self, motion, count) + async def prepare_key(self) -> bool: + mode = self.editing_mode + ready = await mode.prepare_input(self) + if mode is not self.editing_mode: + return await self.prepare_key() + return ready + + async def switch_editing_mode(self, mode: EditingMode) -> None: + """Transfer only after the previous engine has acknowledged its input.""" + previous = self.editing_mode + try: + if not await previous.finish(self): + self.notify("Finish pending editor input before changing the editing profile.", severity="warning") + return + except EditingFailure: + # Explicitly selecting another editor recovers the displayed draft, + # not the failed engine's uncertain input. + pass + await previous.close() + self.editing_mode = mode + + async def on_unmount(self) -> None: + """The public lifecycle hook awaits the editor's owned child retirement.""" + await self.editing_mode.unmount(self) DEFAULT_CSS = """\ TextArea { @@ -805,7 +818,7 @@ def _build_highlight_map(self) -> None: def _watch_editing_mode(self, previous: EditingMode, current: EditingMode) -> None: previous.suspend(self) - current.resume(self) + current.attach(self) self.show_editing_mode(current) def show_editing_mode(self, mode: EditingMode) -> None: @@ -816,12 +829,6 @@ def leave_modal_editor(self) -> None: """Hook for an embedding application to focus its navigation surface.""" self.screen.focus_next() - def paste_editor_text(self, *, before: bool, count: int = 1) -> None: - """Paste the retained native register with its original range semantics.""" - content = self.app.clipboard_content - if not self.read_only and content.text: - content.paste(self, before, count) - def _watch_has_focus(self, focus: bool) -> None: self._cursor_visible = focus if focus: @@ -832,7 +839,22 @@ def _watch_has_focus(self, focus: bool) -> None: self.editing_mode.suspend(self) self.editing_mode = self.editing_mode.settled() - def _watch_selection( + def paint_selection(self, selection: Selection) -> None: + """Paint acknowledged cursor state without issuing another cursor request.""" + previous = self.selection + start, end = selection + selection = Selection(self.clamp_visitable(start), self.clamp_visitable(end)) + self.set_reactive(TextArea.selection, selection) + self._present_selection(previous, selection) + self.watch_selection(previous, selection) + + def watch_selection(self, previous_selection: Selection, selection: Selection) -> None: + """Application presentation hook shared by native and engine cursor updates.""" + + def _watch_selection(self, previous_selection: Selection, selection: Selection) -> None: + self._present_selection(previous_selection, selection) + + def _present_selection( self, previous_selection: Selection, selection: Selection ) -> None: """When the cursor moves, scroll it into view.""" @@ -937,7 +959,9 @@ def _validate_selection(self, selection: Selection) -> Selection: """Clamp the selection to valid locations.""" start, end = selection clamp_visitable = self.clamp_visitable - return Selection(clamp_visitable(start), clamp_visitable(end)) + return self.editing_mode.request_selection( + self, Selection(clamp_visitable(start), clamp_visitable(end)) + ) def _watch_language(self, language: str | None) -> None: """When the language is updated, update the type of document.""" @@ -1149,6 +1173,10 @@ def _set_document(self, text: str, language: str | None) -> None: # Use a regular plain-text document. document = Document(text) + self.set_document(document) + + def set_document(self, document: DocumentBase) -> None: + """Adopt an existing document, keeping width-dependent view resources local.""" self._set_document_view(WrappedDocument(document, tab_width=self.indent_width)) self._rewrap_and_refresh_virtual_size() @@ -1158,7 +1186,6 @@ def _set_document_view(self, wrapped_document: WrappedDocument) -> None: self.wrapped_document = wrapped_document self.navigator = DocumentNavigator(self.wrapped_document) self._build_highlight_map() - self.move_cursor((0, 0)) @property def _visible_line_indices(self) -> tuple[int, int]: @@ -1171,6 +1198,9 @@ def _visible_line_indices(self) -> tuple[int, int]: return scroll_offset_y, scroll_offset_y + self.size.height def load_text(self, text: str) -> None: + self.editing_mode.load_text(self, text) + + def _load_text(self, text: str) -> None: """Load text into the TextArea. This will replace the text currently in the TextArea and clear the edit history. @@ -1181,6 +1211,7 @@ def load_text(self, text: str) -> None: self.history.clear() self.editing_mode = self.editing_mode.document_changed() self._set_document(text, self.language) + self.move_cursor((0, 0)) self.editing_mode.resume(self) self.post_message(self.Changed(self).set_sender(self)) self.update_suggestion() @@ -1190,26 +1221,29 @@ def capture_editor_state(self) -> TextAreaState: return TextAreaState( self.document, self.history, self.selection, self.scroll_x, self.scroll_y, self.language, tuple(self._languages.values()), self._highlight_query, - self.editing_mode.settled(), + self.editing_mode.capture(self), ) - def restore_editor_state(self, state: TextAreaState) -> None: + async def restore_editor_state(self, state: TextAreaState) -> None: """Attach captured editor state without loading text or clearing history. The receiving widget owns fresh width-dependent wrapping and paint. Selection and scroll are restored through their ordinary native paths. """ + previous_mode = self.editing_mode + if previous_mode is not state.editing_mode: + # This surface is being reconstructed, not transferring user input. + # The retained owner replaces the unused temporary owner outright. + await previous_mode.close() self._languages = {language.name: language for language in state.languages} self.set_reactive(TextArea.language, state.language) - self.document = state.document self.history = state.history self._highlight_query = state.highlight_query - self.wrapped_document = WrappedDocument(self.document, tab_width=self.indent_width) - self.navigator = DocumentNavigator(self.wrapped_document) - self._build_highlight_map() - self._rewrap_and_refresh_virtual_size() - self.selection = state.selection + self.set_document(state.document) + self.paint_selection(state.selection) self.editing_mode = state.editing_mode + if previous_mode is state.editing_mode: + self.editing_mode.attach(self) self.record_cursor_width() self.call_after_refresh(self.scroll_to, x=state.scroll_x, y=state.scroll_y, animate=False, immediate=True) @@ -1218,6 +1252,7 @@ def restore_editor_state(self, state: TextAreaState) -> None: def _on_resize(self) -> None: self._rewrap_and_refresh_virtual_size() + self.editing_mode.resize(self) def _watch_soft_wrap(self) -> None: self._rewrap_and_refresh_virtual_size() @@ -1437,7 +1472,7 @@ def _render_line(self, y: int, paint: PaintState) -> Strip: line.set_length(line_character_count + 1) # space at end for cursor virtual_width, _virtual_height = self.virtual_size - selection = self.editing_mode.selection_range(self) + selection = self.editing_mode.selection_for_line(self, line_index) start, end = selection cursor_row, cursor_column = self.cursor_location @@ -1640,8 +1675,7 @@ def text(self, value: str) -> None: @property def selected_text(self) -> str: """The selected range, shared with paint and modal operators.""" - start, end = self.editing_mode.selection_range(self) - return self.get_text_range(start, end) + return self.editing_mode.selected_text(self) @property def matching_bracket_location(self) -> Location | None: @@ -1661,7 +1695,50 @@ def get_text_range(self, start: Location, end: Location) -> str: start, end = sorted((start, end)) return self.document.get_text_range(start, end) - def edit(self, edit: Edit) -> EditResult: + def invalidate_engine_regions(self, regions: Sequence[Selection]) -> None: + """Invalidate only cached/visible rows intersecting engine-painted regions. + + Document-to-display coordinates and cache keys belong to TextArea, not + to an external editor. Both the old and new regions belong in this call. + """ + def affected(offset: int) -> bool: + row, _ = self.wrapped_document.offset_to_location(Offset(0, offset)) + return any(region.contains_line(row) for region in regions) + + for key in tuple(self._line_cache.keys()): + if affected(key[2]): + self._line_cache.discard(key) + first, last = self._visible_line_indices + for offset in range(first, last): + if affected(offset): + self.refresh_lines(offset) + + def project_engine_edit(self, result: RangedEditResult) -> None: + """Update this view after the document owner has applied an engine edit.""" + previous_height = self.wrapped_document.height + self.wrapped_document.wrap_range(result.start, result.old_end, result.end_location) + final = max(result.old_end[0] + 1, result.end_location[0] + 1) + if self.wrapped_document.height != previous_height: + final = max(final, self.document.line_count) + self.invalidate_engine_regions((Selection((result.start[0], 0), (final, 0)),)) + + def finish_engine_changes(self) -> None: + """Finish one batch of projected ranges, never once per buffer event. + + Syntax queries can have nonlocal effects; rebuild them once when present. + Plain/custom-highlighted editors retain their unaffected row cache here. + """ + if self._highlight_query: + self._build_highlight_map() + self._refresh_size() + self.post_message(self.Changed(self)) + self.update_suggestion() + + def edit(self, edit: Edit) -> EditResult | None: + """Submit an edit; asynchronous engines publish its acknowledged result.""" + return self.editing_mode.edit(self, edit) + + def _edit(self, edit: Edit) -> EditResult: """Perform an Edit. Args: @@ -1697,6 +1774,9 @@ def edit(self, edit: Edit) -> EditResult: return result def undo(self) -> None: + self.editing_mode.undo(self) + + def _undo(self) -> None: """Undo the edits since the last checkpoint (the most recent batch of edits).""" if edits := self.history._pop_undo(): self._undo_batch(edits) @@ -1707,6 +1787,9 @@ def action_undo(self) -> None: self.undo() def redo(self) -> None: + self.editing_mode.redo(self) + + def _redo(self) -> None: """Redo the most recently undone batch of edits.""" if edits := self.history._pop_redo(): self._redo_batch(edits) @@ -1818,7 +1901,7 @@ async def _on_key(self, event: events.Key) -> None: if self.read_only: return - if self.editing_mode.handle_key(self, event): + if await self.editing_mode.handle_key(self, event): event.stop() event.prevent_default() return @@ -1979,7 +2062,7 @@ async def _on_mouse_up(self, event: events.MouseUp) -> None: """Finalize the selection that has been made using the mouse.""" self._end_mouse_selection() - async def _on_hide(self, event: events.Hide) -> None: + def _on_hide(self, event: events.Hide) -> None: """Finalize the selection that has been made using the mouse when the widget is hidden.""" self._end_mouse_selection() @@ -1987,10 +2070,8 @@ async def _on_paste(self, event: events.Paste) -> None: """When a paste occurs, insert the text from the paste event into the document.""" if self.read_only: return - self.editing_mode = self.editing_mode.pasted() - if result := self._replace_via_keyboard(event.text, *self.selection): - self.move_cursor(result.end_location) - self.focus() + self.editing_mode.paste(self, event.text) + self.focus() def cell_width_to_column_index(self, cell_width: int, row_index: int) -> int: """Return the column that the cell width corresponds to on the given row. @@ -2469,7 +2550,7 @@ def insert( location: Location | None = None, *, maintain_selection_offset: bool = True, - ) -> EditResult: + ) -> EditResult | None: """Insert text into the document. Args: @@ -2485,9 +2566,8 @@ def insert( """ if len(text) > 1: self._restart_blink() - if location is None: - location = self.cursor_location - return self.edit(Edit(text, location, location, maintain_selection_offset)) + point = InsertionPoint() if location is None else InsertAt(location) + return self.editing_mode.insert(self, text, point, maintain_selection_offset) def delete( self, @@ -2495,7 +2575,7 @@ def delete( end: Location, *, maintain_selection_offset: bool = True, - ) -> EditResult: + ) -> EditResult | None: """Delete the text between two locations in the document. Args: @@ -2518,7 +2598,7 @@ def replace( end: Location, *, maintain_selection_offset: bool = True, - ) -> EditResult: + ) -> EditResult | None: """Replace text in the document with new text. Args: @@ -2535,14 +2615,13 @@ def replace( """ return self.edit(Edit(insert, start, end, maintain_selection_offset)) - def clear(self) -> EditResult: + def clear(self) -> EditResult | None: """Delete all text from the document. Returns: An EditResult relating to the deletion of all content. """ - self.editing_mode = self.editing_mode.document_changed() - return self.delete((0, 0), self.document.end, maintain_selection_offset=False) + return self.editing_mode.clear(self) def _delete_via_keyboard( self, @@ -2664,10 +2743,7 @@ def action_paste(self) -> None: """Paste from local clipboard.""" if self.read_only: return - self.editing_mode = self.editing_mode.pasted() - clipboard = self.app.clipboard - if result := self._replace_via_keyboard(clipboard, *self.selection): - self.move_cursor(result.end_location) + self.editing_mode.paste(self, self.app.clipboard) def action_delete_to_start_of_line(self) -> None: """Deletes from the cursor location to the start of the line.""" diff --git a/src/textual/widgets/text_area.py b/src/textual/widgets/text_area.py index e02e3849d56..34b327a0aff 100644 --- a/src/textual/widgets/text_area.py +++ b/src/textual/widgets/text_area.py @@ -8,7 +8,7 @@ ) from textual.document._document_navigator import DocumentNavigator from textual.document._edit import Edit -from textual.document._editing import EditingMode, VimInsert, VimMode, VimNormal, VimVisual +from textual.document._editing import EditingMode, VimMode from textual.document._history import EditHistory from textual.document._syntax_aware_document import SyntaxAwareDocument from textual.document._wrapped_document import WrappedDocument @@ -32,10 +32,7 @@ "EditResult", "EditHistory", "EditingMode", - "VimInsert", "VimMode", - "VimNormal", - "VimVisual", "EndColumn", "Highlight", "HighlightName", diff --git a/tests/text_area/test_application_command_mode.py b/tests/text_area/test_application_command_mode.py index 0a597761283..758c55e3491 100644 --- a/tests/text_area/test_application_command_mode.py +++ b/tests/text_area/test_application_command_mode.py @@ -1,8 +1,7 @@ -"""Real key dispatch: idle Normal permits app commands, editing retains its keys.""" +"""Ordinary Input retains its keys when application commands are unavailable.""" from textual.app import App, ComposeResult from textual.binding import Binding -from textual.document._editing import AwaitKey, Escape, Left, VimInsert, VimNormal, VimVisual -from textual.widgets import Input, TextArea +from textual.widgets import Input class CommandModeApp(App): @@ -11,9 +10,6 @@ class CommandModeApp(App): navigations = 0 def compose(self) -> ComposeResult: - editor = TextArea("abcHdef") - editor.editing_mode = VimInsert() - yield editor yield Input("abcHdef", select_on_focus=False) def check_action(self, action, parameters): @@ -25,24 +21,6 @@ def action_navigate(self): self.navigations += 1 -async def test_idle_normal_allows_priority_without_stealing_editing(): - app = CommandModeApp() - async with app.run_test() as pilot: - editor = app.query_one(TextArea) - editor.focus() - await pilot.press("H") - assert editor.text == "HabcHdef" and app.navigations == 0 - await pilot.press("escape", "H", "ctrl+shift+k") - assert app.navigations == 2 and editor.has_focus - assert editor.text == "HabcHdef" - await pilot.press("2", "H", "v", "H", "escape", "d", "H") - assert app.navigations == 2 and editor.text == "HabcHdef" - await pilot.press("0", "f", "H") - assert editor.cursor_location == (0, 4) and app.navigations == 2 - await pilot.press("i", "ctrl+shift+k") - assert editor.text == "" and app.navigations == 2 - - async def test_ordinary_input_retains_printable_and_nonprintable_keys(): app = CommandModeApp() async with app.run_test() as pilot: @@ -56,65 +34,3 @@ async def test_ordinary_input_retains_printable_and_nonprintable_keys(): app.screen.set_focus(None) await pilot.press("H", "ctrl+shift+k") assert app.navigations == 2 - - -async def test_remapped_editor_commands_keep_counts_operators_and_visual_scope(): - app = CommandModeApp() - async with app.run_test() as pilot: - editor = app.query_one(TextArea) - editor.focus() - await pilot.press("escape") - app.set_keymap({Left.binding_id(VimNormal): "z", Left.binding_id(VimVisual): "q"}) - editor.cursor_location = (0, 4) - await pilot.press("h") - assert editor.cursor_location == (0, 4) - await pilot.press("z") - assert editor.cursor_location == (0, 3) - await pilot.press("2", "z") - assert editor.cursor_location == (0, 1) - await pilot.press("v", "z") - assert editor.cursor_location == (0, 1) - await pilot.press("q") - assert editor.cursor_location == (0, 0) - assert editor.selection.start != editor.selection.end - await pilot.press("escape") - editor.cursor_location = (0, 3) - await pilot.press("d", "z") - assert editor.text == "abHdef" - await pilot.press("f", "H") - assert editor.cursor_location == (0, 2) - assert app.navigations == 0 - editor.read_only = True - await pilot.press("x", "d", "z") - assert editor.text == "abHdef" - - -async def test_escape_remaps_cover_insert_visual_and_pending_character_commands(): - app = CommandModeApp() - async with app.run_test() as pilot: - editor = app.query_one(TextArea) - editor.focus() - app.set_keymap({Escape.binding_id(VimInsert): "f6", - Escape.binding_id(VimNormal): "f7", - Escape.binding_id(VimVisual): "f8"}) - await pilot.press("escape") - assert isinstance(editor.editing_mode, VimInsert) - await pilot.press("f6") - assert isinstance(editor.editing_mode, VimNormal) - before = editor.cursor_location - await pilot.press("f") - assert isinstance(editor.editing_mode, AwaitKey) - await pilot.press("f7") - assert isinstance(editor.editing_mode, VimNormal) - assert editor.cursor_location == before - await pilot.press("f", "H") - assert editor.cursor_location == (0, 3) - await pilot.press("v", "escape") - assert isinstance(editor.editing_mode, VimVisual) - await pilot.press("f8") - assert type(editor.editing_mode) is VimNormal - editor.read_only = True - editor.editing_mode = VimInsert() - await pilot.press("f6") - assert type(editor.editing_mode) is VimNormal - assert editor.text == "abcHdef" diff --git a/tests/text_area/test_cursor_shape.py b/tests/text_area/test_cursor_shape.py index 755b30dfa3e..dcdd65c793a 100644 --- a/tests/text_area/test_cursor_shape.py +++ b/tests/text_area/test_cursor_shape.py @@ -1,12 +1,7 @@ import pytest from textual.app import App, ComposeResult -from textual.document._editing import ( - EditingMode, - VimInsert, - VimNormal, - VimVisual, -) +from textual.document._editing import EditingMode from textual.widgets import TextArea @@ -24,9 +19,6 @@ def compose(self) -> ComposeResult: ("mode", "focused_sequence", "draws_cell"), [ (EditingMode(), "", True), - (VimInsert(), "\x1b[5 q\x1b[?25h", False), - (VimNormal(), "\x1b[2 q\x1b[?25h", False), - (VimVisual(), "\x1b[2 q\x1b[?25h", False), ], ) async def test_editing_mode_sets_terminal_cursor_shape(