"""Selection, clipboard and word boundaries shared by the text-editing widgets.
``TextEdit`` places its caret at a character offset and ``MultiLineTextEdit``
at a ``(line, column)`` pair. Both order with ``<``, so one set of selection
and clipboard methods serves both: a widget supplies its caret as ``_caret``
and the two text operations ``_text_between`` and ``_remove_between``.
"""
from __future__ import annotations
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any
from . import clipboard
if TYPE_CHECKING:
from ..signals import Signal
__all__ = ["TextSelectionMixin"]
#: A key's ``(ctrl, shift, alt)`` state, as each editing binding names it. Every
#: binding is exact in the modifiers it does not name, so a chord the editor
#: does not know composes a character instead of firing a shortcut that looks
#: close to it.
_PLAIN = (False, False, False)
_SHIFT = (False, True, False)
_CTRL = (True, False, False)
_CTRL_SHIFT = (True, True, False)
_ALT = (False, False, True)
#: Shift on a motion decides whether it extends the selection, not which
#: motion it is, so both spellings run the same binding.
_MOTION = (_PLAIN, _SHIFT)
_WORD_MOTION = (_CTRL, _CTRL_SHIFT)
def _is_word_char(ch: str) -> bool:
"""True for characters that word-wise motion and select-word treat as one word."""
return ch.isalnum() or ch == "_"
def _word_span(line: str, col: int) -> tuple[int, int] | None:
"""``(start, end)`` of the word holding the character at *col*, or None off a word.
A column at the end of the line reads the last character, so a click past
the text still finds the word it ends with.
"""
col = min(col, len(line) - 1)
if col < 0 or not _is_word_char(line[col]):
return None
start, end = col, col + 1
while start > 0 and _is_word_char(line[start - 1]):
start -= 1
while end < len(line) and _is_word_char(line[end]):
end += 1
return start, end
def _word_left(line: str, col: int) -> int:
"""The start of the word before *col*, skipping the separators in between.
A caret after ``foo, `` lands on the ``f``.
"""
while col > 0 and not _is_word_char(line[col - 1]):
col -= 1
while col > 0 and _is_word_char(line[col - 1]):
col -= 1
return col
def _word_right(line: str, col: int) -> int:
"""The end of the word after *col*, skipping the separators in between."""
while col < len(line) and not _is_word_char(line[col]):
col += 1
while col < len(line) and _is_word_char(line[col]):
col += 1
return col
[docs]
class TextSelectionMixin:
"""Selection and clipboard for a text widget whose caret positions order with ``<``.
The selection runs from ``_select_start``, the anchor, to ``_select_end``,
which follows the caret; None at either end means nothing is selected. A
widget using it provides ``_caret`` (read and assigned), ``_text_between``,
``_remove_between``, ``insert`` and a ``text_changed`` signal.
"""
_select_start: Any = None
_select_end: Any = None
if TYPE_CHECKING:
# What the widget provides, declared for the type checker.
_caret: Any
text: str
text_changed: Signal
def _text_between(self, start: Any, end: Any) -> str: ...
def _remove_between(self, start: Any, end: Any) -> None: ...
def insert(self, text: str) -> None: ...
def queue_redraw(self) -> None: ...
def _has_selection(self) -> bool:
"""Check if there is an active selection."""
return (
self._select_start is not None and self._select_end is not None and self._select_start != self._select_end
)
def _ordered_selection(self):
"""Return selection start/end in text order (start <= end); the caret twice when nothing is selected."""
if not self._has_selection():
return (self._caret, self._caret)
start, end = self._select_start, self._select_end
return (start, end) if start < end else (end, start)
def _clear_selection(self):
"""Remove the active selection."""
self._select_start = None
self._select_end = None
def _start_selection(self):
"""Begin a new selection at the caret, keeping an anchor already set."""
if self._select_start is None:
self._select_start = self._caret
self._select_end = self._caret
def _update_selection_end(self):
"""Update the selection end to the caret."""
self._select_end = self._caret
@contextmanager
def _cursor_motion(self, shift: bool):
"""Wrap a caret move so *shift* extends the selection instead of dropping it."""
if shift:
self._start_selection()
else:
self._clear_selection()
yield
if shift:
self._update_selection_end()
def _get_selection_text(self) -> str:
"""Return the currently selected text as a string."""
if not self._has_selection():
return ""
return self._text_between(*self._ordered_selection())
def _delete_selection(self):
"""Remove the selected text and collapse the caret to where it started."""
if not self._has_selection():
return
start, end = self._ordered_selection()
self._remove_between(start, end)
self._caret = start
self._clear_selection()
[docs]
def copy(self) -> None:
"""Copy the selected text to the clipboard."""
txt = self._get_selection_text()
if txt:
clipboard.copy(txt)
[docs]
def cut(self) -> None:
"""Copy the selected text to the clipboard and remove it."""
if not self._has_selection():
return
self.copy()
self._delete_selection()
self.queue_redraw()
self.text_changed.emit(self.text)
[docs]
def paste(self) -> None:
"""Insert the clipboard's text at the caret, replacing the selection."""
self.insert(clipboard.paste())