Source code for simvx.core.ui.multiline

"""MultiLineTextEdit -- Multi-line text editor widget with selection,
scrollbar, line numbers, cursor blink, undo/redo, and clipboard.

Supports keyboard navigation (including word-wise ctrl+left/right and
document-wise ctrl+home/end), mouse click positioning, text selection via
shift+arrow keys, vertical scrolling via mouse wheel and scrollbar thumb
drag, undo/redo history (ctrl+z, and ctrl+shift+z or ctrl+y to redo),
clipboard cut/copy/paste, line comments, and select-word (ctrl+d).
"""

import logging
from collections import deque
from contextlib import contextmanager

from ..descriptors import Property
from ..input.controls import Key, Pointer
from ..input.enums import CursorShape, PointerPhase
from ..input.events import KeyEvent, PointerEvent, ScrollEvent, TextEvent
from ..signals import Signal
from ..text.measure import LOGICAL_EM, caret_index_at, measure_text_width
from .core import Colour, Control, ThemeColour, _cursor_shape
from .enums import FocusMode, PointerFilter
from .text_selection import (
    _ALT,
    _CTRL,
    _CTRL_SHIFT,
    _MOTION,
    _PLAIN,
    _SHIFT,
    _WORD_MOTION,
    TextSelectionMixin,
    _word_left,
    _word_right,
    _word_span,
)

log = logging.getLogger(__name__)

__all__ = ["MultiLineTextEdit"]

# Scrollbar styling constants
_SCROLLBAR_WIDTH = 12.0

# Text layout constants
_PADDING_LEFT = 6.0
_PADDING_TOP = 4.0
_PADDING_RIGHT = 4.0
_LINE_NUMBER_PAD = 8.0


[docs] class MultiLineTextEdit(TextSelectionMixin, Control): """Multi-line text editor with selection, scrolling, and line numbers. Features: - Multi-line text buffer with cursor navigation, word-wise (ctrl+left, ctrl+right) and document-wise (ctrl+home, ctrl+end) - Text selection via shift+arrow keys, and by holding shift with any of the ctrl motions above - Undo (ctrl+z) and redo (ctrl+shift+z or ctrl+y) - Vertical scrollbar with thumb drag and mouse wheel - Optional line number gutter - Cursor blink animation - Read-only mode Example: editor = MultiLineTextEdit() editor.text = "Hello\\nWorld" editor.text_changed.connect(lambda t: print("Changed:", t)) editor.show_line_numbers = True """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") cursor_shape = Property(CursorShape.IBEAM, coerce=_cursor_shape, hint="The pointer's shape over this control") _draw_caching = True _draws_children = True # Theme-aware colours text_colour = ThemeColour("text") bg_colour = ThemeColour("bg_darker") border_colour = ThemeColour("border") focus_colour = ThemeColour("accent") selection_colour = ThemeColour("selection") line_number_colour = ThemeColour("line_number") current_line_colour = ThemeColour("current_line") gutter_bg_colour = ThemeColour("gutter_bg") # Editor-visible settings font_size = Property(14.0, range=(8, 72), hint="Font size") show_line_numbers = Property(False, hint="Show line number gutter") read_only = Property(False, hint="Read-only mode") tab_size = Property(4, range=(1, 8), hint="Tab width in spaces") size_x = Property(400.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed") size_y = Property(300.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed") # Clicking one focuses it, but Tab is the editor's own (indent), so it is a # focus source and never a Tab destination. Declared rather than assigned in # ``__init__``, so a caller's ``focus_mode=`` survives construction. focus_mode = Property(FocusMode.CLICK, hint="Whether this control can take keyboard focus")
[docs] @property def wants_text_input(self) -> bool: """Whether this view takes typing: an editable one does, a read-only one only selects and copies.""" return not self.read_only
def __init__(self, text: str = "", **kwargs): super().__init__(**kwargs) # Text buffer self._lines: list[str] = [""] self._cursor_line: int = 0 self._cursor_col: int = 0 # Selection (None = no selection) self._select_start: tuple[int, int] | None = None self._select_end: tuple[int, int] | None = None # Scrolling self._scroll_y: float = 0.0 # line offset (fractional) self._dragging_scrollbar: bool = False self._drag_start_y: float = 0.0 self._drag_start_scroll: float = 0.0 # Mouse drag selection self._dragging_text: bool = False # Deferred pointer positions, resolved at draw time when the glyph # metrics that turn a position into a caret are available. self._pending_click: tuple[float, float] | None = None self._pending_drag: tuple[float, float] | None = None self._pending_click_extend: bool = False # A double click selects the word under it; the press says which click # of the run it is. self._pending_double_click: bool = False # Cursor blink self._cursor_blink: float = 0.0 # Undo/redo history (snapshots of lines + cursor) self._undo_stack: deque[tuple[list[str], int, int]] = deque(maxlen=100) self._redo_stack: deque[tuple[list[str], int, int]] = deque(maxlen=100) # Signals self.text_changed = Signal() # Apply initial text if text: self.text = text # ---------------------------------------------------------------- # text property # ---------------------------------------------------------------- @property def text(self) -> str: """Get full text content (lines joined with newlines).""" return "\n".join(self._lines)
[docs] @text.setter def text(self, value: str): """Set text content (splits by newlines into line buffer).""" self._lines = value.split("\n") if value else [""] # Clamp cursor to valid position self._cursor_line = min(self._cursor_line, len(self._lines) - 1) self._cursor_col = min(self._cursor_col, len(self._lines[self._cursor_line])) self._clear_selection()
# ---------------------------------------------------------------- # Geometry helpers # ---------------------------------------------------------------- def _line_height(self) -> float: """Height of a single line in pixels.""" return self.font_size * 1.4 def _font_scale(self) -> float: """Font scale factor for renderer calls.""" return self.font_size / LOGICAL_EM def _gutter_width(self) -> float: """Width of the line number gutter (0 if disabled). Measured rather than taken from a renderer, so input arriving before the next draw classifies a click against the same gutter that will be drawn. """ if not self.show_line_numbers: return 0.0 digits = max(2, len(str(len(self._lines)))) return measure_text_width("0" * digits, self._font_scale()) + _LINE_NUMBER_PAD * 2 def _visible_lines(self) -> int: """Number of lines visible in the content area.""" _, _, _, h = self._local_rect() lh = self._line_height() if lh <= 0: return 1 return max(1, int((h - _PADDING_TOP * 2) / lh)) def _content_x(self) -> float: """Local x where text content starts (after gutter).""" return self._gutter_width() + _PADDING_LEFT def _max_scroll(self) -> float: """Maximum scroll_y value (in lines).""" visible = self._visible_lines() return max(0.0, len(self._lines) - visible) def _clamp_scroll(self): """Keep scroll within valid range.""" self._scroll_y = max(0.0, min(self._scroll_y, self._max_scroll())) @property def scroll_y(self) -> float: """How far the view is scrolled, in lines from the top. Assigning it scrolls, clamped to the text.""" return self._scroll_y
[docs] @scroll_y.setter def scroll_y(self, value: float): self._scroll_y = float(value) self._clamp_scroll() self.queue_redraw()
# ---------------------------------------------------------------- # Coordinate conversion helpers # ---------------------------------------------------------------- def _line_y(self, line_index: int) -> float: """Local y for a given line (accounts for scroll).""" lh = self._line_height() return _PADDING_TOP + (line_index - self._scroll_y) * lh def _cursor_draw_pos(self, renderer) -> tuple[float, float]: """Local (x, y) the cursor is drawn at.""" cx = self._content_x() line_text = self._lines[self._cursor_line][: self._cursor_col] text_offset = renderer.text_width(line_text, self._font_scale()) sx = cx + text_offset sy = self._line_y(self._cursor_line) return (sx, sy) def _resolve_pending_pointer(self) -> None: """Apply a queued click/drag to the caret and selection. Every measurement this needs (line height, gutter, glyph widths) is renderer-free, so the input handler resolves the caret the moment the event arrives -- a headless test sees the same caret a windowed run does. The draw path calls it too, as a no-op fallback. """ if self._pending_click is not None: line, col = self._pos_to_cursor(self._pending_click) if getattr(self, "_pending_double_click", False): # Double-click: position cursor then select word self._cursor_line = line self._cursor_col = col self._clear_selection() self._select_word_at_cursor() self._pending_double_click = False elif self._pending_click_extend: # Shift+click: extend selection to clicked position self._cursor_line = line self._cursor_col = col self._update_selection_end() else: # Normal click: move cursor and start new selection anchor self._cursor_line = line self._cursor_col = col self._clear_selection() self._start_selection() self._cursor_blink = 0.0 self._pending_click = None if self._pending_drag is not None: line, col = self._pos_to_cursor(self._pending_drag) self._cursor_line = line self._cursor_col = col self._update_selection_end() self._ensure_cursor_visible() self._pending_drag = None def _pos_to_cursor(self, screen_pos) -> tuple[int, int]: """Convert screen position to (line, col) in text buffer.""" px, py = self.to_local(screen_pos) lh = self._line_height() # Determine line from y coordinate line_f = (py - _PADDING_TOP) / lh + self._scroll_y line = int(line_f) line = max(0, min(line, len(self._lines) - 1)) col = self._column_at_offset(self._lines[line], px - self._content_x()) return (line, col) def _column_at_offset(self, line_text: str, offset: float) -> int: """Column of *line_text* nearest *offset* pixels from where the text starts.""" return caret_index_at(line_text, offset, self._font_scale()) # ---------------------------------------------------------------- # Scroll management # ---------------------------------------------------------------- def _ensure_cursor_visible(self): """Adjust scroll so the cursor line is within the visible area.""" visible = self._visible_lines() if self._cursor_line < self._scroll_y: self._scroll_y = float(self._cursor_line) elif self._cursor_line >= self._scroll_y + visible: self._scroll_y = float(self._cursor_line - visible + 1) self._clamp_scroll() # ---------------------------------------------------------------- # Selection helpers # ---------------------------------------------------------------- @property def _caret(self) -> tuple[int, int]: """The caret as ``(line, column)``, the position the shared selection methods order.""" return (self._cursor_line, self._cursor_col) @_caret.setter def _caret(self, pos: tuple[int, int]) -> None: self._cursor_line, self._cursor_col = pos def _text_between(self, start: tuple[int, int], end: tuple[int, int]) -> str: """The text from *start* to *end*, lines joined with ``\\n``.""" (sl, sc), (el, ec) = start, end if sl == el: return self._lines[sl][sc:ec] return "\n".join([self._lines[sl][sc:], *self._lines[sl + 1 : el], self._lines[el][:ec]]) def _remove_between(self, start: tuple[int, int], end: tuple[int, int]) -> None: """Remove the text from *start* to *end*, joining the lines either side.""" (sl, sc), (el, ec) = start, end self._lines[sl : el + 1] = [self._lines[sl][:sc] + self._lines[el][ec:]] @contextmanager def _cursor_motion(self, shift: bool): """Move the caret as the shared motion does, then scroll it into view.""" with super()._cursor_motion(shift): yield self._ensure_cursor_visible() # ---------------------------------------------------------------- # Word-wise motion # ---------------------------------------------------------------- def _move_word_left(self): """Move the cursor to the start of the word before it. At column zero it steps to the end of the previous line. """ if self._cursor_col == 0: if self._cursor_line > 0: self._cursor_line -= 1 self._cursor_col = len(self._lines[self._cursor_line]) return self._cursor_col = _word_left(self._lines[self._cursor_line], self._cursor_col) def _move_word_right(self): """Move the cursor past the end of the word after it. At the end of a line it steps to the start of the next one. """ line = self._lines[self._cursor_line] if self._cursor_col >= len(line): if self._cursor_line < len(self._lines) - 1: self._cursor_line += 1 self._cursor_col = 0 return self._cursor_col = _word_right(line, self._cursor_col) # ---------------------------------------------------------------- # Undo / Redo # ---------------------------------------------------------------- def _record_undo(self): """Snapshot current state onto the undo stack (clears redo).""" self._undo_stack.append((list(self._lines), self._cursor_line, self._cursor_col)) self._redo_stack.clear()
[docs] def undo(self): """Restore the previous text state from the undo stack.""" if not self._undo_stack: return # Save current state for redo self._redo_stack.append((list(self._lines), self._cursor_line, self._cursor_col)) lines, cl, cc = self._undo_stack.pop() self._lines = lines self._cursor_line = cl self._cursor_col = cc self._clear_selection() self._ensure_cursor_visible() self.text_changed.emit(self.text)
[docs] def redo(self): """Re-apply the last undone change from the redo stack.""" if not self._redo_stack: return self._undo_stack.append((list(self._lines), self._cursor_line, self._cursor_col)) lines, cl, cc = self._redo_stack.pop() self._lines = lines self._cursor_line = cl self._cursor_col = cc self._clear_selection() self._ensure_cursor_visible() self.text_changed.emit(self.text)
# ---------------------------------------------------------------- # Clipboard # ----------------------------------------------------------------
[docs] def cut(self) -> None: """Cut the selected text to the clipboard, as one undoable step.""" if self._has_selection(): self._record_undo() super().cut()
[docs] def insert(self, text: str) -> None: """Insert *text* at the caret, replacing the selection, and move the caret past it. Typing, a paste and an IME commit all arrive here, whatever their length. Line breaks (``\\n``, ``\\r\\n`` or ``\\r``) split the line. The insertion is one undo step and ``text_changed`` fires once for it, and not at all for empty text. """ if not text: return self._record_undo() if self._has_selection(): self._delete_selection() new_lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") line = self._lines[self._cursor_line] before = line[: self._cursor_col] after = line[self._cursor_col :] if len(new_lines) == 1: self._lines[self._cursor_line] = before + new_lines[0] + after self._cursor_col += len(new_lines[0]) else: self._lines[self._cursor_line] = before + new_lines[0] for i, pl in enumerate(new_lines[1:-1], start=1): self._lines.insert(self._cursor_line + i, pl) last = new_lines[-1] self._lines.insert(self._cursor_line + len(new_lines) - 1, last + after) self._cursor_line += len(new_lines) - 1 self._cursor_col = len(last) self._clear_selection() self._cursor_blink = 0.0 self._ensure_cursor_visible() self.queue_redraw() self.text_changed.emit(self.text)
[docs] def apply_text_edits(self, edits: list[tuple[int, int, int, int, str]]): """Apply a batch of text edits atomically. Each edit is ``(start_line, start_col, end_line, end_col, new_text)``. Edits are sorted bottom-to-top, right-to-left so earlier line numbers remain valid as later edits are applied. Records a single undo snapshot for the entire batch and emits ``text_changed`` once. """ if not edits: return self._record_undo() # Sort reverse: highest line first, then highest col for same line sorted_edits = sorted(edits, key=lambda e: (e[0], e[1]), reverse=True) for sl, sc, el, ec, new_text in sorted_edits: # Clamp to buffer bounds sl = max(0, min(sl, len(self._lines) - 1)) el = max(0, min(el, len(self._lines) - 1)) sc = max(0, min(sc, len(self._lines[sl]))) ec = max(0, min(ec, len(self._lines[el]))) before = self._lines[sl][:sc] after = self._lines[el][ec:] replacement_lines = (before + new_text + after).split("\n") self._lines[sl : el + 1] = replacement_lines # Clamp cursor self._cursor_line = min(self._cursor_line, len(self._lines) - 1) self._cursor_col = min(self._cursor_col, len(self._lines[self._cursor_line])) self._clear_selection() self.text_changed.emit(self.text)
# ---------------------------------------------------------------- # Editing helpers # ----------------------------------------------------------------
[docs] def select_all(self): """Select the entire text buffer.""" self._select_start = (0, 0) last = len(self._lines) - 1 self._select_end = (last, len(self._lines[last])) self._cursor_line = last self._cursor_col = len(self._lines[last]) self._ensure_cursor_visible()
[docs] def toggle_comment(self): """Toggle Python ``# `` comment on current line or selected lines. Preserves indentation: comments are inserted at the minimum indentation level of the affected non-blank lines (VS Code-style). """ if self._has_selection(): (sl, _), (el, _) = self._ordered_selection() else: sl = el = self._cursor_line self._record_undo() lines = self._lines[sl : el + 1] non_blank = [ln for ln in lines if ln.strip()] if not non_blank: # All blank lines -- insert "# " at cursor column 0 for i in range(sl, el + 1): self._lines[i] = "# " + self._lines[i] self._cursor_col += 2 self.text_changed.emit(self.text) return all_commented = all(ln.lstrip().startswith("#") for ln in non_blank) if all_commented: # Uncomment: remove first "# " or "#" preserving indentation for i in range(sl, el + 1): ln = self._lines[i] idx = ln.find("#") if idx >= 0: if idx + 1 < len(ln) and ln[idx + 1] == " ": self._lines[i] = ln[:idx] + ln[idx + 2 :] else: self._lines[i] = ln[:idx] + ln[idx + 1 :] else: # Comment: insert "# " at the minimum indentation of non-blank lines min_indent = min(len(ln) - len(ln.lstrip()) for ln in non_blank) for i in range(sl, el + 1): ln = self._lines[i] if not ln.strip(): continue # Leave blank lines unchanged self._lines[i] = ln[:min_indent] + "# " + ln[min_indent:] self._cursor_col = min(self._cursor_col, len(self._lines[self._cursor_line])) self.text_changed.emit(self.text)
[docs] def delete_line(self): """Delete the current line (or all selected lines).""" self._record_undo() if self._has_selection(): (sl, _), (el, _) = self._ordered_selection() del self._lines[sl : el + 1] if not self._lines: self._lines = [""] self._cursor_line = min(sl, len(self._lines) - 1) else: del self._lines[self._cursor_line] if not self._lines: self._lines = [""] self._cursor_line = min(self._cursor_line, len(self._lines) - 1) self._cursor_col = min(self._cursor_col, len(self._lines[self._cursor_line])) self._clear_selection() self._ensure_cursor_visible() self.text_changed.emit(self.text)
[docs] def duplicate_line(self): """Duplicate the current line or all selected lines below.""" self._record_undo() if self._has_selection(): start, end = self._ordered_selection() lines_to_dup = self._lines[start[0] : end[0] + 1] for i, line in enumerate(lines_to_dup): self._lines.insert(end[0] + 1 + i, line) self._cursor_line = end[0] + len(lines_to_dup) self._cursor_col = end[1] self._clear_selection() else: self._lines.insert(self._cursor_line + 1, self._lines[self._cursor_line]) self._cursor_line += 1 self.text_changed.emit(self.text)
[docs] def move_line_up(self): """Move the current line (or selected lines) up one position.""" if self._cursor_line <= 0: return self._record_undo() if self._has_selection(): start, end = self._ordered_selection() if start[0] <= 0: return removed = self._lines.pop(start[0] - 1) self._lines.insert(end[0], removed) self._cursor_line -= 1 self._select_start = (start[0] - 1, start[1]) self._select_end = (end[0] - 1, end[1]) else: self._lines[self._cursor_line], self._lines[self._cursor_line - 1] = ( self._lines[self._cursor_line - 1], self._lines[self._cursor_line], ) self._cursor_line -= 1 self.text_changed.emit(self.text)
[docs] def move_line_down(self): """Move the current line (or selected lines) down one position.""" if self._cursor_line >= len(self._lines) - 1: return self._record_undo() if self._has_selection(): start, end = self._ordered_selection() if end[0] >= len(self._lines) - 1: return removed = self._lines.pop(end[0] + 1) self._lines.insert(start[0], removed) self._cursor_line += 1 self._select_start = (start[0] + 1, start[1]) self._select_end = (end[0] + 1, end[1]) else: self._lines[self._cursor_line], self._lines[self._cursor_line + 1] = ( self._lines[self._cursor_line + 1], self._lines[self._cursor_line], ) self._cursor_line += 1 self.text_changed.emit(self.text)
[docs] def select_word(self): """Select the word at cursor, or the next occurrence of the current selection.""" if self._has_selection(): # Find next occurrence of the selected text needle = self._get_selection_text() if not needle: return (_, _), (el, ec) = self._ordered_selection() # Search from selection end for li in range(el, len(self._lines)): start_col = ec if li == el else 0 idx = self._lines[li].find(needle, start_col) if idx >= 0: self._select_start = (li, idx) self._select_end = (li, idx + len(needle)) self._cursor_line = li self._cursor_col = idx + len(needle) self._ensure_cursor_visible() return else: self._select_word_at_cursor() self._ensure_cursor_visible()
def _select_word_at_cursor(self): """Select the word at the current cursor position (for double-click).""" line = self._lines[self._cursor_line] if self._cursor_line < len(self._lines) else "" span = _word_span(line, self._cursor_col) if span is None: return start, end = span self._select_start = (self._cursor_line, start) self._select_end = (self._cursor_line, end) self._cursor_col = end # ---------------------------------------------------------------- # Scrollbar geometry # ---------------------------------------------------------------- def _scrollbar_track_rect(self) -> tuple[float, float, float, float]: """Return (x, y, w, h) of the scrollbar track in local space.""" x, y, w, h = self._local_rect() return (x + w - _SCROLLBAR_WIDTH, y, _SCROLLBAR_WIDTH, h) def _scrollbar_thumb_rect(self) -> tuple[float, float, float, float]: """Return (x, y, w, h) of the scrollbar thumb, or zeros if not needed.""" total_lines = len(self._lines) visible = self._visible_lines() if total_lines <= visible: return (0, 0, 0, 0) tx, ty, tw, th = self._scrollbar_track_rect() ratio = visible / total_lines thumb_h = max(20.0, th * ratio) max_scroll = self._max_scroll() scroll_ratio = self._scroll_y / max_scroll if max_scroll > 0 else 0.0 thumb_y = ty + scroll_ratio * (th - thumb_h) return (tx, thumb_y, tw, thumb_h) # ---------------------------------------------------------------- # Input handling # ----------------------------------------------------------------
[docs] def on_gui_input(self, event): # Mouse wheel scrolling. Claimed here so the wheel moves this view # rather than the container it sits in. if isinstance(event, ScrollEvent): self.scroll_y += -3.0 if event.scroll[1] > 0 else 3.0 return True if isinstance(event, (KeyEvent, TextEvent)): return self._on_text_key(event) if not isinstance(event, PointerEvent): return None # Focus on click if event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN: if self.is_point_inside(event.position): self.grab_focus() # Scrollbar thumb drag if event.button in Pointer.PRIMARY: if event.phase is PointerPhase.DOWN: sx, sy, sw, sh = self._scrollbar_thumb_rect() if sw > 0 and sh > 0: px, py = self.to_local(event.position) if sx <= px <= sx + sw and sy <= py <= sy + sh: self._dragging_scrollbar = True self._drag_start_y = py self._drag_start_scroll = self._scroll_y return True elif event.phase in (PointerPhase.UP, PointerPhase.CANCEL): # A cancel ends either drag the same way a release does. The # selection and the scroll position stand; what stops is the # view following a pointer it no longer has. if self._dragging_scrollbar: self._dragging_scrollbar = False return True if self._dragging_text: self._dragging_text = False self.release_pointer() return True # Scrollbar drag motion if self._dragging_scrollbar: py = self.to_local(event.position).y _, track_y, _, track_h = self._scrollbar_track_rect() total_lines = len(self._lines) visible = self._visible_lines() if total_lines > visible and track_h > 0: ratio = visible / total_lines thumb_h = max(20.0, track_h * ratio) usable_track = track_h - thumb_h if usable_track > 0: delta_px = py - self._drag_start_y max_scroll = self._max_scroll() delta_scroll = (delta_px / usable_track) * max_scroll self._scroll_y = self._drag_start_scroll + delta_scroll self._clamp_scroll() self.queue_redraw() return None # Text drag motion, fed by the grab this view took on the press. if self._dragging_text and event.phase is PointerPhase.MOVE: self._pending_drag = event.position self._resolve_pending_pointer() self.queue_redraw() return None if not self.focused: return None # Mouse click: position cursor, start drag selection, shift+click extends, # double-click selects word. Claimed: the click placed a caret in this view. if event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN and not self._dragging_scrollbar: if self.is_point_inside(event.position): self.grab_focus() shift = event.shift if event.click_count >= 2 and not shift: # Double-click: position cursor then select word self._pending_click = event.position self._pending_click_extend = False self._pending_double_click = True self._resolve_pending_pointer() return True elif shift: # Shift+click: extend selection if self._select_start is None: self._select_start = (self._cursor_line, self._cursor_col) self._select_end = (self._cursor_line, self._cursor_col) self._pending_click = event.position self._pending_click_extend = True else: # Normal click: start new selection, begin drag self._pending_click = event.position self._pending_click_extend = False self._resolve_pending_pointer() self._dragging_text = True self.capture_pointer() return True return None
def _on_text_key(self, event: KeyEvent | TextEvent) -> bool | None: """The keyboard half: editing keys and typed characters.""" if not self.focused: return None if isinstance(event, KeyEvent): if not event.pressed: return None claimed = self._handle_key(event) self.queue_redraw() return claimed # Character input. A lone newline arriving as a CHARACTER (a driver or # IME path that never synthesises an enter key) acts as Enter, so it # gets Enter's auto-indent; any other text goes through ``insert``. if self.read_only or event.composing: return None if event.text in ("\n", "\r", "\r\n"): claimed = self._handle_key(KeyEvent(key=Key.ENTER, pressed=True, time=event.time)) self.queue_redraw() return claimed self.insert(event.text) return True def _handle_key(self, event: KeyEvent) -> bool: """Process a key press event. Returns True when the key was consumed, so the caller can claim the event and keep router-level fallbacks (Tab focus cycling, Escape) from acting on a key this widget has already used. Every binding names the modifiers it wants and no others, so AltGr (which reports Ctrl and Alt together on Windows and Linux) composes a character instead of firing the Ctrl shortcut on the same key. The one exception is Shift on a motion, which decides whether that motion extends the selection rather than which motion it is. """ self._cursor_blink = 0.0 key = event.key mods = (event.ctrl, event.shift, event.alt) shift = event.shift if key is Key.Z and mods in (_CTRL, _CTRL_SHIFT): if shift: self.redo() else: self.undo() return True if key is Key.Y and mods == _CTRL: self.redo() return True if key is Key.C and mods == _CTRL: self.copy() return True if key is Key.X and mods == _CTRL: if not self.read_only: self.cut() return True if key is Key.V and mods == _CTRL: if not self.read_only: self.paste() return True if key is Key.A and mods == _CTRL: self.select_all() return True if key is Key.SLASH and mods == _CTRL: if not self.read_only: self.toggle_comment() return True if key is Key.K and mods == _CTRL_SHIFT: if not self.read_only: self.delete_line() return True if key is Key.D and mods == _CTRL: self.select_word() return True if key is Key.D and mods == _CTRL_SHIFT: if not self.read_only: self.duplicate_line() return True if key is Key.UP and mods == _ALT: if not self.read_only: self.move_line_up() return True if key is Key.DOWN and mods == _ALT: if not self.read_only: self.move_line_down() return True # Navigation. Each binding only has to move the cursor: the context manager # anchors or drops the selection around it and scrolls the result into view. if key is Key.LEFT and mods in _MOTION: with self._cursor_motion(shift): if self._cursor_col > 0: self._cursor_col -= 1 elif self._cursor_line > 0: self._cursor_line -= 1 self._cursor_col = len(self._lines[self._cursor_line]) return True if key is Key.RIGHT and mods in _MOTION: with self._cursor_motion(shift): line_len = len(self._lines[self._cursor_line]) if self._cursor_col < line_len: self._cursor_col += 1 elif self._cursor_line < len(self._lines) - 1: self._cursor_line += 1 self._cursor_col = 0 return True if key is Key.LEFT and mods in _WORD_MOTION: with self._cursor_motion(shift): self._move_word_left() return True if key is Key.RIGHT and mods in _WORD_MOTION: with self._cursor_motion(shift): self._move_word_right() return True if key is Key.UP and mods in _MOTION: with self._cursor_motion(shift): if self._cursor_line > 0: self._cursor_line -= 1 self._cursor_col = min(self._cursor_col, len(self._lines[self._cursor_line])) return True if key is Key.DOWN and mods in _MOTION: with self._cursor_motion(shift): if self._cursor_line < len(self._lines) - 1: self._cursor_line += 1 self._cursor_col = min(self._cursor_col, len(self._lines[self._cursor_line])) return True if key is Key.HOME and mods in _MOTION: with self._cursor_motion(shift): self._cursor_col = 0 return True if key is Key.END and mods in _MOTION: with self._cursor_motion(shift): self._cursor_col = len(self._lines[self._cursor_line]) return True if key is Key.HOME and mods in _WORD_MOTION: with self._cursor_motion(shift): self._cursor_line = 0 self._cursor_col = 0 return True if key is Key.END and mods in _WORD_MOTION: with self._cursor_motion(shift): self._cursor_line = len(self._lines) - 1 self._cursor_col = len(self._lines[self._cursor_line]) return True if key is Key.PAGE_UP and mods in _MOTION: with self._cursor_motion(shift): visible = self._visible_lines() self._cursor_line = max(0, self._cursor_line - visible) self._cursor_col = min(self._cursor_col, len(self._lines[self._cursor_line])) self._scroll_y = max(0.0, self._scroll_y - visible) self._clamp_scroll() return True if key is Key.PAGE_DOWN and mods in _MOTION: with self._cursor_motion(shift): visible = self._visible_lines() self._cursor_line = min(len(self._lines) - 1, self._cursor_line + visible) self._cursor_col = min(self._cursor_col, len(self._lines[self._cursor_line])) self._scroll_y += visible self._clamp_scroll() return True # Editing keys (skip if read-only) if self.read_only: return False if key is Key.BACKSPACE and mods in (_PLAIN, _SHIFT): self._record_undo() if self._has_selection(): self._delete_selection() self.text_changed.emit(self.text) elif self._cursor_col > 0: line = self._lines[self._cursor_line] self._lines[self._cursor_line] = line[: self._cursor_col - 1] + line[self._cursor_col :] self._cursor_col -= 1 self.text_changed.emit(self.text) elif self._cursor_line > 0: # Merge with previous line prev_len = len(self._lines[self._cursor_line - 1]) self._lines[self._cursor_line - 1] += self._lines[self._cursor_line] del self._lines[self._cursor_line] self._cursor_line -= 1 self._cursor_col = prev_len self.text_changed.emit(self.text) self._ensure_cursor_visible() return True if key is Key.DELETE and mods in (_PLAIN, _SHIFT): self._record_undo() if self._has_selection(): self._delete_selection() self.text_changed.emit(self.text) elif self._cursor_col < len(self._lines[self._cursor_line]): line = self._lines[self._cursor_line] self._lines[self._cursor_line] = line[: self._cursor_col] + line[self._cursor_col + 1 :] self.text_changed.emit(self.text) elif self._cursor_line < len(self._lines) - 1: # Merge next line into current self._lines[self._cursor_line] += self._lines[self._cursor_line + 1] del self._lines[self._cursor_line + 1] self.text_changed.emit(self.text) self._ensure_cursor_visible() return True if key is Key.ENTER and mods in (_PLAIN, _SHIFT): self._record_undo() if self._has_selection(): self._delete_selection() line = self._lines[self._cursor_line] before = line[: self._cursor_col] after = line[self._cursor_col :] self._lines[self._cursor_line] = before self._lines.insert(self._cursor_line + 1, after) self._cursor_line += 1 self._cursor_col = 0 self._ensure_cursor_visible() self.text_changed.emit(self.text) return True if key is Key.TAB and mods == _PLAIN: self._record_undo() if self._has_selection(): self._delete_selection() spaces = " " * self.tab_size line = self._lines[self._cursor_line] self._lines[self._cursor_line] = line[: self._cursor_col] + spaces + line[self._cursor_col :] self._cursor_col += self.tab_size self._ensure_cursor_visible() self.text_changed.emit(self.text) return True if key is Key.TAB and mods == _SHIFT: # An editable editor owns both halves of the indent key; a read-only # instance released them at the gate above, so focus can leave it. self._record_undo() self._dedent_lines() self._ensure_cursor_visible() return True return False def _dedent_lines(self): """Remove up to ``tab_size`` leading spaces from the current or selected lines.""" (sl, _), (el, _) = self._ordered_selection() changed = False for li in range(sl, el + 1): line = self._lines[li] removed = 0 for ch in line[: self.tab_size]: if ch != " ": break removed += 1 if removed: self._lines[li] = line[removed:] changed = True if li == self._cursor_line: self._cursor_col = max(0, self._cursor_col - removed) if changed: self.text_changed.emit(self.text) # ---------------------------------------------------------------- # Process (cursor blink timer) # ----------------------------------------------------------------
[docs] def on_update(self, dt: float): """Update cursor blink timer.""" old_visible = self._cursor_blink < 0.5 self._cursor_blink += dt if self._cursor_blink > 1.0: self._cursor_blink = 0.0 if old_visible != (self._cursor_blink < 0.5): self.queue_redraw()
# ---------------------------------------------------------------- # Drawing # ----------------------------------------------------------------
[docs] def on_draw(self, renderer): x, y, w, h = self._local_rect() scale = self._font_scale() lh = self._line_height() gutter_w = self._gutter_width() content_x = x + gutter_w + _PADDING_LEFT w - gutter_w - _PADDING_LEFT - _PADDING_RIGHT - _SCROLLBAR_WIDTH visible = self._visible_lines() # Any still-pending click/drag (queued by a path that skipped the # immediate resolution) lands before this frame draws the caret. self._resolve_pending_pointer() # 1. Background renderer.rect((x, y), (w, h), fill=self.bg_colour) # 2. Border border = self.focus_colour if self.focused else self.border_colour renderer.rect((x, y), (w, h), stroke=border) # 3. Line numbers gutter if self.show_line_numbers and gutter_w > 0: renderer.rect((x, y), (gutter_w, h), fill=self.gutter_bg_colour) # Gutter separator line renderer.line((x + gutter_w, y), (x + gutter_w, y + h), stroke=self.border_colour) # 4. Clip to content area (text + gutter area, excluding scrollbar) clip_x = x clip_w = w - _SCROLLBAR_WIDTH renderer.push_clip(clip_x, y, clip_w, h) # Determine visible line range first_visible = int(self._scroll_y) last_visible = min(len(self._lines), first_visible + visible + 1) # 5. Current line highlight if first_visible <= self._cursor_line < last_visible: cur_y = self._line_y(self._cursor_line) cw = w - gutter_w - _SCROLLBAR_WIDTH renderer.rect((x + gutter_w, cur_y), (cw, lh), fill=self.current_line_colour) # 6. Selection highlight if self._has_selection(): (sl, sc), (el, ec) = self._ordered_selection() for li in range(max(sl, first_visible), min(el + 1, last_visible)): line_text = self._lines[li] line_top = self._line_y(li) if li == sl and li == el: # Selection within a single line sel_x1 = content_x + renderer.text_width(line_text[:sc], scale) sel_x2 = content_x + renderer.text_width(line_text[:ec], scale) renderer.rect((sel_x1, line_top), (sel_x2 - sel_x1, lh), fill=self.selection_colour) elif li == sl: # First line of selection sel_x1 = content_x + renderer.text_width(line_text[:sc], scale) sel_x2 = content_x + renderer.text_width(line_text, scale) renderer.rect( (sel_x1, line_top), (max(sel_x2 - sel_x1, self.font_size * 0.5), lh), fill=self.selection_colour ) elif li == el: # Last line of selection sel_x2 = content_x + renderer.text_width(line_text[:ec], scale) renderer.rect((content_x, line_top), (sel_x2 - content_x, lh), fill=self.selection_colour) else: # Full line selected full_w = renderer.text_width(line_text, scale) renderer.rect( (content_x, line_top), (max(full_w, self.font_size * 0.5), lh), fill=self.selection_colour ) # 7. Line numbers (drawn over gutter background) if self.show_line_numbers and gutter_w > 0: for li in range(first_visible, last_visible): num_str = str(li + 1) num_w = renderer.text_width(num_str, scale) num_x = x + gutter_w - num_w - _LINE_NUMBER_PAD num_y = self._line_y(li) + (lh - self.font_size) / 2 renderer.text(num_str, (num_x, num_y), fill=self.line_number_colour, scale=scale) # 8. Text content for li in range(first_visible, last_visible): line_text = self._lines[li] if line_text: text_y = self._line_y(li) + (lh - self.font_size) / 2 renderer.text(line_text, (content_x, text_y), fill=self.text_colour, scale=scale) # 9. Cursor (blinking vertical line) if self.focused and self._cursor_blink < 0.5: if first_visible <= self._cursor_line < last_visible: cur_x, cur_y = self._cursor_draw_pos(renderer) cursor_top = cur_y + 2 cursor_bot = cur_y + lh - 2 renderer.line((cur_x, cursor_top), (cur_x, cursor_bot), stroke=Colour.WHITE) renderer.pop_clip() # 10. Scrollbar total_lines = len(self._lines) if total_lines > visible: # Track tx, ty, tw, th = self._scrollbar_track_rect() theme = self.get_theme() renderer.rect((tx, ty), (tw, th), fill=theme.scrollbar_track) # Thumb sx, sy, sw, sh = self._scrollbar_thumb_rect() if sw > 0 and sh > 0: thumb_colour = theme.scrollbar_hover if self._dragging_scrollbar else theme.scrollbar_fg renderer.rect((sx, sy), (sw, sh), fill=thumb_colour)