Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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