From 2aa2001fa4d7c36d5cf7eb2bc2ccada4be4e08b6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 01:44:57 -0400 Subject: [PATCH 1/7] Let TextArea editing capabilities own async engines; delete handwritten Vim --- src/textual/app.py | 1 + src/textual/document/_editing.py | 736 ++---------------------------- src/textual/keys.py | 7 + src/textual/widget.py | 3 + src/textual/widgets/_text_area.py | 59 ++- src/textual/widgets/text_area.py | 5 +- 6 files changed, 97 insertions(+), 714 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index f822d7083e..bfc08de402 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -4265,6 +4265,7 @@ async def on_event(self, event: events.Event) -> None: self.screen.minimize() return if self.focused: + await self.focused.prepare_key() try: self.screen._clear_tooltip() except NoScreen: diff --git a/src/textual/document/_editing.py b/src/textual/document/_editing.py index 1f7780213e..38cdbffeea 100644 --- a/src/textual/document/_editing.py +++ b/src/textual/document/_editing.py @@ -1,79 +1,53 @@ -"""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 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, Selection if TYPE_CHECKING: + from textual.document._edit import Edit from textual.events import Key - from textual.dom import DOMNode from textual.widgets import TextArea 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 label(self) -> str: - return "" + label = "" @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() + binding.call(editor) + return True 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 () - @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 settled(self) -> EditingMode: return self @@ -83,683 +57,63 @@ def pasted(self) -> EditingMode: 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 pasted(self) -> EditingMode: - return VimInsert() - - def command_bindings(self) -> tuple[Binding, ...]: - return tuple(binding for command in EditorCommand.declarations() - for binding in self.declared_command_bindings(command)) - - def declared_command_bindings(self, command: type[EditorCommand]) -> tuple[Binding, ...]: - return command.bindings_for(self.binding_mode) - - def apply_command(self, editor: TextArea, command: type[EditorCommand]) -> bool: - command.apply_mode(editor, self) - 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 - - 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) - 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 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 + def capture(self, editor: TextArea) -> EditingMode: + return self.settled() - @property - def allows_application_commands(self) -> bool: - return False + async def unmount(self, editor: TextArea) -> None: + await self.close() - def document_changed(self) -> EditingMode: - return VimNormal() + async def close(self) -> None: + pass - def move_to(self, editor: TextArea, location: Location) -> None: - editor.move_cursor(location, select=True) + async def flush(self, editor: TextArea) -> None: + """Finish admitted input before submission, transfer or retirement.""" - def character_range(self, editor: TextArea, count: int) -> EditorRange: - return EditorRange(*self.selection_range(editor)) + async def prepare_input(self, editor: TextArea) -> None: + """Asynchronous engines may fence earlier widget input before binding lookup.""" - def visual(self, editor: TextArea) -> None: - self.escape(editor) + def selection_changed(self, editor: TextArea, selection: Selection) -> None: + pass 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() - - -class PendingMode(EditingMode): - """An unfinished command owns printable operands and suppresses history.""" - - @property - def allows_history_navigation(self) -> bool: - return False - - @property - def allows_application_commands(self) -> bool: - return False - - 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) - - -@dataclass(frozen=True, kw_only=True) -class VimOperator(PendingMode, VimNormal): - operator: type[Operator] - operator_count: int = 1 - - def settled(self) -> EditingMode: - return VimNormal() - - 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 escape(self, editor: TextArea) -> None: - editor.editing_mode = VimNormal() - - -@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 - - @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",) - - @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() - + return editor.selection -class Redo(Undo): - keys = ("ctrl+r",) + def load_text(self, editor: TextArea, text: str) -> None: + editor._load_text(text) - @classmethod - def apply(cls, editor, mode, count): - for _ in range(min(count, len(editor.history.redo_stack))): - editor.redo() + def edit(self, editor: TextArea, edit: Edit) -> EditResult | None: + return editor._edit(edit) + def undo(self, editor: TextArea) -> None: + editor._undo() -class Paste(EditorCommand): - keys = ("p",) - before = False + def redo(self, editor: TextArea) -> None: + editor._redo() - @classmethod - def apply(cls, editor, mode, count): - editor.paste_editor_text(before=cls.before, count=count) + def paste(self, editor: TextArea, text: str) -> None: + if result := editor._replace_via_keyboard(text, *editor.selection): + editor.move_cursor(result.end_location) -class PasteBefore(Paste): - keys = ("P",) - before = True +class VimMode(EditingMode, ABC): + """Modal binding capability, not a second implementation of Vim.""" + modal = True + cursor_shape = SteadyBlock() -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/keys.py b/src/textual/keys.py index ca7b509b73..54271023c8 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_key(self, event: Key) -> str: + """The external editor receives native input identity, not a remapped spelling.""" + return event.key + 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_key(self, event: Key) -> str: + return 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 42036292cf..a7c18a7e9d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -4588,6 +4588,9 @@ async def _on_click(self, event: events.Click) -> None: await self.broker_event("click", event) + async def prepare_key(self) -> None: + """Allow an asynchronous editor to settle previous input before key lookup.""" + 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 9a9280762c..fb0b15380c 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -34,7 +34,7 @@ from textual.document._document_navigator import DocumentNavigator from textual.document._edit import Edit from textual.document._history import EditHistory -from textual.document._editing import EditingMode, LineMotion, VimNormal +from textual.document._editing import EditingMode from textual.document._syntax_aware_document import ( SyntaxAwareDocument, SyntaxAwareDocumentError, @@ -161,13 +161,22 @@ def editor_command_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...] 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, key: str) -> 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) -> None: + await self.editing_mode.prepare_input(self) + + async def switch_editing_mode(self, mode: EditingMode) -> None: + """Transfer only after the previous engine has acknowledged its input.""" + previous = self.editing_mode + await previous.flush(self) + await previous.close() + self.editing_mode = mode + + async def _on_unmount(self) -> None: + await self.editing_mode.unmount(self) DEFAULT_CSS = """\ TextArea { @@ -800,7 +809,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: @@ -836,6 +845,7 @@ def _watch_selection( if not self.is_mounted: return + self.editing_mode.selection_changed(self, selection) self.app.clear_selection() cursor_location = selection.end @@ -1166,6 +1176,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. @@ -1185,7 +1198,7 @@ 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: @@ -1656,7 +1669,11 @@ 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 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: @@ -1692,6 +1709,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) @@ -1702,6 +1722,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) @@ -1813,7 +1836,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 @@ -1982,10 +2005,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. @@ -2464,7 +2485,7 @@ def insert( location: Location | None = None, *, maintain_selection_offset: bool = True, - ) -> EditResult: + ) -> EditResult | None: """Insert text into the document. Args: @@ -2490,7 +2511,7 @@ def delete( end: Location, *, maintain_selection_offset: bool = True, - ) -> EditResult: + ) -> EditResult | None: """Delete the text between two locations in the document. Args: @@ -2513,7 +2534,7 @@ def replace( end: Location, *, maintain_selection_offset: bool = True, - ) -> EditResult: + ) -> EditResult | None: """Replace text in the document with new text. Args: @@ -2530,7 +2551,7 @@ 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: diff --git a/src/textual/widgets/text_area.py b/src/textual/widgets/text_area.py index e02e3849d5..34b327a0af 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", From 3ec165a7ac1ab7f7a1d53b303682a5e71c510c0e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 02:02:24 -0400 Subject: [PATCH 2/7] Project engine deltas without native undo and fence async editing lifetime --- src/textual/_clipboard.py | 22 +---- src/textual/app.py | 3 +- src/textual/document/_document_navigator.py | 62 -------------- src/textual/document/_editing.py | 38 +++++++-- src/textual/dom.py | 9 --- src/textual/keys.py | 10 +-- src/textual/widget.py | 3 +- src/textual/widgets/_text_area.py | 90 +++++++++++++-------- 8 files changed, 96 insertions(+), 141 deletions(-) diff --git a/src/textual/_clipboard.py b/src/textual/_clipboard.py index d1661a8c59..f6f2d24b09 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 bfc08de402..e6c5350b97 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -4265,7 +4265,8 @@ async def on_event(self, event: events.Event) -> None: self.screen.minimize() return if self.focused: - await self.focused.prepare_key() + if not await self.focused.prepare_key(): + return try: self.screen._clear_tooltip() except NoScreen: diff --git a/src/textual/document/_document_navigator.py b/src/textual/document/_document_navigator.py index 027a466bbf..1507b94f64 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/_editing.py b/src/textual/document/_editing.py index 38cdbffeea..4550d08623 100644 --- a/src/textual/document/_editing.py +++ b/src/textual/document/_editing.py @@ -6,7 +6,7 @@ from textual.binding import Binding, MethodBinding from textual.cursor_shape import CellCursor, CursorShape, SteadyBlock -from textual.document._document import EditResult, Selection +from textual.document._document import EditResult, Location, Selection if TYPE_CHECKING: from textual.document._edit import Edit @@ -14,6 +14,10 @@ from textual.widgets import TextArea +class EditingFailure(RuntimeError): + """The engine failed; preserve its displayed draft and never replay input.""" + + class EditingMode: """Native text entry, with overridable document and input ownership.""" @@ -41,17 +45,14 @@ def apply_native_binding(self, editor: TextArea, binding: MethodBinding, event: binding.call(editor) return True - def command_bindings(self) -> tuple[Binding, ...]: - return () - @property def binding_mode(self) -> type[EditingMode]: return EditingMode - def settled(self) -> EditingMode: - return self + def enter_text(self, editor: TextArea) -> None: + """An application may focus an existing editor in text-entry mode.""" - def pasted(self) -> EditingMode: + def settled(self) -> EditingMode: return self def document_changed(self) -> EditingMode: @@ -75,11 +76,18 @@ async def unmount(self, editor: TextArea) -> None: async def close(self) -> None: pass + async def finish(self, editor: TextArea) -> None: + await self.flush(editor) + async def flush(self, editor: TextArea) -> None: """Finish admitted input before submission, transfer or retirement.""" - async def prepare_input(self, editor: TextArea) -> None: + async def prepare_input(self, editor: TextArea) -> bool: """Asynchronous engines may fence earlier widget input before binding lookup.""" + return True + + def resize(self, editor: TextArea) -> None: + pass def selection_changed(self, editor: TextArea, selection: Selection) -> None: pass @@ -87,12 +95,26 @@ def selection_changed(self, editor: TextArea, selection: Selection) -> None: def selection_range(self, editor: TextArea) -> Selection: return editor.selection + def selection_for_line(self, editor: TextArea, row: int) -> Selection: + return self.selection_range(editor) + + def selected_text(self, editor: TextArea) -> str: + return editor.get_text_range(*self.selection_range(editor)) + def load_text(self, editor: TextArea, text: str) -> None: editor._load_text(text) def edit(self, editor: TextArea, edit: Edit) -> EditResult | None: return editor._edit(edit) + def insert(self, editor: TextArea, text: str, location: Location | None, maintain_selection_offset: bool) -> EditResult | None: + from textual.document._edit import Edit + location = editor.cursor_location if location is None else location + return self.edit(editor, Edit(text, location, location, maintain_selection_offset)) + + def clear(self, editor: TextArea) -> EditResult | None: + return editor.delete((0, 0), editor.document.end, maintain_selection_offset=False) + def undo(self, editor: TextArea) -> None: editor._undo() diff --git a/src/textual/dom.py b/src/textual/dom.py index f2ccbc9e96..ff30bc5ca3 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -547,11 +547,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.""" @@ -573,10 +568,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 54271023c8..a285239b4e 100644 --- a/src/textual/keys.py +++ b/src/textual/keys.py @@ -340,9 +340,9 @@ def invocations(self, defaults: tuple[KeyInvocation, ...]) -> tuple[KeyInvocatio def preempts(self, binding: Binding, event: Key, keymap: Mapping[str, str]) -> bool: return False - def input_key(self, event: Key) -> str: - """The external editor receives native input identity, not a remapped spelling.""" - return event.key + def input_bindings(self, spelling: str) -> tuple[tuple[str, str], ...]: + """Native input aliases for an external editing engine.""" + return () class UndecodedKey(DeclaredKey): @@ -387,8 +387,8 @@ def preempts(self, binding: Binding, event: Key, keymap: Mapping[str, str]) -> b def key_identity(self) -> str: return self.qualified(type(self)) - def input_key(self, event: Key) -> str: - return self.key.value + 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 a7c18a7e9d..78932ebc31 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -4588,8 +4588,9 @@ async def _on_click(self, event: events.Click) -> None: await self.broker_event("click", event) - async def prepare_key(self) -> None: + 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 fb0b15380c..3db7ff9c53 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -34,7 +34,7 @@ from textual.document._document_navigator import DocumentNavigator from textual.document._edit import Edit from textual.document._history import EditHistory -from textual.document._editing import EditingMode +from textual.document._editing import EditingFailure, EditingMode from textual.document._syntax_aware_document import ( SyntaxAwareDocument, SyntaxAwareDocumentError, @@ -46,11 +46,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 @@ -149,15 +148,6 @@ def key_mode(self) -> type[EditingMode]: 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) @@ -165,13 +155,22 @@ def before_engine_input(self, key: str) -> bool: """Offer a declared application boundary without interpreting engine commands.""" return False - async def prepare_key(self) -> None: - await self.editing_mode.prepare_input(self) + 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 - await previous.flush(self) + try: + await previous.finish(self) + 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 @@ -820,12 +819,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: @@ -1201,12 +1194,16 @@ def capture_editor_state(self) -> TextAreaState: 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: + await previous_mode.flush(self) + 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 @@ -1218,6 +1215,8 @@ def restore_editor_state(self, state: TextAreaState) -> None: self._rewrap_and_refresh_virtual_size() self.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) @@ -1226,6 +1225,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() @@ -1445,7 +1445,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 @@ -1648,8 +1648,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: @@ -1669,6 +1668,35 @@ def get_text_range(self, start: Location, end: Location) -> str: start, end = sorted((start, end)) return self.document.get_text_range(start, end) + def apply_engine_lines(self, first: int, last: int, lines: tuple[str, ...]) -> None: + """Project an engine's line-range notification without writing native undo.""" + if last == -1: + # Initial ownership transfer is the only notification without a + # range in the existing rendering document. + self.history.clear() + self._set_document("\n".join(lines), self.language) + else: + document = self.document + count = document.line_count + end = (last, 0) if last < count else document.end + start = (first, 0) if first < count else document.end + text = document.newline.join(lines) + if last < count: + if lines: + text += document.newline + elif first > 0: + # The last line owns its preceding newline. Consume it when + # removing the tail, and restore it only if new lines exist. + start = first - 1, len(document[first - 1]) + if lines: + text = document.newline + text + result = document.replace_range(start, end, text) + self.wrapped_document.wrap_range(start, end, result.end_location) + 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) @@ -2501,9 +2529,7 @@ 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)) + return self.editing_mode.insert(self, text, location, maintain_selection_offset) def delete( self, @@ -2557,8 +2583,7 @@ def clear(self) -> EditResult | None: 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, @@ -2680,10 +2705,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.""" From e49bf4f602baa6d5d94ed97697acbd4f92ae8400 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 02:11:47 -0400 Subject: [PATCH 3/7] Keep acknowledged engine cursor state out of the input write path --- src/textual/document/_editing.py | 10 ++++++++-- src/textual/widgets/_text_area.py | 26 +++++++++++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/textual/document/_editing.py b/src/textual/document/_editing.py index 4550d08623..e8b9ac27c8 100644 --- a/src/textual/document/_editing.py +++ b/src/textual/document/_editing.py @@ -82,6 +82,9 @@ async def finish(self, editor: TextArea) -> None: async def flush(self, editor: TextArea) -> None: """Finish admitted input before submission, transfer or retirement.""" + async def acknowledge(self, editor: TextArea) -> None: + """Read actual engine state without waiting for a modal command to finish.""" + async def prepare_input(self, editor: TextArea) -> bool: """Asynchronous engines may fence earlier widget input before binding lookup.""" return True @@ -89,8 +92,8 @@ async def prepare_input(self, editor: TextArea) -> bool: def resize(self, editor: TextArea) -> None: pass - def selection_changed(self, editor: TextArea, selection: Selection) -> None: - pass + def request_selection(self, editor: TextArea, selection: Selection) -> Selection: + return selection def selection_range(self, editor: TextArea) -> Selection: return editor.selection @@ -115,6 +118,9 @@ def insert(self, editor: TextArea, text: str, location: Location | None, maintai def clear(self, editor: TextArea) -> EditResult | None: return editor.delete((0, 0), editor.document.end, maintain_selection_offset=False) + def move_to_end(self, editor: TextArea) -> None: + editor.move_cursor(editor.document.end) + def undo(self, editor: TextArea) -> None: editor._undo() diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 3db7ff9c53..709a787b37 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -829,7 +829,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 apply_engine_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.""" @@ -838,7 +853,6 @@ def _watch_selection( if not self.is_mounted: return - self.editing_mode.selection_changed(self, selection) self.app.clear_selection() cursor_location = selection.end @@ -935,7 +949,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.""" @@ -1156,7 +1172,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]: @@ -1182,6 +1197,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() @@ -1213,10 +1229,10 @@ async def restore_editor_state(self, state: TextAreaState) -> None: self.navigator = DocumentNavigator(self.wrapped_document) self._build_highlight_map() self._rewrap_and_refresh_virtual_size() - self.selection = state.selection self.editing_mode = state.editing_mode if previous_mode is state.editing_mode: self.editing_mode.attach(self) + self.selection = state.selection self.record_cursor_width() self.call_after_refresh(self.scroll_to, x=state.scroll_x, y=state.scroll_y, animate=False, immediate=True) From cd4d8609460a01f6e1bab4d507fd98a9d72bb9a7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:42:43 -0400 Subject: [PATCH 4/7] Refuse pending engine transfers and retain declared navigation boundaries --- src/textual/binding.py | 15 ++++ src/textual/document/_editing.py | 29 ++++-- src/textual/widgets/_text_area.py | 74 +++++++++------- .../test_application_command_mode.py | 88 +------------------ tests/text_area/test_cursor_shape.py | 10 +-- 5 files changed, 81 insertions(+), 135 deletions(-) diff --git a/src/textual/binding.py b/src/textual/binding.py index 985f0af4fe..ffb1d974c7 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/_editing.py b/src/textual/document/_editing.py index e8b9ac27c8..ba4498e80e 100644 --- a/src/textual/document/_editing.py +++ b/src/textual/document/_editing.py @@ -2,7 +2,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING from textual.binding import Binding, MethodBinding from textual.cursor_shape import CellCursor, CursorShape, SteadyBlock @@ -22,9 +22,17 @@ class EditingMode: """Native text entry, with overridable document and input ownership.""" modal = False - accepts_text = True - cursor_shape: ClassVar[CursorShape] = CellCursor() - label = "" + @property + def accepts_text(self) -> bool: + return True + + @property + def cursor_shape(self) -> CursorShape: + return CellCursor() + + @property + def label(self) -> str: + return "" @property def allows_application_commands(self) -> bool: @@ -76,11 +84,12 @@ async def unmount(self, editor: TextArea) -> None: async def close(self) -> None: pass - async def finish(self, editor: TextArea) -> None: - await self.flush(editor) + async def finish(self, editor: TextArea) -> bool: + return await self.flush(editor) - async def flush(self, editor: TextArea) -> None: - """Finish admitted input before submission, transfer or retirement.""" + async def flush(self, editor: TextArea) -> bool: + """Acknowledge input, or explicitly refuse transfer while input is pending.""" + return True async def acknowledge(self, editor: TextArea) -> None: """Read actual engine state without waiting for a modal command to finish.""" @@ -136,7 +145,9 @@ class VimMode(EditingMode, ABC): """Modal binding capability, not a second implementation of Vim.""" modal = True - cursor_shape = SteadyBlock() + @property + def cursor_shape(self) -> CursorShape: + return SteadyBlock() @classmethod def fallback_bindings(cls, binding: Binding) -> tuple[Binding, ...]: diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 709a787b37..ea85505e8e 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 @@ -151,7 +152,7 @@ def binding_mode(self) -> type[EditingMode]: def apply_editor_binding(self, binding: MethodBinding, event: events.Key) -> bool: return self.editing_mode.apply_native_binding(self, binding, event) - def before_engine_input(self, key: str) -> bool: + def before_engine_input(self, action: FunctionType) -> bool: """Offer a declared application boundary without interpreting engine commands.""" return False @@ -166,7 +167,9 @@ async def switch_editing_mode(self, mode: EditingMode) -> None: """Transfer only after the previous engine has acknowledged its input.""" previous = self.editing_mode try: - await previous.finish(self) + 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. @@ -174,7 +177,8 @@ async def switch_editing_mode(self, mode: EditingMode) -> None: await previous.close() self.editing_mode = mode - async def _on_unmount(self) -> None: + 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 = """\ @@ -1210,7 +1214,7 @@ def capture_editor_state(self) -> TextAreaState: self.editing_mode.capture(self), ) - async def restore_editor_state(self, state: TextAreaState) -> None: + async def restore_editor_state(self, state: TextAreaState) -> bool: """Attach captured editor state without loading text or clearing history. The receiving widget owns fresh width-dependent wrapping and paint. @@ -1218,7 +1222,11 @@ async def restore_editor_state(self, state: TextAreaState) -> None: """ previous_mode = self.editing_mode if previous_mode is not state.editing_mode: - await previous_mode.flush(self) + try: + if not await previous_mode.finish(self): + return False + except EditingFailure: + pass # The retained owner replaces a failed temporary editor. await previous_mode.close() self._languages = {language.name: language for language in state.languages} self.set_reactive(TextArea.language, state.language) @@ -1238,6 +1246,7 @@ async def restore_editor_state(self, state: TextAreaState) -> None: animate=False, immediate=True) self.post_message(self.Changed(self).set_sender(self)) self.update_suggestion() + return True def _on_resize(self) -> None: self._rewrap_and_refresh_virtual_size() @@ -1684,32 +1693,35 @@ def get_text_range(self, start: Location, end: Location) -> str: start, end = sorted((start, end)) return self.document.get_text_range(start, end) + def apply_engine_text(self, text: str) -> None: + """Transfer a complete external buffer without adding native undo entries.""" + self.history.clear() + self._set_document(text, self.language) + self._engine_document_changed() + def apply_engine_lines(self, first: int, last: int, lines: tuple[str, ...]) -> None: - """Project an engine's line-range notification without writing native undo.""" - if last == -1: - # Initial ownership transfer is the only notification without a - # range in the existing rendering document. - self.history.clear() - self._set_document("\n".join(lines), self.language) - else: - document = self.document - count = document.line_count - end = (last, 0) if last < count else document.end - start = (first, 0) if first < count else document.end - text = document.newline.join(lines) - if last < count: - if lines: - text += document.newline - elif first > 0: - # The last line owns its preceding newline. Consume it when - # removing the tail, and restore it only if new lines exist. - start = first - 1, len(document[first - 1]) - if lines: - text = document.newline + text - result = document.replace_range(start, end, text) - self.wrapped_document.wrap_range(start, end, result.end_location) - self._build_highlight_map() - self._refresh_size() + """Replace an end-exclusive line range; no backend wire sentinels enter here.""" + document = self.document + count = document.line_count + end = (last, 0) if last < count else document.end + start = (first, 0) if first < count else document.end + text = document.newline.join(lines) + if last < count: + if lines: + text += document.newline + elif first > 0: + # Tail replacement consumes its preceding newline, restoring it + # only when replacement lines exist. + start = first - 1, len(document[first - 1]) + if lines: + text = document.newline + text + result = document.replace_range(start, end, text) + self.wrapped_document.wrap_range(start, end, result.end_location) + self._engine_document_changed() + + def _engine_document_changed(self) -> None: + self._build_highlight_map() + self._refresh_size() self.post_message(self.Changed(self)) self.update_suggestion() @@ -2041,7 +2053,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() diff --git a/tests/text_area/test_application_command_mode.py b/tests/text_area/test_application_command_mode.py index 0a59776128..758c55e349 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 755b30dfa3..dcdd65c793 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( From d24ac242454678fd237d821a2b57d7e0d0a094f6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 12:25:14 -0400 Subject: [PATCH 5/7] Project engine changes once and restore retained editor ownership --- src/textual/document/_edit.py | 21 ++++++++++ src/textual/document/_editing.py | 7 ++-- src/textual/document/_wrapped_document.py | 2 +- src/textual/widgets/_text_area.py | 51 +++++++++++++++++------ 4 files changed, 63 insertions(+), 18 deletions(-) diff --git a/src/textual/document/_edit.py b/src/textual/document/_edit.py index dc9c04aeba..18954104bd 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 ba4498e80e..7cf77d9168 100644 --- a/src/textual/document/_editing.py +++ b/src/textual/document/_editing.py @@ -7,9 +7,9 @@ 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.document._edit import Edit from textual.events import Key from textual.widgets import TextArea @@ -119,9 +119,8 @@ def load_text(self, editor: TextArea, text: str) -> None: def edit(self, editor: TextArea, edit: Edit) -> EditResult | None: return editor._edit(edit) - def insert(self, editor: TextArea, text: str, location: Location | None, maintain_selection_offset: bool) -> EditResult | None: - from textual.document._edit import Edit - location = editor.cursor_location if location is None else location + 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)) def clear(self, editor: TextArea) -> EditResult | None: diff --git a/src/textual/document/_wrapped_document.py b/src/textual/document/_wrapped_document.py index 68846bedab..5ab67f9778 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/widgets/_text_area.py b/src/textual/widgets/_text_area.py index ea85505e8e..0b5a081210 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -33,7 +33,7 @@ _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 EditingFailure, EditingMode from textual.document._syntax_aware_document import ( @@ -1214,7 +1214,7 @@ def capture_editor_state(self) -> TextAreaState: self.editing_mode.capture(self), ) - async def restore_editor_state(self, state: TextAreaState) -> bool: + 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. @@ -1222,11 +1222,8 @@ async def restore_editor_state(self, state: TextAreaState) -> bool: """ previous_mode = self.editing_mode if previous_mode is not state.editing_mode: - try: - if not await previous_mode.finish(self): - return False - except EditingFailure: - pass # The retained owner replaces a failed temporary editor. + # 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) @@ -1246,7 +1243,6 @@ async def restore_editor_state(self, state: TextAreaState) -> bool: animate=False, immediate=True) self.post_message(self.Changed(self).set_sender(self)) self.update_suggestion() - return True def _on_resize(self) -> None: self._rewrap_and_refresh_virtual_size() @@ -1697,7 +1693,24 @@ def apply_engine_text(self, text: str) -> None: """Transfer a complete external buffer without adding native undo entries.""" self.history.clear() self._set_document(text, self.language) - self._engine_document_changed() + + 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 apply_engine_lines(self, first: int, last: int, lines: tuple[str, ...]) -> None: """Replace an end-exclusive line range; no backend wire sentinels enter here.""" @@ -1715,12 +1728,23 @@ def apply_engine_lines(self, first: int, last: int, lines: tuple[str, ...]) -> N start = first - 1, len(document[first - 1]) if lines: text = document.newline + text + previous_height = self.wrapped_document.height result = document.replace_range(start, end, text) self.wrapped_document.wrap_range(start, end, result.end_location) - self._engine_document_changed() + final = max(last, first + len(lines), start[0]) + if self.wrapped_document.height != previous_height: + # Reflow changes the meaning of every following display offset. + final = max(final, document.line_count) + self.invalidate_engine_regions((Selection((start[0], 0), (final, 0)),)) - def _engine_document_changed(self) -> None: - self._build_highlight_map() + 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() @@ -2557,7 +2581,8 @@ def insert( """ if len(text) > 1: self._restart_blink() - return self.editing_mode.insert(self, text, 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, From 46df7c0eb5d89304eca7e21940d3244a80ff431c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 13:58:42 -0400 Subject: [PATCH 6/7] Keep engine projection on the retained document owner --- src/textual/document/_document.py | 24 ++++++++++++++++ src/textual/widgets/_text_area.py | 46 +++++++++---------------------- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/src/textual/document/_document.py b/src/textual/document/_document.py index 92d5a12a99..ee967ee2f2 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/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 0b5a081210..98d3438fdd 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -29,6 +29,7 @@ DocumentBase, EditResult, Location, + RangedEditResult, Selection, _utf8_encode, ) @@ -1167,6 +1168,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() @@ -1227,17 +1232,13 @@ async def restore_editor_state(self, state: TextAreaState) -> None: 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.set_document(state.document) + self.apply_engine_selection(state.selection) self.editing_mode = state.editing_mode if previous_mode is state.editing_mode: self.editing_mode.attach(self) - self.selection = state.selection self.record_cursor_width() self.call_after_refresh(self.scroll_to, x=state.scroll_x, y=state.scroll_y, animate=False, immediate=True) @@ -1689,11 +1690,6 @@ def get_text_range(self, start: Location, end: Location) -> str: start, end = sorted((start, end)) return self.document.get_text_range(start, end) - def apply_engine_text(self, text: str) -> None: - """Transfer a complete external buffer without adding native undo entries.""" - self.history.clear() - self._set_document(text, self.language) - def invalidate_engine_regions(self, regions: Sequence[Selection]) -> None: """Invalidate only cached/visible rows intersecting engine-painted regions. @@ -1712,30 +1708,14 @@ def affected(offset: int) -> bool: if affected(offset): self.refresh_lines(offset) - def apply_engine_lines(self, first: int, last: int, lines: tuple[str, ...]) -> None: - """Replace an end-exclusive line range; no backend wire sentinels enter here.""" - document = self.document - count = document.line_count - end = (last, 0) if last < count else document.end - start = (first, 0) if first < count else document.end - text = document.newline.join(lines) - if last < count: - if lines: - text += document.newline - elif first > 0: - # Tail replacement consumes its preceding newline, restoring it - # only when replacement lines exist. - start = first - 1, len(document[first - 1]) - if lines: - text = document.newline + text + 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 - result = document.replace_range(start, end, text) - self.wrapped_document.wrap_range(start, end, result.end_location) - final = max(last, first + len(lines), start[0]) + 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: - # Reflow changes the meaning of every following display offset. - final = max(final, document.line_count) - self.invalidate_engine_regions((Selection((start[0], 0), (final, 0)),)) + 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. From 7435e87eb733bd259a55856d12bc5552322f63f0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 14:19:41 -0400 Subject: [PATCH 7/7] Name selection painting independently of engine requests --- src/textual/widgets/_text_area.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 98d3438fdd..7306790beb 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -834,7 +834,7 @@ def _watch_has_focus(self, focus: bool) -> None: self.editing_mode.suspend(self) self.editing_mode = self.editing_mode.settled() - def apply_engine_selection(self, selection: Selection) -> None: + def paint_selection(self, selection: Selection) -> None: """Paint acknowledged cursor state without issuing another cursor request.""" previous = self.selection start, end = selection @@ -1235,7 +1235,7 @@ async def restore_editor_state(self, state: TextAreaState) -> None: self.history = state.history self._highlight_query = state.highlight_query self.set_document(state.document) - self.apply_engine_selection(state.selection) + self.paint_selection(state.selection) self.editing_mode = state.editing_mode if previous_mode is state.editing_mode: self.editing_mode.attach(self)