Skip to content
22 changes: 1 addition & 21 deletions src/textual/_clipboard.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""The local clipboard payload, including editing semantics lost by plain text."""
"""The application's local clipboard payload."""
from __future__ import annotations

from dataclasses import dataclass
Expand All @@ -11,23 +11,3 @@ class ClipboardContent:
@classmethod
def of(cls, value: str | ClipboardContent) -> ClipboardContent:
return value if isinstance(value, ClipboardContent) else cls(value)

def insertion(self, editor, before, count):
return editor.navigator.get_location_column_offset(editor.cursor_location, 0 if before else 1), self.text * count

def paste(self, editor, before, count):
location, text = self.insertion(editor, before, count)
mode = editor.editing_mode
mode.suspend(editor)
editor.replace(text, location, location, maintain_selection_offset=False)
editor.move_cursor(location)
mode.resume(editor)


class LinewiseClipboard(ClipboardContent):
def insertion(self, editor, before, count):
row = editor.cursor_location[0] + (0 if before else 1)
text = self.text * count
if row < editor.document.line_count:
return (row, 0), text
return editor.document.end, editor.document.newline + text.removesuffix(editor.document.newline)
2 changes: 2 additions & 0 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4265,6 +4265,8 @@ async def on_event(self, event: events.Event) -> None:
self.screen.minimize()
return
if self.focused:
if not await self.focused.prepare_key():
return
try:
self.screen._clear_tooltip()
except NoScreen:
Expand Down
15 changes: 15 additions & 0 deletions src/textual/binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
NamedTuple,
Self,
TypeVar,
overload,
)

import rich.repr
Expand All @@ -38,6 +39,7 @@
from textual.dom import DOMNode
from textual.events import Key
from textual.widget import Widget
from textual.widgets import TextArea

BindingType: TypeAlias = "Binding | tuple[str, str] | tuple[str, str, str]"
"""The possible types of a binding found in the `BINDINGS` class variable."""
Expand Down Expand Up @@ -181,6 +183,10 @@ def fallback_bindings(self) -> tuple[Binding, ...]:
"""Ordinary keys stay on the focused native ancestor chain."""
return ()

def engine_boundary(self, editor: TextArea) -> bool:
"""Native declarations may offer an application edge to an external editor."""
return False

@classmethod
def make_bindings(cls, bindings: Iterable[BindingType]) -> Iterable[Binding]:
"""Convert a list of BindingType (the types that can be specified in BINDINGS)
Expand Down Expand Up @@ -335,6 +341,12 @@ def declared(method: Callable[..., object]) -> MethodBinding:
tooltip=method.__doc__ or "", show=False, selecting=selecting)
return declared

@overload
def __get__(self, instance: None, owner: type) -> FunctionType: ...

@overload
def __get__(self, instance: object, owner: type) -> Callable[..., object]: ...

def __get__(self, instance: object, owner: type) -> Callable[..., object]:
return self.action.__get__(instance, owner)

Expand All @@ -350,6 +362,9 @@ def __set_name__(self, owner: type[DOMNode], name: str) -> None:
def arguments(self) -> tuple[object, ...]:
return ()

def engine_boundary(self, editor: TextArea) -> bool:
return editor.before_engine_input(self.action)

def call(self, node: DOMNode) -> object:
return invoke_sync(getattr(node, self.action.__name__), *self.arguments())

Expand Down
24 changes: 24 additions & 0 deletions src/textual/document/_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ class EditResult:
"""The text that was replaced."""


@dataclass
class RangedEditResult(EditResult):
"""A projected edit retains the original range needed by its view."""

start: Location
old_end: Location


@lru_cache(maxsize=1024)
def _utf8_encode(text: str) -> bytes:
"""Encode the input text as utf-8 bytes.
Expand Down Expand Up @@ -80,6 +88,22 @@ def replace_range(self, start: Location, end: Location, text: str) -> EditResult
The new end location after the edit is complete.
"""

def replace_lines(self, first: int, last: int, lines: tuple[str, ...]) -> RangedEditResult:
"""Project an end-exclusive line change into this same document."""
count = self.line_count
end = (last, 0) if last < count else self.end
start = (first, 0) if first < count else self.end
text = self.newline.join(lines)
if last < count:
if lines:
text += self.newline
elif first > 0:
start = first - 1, len(self.get_line(first - 1))
if lines:
text = self.newline + text
result = self.replace_range(start, end, text)
return RangedEditResult(result.end_location, result.replaced_text, start, end)

@property
@abstractmethod
def text(self) -> str:
Expand Down
62 changes: 0 additions & 62 deletions src/textual/document/_document_navigator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,37 +8,6 @@
from textual.geometry import Offset, clamp


class WordBoundary:
"""Vim word boundaries in document coordinates, independent of the cursor."""

direction = 1
pattern = re.compile(r"\w+|[^\w\s]+")

def matches(self, line: str):
return self.pattern.finditer(line)

def position(self, match) -> int:
return match.start()

def accepts(self, column: int, origin: int) -> bool:
return column > origin


class PreviousWordBoundary(WordBoundary):
direction = -1

def matches(self, line: str):
return reversed(tuple(super().matches(line)))

def accepts(self, column: int, origin: int) -> bool:
return column < origin


class EndWordBoundary(WordBoundary):
def position(self, match) -> int:
return match.end() - 1


class DocumentNavigator:
"""Cursor navigation in the TextArea is "wrapping-aware".

Expand Down Expand Up @@ -102,37 +71,6 @@ def __init__(self, wrapped_document: WrappedDocument) -> None:
"""Remembers the last x offset (cell width) the cursor was moved horizontally to,
so that it can be restored on vertical movement where possible."""

def get_location_column_offset(self, location: Location, columns: int) -> Location:
"""Move within a raw document line without crossing its newline."""
row, column = location
return row, clamp(column + columns, 0, len(self._document[row]))

def get_location_line_offset(self, location: Location, rows: int) -> Location:
"""Move by logical lines, as opposed to wrapped visual sections."""
row = clamp(location[0] + rows, 0, self._document.line_count - 1)
return row, min(location[1], len(self._document[row]))

def get_location_first_nonblank(self, location: Location) -> Location:
line = self._document[location[0]]
return location[0], len(line) - len(line.lstrip())

def get_location_document_line_end(self, location: Location, lines: int = 1) -> Location:
row = min(self._document.line_count - 1, location[0] + lines - 1)
return row, len(self._document[row])

def get_location_word_boundary(self, location: Location, count: int, boundary: WordBoundary) -> Location:
"""Find counted raw-word boundaries without moving an editor selection."""
row, column = location
stop = self._document.line_count if boundary.direction > 0 else -1
for next_row in range(row, stop, boundary.direction):
for match in boundary.matches(self._document[next_row]):
next_column = boundary.position(match)
if next_row != row or boundary.accepts(next_column, column):
count -= 1
if count == 0:
return next_row, next_column
return self._document.end if boundary.direction > 0 else self._document.start

def get_location_word_left(self, location: Location) -> Location:
"""The native word-left boundary, including its whitespace semantics."""
row, column = location
Expand Down
21 changes: 21 additions & 0 deletions src/textual/document/_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,27 @@
from textual.widgets import TextArea


class InsertionPoint:
"""An omitted position follows the executing editor's cursor, not its view."""

def resolve(self, cursor: Location) -> Location:
return cursor

def engine_location(self) -> object:
return None


@dataclass(frozen=True)
class InsertAt(InsertionPoint):
location: Location

def resolve(self, cursor: Location) -> Location:
return self.location

def engine_location(self) -> object:
return self.location


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