diff --git a/src/textual/_activation.py b/src/textual/_activation.py new file mode 100644 index 00000000000..3f5d893f89a --- /dev/null +++ b/src/textual/_activation.py @@ -0,0 +1,111 @@ +"""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.strip import Strip + from textual.widget import Widget + + +@dataclass(frozen=True) +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 ( + 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: + 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: ... diff --git a/src/textual/_clipboard.py b/src/textual/_clipboard.py new file mode 100644 index 00000000000..d1661a8c593 --- /dev/null +++ b/src/textual/_clipboard.py @@ -0,0 +1,33 @@ +"""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 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/_compositor.py b/src/textual/_compositor.py index 5694dabd024..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 @@ -1687,7 +1688,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 +1897,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 +1933,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 +2183,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 Activation.from_paint(widget, region, strips): + if target.identity not in seen and target.available(): + 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 +2226,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 +2285,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/_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..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 @@ -4280,6 +4285,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 @@ -4428,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 6dbc3c6d599..027a466bbf5 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,56 @@ 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 + 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..0f5785bab10 --- /dev/null +++ b/src/textual/document/_editing.py @@ -0,0 +1,569 @@ +"""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 abc import ABC, abstractmethod +from dataclasses import dataclass, replace +from typing import TYPE_CHECKING, ClassVar + +from textual.document._document import Location, Selection +from textual._clipboard import ClipboardContent, LinewiseClipboard +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() + + def selection_range(self, editor: TextArea) -> Selection: + return editor.selection + + +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() + + +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 + editor.editing_mode = self.settled() + 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 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" + + def document_changed(self) -> EditingMode: + return VimNormal() + + def move_to(self, editor: TextArea, location: Location) -> None: + editor.move_cursor(location, select=True) + + def character_range(self, editor: TextArea, count: int) -> EditorRange: + return EditorRange(*self.selection_range(editor)) + + def visual(self, editor: TextArea) -> None: + self.escape(editor) + + def 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() + + +@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 document_changed(self) -> EditingMode: + return self.previous.document_changed() + + 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 + if not event.is_printable: + return False + 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 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 = 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",) + + @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.paste_editor_text(before=cls.before, count=count) + + +class PasteBefore(Paste): + keys = ("P",) + before = True + + +class CharacterCommand(EditorCommand): + @classmethod + def apply(cls, editor, mode, count): + editor.editing_mode = AwaitKey(cls, mode, count) + + @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(): + mode.character_range(editor, count).replace_characters(editor, character) + editor.editing_mode = VimNormal() diff --git a/src/textual/document/_history.py b/src/textual/document/_history.py index c779fdd7ab4..3a0b86277e7 100644 --- a/src/textual/document/_history.py +++ b/src/textual/document/_history.py @@ -41,6 +41,19 @@ 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: + 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 +95,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 +145,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 +163,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 +176,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/screen.py b/src/textual/screen.py index 4e67bb4c463..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,9 +1276,6 @@ async def _on_idle(self, event: events.Idle) -> None: self._invoke_and_clear_callbacks() - def _on_frame_published(self) -> None: - """Observe an actual displayed update.""" - def _after_refresh_pending( self, screen, *, refresh_requested: dict[Screen, bool], refresh_pending: bool, ) -> bool: @@ -1912,6 +1910,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 +1964,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 +2263,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 19aa3d54ee2..ca337c54d7c 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -63,6 +63,7 @@ ) from textual.message_pump import MessagePump from textual._styles_cache import StylesCache +from textual._activation import PrimaryActivation from textual._types import AnimationLevel from textual.actions import SkipAction from textual.await_complete import AwaitComplete, AwaitCompletion @@ -4139,6 +4140,9 @@ 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 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..e7cec7d67e3 --- /dev/null +++ b/src/textual/widgets/_hints.py @@ -0,0 +1,142 @@ +"""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 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" + 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 + targets = sorted(targets, key=lambda target: (target.region.y, target.region.x)) + self.prefix = "" + width = 1 + while len(self.alphabet) ** width < len(targets): + width += 1 + 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 + 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 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()) + + 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 for label in self.labels + if str(label.content).startswith(self.prefix)] + if not remaining: + self.dismiss() + elif len(remaining) == 1 and remaining[0].content == self.prefix: + self.dismiss(target=remaining[0].target) + else: + for label in self.labels: + label.display = str(label.content).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 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 + if restore: + self.restore_focus() + screen = self.screen + 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: + await target.activate() + + def on_blur(self): + self.dismiss(restore=False) + + def reconcile_publication(self): + if self.disabled: + return + if any(not label.target.available() for label in self.labels): + self.dismiss() diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 38b6a2da80e..97660bc57e4 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,34 @@ 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.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() + + 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: 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,7 +1256,9 @@ 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.editing_mode.resume(self) self.post_message(self.Changed(self).set_sender(self)) self.update_suggestion() @@ -1241,6 +1267,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 +1286,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) @@ -1413,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, @@ -1424,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, @@ -1493,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 @@ -1514,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 @@ -1696,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 @@ -1757,6 +1776,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 +1786,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 +1894,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 +2063,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 +2443,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 +2461,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.""" @@ -2597,6 +2596,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( @@ -2613,7 +2613,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) @@ -2633,7 +2633,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) @@ -2719,6 +2719,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) 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",