From f02c3383780acd890aab8bcd664840246419eada Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 07:15:51 -0400 Subject: [PATCH 01/10] Own modal editor commands and retained mode in native TextArea --- src/textual/_xterm_parser.py | 3 +- src/textual/app.py | 5 + src/textual/document/_document_navigator.py | 87 ++++ src/textual/document/_editing.py | 532 ++++++++++++++++++++ src/textual/document/_history.py | 24 + src/textual/widgets/_text_area.py | 86 ++-- src/textual/widgets/text_area.py | 5 + 7 files changed, 707 insertions(+), 35 deletions(-) create mode 100644 src/textual/document/_editing.py diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 6474cd256d6..914b2b9ac95 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -441,7 +441,8 @@ def _sequence_to_key_events( # If the sequence mapped to a tuple, then it's values from the # `Keys` enum. Raise key events from what we find in the tuple. for key in keys: - yield events.Key(key.value, sequence if len(sequence) == 1 else None) + name = f"alt+{key.value}" if alt else key.value + yield events.Key(name, sequence if len(sequence) == 1 else None) return # If keys is a string, the intention is that it's a mapping to a # character, which should really be treated as the sequence for the diff --git a/src/textual/app.py b/src/textual/app.py index 69e3a5b2838..5a7dcc239f4 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -4280,6 +4280,11 @@ def escape_to_minimize(self) -> bool: If the classvar on the screen is *not* set (and left as `None`), then the app's `ESCAPE_TO_MINIMIZE` is used. """ + focused = self.screen.focused + if focused is not None and any( + node.check_consume_key("escape", None) for node in focused.ancestors_with_self + ): + return False return bool( self.ESCAPE_TO_MINIMIZE if self.screen.ESCAPE_TO_MINIMIZE is None diff --git a/src/textual/document/_document_navigator.py b/src/textual/document/_document_navigator.py index 6dbc3c6d599..9d33aa8174f 100644 --- a/src/textual/document/_document_navigator.py +++ b/src/textual/document/_document_navigator.py @@ -8,6 +8,37 @@ 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". @@ -71,6 +102,62 @@ 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.""" + for _ in range(count): + row, column = location + stop = self._document.line_count if boundary.direction > 0 else -1 + found = False + 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): + location = next_row, next_column + found = True + break + if found: + break + if not found: + return self._document.end if boundary.direction > 0 else self._document.start + return location + + def get_location_word_left(self, location: Location) -> Location: + """The native word-left boundary, including its whitespace semantics.""" + row, column = location + if row > 0 and column == 0: + return row - 1, len(self._document[row - 1]) + matches = tuple(self._word_pattern.finditer(self._document[row][:column].rstrip())) + return row, matches[-1].start() if matches else 0 + + def get_location_word_right(self, location: Location) -> Location: + """The native word-right boundary, including its whitespace semantics.""" + row, column = location + line = self._document[row] + if row < self._document.line_count - 1 and column == len(line): + return row + 1, 0 + remaining = line[column:] + stripped = remaining.lstrip() + match = next(self._word_pattern.finditer(stripped), None) + return row, column + len(remaining) - len(stripped) + match.start() if match else len(line) + def is_start_of_document_line(self, location: Location) -> bool: """True when the location is at the start of the first document line. diff --git a/src/textual/document/_editing.py b/src/textual/document/_editing.py new file mode 100644 index 00000000000..b8e78e98854 --- /dev/null +++ b/src/textual/document/_editing.py @@ -0,0 +1,532 @@ +"""Native editor modes and commands, sharing TextArea's document and history. + +Terminal key names are decoded here once. A command declares its keys and +behavior; modes own how that behavior participates in an unfinished operation. +""" +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import TYPE_CHECKING, ClassVar + +from textual.document._document import Location +from textual.document._document_navigator import WordBoundary, PreviousWordBoundary, EndWordBoundary + +if TYPE_CHECKING: + from textual.events import Key + from textual.widgets import TextArea + + +class EditingMode: + """Ordinary text entry; subclasses declare additional editing behavior.""" + + modal = False + accepts_text = True + label = "" + + def handle_key(self, editor: TextArea, event: Key) -> bool: + return False + + def settled(self) -> EditingMode: + return self + + def pasted(self) -> EditingMode: + return self + + def document_changed(self) -> EditingMode: + return self.settled() + + def resume(self, editor: TextArea) -> None: + editor.history.checkpoint() + + def suspend(self, editor: TextArea) -> None: + editor.history.end_group() + + +class VimMode(EditingMode): + modal = True + + def pasted(self) -> EditingMode: + return VimInsert() + + +class VimInsert(VimMode): + label = "INSERT" + + def resume(self, editor: TextArea) -> None: + editor.history.begin_group() + + def handle_key(self, editor: TextArea, event: Key) -> bool: + if event.key != "escape": + return False + editor.editing_mode = VimNormal() + return True + + +@dataclass(frozen=True) +class VimNormal(VimMode): + count: int = 0 + accepts_text = False + label = "NORMAL" + + def settled(self) -> EditingMode: + return replace(self, count=0) + + 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 + command = EditorCommand.decode(event.key) + if command is not None: + self.command(editor, command) + 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 + return False + + def command(self, editor: TextArea, command: type[EditorCommand]) -> None: + editor.editing_mode = self.settled() + command.apply(editor, self, self.count or 1) + + 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 characters(self, editor: TextArea, operator: type[Operator], count: int) -> None: + operator.replace(editor, EditorRange(editor.cursor_location, + Right.destination(editor, editor.cursor_location, 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" + + def document_changed(self) -> EditingMode: + return VimNormal() + + def move_to(self, editor: TextArea, location: Location) -> None: + editor.move_cursor(location, select=True) + + def characters(self, editor: TextArea, operator: type[Operator], count: int) -> None: + self.operate(editor, operator, count) + + def visual(self, editor: TextArea) -> None: + self.escape(editor) + + def operate(self, editor: TextArea, operator: type[Operator], count: int) -> None: + start, end = sorted(editor.selection) + # Native Selection is half-open; characterwise Visual includes its end. + end = editor.navigator.get_location_right(end) + operator.replace(editor, EditorRange(start, end)) + + def escape(self, editor: TextArea) -> None: + editor.move_cursor(editor.cursor_location) + editor.editing_mode = VimNormal() + + +@dataclass(frozen=True, kw_only=True) +class VimOperator(VimNormal): + operator: type[Operator] + operator_count: int = 1 + + def settled(self) -> EditingMode: + return VimNormal() + + def command(self, editor: TextArea, command: type[EditorCommand]) -> None: + editor.editing_mode = VimNormal() + command.pending(editor, self, self.operator_count * (self.count or 1)) + + def escape(self, editor: TextArea) -> None: + editor.editing_mode = VimNormal() + + +@dataclass(frozen=True) +class AwaitKey(VimMode): + command: type[CharacterCommand] + previous: VimNormal + count: int + accepts_text = False + + @property + def label(self) -> str: + return self.previous.label + + def settled(self) -> EditingMode: + return self.previous.settled() + + def handle_key(self, editor: TextArea, event: Key) -> bool: + editor.editing_mode = self.settled() + if event.key != "escape": + self.command.character(editor, self.previous, event.character, self.count) + return True + + +class EditorCommand: + keys: ClassVar[tuple[str, ...]] = () + + @classmethod + def declarations(cls): + for declaration in cls.__subclasses__(): + yield declaration + yield from declaration.declarations() + + @classmethod + def decode(cls, key: str) -> type[EditorCommand] | None: + return next((command for command in cls.declarations() if key in command.keys), None) + + @classmethod + def apply(cls, editor: TextArea, mode: VimNormal, count: int) -> None: + 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 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 deletion_start(self, editor: TextArea) -> Location: + return self.start + + def change_text(self, editor: TextArea) -> str: + return "" + + +class LineRange(EditorRange): + 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.text(editor)) + editor.move_cursor(selection.start) + editor.editing_mode = VimNormal() + + +class Delete(Yank): + keys = ("d",) + following_mode = 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) + editor.history.checkpoint() + + +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",) + + @classmethod + def apply(cls, editor, mode, count): + 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.run_worker(editor.paste_editor_text(before=cls.before, count=count), + group="editor-paste", exclusive=True) + + +class PasteBefore(Paste): + keys = ("P",) + before = True + + +class CharacterCommand(EditorCommand): + @classmethod + def apply(cls, editor, mode, count): + editor.editing_mode = AwaitKey(cls, mode, count) + + @classmethod + def character(cls, editor, mode, character, count): + 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(): + start = editor.cursor_location + end = Right.destination(editor, start, count) + editor.replace(character * (end[1] - start[1]), start, end, maintain_selection_offset=False) + editor.move_cursor(start) diff --git a/src/textual/document/_history.py b/src/textual/document/_history.py index c779fdd7ab4..c0c7ec1a4af 100644 --- a/src/textual/document/_history.py +++ b/src/textual/document/_history.py @@ -41,6 +41,20 @@ class EditHistory: Every other edit is considered a pure insertion. """ + _group: list[Edit] | None = field(init=False, default=None) + """The current explicit group, by identity in the original undo stack.""" + + def begin_group(self) -> None: + """Keep a modal insert/change interaction in one native undo batch.""" + if self._group is None: + self.checkpoint() + self._group = [] + + def end_group(self) -> None: + if self._group is not None: + self._group = None + self.checkpoint() + def __post_init__(self) -> None: self._undo_stack: deque[list[Edit]] = deque(maxlen=self.max_checkpoints) """Batching Edit operations together (edits are simply grouped together in lists).""" @@ -82,6 +96,13 @@ def record(self, edit: Edit) -> None: if edit.text == "" and edit_result.replaced_text == "": return None + if self._group is not None: + if not self._group: + self._undo_stack.append(self._group) + self._group.append(edit) + self._redo_stack.clear() + return + is_replacement = bool(edit_result.replaced_text) undo_stack = self._undo_stack current_time = self._get_time() @@ -125,6 +146,7 @@ def _pop_undo(self) -> list[Edit] | None: Returns: The batch of Edits from the top of the undo stack or None if it's empty. """ + self.end_group() undo_stack = self._undo_stack redo_stack = self._redo_stack if undo_stack: @@ -142,6 +164,7 @@ def _pop_redo(self) -> list[Edit] | None: Returns: The batch of Edits from the top of the redo stack or None if it's empty. """ + self.end_group() undo_stack = self._undo_stack redo_stack = self._redo_stack if redo_stack: @@ -154,6 +177,7 @@ def _pop_redo(self) -> list[Edit] | None: def clear(self) -> None: """Completely clear the history.""" + self._group = None self._undo_stack.clear() self._redo_stack.clear() self._last_edit_time = time.monotonic() diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 38b6a2da80e..b4d3d0ceb32 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -31,6 +31,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._syntax_aware_document import ( SyntaxAwareDocument, SyntaxAwareDocumentError, @@ -127,9 +128,12 @@ class TextAreaState: language: str | None languages: tuple[TextAreaLanguage, ...] highlight_query: Query | None + editing_mode: EditingMode class TextArea(ScrollView): + editing_mode: reactive[EditingMode] = reactive(EditingMode, init=False, bindings=True) + """The active native editing capability. Ordinary text entry is the default.""" DEFAULT_CSS = """\ TextArea { width: 1fr; @@ -661,9 +665,6 @@ def __init__( self.indent_type: Literal["tabs", "spaces"] = "spaces" """Whether to indent using tabs or spaces.""" - self._word_pattern = re.compile(r"(?<=\W)(?=\w)|(?<=\w)(?=\W)") - """Compiled regular expression for what we consider to be a 'word'.""" - self.history: EditHistory = EditHistory( max_checkpoints=max_checkpoints, checkpoint_timer=2.0, @@ -836,6 +837,8 @@ def check_consume_key(self, key: str, character: str | None = None) -> bool: Returns: `True` if the widget may capture the key in its `Key` message, or `False` if it won't. """ + if key == "escape" and self.editing_mode.modal: + return True if self.read_only: # In read only mode we don't consume any key events return False @@ -877,13 +880,45 @@ def _build_highlight_map(self) -> None: (0, node_end_column, highlight_name) ) + def _watch_editing_mode(self, previous: EditingMode, current: EditingMode) -> None: + previous.suspend(self) + current.resume(self) + self.border_subtitle = current.label + + def leave_modal_editor(self) -> None: + """Hook for an embedding application to focus its navigation surface.""" + self.screen.focus_next() + + async def read_editor_clipboard(self) -> str: + return self.app.clipboard + + async def paste_editor_text(self, *, before: bool, count: int = 1) -> None: + """Paste a clipboard payload without interpreting its text as keys.""" + text = await self.read_editor_clipboard() + if self.read_only or not self.is_attached or not self.has_focus or not text: + return + row, column = self.cursor_location + if text.endswith(self.document.newline): + row += not before + if row < self.document.line_count: + location = (row, 0) + else: + location = self.document.end + text = self.document.newline + text.removesuffix(self.document.newline) + else: + location = self.navigator.get_location_column_offset((row, column), not before) + self._replace_via_keyboard(text * count, location, location) + self.move_cursor(location) + def _watch_has_focus(self, focus: bool) -> None: self._cursor_visible = focus if focus: self._restart_blink() - self.history.checkpoint() + self.editing_mode.resume(self) else: self._pause_blink(visible=False) + self.editing_mode.suspend(self) + self.editing_mode = self.editing_mode.settled() def _watch_selection( self, previous_selection: Selection, selection: Selection @@ -1232,6 +1267,7 @@ def load_text(self, text: str) -> None: text: The text to load into the TextArea. """ self.history.clear() + self.editing_mode = self.editing_mode.document_changed() self._set_document(text, self.language) self.post_message(self.Changed(self).set_sender(self)) self.update_suggestion() @@ -1241,6 +1277,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(), ) def restore_editor_state(self, state: TextAreaState) -> None: @@ -1259,6 +1296,7 @@ def restore_editor_state(self, state: TextAreaState) -> None: self._build_highlight_map() self._rewrap_and_refresh_virtual_size() self.selection = state.selection + self.editing_mode = state.editing_mode self.record_cursor_width() self.call_after_refresh(self.scroll_to, x=state.scroll_x, y=state.scroll_y, animate=False, immediate=True) @@ -1757,6 +1795,7 @@ 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) + self.editing_mode.resume(self) def action_undo(self) -> None: """Undo the edits since the last checkpoint (the most recent batch of edits).""" @@ -1766,6 +1805,7 @@ def redo(self) -> None: """Redo the most recently undone batch of edits.""" if edits := self.history._pop_redo(): self._redo_batch(edits) + self.editing_mode.resume(self) def action_redo(self) -> None: """Redo the most recently undone batch of edits.""" @@ -1873,6 +1913,11 @@ async def _on_key(self, event: events.Key) -> None: if self.read_only: return + if self.editing_mode.handle_key(self, event): + event.stop() + event.prevent_default() + return + key = event.key insert_values = { "enter": "\n", @@ -2037,6 +2082,7 @@ 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() @@ -2416,17 +2462,7 @@ def get_cursor_word_left_location(self) -> Location: Returns: The location the cursor will jump on "jump word left". """ - cursor_row, cursor_column = self.cursor_location - if cursor_row > 0 and cursor_column == 0: - # Going to the previous row - return cursor_row - 1, len(self.document[cursor_row - 1]) - - # Staying on the same row - line = self.document[cursor_row][:cursor_column] - search_string = line.rstrip() - matches = list(re.finditer(self._word_pattern, search_string)) - cursor_column = matches[-1].start() if matches else 0 - return cursor_row, cursor_column + return self.navigator.get_location_word_left(self.cursor_location) def action_cursor_word_right(self, select: bool = False) -> None: """Move the cursor right by a single word, skipping leading whitespace.""" @@ -2444,25 +2480,7 @@ def get_cursor_word_right_location(self) -> Location: Returns: The location the cursor will jump on "jump word right". """ - cursor_row, cursor_column = self.selection.end - line = self.document[cursor_row] - if cursor_row < self.document.line_count - 1 and cursor_column == len(line): - # Moving to the line below - return cursor_row + 1, 0 - - # Staying on the same line - search_string = line[cursor_column:] - pre_strip_length = len(search_string) - search_string = search_string.lstrip() - strip_offset = pre_strip_length - len(search_string) - - matches = list(re.finditer(self._word_pattern, search_string)) - if matches: - cursor_column += matches[0].start() + strip_offset - else: - cursor_column = len(line) - - return cursor_row, cursor_column + return self.navigator.get_location_word_right(self.cursor_location) def action_cursor_page_up(self) -> None: """Move the cursor and scroll up one page.""" diff --git a/src/textual/widgets/text_area.py b/src/textual/widgets/text_area.py index 655dabd8803..38f92fcca21 100644 --- a/src/textual/widgets/text_area.py +++ b/src/textual/widgets/text_area.py @@ -8,6 +8,7 @@ ) from textual.document._document_navigator import DocumentNavigator from textual.document._edit import Edit +from textual.document._editing import EditingMode, VimInsert, VimNormal, VimVisual from textual.document._history import EditHistory from textual.document._syntax_aware_document import SyntaxAwareDocument from textual.document._wrapped_document import WrappedDocument @@ -30,6 +31,10 @@ "Edit", "EditResult", "EditHistory", + "EditingMode", + "VimInsert", + "VimNormal", + "VimVisual", "EndColumn", "Highlight", "HighlightName", From 4cbaf90b28165b5e8759b0ea12bb79c9c1c8dbbf Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 08:20:40 -0400 Subject: [PATCH 02/10] Share activation discovery and retain typed clipboard semantics --- src/textual/_activation.py | 89 +++++++++++++++ src/textual/_clipboard.py | 39 +++++++ src/textual/_compositor.py | 33 +++++- src/textual/_event_broker.py | 26 ++++- src/textual/app.py | 39 +++---- src/textual/document/_document_navigator.py | 26 ++--- src/textual/document/_editing.py | 20 +++- src/textual/document/_history.py | 5 +- src/textual/screen.py | 24 ++++ src/textual/widget.py | 20 ++++ src/textual/widgets/_button.py | 8 +- src/textual/widgets/_hints.py | 116 ++++++++++++++++++++ src/textual/widgets/_text_area.py | 34 +++--- 13 files changed, 406 insertions(+), 73 deletions(-) create mode 100644 src/textual/_activation.py create mode 100644 src/textual/_clipboard.py create mode 100644 src/textual/widgets/_hints.py diff --git a/src/textual/_activation.py b/src/textual/_activation.py new file mode 100644 index 00000000000..64c612b6fea --- /dev/null +++ b/src/textual/_activation.py @@ -0,0 +1,89 @@ +"""Activation targets borrow the widget's original effect and displayed cells.""" +from __future__ import annotations + +from abc import abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING, Callable + +from textual._callback import invoke +from textual._event_broker import HandlerArguments, NoHandler, extract_handler_actions +from textual.geometry import Region +from textual.errors import NoWidget + +if TYPE_CHECKING: + from textual.widget import Widget + + +@dataclass(frozen=True) +class Activation: + widget: Widget + region: Region + + def available(self) -> bool: + widget = self.widget + if not widget.is_attached or widget.disabled or not widget.visible: + return False + screen = widget.screen + if screen is not widget.app.screen: + return False + try: + hit, _ = screen.get_widget_at(self.region.x, self.region.y) + except NoWidget: + return False + return hit is widget or widget in hit.ancestors + + @property + @abstractmethod + def identity(self): ... + + @abstractmethod + async def activate(self) -> None: ... + + +@dataclass(frozen=True) +class PrimaryTarget(Activation): + callback: Callable + + @property + def identity(self): + return self.widget, self.callback + + async def activate(self) -> None: + if self.available(): + await invoke(self.callback) + + +@dataclass(frozen=True) +class StyleTarget(Activation): + handler: HandlerArguments + + @property + def identity(self): + return self.widget, self.handler.action + + def available(self) -> bool: + if not super().available(): + return False + style = self.widget.screen.get_style_at(self.region.x, self.region.y) + try: + current = extract_handler_actions("click", style.meta) + except NoHandler: + return False + return current == self.handler + + async def activate(self) -> None: + if self.available(): + await self.widget.app.dispatch_style_action(self.handler, None, self.widget) + + +class PrimaryActivation: + """A widget declares its existing primary effect; discovery is shared.""" + + @property + @abstractmethod + def primary_activation(self) -> Callable: ... + + def activation_targets(self, region, strips): + if not self.disabled: + yield PrimaryTarget(self, region, self.primary_activation) + yield from super().activation_targets(region, strips) diff --git a/src/textual/_clipboard.py b/src/textual/_clipboard.py new file mode 100644 index 00000000000..9beb0170f28 --- /dev/null +++ b/src/textual/_clipboard.py @@ -0,0 +1,39 @@ +"""The local clipboard payload, including editing semantics lost by plain text.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ClipboardContent: + text: str + + @classmethod + def of(cls, value: str | ClipboardContent) -> ClipboardContent: + return value if isinstance(value, ClipboardContent) else cls(value) + + def observed(self, text: str) -> ClipboardContent: + """Unchanged transport text retains known local provenance. + + A different external value is ordinary characterwise text; plain-text + clipboard protocols cannot communicate another application's Vim kind. + """ + return self if text == self.text else ClipboardContent(text) + + 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) + editor.history.end_group() + editor.replace(text, location, location, maintain_selection_offset=False) + editor.move_cursor(location) + + +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/_compositor.py b/src/textual/_compositor.py index 5694dabd024..37ec6521886 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -1687,7 +1687,8 @@ def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]: contains = Region.contains if self.size.height > y >= 0: for widget, (region, clip) in self.published_widgets.items(): - if contains(region, x, y) and contains(clip, x, y) and widget.visible: + if (contains(region, x, y) and contains(clip, x, y) and widget.visible + and not widget.hit_test_transparent): yield widget, region def get_style_at(self, x: int, y: int) -> Style: @@ -1895,7 +1896,7 @@ def _get_renders( self, crop: Region | None = None, render_regions: Callable[[Region], Iterable[Region]] | None = None, *, widgets: Mapping[Widget, tuple[Region, Region]], - ) -> Iterable[tuple[Region, list[Strip]]]: + ) -> Iterable[tuple[Widget, Region, list[Strip]]]: """Get rendered widgets (lists of segments) in the composition. Args: @@ -1931,7 +1932,7 @@ def _get_renders( for render_region in regions: new_x, new_y, new_width, new_height = render_region yield ( - render_region, + widget, render_region, widget.render_lines( _Region( new_x - region.x, @@ -2181,6 +2182,27 @@ def render_strips(self, size: Size | None = None) -> list[Strip]: ) for y, chop in enumerate(chops[: size.height])] return render_strips + def activation_targets(self): + """Borrow only exposed published rows through the existing paint walk.""" + targets = [] + seen = [] + widgets = self.published_widgets + bounds = self.size.region + if self.root is None or not widgets: + return targets + + def collect(widget, region, strips): + for target in widget.activation_targets(region, strips): + if target.identity not in seen: + seen.append(target.identity) + targets.append(target) + + cuts = self._cuts_for_regions(bounds, widgets) + with self._using_geometry(self.root, self._published_map): + self._render_chops(bounds, self._regions_to_spans((bounds,)), widgets=widgets, + cuts=cuts, bounds=bounds, on_render=collect) + return targets + def published_geometry( self, roots: Iterable[Widget], ) -> Iterator[tuple[Widget, MapGeometry]]: @@ -2203,6 +2225,7 @@ def _render_chops( spans: Iterable[tuple[int, int, int]], *, widgets: Mapping[Widget, tuple[Region, Region]], cuts: list[list[int]], bounds: Region, + on_render: Callable[[Widget, Region, list[Strip]], None] | None = None, ) -> Sequence[Mapping[int, Strip]]: """Render update 'chops'. @@ -2261,7 +2284,9 @@ def render_regions(region: Region) -> Iterable[Region]: # Go through all the renders in reverse order and fill buckets with no render renders = self._get_renders(crop, render_regions, widgets=widgets) - for render_region, strips in renders: + for widget, render_region, strips in renders: + if on_render is not None: + on_render(widget, render_region, strips) render_x = render_region.x first_cut, last_cut = render_region.column_span diff --git a/src/textual/_event_broker.py b/src/textual/_event_broker.py index fe6727e1050..7f7fdcfe6b5 100644 --- a/src/textual/_event_broker.py +++ b/src/textual/_event_broker.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import Any, NamedTuple +from textual import actions, log class NoHandler(Exception): @@ -11,7 +12,28 @@ class HandlerArguments(NamedTuple): """Information for event handler.""" modifiers: set[str] - action: Any + action: actions.ActionParseResult | None + + @classmethod + def decode(cls, modifiers: set[str], value: Any) -> HandlerArguments: + if isinstance(value, str): + action = actions.parse(value) + elif isinstance(value, tuple) and len(value) == 2: + name, parameters = value + namespace, parsed_name, _ = actions.parse(name) + action = namespace, parsed_name, parameters + else: + action = None + return cls(modifiers, action) + + async def run(self, app, default_namespace) -> bool: + """Consumers use the action decoded once at the style boundary.""" + if self.action is None: + if app.debug: + log.warning("Can't parse action from style meta; check your console markup syntax") + return False + await app.run_action(self.action, default_namespace) + return True def extract_handler_actions(event_name: str, meta: dict[str, Any]) -> HandlerArguments: @@ -33,5 +55,5 @@ def extract_handler_actions(event_name: str, meta: dict[str, Any]) -> HandlerArg name_args = key[1:].split(".") if name_args[: len(event_path)] == event_path: modifiers = name_args[len(event_path) :] - return HandlerArguments(set(modifiers), value) + return HandlerArguments.decode(set(modifiers), value) raise NoHandler(f"No handler for {event_name!r}") diff --git a/src/textual/app.py b/src/textual/app.py index 5a7dcc239f4..41120a2e61c 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -81,6 +81,7 @@ from textual._context import message_hook as message_hook_context_var from textual._dispatch_key import dispatch_key from textual._event_broker import NoHandler, extract_handler_actions +from textual._clipboard import ClipboardContent from textual._files import generate_datetime_filename from textual._path import ( CSSPathType, @@ -873,7 +874,7 @@ def __init__( self._css_update_count: int = 0 """Incremented when CSS is invalidated.""" - self._clipboard: str = "" + self.clipboard_content = ClipboardContent("") """Contents of local clipboard.""" self.supports_smooth_scrolling: bool = False @@ -1050,7 +1051,7 @@ def clipboard(self) -> str: Note, that this only contains text copied in the app, and not text copied from elsewhere in the OS. """ - return self._clipboard + return self.clipboard_content.text def _realtime_animation_begin(self) -> None: """A scroll or other animation that must be smooth has begun.""" @@ -1818,7 +1819,7 @@ def get_loading_widget(self) -> Widget: return LoadingIndicator() - def copy_to_clipboard(self, text: str) -> None: + def copy_to_clipboard(self, text: str | ClipboardContent) -> None: """Copy text to the clipboard. !!! note @@ -1828,7 +1829,11 @@ def copy_to_clipboard(self, text: str) -> None: Args: text: Text you wish to copy to the clipboard. """ - self._clipboard = text + self.clipboard_content = ClipboardContent.of(text) + self.publish_clipboard(self.clipboard_content.text) + + def publish_clipboard(self, text: str) -> None: + """Transport hook; retaining the typed local payload is App's job.""" if self._driver is None: return import base64 @@ -4433,31 +4438,17 @@ async def _broker_event( except AttributeError: return False try: - _modifiers, action = extract_handler_actions(event_name, style.meta) + handler = extract_handler_actions(event_name, style.meta) except NoHandler: return False else: event.stop() - if isinstance(action, str): - await self.run_action(action, default_namespace) - elif isinstance(action, tuple) and len(action) == 2: - action_name, action_params = action - namespace, parsed_action, _ = actions.parse(action_name) - await self.run_action( - (namespace, parsed_action, action_params), - default_namespace, - ) - else: - if isinstance(action, tuple) and self.debug: - # It's a tuple and made it this far, which means it'll be a - # malformed action. This is a no-op, but let's log that - # anyway. - log.warning( - f"Can't parse @{event_name} action from style meta; check your console markup syntax" - ) - return False - return True + return await self.dispatch_style_action(handler, event, default_namespace) + + async def dispatch_style_action(self, handler, event, default_namespace) -> bool: + """Shared decoded-action hook for pointer and keyboard activation.""" + return await handler.run(self, default_namespace) async def _on_update(self, message: messages.Update) -> None: message.stop() diff --git a/src/textual/document/_document_navigator.py b/src/textual/document/_document_navigator.py index 9d33aa8174f..027a466bbf5 100644 --- a/src/textual/document/_document_navigator.py +++ b/src/textual/document/_document_navigator.py @@ -122,22 +122,16 @@ def get_location_document_line_end(self, location: Location, lines: int = 1) -> def get_location_word_boundary(self, location: Location, count: int, boundary: WordBoundary) -> Location: """Find counted raw-word boundaries without moving an editor selection.""" - for _ in range(count): - row, column = location - stop = self._document.line_count if boundary.direction > 0 else -1 - found = False - 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): - location = next_row, next_column - found = True - break - if found: - break - if not found: - return self._document.end if boundary.direction > 0 else self._document.start - return location + 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.""" diff --git a/src/textual/document/_editing.py b/src/textual/document/_editing.py index b8e78e98854..fcbc9bc9854 100644 --- a/src/textual/document/_editing.py +++ b/src/textual/document/_editing.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, ClassVar from textual.document._document import Location +from textual._clipboard import ClipboardContent, LinewiseClipboard from textual.document._document_navigator import WordBoundary, PreviousWordBoundary, EndWordBoundary if TYPE_CHECKING: @@ -83,6 +84,7 @@ def handle_key(self, editor: TextArea, event: Key) -> bool: if event.is_printable or event.key == "enter": editor.editing_mode = self.settled() return True + editor.editing_mode = self.settled() return False def command(self, editor: TextArea, command: type[EditorCommand]) -> None: @@ -166,10 +168,16 @@ def label(self) -> str: def settled(self) -> EditingMode: return self.previous.settled() + def document_changed(self) -> EditingMode: + return self.previous.document_changed() + def handle_key(self, editor: TextArea, event: Key) -> bool: editor.editing_mode = self.settled() - if event.key != "escape": - self.command.character(editor, self.previous, event.character, self.count) + if event.key == "escape": + return True + if not event.is_printable: + return False + self.command.character(editor, self.previous, event.character, self.count) return True @@ -315,6 +323,9 @@ class EditorRange: 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 deletion_start(self, editor: TextArea) -> Location: return self.start @@ -323,6 +334,9 @@ def change_text(self, editor: TextArea) -> str: 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 @@ -371,7 +385,7 @@ class Yank(Operator): @classmethod def replace(cls, editor, selection: EditorRange): - editor.app.copy_to_clipboard(selection.text(editor)) + editor.app.copy_to_clipboard(selection.clipboard_content(editor)) editor.move_cursor(selection.start) editor.editing_mode = VimNormal() diff --git a/src/textual/document/_history.py b/src/textual/document/_history.py index c0c7ec1a4af..3a0b86277e7 100644 --- a/src/textual/document/_history.py +++ b/src/textual/document/_history.py @@ -51,9 +51,8 @@ def begin_group(self) -> None: self._group = [] def end_group(self) -> None: - if self._group is not None: - self._group = None - self.checkpoint() + self._group = None + self.checkpoint() def __post_init__(self) -> None: self._undo_stack: deque[list[Edit]] = deque(maxlen=self.max_checkpoints) diff --git a/src/textual/screen.py b/src/textual/screen.py index 4e67bb4c463..f729d3533db 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1275,8 +1275,28 @@ async def _on_idle(self, event: events.Idle) -> None: self._invoke_and_clear_callbacks() + _hint_session = None + + async def action_show_hints(self) -> None: + """Label the original primary effects and links in the displayed scene.""" + from textual.widgets._hints import HintSession + self.dismiss_hints() + targets = self._compositor.activation_targets() + if not targets: + return + session = HintSession(self, targets) + await self.mount(*session.labels, session) + self._hint_session = session + session.focus(scroll_visible=False) + + def dismiss_hints(self, *, restore=True) -> None: + if self._hint_session is not None: + self._hint_session.dismiss(restore=restore) + def _on_frame_published(self) -> None: """Observe an actual displayed update.""" + if self._hint_session is not None: + self._hint_session.reconcile_publication() def _after_refresh_pending( self, screen, *, refresh_requested: dict[Screen, bool], refresh_pending: bool, @@ -1912,6 +1932,7 @@ def _update_auto_focus(self) -> None: def _on_screen_suspend(self) -> None: """Screen has suspended.""" + self.dismiss_hints(restore=False) # Releases now route to another Screen. Retain the copyable range, # but retire the press and its auto-scroll through the original owner. self._mouse_down_offset = None @@ -1965,6 +1986,7 @@ def _retire_inactive_presentation(self) -> None: async def _on_resize(self, event: events.Resize) -> None: event.stop() + self.dismiss_hints() self._screen_resized(event.size) horizontal_breakpoints = ( @@ -2263,6 +2285,8 @@ def _update_select(self) -> None: self._watch__select_state(self._select_state) def _forward_event(self, event: events.Event) -> AwaitComplete: + if isinstance(event, events.MouseEvent) and not isinstance(event, events.MouseMove): + self.dismiss_hints() if event.is_forwarded: return AwaitComplete.nothing() event._set_forwarded() diff --git a/src/textual/widget.py b/src/textual/widget.py index b8bf8d4e87e..dbb06fe45fd 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -63,6 +63,8 @@ ) from textual.message_pump import MessagePump from textual._styles_cache import StylesCache +from textual._activation import PrimaryActivation, StyleTarget +from textual._event_broker import NoHandler, extract_handler_actions from textual._types import AnimationLevel from textual.actions import SkipAction from textual.await_complete import AwaitComplete, AwaitCompletion @@ -4139,6 +4141,24 @@ def render_line(self, y: int) -> Strip: return line + hit_test_transparent: ClassVar[bool] = False + """Decorative paint may opt out of the original pointer hit-test owner.""" + + def activation_targets(self, region: Region, strips: list[Strip]): + """Expose click handlers from admitted strips, without another render.""" + for y, strip in zip(region.line_range, strips): + x = region.x + for segment in strip: + width = segment.cell_length + if segment.style is not None and width: + try: + handler = extract_handler_actions("click", segment.style.meta) + except NoHandler: + pass + else: + yield StyleTarget(self, Region(x, y, width, 1), handler) + x += width + def render_lines(self, crop: Region) -> list[Strip]: """Render the widget into lines. diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index ad821401f08..daf629ecc94 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -21,7 +21,7 @@ from textual.geometry import Size from textual.message import Message from textual.reactive import reactive -from textual.widget import Widget +from textual.widget import PrimaryActivation, Widget ButtonVariant = Literal["default", "primary", "success", "warning", "error"] """The names of the valid button variants. @@ -36,7 +36,7 @@ class InvalidButtonVariant(Exception): """Exception raised if an invalid button variant is used.""" -class Button(Widget, can_focus=True): +class Button(PrimaryActivation, Widget, can_focus=True): """A simple clickable button. Clicking the button will send a [Button.Pressed][textual.widgets.Button.Pressed] message, @@ -413,6 +413,10 @@ def post_render( ) -> ConsoleRenderable: return cast(ConsoleRenderable, renderable) + @property + def primary_activation(self): + return self.action_press + async def _on_click(self, event: events.Click) -> None: event.stop() if not self.has_class("-active"): diff --git a/src/textual/widgets/_hints.py b/src/textual/widgets/_hints.py new file mode 100644 index 00000000000..d204ca8ff3c --- /dev/null +++ b/src/textual/widgets/_hints.py @@ -0,0 +1,116 @@ +"""One transient native hint interaction over published activation targets.""" +from __future__ import annotations + +from textual import events +from textual.geometry import Offset +from textual.widget import Widget +from textual.widgets._static import Static + + +class HintLabel(Static): + hit_test_transparent = True + ALLOW_SELECT = False + + def __init__(self, text, target): + super().__init__(text, markup=False) + self.styles.position = "absolute" + self.styles.overlay = "screen" + self.styles.layer = "_tooltips" + self.styles.width = len(text) + self.styles.height = 1 + self.styles.text_style = "bold reverse" + column = min(target.region.x, max(0, target.widget.screen.size.width - len(text))) + self.absolute_offset = Offset(column, target.region.y) + + +class HintSession(Widget, can_focus=True): + """Own label input and its short-lived paint; never copy navigation state.""" + + alphabet = "asdfghjklqwertyuiopzxcvbnm" + ALLOW_SELECT = False + hit_test_transparent = True + + def __init__(self, screen, targets): + super().__init__() + self.original_focus = screen.focused + self.source_scene = screen._compositor._published_map + self.targets = tuple(sorted(targets, key=lambda target: (target.region.y, target.region.x))) + self.prefix = "" + width = 1 + while len(self.alphabet) ** width < len(self.targets): + width += 1 + self.choices = tuple((self.label(index, width), target) for index, target in enumerate(self.targets)) + self.labels = tuple(HintLabel(label, target) for label, target in self.choices) + self.styles.position = "absolute" + self.styles.overlay = "screen" + self.styles.width = 0 + self.styles.height = 0 + + @classmethod + def label(cls, index, width): + characters = [] + for _ in range(width): + index, digit = divmod(index, len(cls.alphabet)) + characters.append(cls.alphabet[digit]) + return "".join(reversed(characters)) + + def check_consume_key(self, key, character=None): + return key == "escape" or (character is not None and character.isprintable()) + + def on_key(self, event: events.Key): + if self.disabled: + return + if event.key == "escape": + event.stop() + event.prevent_default() + self.dismiss() + elif event.is_printable: + event.stop() + event.prevent_default() + self.prefix += event.character + remaining = [(label, target) for label, target in self.choices if label.startswith(self.prefix)] + if not remaining: + self.dismiss() + elif len(remaining) == 1: + self.dismiss(target=remaining[0][1]) + else: + for widget, (label, _target) in zip(self.labels, self.choices): + widget.display = label.startswith(self.prefix) + + def on_paste(self, event: events.Paste): + original = self.original_focus + self.dismiss() + event.stop() + if original is not None and original.is_attached: + original.post_message(event) + + def dismiss(self, *, target=None, restore=True): + if self.disabled: + return + self.disabled = True + # Restore before a pointer's ordinary dispatch, never asynchronously + # after that click has already chosen a different focus target. + original = self.original_focus + if restore and original is not None and original.is_attached and self.screen is self.app.screen: + original.focus(scroll_visible=False) + self.run_worker(self._retire(target), group="hint-retirement", exclusive=True) + + async def _retire(self, target): + screen = self.screen + if screen._hint_session is self: + screen._hint_session = None + await screen.remove_children(self.labels) + await self.remove() + if target is not None: + await target.activate() + + def on_blur(self): + self.dismiss(restore=False) + + def reconcile_publication(self): + if self.disabled: + return + current = self.screen._compositor._published_map + if any(not target.widget.is_attached or current.get(target.widget) != self.source_scene.get(target.widget) + for target in self.targets): + self.dismiss() diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index b4d3d0ceb32..0596301b999 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -883,32 +883,25 @@ def _build_highlight_map(self) -> None: def _watch_editing_mode(self, previous: EditingMode, current: EditingMode) -> None: previous.suspend(self) current.resume(self) - self.border_subtitle = current.label + self.show_editing_mode(current) + + def show_editing_mode(self, mode: EditingMode) -> None: + """Present the mode at the editor's native border unless embedded.""" + self.border_subtitle = mode.label def leave_modal_editor(self) -> None: """Hook for an embedding application to focus its navigation surface.""" self.screen.focus_next() - async def read_editor_clipboard(self) -> str: - return self.app.clipboard + async def read_editor_clipboard(self): + return self.app.clipboard_content async def paste_editor_text(self, *, before: bool, count: int = 1) -> None: """Paste a clipboard payload without interpreting its text as keys.""" - text = await self.read_editor_clipboard() - if self.read_only or not self.is_attached or not self.has_focus or not text: + content = await self.read_editor_clipboard() + if self.read_only or not self.is_attached or not self.has_focus or not content.text: return - row, column = self.cursor_location - if text.endswith(self.document.newline): - row += not before - if row < self.document.line_count: - location = (row, 0) - else: - location = self.document.end - text = self.document.newline + text.removesuffix(self.document.newline) - else: - location = self.navigator.get_location_column_offset((row, column), not before) - self._replace_via_keyboard(text * count, location, location) - self.move_cursor(location) + content.paste(self, before, count) def _watch_has_focus(self, focus: bool) -> None: self._cursor_visible = focus @@ -1269,6 +1262,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.editing_mode.resume(self) self.post_message(self.Changed(self).set_sender(self)) self.update_suggestion() @@ -2615,6 +2609,7 @@ def clear(self) -> EditResult: 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) def _delete_via_keyboard( @@ -2631,7 +2626,7 @@ def _delete_via_keyboard( Returns: An EditResult or None if no edit was performed (e.g. on read-only mode). """ - if self.read_only: + if self.read_only or not self.editing_mode.accepts_text: return None return self.delete(start, end, maintain_selection_offset=False) @@ -2651,7 +2646,7 @@ def _replace_via_keyboard( Returns: An EditResult or None if no edit was performed (e.g. on read-only mode). """ - if self.read_only: + if self.read_only or not self.editing_mode.accepts_text: return None return self.replace(insert, start, end, maintain_selection_offset=False) @@ -2737,6 +2732,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) From 5be68d4cf053462dc7fbcc5c241cf0e2c22ef6ef Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 08:33:44 -0400 Subject: [PATCH 03/10] Discover primary effects through exposed paint ancestry --- src/textual/_activation.py | 5 ----- src/textual/widget.py | 17 +++++++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/textual/_activation.py b/src/textual/_activation.py index 64c612b6fea..5a842ceb0d7 100644 --- a/src/textual/_activation.py +++ b/src/textual/_activation.py @@ -82,8 +82,3 @@ class PrimaryActivation: @property @abstractmethod def primary_activation(self) -> Callable: ... - - def activation_targets(self, region, strips): - if not self.disabled: - yield PrimaryTarget(self, region, self.primary_activation) - yield from super().activation_targets(region, strips) diff --git a/src/textual/widget.py b/src/textual/widget.py index 6b7a79af686..8796f8aea94 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -63,7 +63,7 @@ ) from textual.message_pump import MessagePump from textual._styles_cache import StylesCache -from textual._activation import PrimaryActivation, StyleTarget +from textual._activation import PrimaryActivation, PrimaryTarget, StyleTarget from textual._event_broker import NoHandler, extract_handler_actions from textual._types import AnimationLevel from textual.actions import SkipAction @@ -4145,18 +4145,23 @@ def render_line(self, y: int) -> Strip: """Decorative paint may opt out of the original pointer hit-test owner.""" def activation_targets(self, region: Region, strips: list[Strip]): - """Expose click handlers from admitted strips, without another render.""" + """Expose original click effects from admitted paint and its ancestry.""" + primary = next((owner for owner in self.ancestors_with_self + if isinstance(owner, PrimaryActivation)), None) for y, strip in zip(region.line_range, strips): x = region.x for segment in strip: width = segment.cell_length - if segment.style is not None and width: + if width: + exposed = Region(x, y, width, 1) try: - handler = extract_handler_actions("click", segment.style.meta) + handler = extract_handler_actions("click", segment.style.meta if segment.style else {}) except NoHandler: - pass + if primary is not None and not primary.disabled: + yield PrimaryTarget(primary, exposed, primary.primary_activation) else: - yield StyleTarget(self, Region(x, y, width, 1), handler) + if handler.action is not None: + yield StyleTarget(self, exposed, handler) x += width def render_lines(self, crop: Region) -> list[Strip]: From 759e380db92475f5de973dddc095fbd68ed2ae96 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 08:40:29 -0400 Subject: [PATCH 04/10] Reconcile hints through original activation admission --- src/textual/widgets/_hints.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/textual/widgets/_hints.py b/src/textual/widgets/_hints.py index d204ca8ff3c..a94649a3441 100644 --- a/src/textual/widgets/_hints.py +++ b/src/textual/widgets/_hints.py @@ -33,7 +33,6 @@ class HintSession(Widget, can_focus=True): def __init__(self, screen, targets): super().__init__() self.original_focus = screen.focused - self.source_scene = screen._compositor._published_map self.targets = tuple(sorted(targets, key=lambda target: (target.region.y, target.region.x))) self.prefix = "" width = 1 @@ -110,7 +109,5 @@ def on_blur(self): def reconcile_publication(self): if self.disabled: return - current = self.screen._compositor._published_map - if any(not target.widget.is_attached or current.get(target.widget) != self.source_scene.get(target.widget) - for target in self.targets): + if any(not target.available() for target in self.targets): self.dismiss() From 2ac20ba03f0835bc44203d140dac1d65064b61ab Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 08:48:03 -0400 Subject: [PATCH 05/10] Keep external clipboard observations distinct from retained editing intent --- src/textual/_clipboard.py | 8 -------- src/textual/document/_editing.py | 3 +-- src/textual/widgets/_hints.py | 2 +- src/textual/widgets/_text_area.py | 14 +++++--------- 4 files changed, 7 insertions(+), 20 deletions(-) diff --git a/src/textual/_clipboard.py b/src/textual/_clipboard.py index 9beb0170f28..a0bbff47102 100644 --- a/src/textual/_clipboard.py +++ b/src/textual/_clipboard.py @@ -12,14 +12,6 @@ class ClipboardContent: def of(cls, value: str | ClipboardContent) -> ClipboardContent: return value if isinstance(value, ClipboardContent) else cls(value) - def observed(self, text: str) -> ClipboardContent: - """Unchanged transport text retains known local provenance. - - A different external value is ordinary characterwise text; plain-text - clipboard protocols cannot communicate another application's Vim kind. - """ - return self if text == self.text else ClipboardContent(text) - def insertion(self, editor, before, count): return editor.navigator.get_location_column_offset(editor.cursor_location, 0 if before else 1), self.text * count diff --git a/src/textual/document/_editing.py b/src/textual/document/_editing.py index fcbc9bc9854..afcaed84c8b 100644 --- a/src/textual/document/_editing.py +++ b/src/textual/document/_editing.py @@ -489,8 +489,7 @@ class Paste(EditorCommand): @classmethod def apply(cls, editor, mode, count): - editor.run_worker(editor.paste_editor_text(before=cls.before, count=count), - group="editor-paste", exclusive=True) + editor.paste_editor_text(before=cls.before, count=count) class PasteBefore(Paste): diff --git a/src/textual/widgets/_hints.py b/src/textual/widgets/_hints.py index a94649a3441..3e26df82d50 100644 --- a/src/textual/widgets/_hints.py +++ b/src/textual/widgets/_hints.py @@ -92,7 +92,7 @@ def dismiss(self, *, target=None, restore=True): original = self.original_focus if restore and original is not None and original.is_attached and self.screen is self.app.screen: original.focus(scroll_visible=False) - self.run_worker(self._retire(target), group="hint-retirement", exclusive=True) + self.screen.run_worker(self._retire(target), group="hint-retirement") async def _retire(self, target): screen = self.screen diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 0596301b999..28a56219a83 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -893,15 +893,11 @@ def leave_modal_editor(self) -> None: """Hook for an embedding application to focus its navigation surface.""" self.screen.focus_next() - async def read_editor_clipboard(self): - return self.app.clipboard_content - - async def paste_editor_text(self, *, before: bool, count: int = 1) -> None: - """Paste a clipboard payload without interpreting its text as keys.""" - content = await self.read_editor_clipboard() - if self.read_only or not self.is_attached or not self.has_focus or not content.text: - return - content.paste(self, before, count) + 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 From fdf284d31acf9f1ce9bc2dbdea556e987336c68e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 09:11:05 -0400 Subject: [PATCH 06/10] Complete the displayed hint label before activating its effect --- src/textual/widgets/_hints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_hints.py b/src/textual/widgets/_hints.py index 3e26df82d50..0755c5df90e 100644 --- a/src/textual/widgets/_hints.py +++ b/src/textual/widgets/_hints.py @@ -70,7 +70,7 @@ def on_key(self, event: events.Key): remaining = [(label, target) for label, target in self.choices if label.startswith(self.prefix)] if not remaining: self.dismiss() - elif len(remaining) == 1: + elif len(remaining) == 1 and remaining[0][0] == self.prefix: self.dismiss(target=remaining[0][1]) else: for widget, (label, _target) in zip(self.labels, self.choices): From bed26c1f1f1e8ccc47c58944acba9a7c2fcbdde8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 09:34:58 -0400 Subject: [PATCH 07/10] Derive visual paint and operations from the mode-owned range --- src/textual/_clipboard.py | 4 ++- src/textual/document/_editing.py | 60 +++++++++++++++++++++---------- src/textual/widgets/_text_area.py | 21 ++++------- 3 files changed, 51 insertions(+), 34 deletions(-) diff --git a/src/textual/_clipboard.py b/src/textual/_clipboard.py index a0bbff47102..d1661a8c593 100644 --- a/src/textual/_clipboard.py +++ b/src/textual/_clipboard.py @@ -17,9 +17,11 @@ def insertion(self, editor, before, count): def paste(self, editor, before, count): location, text = self.insertion(editor, before, count) - editor.history.end_group() + 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): diff --git a/src/textual/document/_editing.py b/src/textual/document/_editing.py index afcaed84c8b..0f5785bab10 100644 --- a/src/textual/document/_editing.py +++ b/src/textual/document/_editing.py @@ -5,10 +5,11 @@ """ from __future__ import annotations +from abc import ABC, abstractmethod from dataclasses import dataclass, replace from typing import TYPE_CHECKING, ClassVar -from textual.document._document import Location +from textual.document._document import Location, Selection from textual._clipboard import ClipboardContent, LinewiseClipboard from textual.document._document_navigator import WordBoundary, PreviousWordBoundary, EndWordBoundary @@ -42,10 +43,24 @@ def resume(self, editor: TextArea) -> None: def suspend(self, editor: TextArea) -> None: editor.history.end_group() + def selection_range(self, editor: TextArea) -> Selection: + return editor.selection -class VimMode(EditingMode): + +class VimMode(EditingMode, ABC): modal = True + 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() @@ -56,7 +71,7 @@ class VimInsert(VimMode): def resume(self, editor: TextArea) -> None: editor.history.begin_group() - def handle_key(self, editor: TextArea, event: Key) -> bool: + def _handle_key(self, editor: TextArea, event: Key) -> bool: if event.key != "escape": return False editor.editing_mode = VimNormal() @@ -72,7 +87,7 @@ class VimNormal(VimMode): def settled(self) -> EditingMode: return replace(self, count=0) - def handle_key(self, editor: TextArea, event: Key) -> bool: + 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 @@ -97,9 +112,12 @@ def move_to(self, editor: TextArea, location: Location) -> None: 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, EditorRange(editor.cursor_location, - Right.destination(editor, editor.cursor_location, count))) + operator.replace(editor, self.character_range(editor, count)) def visual(self, editor: TextArea) -> None: editor.editing_mode = VimVisual() @@ -121,17 +139,18 @@ def document_changed(self) -> EditingMode: def move_to(self, editor: TextArea, location: Location) -> None: editor.move_cursor(location, select=True) - def characters(self, editor: TextArea, operator: type[Operator], count: int) -> None: - self.operate(editor, operator, count) + 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 operate(self, editor: TextArea, operator: type[Operator], count: int) -> None: + def selection_range(self, editor: TextArea) -> Selection: start, end = sorted(editor.selection) - # Native Selection is half-open; characterwise Visual includes its end. - end = editor.navigator.get_location_right(end) - operator.replace(editor, EditorRange(start, end)) + 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) @@ -171,7 +190,10 @@ def settled(self) -> EditingMode: def document_changed(self) -> EditingMode: return self.previous.document_changed() - def handle_key(self, editor: TextArea, event: Key) -> bool: + def selection_range(self, editor: TextArea) -> Selection: + return self.previous.selection_range(editor) + + def _handle_key(self, editor: TextArea, event: Key) -> bool: editor.editing_mode = self.settled() if event.key == "escape": return True @@ -326,6 +348,11 @@ def text(self, editor: TextArea) -> str: 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 @@ -408,7 +435,6 @@ def replace(cls, editor, selection: EditorRange): editor.editing_mode = cls.following_mode() editor.replace(cls.inserted_text(editor, selection), cls.deletion_start(editor, selection), selection.end, maintain_selection_offset=False) - editor.history.checkpoint() class Change(Delete): @@ -539,7 +565,5 @@ class ReplaceCharacter(CharacterCommand): @classmethod def character(cls, editor, mode, character, count): if character is not None and character.isprintable(): - start = editor.cursor_location - end = Right.destination(editor, start, count) - editor.replace(character * (end[1] - start[1]), start, end, maintain_selection_offset=False) - editor.move_cursor(start) + mode.character_range(editor, count).replace_characters(editor, character) + editor.editing_mode = VimNormal() diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 28a56219a83..97660bc57e4 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -1441,7 +1441,7 @@ def render_line(self, y: int) -> Strip: scroll_x, scroll_y = self.scroll_offset absolute_y = scroll_y + y - selection = self.selection + selection = self.editing_mode.selection_range(self) _, cursor_y = self._cursor_offset cache_key = ( self.size, @@ -1452,15 +1452,7 @@ def render_line(self, y: int) -> Strip: if selection.contains_line(absolute_y) or self.soft_wrap else selection.end[0] == absolute_y ), - ( - selection.end - if ( - self._cursor_visible - and self.cursor_blink - and absolute_y == cursor_y - ) - else None - ), + (self.cursor_location, self._draw_cursor) if absolute_y == cursor_y else None, self.theme, self._matching_bracket_location, self.match_cursor_bracket, @@ -1521,9 +1513,9 @@ def _render_line(self, y: int) -> Strip: line.set_length(line_character_count + 1) # space at end for cursor virtual_width, _virtual_height = self.virtual_size - selection = self.selection + selection = self.editing_mode.selection_range(self) start, end = selection - cursor_row, cursor_column = end + cursor_row, cursor_column = self.cursor_location selection_top, selection_bottom = sorted(selection) selection_top_row, selection_top_column = selection_top @@ -1542,7 +1534,6 @@ def _render_line(self, y: int) -> Strip: if start != end and selection_top_row <= line_index <= selection_bottom_row: # If this row intersects with the selection range selection_style = theme.selection_style if theme else None - cursor_row, _ = end if selection_style: if line_character_count == 0 and line_index != cursor_row: # A simple highlight to show empty lines are included in the selection @@ -1724,8 +1715,8 @@ def text(self, value: str) -> None: @property def selected_text(self) -> str: - """The text between the start and end points of the current selection.""" - start, end = self.selection + """The selected range, shared with paint and modal operators.""" + start, end = self.editing_mode.selection_range(self) return self.get_text_range(start, end) @property From d15dfbf21e4a96d898ffdb3817609b97c0cacb76 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 11:02:15 -0400 Subject: [PATCH 08/10] Admit hint targets through their shared activation availability --- src/textual/_activation.py | 7 ++++++- src/textual/_compositor.py | 2 +- src/textual/widget.py | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/textual/_activation.py b/src/textual/_activation.py index 5a842ceb0d7..85ebc604dbc 100644 --- a/src/textual/_activation.py +++ b/src/textual/_activation.py @@ -21,7 +21,12 @@ class Activation: def available(self) -> bool: widget = self.widget - if not widget.is_attached or widget.disabled or not widget.visible: + if ( + not widget.is_attached + or widget.is_disabled + or not widget.visible + or any(not node.display for node in widget.ancestors_with_self) + ): return False screen = widget.screen if screen is not widget.app.screen: diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 37ec6521886..ec090af84f1 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -2193,7 +2193,7 @@ def activation_targets(self): def collect(widget, region, strips): for target in widget.activation_targets(region, strips): - if target.identity not in seen: + if target.identity not in seen and target.available(): seen.append(target.identity) targets.append(target) diff --git a/src/textual/widget.py b/src/textual/widget.py index 8796f8aea94..0b76383fbaf 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -4157,7 +4157,7 @@ def activation_targets(self, region: Region, strips: list[Strip]): try: handler = extract_handler_actions("click", segment.style.meta if segment.style else {}) except NoHandler: - if primary is not None and not primary.disabled: + if primary is not None: yield PrimaryTarget(primary, exposed, primary.primary_activation) else: if handler.action is not None: From 9a18a0f0fe41a2d5edde25f9e2371de1c3877559 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 12:04:40 -0400 Subject: [PATCH 09/10] Acquire screen hint custody before awaiting mount completion --- src/textual/screen.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index f729d3533db..f047ce8f5da 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1285,9 +1285,11 @@ async def action_show_hints(self) -> None: if not targets: return session = HintSession(self, targets) - await self.mount(*session.labels, session) + mounted = self.mount(*session.labels, session) self._hint_session = session - session.focus(scroll_visible=False) + await mounted + if self._hint_session is session and not session.disabled and self is self.app.screen: + session.focus(scroll_visible=False) def dismiss_hints(self, *, restore=True) -> None: if self._hint_session is not None: From 18f4e7a67403d56846ef5abf55c93e3e0639be33 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 21:34:34 -0400 Subject: [PATCH 10/10] Own hint lifecycle in HintHost and delete duplicate target state --- src/textual/_activation.py | 22 +++++++++++ src/textual/_compositor.py | 3 +- src/textual/screen.py | 28 +------------- src/textual/widget.py | 23 +----------- src/textual/widgets/_hints.py | 69 +++++++++++++++++++++++++---------- 5 files changed, 76 insertions(+), 69 deletions(-) diff --git a/src/textual/_activation.py b/src/textual/_activation.py index 85ebc604dbc..3f5d893f89a 100644 --- a/src/textual/_activation.py +++ b/src/textual/_activation.py @@ -11,6 +11,7 @@ from textual.errors import NoWidget if TYPE_CHECKING: + from textual.strip import Strip from textual.widget import Widget @@ -19,6 +20,27 @@ class Activation: widget: Widget region: Region + @classmethod + def from_paint(cls, widget: Widget, region: Region, strips: list[Strip]): + """Decode exposed click metadata and primary declarations once.""" + primary = next((owner for owner in widget.ancestors_with_self + if isinstance(owner, PrimaryActivation)), None) + for y, strip in zip(region.line_range, strips): + x = region.x + for segment in strip: + width = segment.cell_length + if width: + exposed = Region(x, y, width, 1) + try: + handler = extract_handler_actions("click", segment.style.meta if segment.style else {}) + except NoHandler: + if primary is not None: + yield PrimaryTarget(primary, exposed, primary.primary_activation) + else: + if handler.action is not None: + yield StyleTarget(widget, exposed, handler) + x += width + def available(self) -> bool: widget = self.widget if ( diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index ec090af84f1..d0dfdb2b03f 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -42,6 +42,7 @@ from rich.style import Style from textual import errors, events +from textual._activation import Activation from textual._loop import loop_last from textual._spatial_map import SpatialMap from textual.geometry import NULL_OFFSET, NULL_SPACING, Offset, Region, Size, Spacing @@ -2192,7 +2193,7 @@ def activation_targets(self): return targets def collect(widget, region, strips): - for target in widget.activation_targets(region, strips): + for target in Activation.from_paint(widget, region, strips): if target.identity not in seen and target.available(): seen.append(target.identity) targets.append(target) diff --git a/src/textual/screen.py b/src/textual/screen.py index f047ce8f5da..1d005476108 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -68,6 +68,7 @@ from textual.timer import Timer from textual.widget import Widget from textual.widgets import Tooltip +from textual.widgets._hints import HintHost from textual.widgets._toast import ToastRack if TYPE_CHECKING: @@ -144,7 +145,7 @@ def __call__(self, result: ScreenResultType) -> None: @rich.repr.auto -class Screen(Generic[ScreenResultType], Widget): +class Screen(HintHost, Generic[ScreenResultType], Widget): """The base class for screens.""" AUTO_FOCUS: ClassVar[str | None] = None @@ -1275,31 +1276,6 @@ async def _on_idle(self, event: events.Idle) -> None: self._invoke_and_clear_callbacks() - _hint_session = None - - async def action_show_hints(self) -> None: - """Label the original primary effects and links in the displayed scene.""" - from textual.widgets._hints import HintSession - self.dismiss_hints() - targets = self._compositor.activation_targets() - if not targets: - return - session = HintSession(self, targets) - mounted = self.mount(*session.labels, session) - self._hint_session = session - await mounted - if self._hint_session is session and not session.disabled and self is self.app.screen: - session.focus(scroll_visible=False) - - def dismiss_hints(self, *, restore=True) -> None: - if self._hint_session is not None: - self._hint_session.dismiss(restore=restore) - - def _on_frame_published(self) -> None: - """Observe an actual displayed update.""" - if self._hint_session is not None: - self._hint_session.reconcile_publication() - def _after_refresh_pending( self, screen, *, refresh_requested: dict[Screen, bool], refresh_pending: bool, ) -> bool: diff --git a/src/textual/widget.py b/src/textual/widget.py index 0b76383fbaf..ca337c54d7c 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -63,8 +63,7 @@ ) from textual.message_pump import MessagePump from textual._styles_cache import StylesCache -from textual._activation import PrimaryActivation, PrimaryTarget, StyleTarget -from textual._event_broker import NoHandler, extract_handler_actions +from textual._activation import PrimaryActivation from textual._types import AnimationLevel from textual.actions import SkipAction from textual.await_complete import AwaitComplete, AwaitCompletion @@ -4144,26 +4143,6 @@ def render_line(self, y: int) -> Strip: hit_test_transparent: ClassVar[bool] = False """Decorative paint may opt out of the original pointer hit-test owner.""" - def activation_targets(self, region: Region, strips: list[Strip]): - """Expose original click effects from admitted paint and its ancestry.""" - primary = next((owner for owner in self.ancestors_with_self - if isinstance(owner, PrimaryActivation)), None) - for y, strip in zip(region.line_range, strips): - x = region.x - for segment in strip: - width = segment.cell_length - if width: - exposed = Region(x, y, width, 1) - try: - handler = extract_handler_actions("click", segment.style.meta if segment.style else {}) - except NoHandler: - if primary is not None: - yield PrimaryTarget(primary, exposed, primary.primary_activation) - else: - if handler.action is not None: - yield StyleTarget(self, exposed, handler) - x += width - def render_lines(self, crop: Region) -> list[Strip]: """Render the widget into lines. diff --git a/src/textual/widgets/_hints.py b/src/textual/widgets/_hints.py index 0755c5df90e..e7cec7d67e3 100644 --- a/src/textual/widgets/_hints.py +++ b/src/textual/widgets/_hints.py @@ -7,12 +7,34 @@ from textual.widgets._static import Static +class HintHost: + """Screen capability; mounted children are the only session inventory.""" + + async def action_show_hints(self) -> None: + self.dismiss_hints() + targets = self._compositor.activation_targets() + if not targets: + return + session = HintSession(self, targets) + await self.mount(*session.labels, session) + session.focus_on_host() + + def dismiss_hints(self, *, restore=True) -> None: + for session in self.query_children(HintSession): + session.dismiss(restore=restore) + + def _on_frame_published(self) -> None: + for session in self.query_children(HintSession): + session.reconcile_publication() + + class HintLabel(Static): hit_test_transparent = True ALLOW_SELECT = False def __init__(self, text, target): super().__init__(text, markup=False) + self.target = target self.styles.position = "absolute" self.styles.overlay = "screen" self.styles.layer = "_tooltips" @@ -33,13 +55,13 @@ class HintSession(Widget, can_focus=True): def __init__(self, screen, targets): super().__init__() self.original_focus = screen.focused - self.targets = tuple(sorted(targets, key=lambda target: (target.region.y, target.region.x))) + targets = sorted(targets, key=lambda target: (target.region.y, target.region.x)) self.prefix = "" width = 1 - while len(self.alphabet) ** width < len(self.targets): + while len(self.alphabet) ** width < len(targets): width += 1 - self.choices = tuple((self.label(index, width), target) for index, target in enumerate(self.targets)) - self.labels = tuple(HintLabel(label, target) for label, target in self.choices) + self.labels = tuple(HintLabel(self.label(index, width), target) + for index, target in enumerate(targets)) self.styles.position = "absolute" self.styles.overlay = "screen" self.styles.width = 0 @@ -53,6 +75,10 @@ def label(cls, index, width): characters.append(cls.alphabet[digit]) return "".join(reversed(characters)) + def focus_on_host(self): + if self.is_attached and self.screen is self.app.screen: + self.screen.set_focus(self, scroll_visible=False) + def check_consume_key(self, key, character=None): return key == "escape" or (character is not None and character.isprintable()) @@ -67,14 +93,15 @@ def on_key(self, event: events.Key): event.stop() event.prevent_default() self.prefix += event.character - remaining = [(label, target) for label, target in self.choices if label.startswith(self.prefix)] + remaining = [label for label in self.labels + if str(label.content).startswith(self.prefix)] if not remaining: self.dismiss() - elif len(remaining) == 1 and remaining[0][0] == self.prefix: - self.dismiss(target=remaining[0][1]) + elif len(remaining) == 1 and remaining[0].content == self.prefix: + self.dismiss(target=remaining[0].target) else: - for widget, (label, _target) in zip(self.labels, self.choices): - widget.display = label.startswith(self.prefix) + for label in self.labels: + label.display = str(label.content).startswith(self.prefix) def on_paste(self, event: events.Paste): original = self.original_focus @@ -83,21 +110,23 @@ def on_paste(self, event: events.Paste): if original is not None and original.is_attached: original.post_message(event) + def restore_focus(self): + if self.screen is not self.app.screen: + return + original = self.original_focus + if original is None or original.is_attached: + self.screen.set_focus(original, scroll_visible=False) + def dismiss(self, *, target=None, restore=True): if self.disabled: return self.disabled = True - # Restore before a pointer's ordinary dispatch, never asynchronously - # after that click has already chosen a different focus target. - original = self.original_focus - if restore and original is not None and original.is_attached and self.screen is self.app.screen: - original.focus(scroll_visible=False) - self.screen.run_worker(self._retire(target), group="hint-retirement") - - async def _retire(self, target): + if restore: + self.restore_focus() screen = self.screen - if screen._hint_session is self: - screen._hint_session = None + screen.run_worker(self._retire(screen, target), group="hint-retirement") + + async def _retire(self, screen, target): await screen.remove_children(self.labels) await self.remove() if target is not None: @@ -109,5 +138,5 @@ def on_blur(self): def reconcile_publication(self): if self.disabled: return - if any(not target.available() for target in self.targets): + if any(not label.target.available() for label in self.labels): self.dismiss()