Source code for simvx.core.ui.text_selection

"""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())