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
import time
from collections import deque
from contextlib import contextmanager

from ..descriptors import Property
from ..input.enums import MouseButton
from ..input.state import Input
from ..signals import Signal
from ..text.measure import LOGICAL_EM, measure_text_width, text_prefix_widths
from .core import Colour, Control, ThemeColour
from .enums import FocusMode

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

#: Keys whose ``ctrl+shift+<key>`` press is a shifted variant of the
#: ``ctrl+<key>`` binding rather than a binding of its own: the four selecting
#: motions, plus ``z`` for redo. The router spells any such press as the single
#: string ``ctrl+shift+<key>``, so ``_handle_key`` folds it back into
#: ``shift=True`` plus ``ctrl+<key>`` before matching. Everything else spelled
#: ``ctrl+shift+…`` (delete-line, duplicate-line, the fold pair in CodeTextEdit)
#: owns that name and is left alone.
_CTRL_SHIFT_FOLDED_KEYS = ("home", "end", "left", "right", "z")


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 == "_"


[docs] class MultiLineTextEdit(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 """ _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") 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 self._pending_click = None # deferred click position (resolved at draw time) self._pending_drag = None # deferred drag position (resolved at draw time) self._pending_click_extend: bool = False # Double-click detection self._last_click_time: float = 0.0 self._click_count: int = 0 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.get_global_rect() lh = self._line_height() if lh <= 0: return 1 return max(1, int((h - _PADDING_TOP * 2) / lh)) def _content_x(self) -> float: """X coordinate where text content starts (after gutter).""" x, _, _, _ = self.get_global_rect() return x + 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())) # ---------------------------------------------------------------- # Coordinate conversion helpers # ---------------------------------------------------------------- def _line_y(self, line_index: int) -> float: """Y coordinate for a given line (accounts for scroll).""" _, y, _, _ = self.get_global_rect() lh = self._line_height() return y + _PADDING_TOP + (line_index - self._scroll_y) * lh def _cursor_screen_pos(self, renderer) -> tuple[float, float]: """Screen (x, y) of the cursor position.""" 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 = screen_pos.x if hasattr(screen_pos, "x") else screen_pos[0] py = screen_pos.y if hasattr(screen_pos, "y") else screen_pos[1] _, y, _, _ = self.get_global_rect() lh = self._line_height() # Determine line from y coordinate line_f = (py - y - _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. Widths come from the font the text is drawn with, so the caret lands between the glyphs the click fell between whatever the typeface. """ # One walk of the line for every column, rather than re-measuring a # longer prefix per column: that is quadratic in the line length, and it # leaves a cached width behind for every column of every line clicked. widths = text_prefix_widths(line_text, self._font_scale()) col = len(line_text) for i, tw in enumerate(widths): if tw >= offset: col = i - 1 if i > 0 and offset - widths[i - 1] < tw - offset else i break return col # ---------------------------------------------------------------- # 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 # ---------------------------------------------------------------- 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) -> tuple[tuple[int, int], tuple[int, int]]: """Return selection start/end in document order (start <= end).""" if not self._has_selection(): pos = (self._cursor_line, self._cursor_col) return (pos, pos) s = self._select_start e = self._select_end if s[0] > e[0] or (s[0] == e[0] and s[1] > e[1]): return (e, s) return (s, e) 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 current cursor position.""" if self._select_start is None: self._select_start = (self._cursor_line, self._cursor_col) self._select_end = (self._cursor_line, self._cursor_col) def _update_selection_end(self): """Update the selection end to the current cursor position.""" self._select_end = (self._cursor_line, self._cursor_col) @contextmanager def _cursor_motion(self, shift: bool): """Wrap a cursor move so *shift* extends the selection instead of dropping it. Every navigation binding shares the same sandwich: anchor or drop the selection, move, then carry the selection end along and scroll the new cursor into view. """ if shift: self._start_selection() else: self._clear_selection() yield if shift: self._update_selection_end() self._ensure_cursor_visible() # ---------------------------------------------------------------- # Word-wise motion # ---------------------------------------------------------------- def _move_word_left(self): """Move the cursor to the start of the word before it. Skips any run of separators first, so a cursor sitting after ``foo, `` lands on the ``f``. At column zero it steps to the end of the previous line. """ line = self._lines[self._cursor_line] col = self._cursor_col if col == 0: if self._cursor_line > 0: self._cursor_line -= 1 self._cursor_col = len(self._lines[self._cursor_line]) return 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 self._cursor_col = 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] col = self._cursor_col if col >= len(line): if self._cursor_line < len(self._lines) - 1: self._cursor_line += 1 self._cursor_col = 0 return 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 self._cursor_col = col def _get_selection_text(self) -> str: """Return the currently selected text as a string.""" if not self._has_selection(): return "" (sl, sc), (el, ec) = self._ordered_selection() if sl == el: return self._lines[sl][sc:ec] parts = [self._lines[sl][sc:]] for i in range(sl + 1, el): parts.append(self._lines[i]) parts.append(self._lines[el][:ec]) return "\n".join(parts) def _delete_selection(self): """Remove selected text and collapse cursor to selection start.""" if not self._has_selection(): return (sl, sc), (el, ec) = self._ordered_selection() # Merge the text before selection start with text after selection end before = self._lines[sl][:sc] after = self._lines[el][ec:] self._lines[sl : el + 1] = [before + after] self._cursor_line = sl self._cursor_col = sc self._clear_selection() # ---------------------------------------------------------------- # 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 (xclip / xsel / wl-copy+wl-paste) # ----------------------------------------------------------------
[docs] def copy(self): """Copy selected text to clipboard.""" from .clipboard import copy as _cb_copy txt = self._get_selection_text() if txt: _cb_copy(txt)
[docs] def cut(self): """Cut selected text to clipboard.""" if not self._has_selection(): return self.copy() self._record_undo() self._delete_selection() self.text_changed.emit(self.text)
[docs] def paste(self): """Paste clipboard text at cursor, replacing selection if any.""" from .clipboard import paste as _cb_paste txt = _cb_paste() if not txt: return self._record_undo() if self._has_selection(): self._delete_selection() paste_lines = txt.split("\n") if len(paste_lines) == 1: line = self._lines[self._cursor_line] self._lines[self._cursor_line] = line[: self._cursor_col] + paste_lines[0] + line[self._cursor_col :] self._cursor_col += len(paste_lines[0]) else: line = self._lines[self._cursor_line] before = line[: self._cursor_col] after = line[self._cursor_col :] self._lines[self._cursor_line] = before + paste_lines[0] for i, pl in enumerate(paste_lines[1:-1], start=1): self._lines.insert(self._cursor_line + i, pl) last = paste_lines[-1] self._lines.insert(self._cursor_line + len(paste_lines) - 1, last + after) self._cursor_line += len(paste_lines) - 1 self._cursor_col = len(last) self._clear_selection() self._ensure_cursor_visible() 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: # Select word under cursor line = self._lines[self._cursor_line] if not line: return col = min(self._cursor_col, len(line) - 1) if col < 0: return if not _is_word_char(line[col]): return start = col while start > 0 and _is_word_char(line[start - 1]): start -= 1 end = col while end < len(line) - 1 and _is_word_char(line[end + 1]): end += 1 end += 1 self._select_start = (self._cursor_line, start) self._select_end = (self._cursor_line, end) self._cursor_col = end 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 "" if not line: return col = min(self._cursor_col, len(line) - 1) if col < 0: return if not (line[col].isalnum() or line[col] == "_"): return start = col while start > 0 and (line[start - 1].isalnum() or line[start - 1] == "_"): start -= 1 end = col while end < len(line) - 1 and (line[end + 1].isalnum() or line[end + 1] == "_"): end += 1 end += 1 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 screen space.""" x, y, w, h = self.get_global_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 # ---------------------------------------------------------------- def _on_gui_input(self, event): # Focus on click if event.button == MouseButton.LEFT and event.pressed: if self.is_point_inside(event.position): self.set_focus() # Mouse wheel scrolling. Claimed here so the wheel moves this view # rather than the container it sits in. if event.key == "scroll_up": self._scroll_y -= 3.0 self._clamp_scroll() self.queue_redraw() event.handled = True return if event.key == "scroll_down": self._scroll_y += 3.0 self._clamp_scroll() self.queue_redraw() event.handled = True return # Scrollbar thumb drag if event.button == MouseButton.LEFT: if event.pressed: sx, sy, sw, sh = self._scrollbar_thumb_rect() if sw > 0 and sh > 0: px = event.position.x py = event.position.y 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 event.handled = True return else: if self._dragging_scrollbar: self._dragging_scrollbar = False event.handled = True return if self._dragging_text: self._dragging_text = False self.release_mouse() event.handled = True return # Scrollbar drag motion if self._dragging_scrollbar and event.position: py = 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 # Text drag motion (mouse grabbed, button=0 motion events) if self._dragging_text and event.button is None and event.position: self._pending_drag = event.position self._resolve_pending_pointer() self.queue_redraw() return if not self.focused: return # 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 == MouseButton.LEFT and event.pressed and not self._dragging_scrollbar: if self.is_point_inside(event.position): self.set_focus() event.handled = True now = time.monotonic() shift = Input._keys.get("shift", False) # Detect double-click (within 400ms) if not shift and (now - self._last_click_time) < 0.4: self._click_count += 1 else: self._click_count = 1 self._last_click_time = now if self._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 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.grab_mouse() return # Keyboard input (process on key press) if not event.pressed and not event.char: return # Key events shift = hasattr(event, "shift") and event.shift if event.key and event.pressed: if self._handle_key(event.key, shift): event.handled = True self.queue_redraw() return # Character input if event.char and len(event.char) == 1 and not self.read_only: # A newline arriving as a CHARACTER (a driver or IME path that never # synthesises an enter key) must split the line like enter does -- # embedding it would break the invariant that no line contains a # newline, leaving a buffer whose ``text`` looks right while the # widget draws, counts, and selects it as one line. if event.char in ("\n", "\r"): if self._handle_key("enter"): event.handled = True self.queue_redraw() return self._record_undo() if self._has_selection(): self._delete_selection() line = self._lines[self._cursor_line] self._lines[self._cursor_line] = line[: self._cursor_col] + event.char + line[self._cursor_col :] self._cursor_col += 1 self._cursor_blink = 0.0 self._ensure_cursor_visible() self.queue_redraw() self.text_changed.emit(self.text) def _handle_key(self, key: str, shift: bool = False) -> bool: """Process a key press event. Returns True when the key was consumed, so the caller can mark the event handled and keep router-level fallbacks (Tab focus cycling, Escape) from acting on a key this widget has already used. """ self._cursor_blink = 0.0 # Fold the shift modifier out of a combo into the flag, so each binding is # named once. The router builds combos in ctrl, shift, alt order, so a # shift combo that also carries ctrl arrives as "ctrl+shift+<key>" and never # as "shift+ctrl+<key>"; the two forms are folded separately below. # "shift+tab" is a binding in its own right and keeps its name. if key.startswith("shift+") and key != "shift+tab": shift = True key = key[6:] # strip "shift+" elif key.startswith("ctrl+shift+") and key[len("ctrl+shift+") :] in _CTRL_SHIFT_FOLDED_KEYS: shift = True key = "ctrl+" + key[len("ctrl+shift+") :] # Ctrl+ shortcuts (key names are prefixed with "ctrl+" by input layer) if key == "ctrl+z": if shift: self.redo() else: self.undo() return True if key == "ctrl+y": self.redo() return True if key == "ctrl+c": self.copy() return True if key == "ctrl+x": if not self.read_only: self.cut() return True if key == "ctrl+v": if not self.read_only: self.paste() return True if key == "ctrl+a": self.select_all() return True if key == "ctrl+/": if not self.read_only: self.toggle_comment() return True if key == "ctrl+shift+k": if not self.read_only: self.delete_line() return True if key == "ctrl+d": self.select_word() return True if key == "ctrl+shift+d": if not self.read_only: self.duplicate_line() return True if key == "alt+up": if not self.read_only: self.move_line_up() return True if key == "alt+down": 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 == "left": 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 == "right": 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 == "ctrl+left": with self._cursor_motion(shift): self._move_word_left() return True if key == "ctrl+right": with self._cursor_motion(shift): self._move_word_right() return True if key == "up": 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 == "down": 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 == "home": with self._cursor_motion(shift): self._cursor_col = 0 return True if key == "end": with self._cursor_motion(shift): self._cursor_col = len(self._lines[self._cursor_line]) return True if key == "ctrl+home": with self._cursor_motion(shift): self._cursor_line = 0 self._cursor_col = 0 return True if key == "ctrl+end": 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 == "page_up": 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 == "page_down": 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 == "backspace": 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 == "delete": 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 == "enter": 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 == "tab": 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 == "shift+tab": # 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.get_global_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.draw_rect((x, y), (w, h), colour=self.bg_colour, filled=True) # 2. Border border = self.focus_colour if self.focused else self.border_colour renderer.draw_rect((x, y), (w, h), colour=border) # 3. Line numbers gutter if self.show_line_numbers and gutter_w > 0: renderer.draw_rect((x, y), (gutter_w, h), colour=self.gutter_bg_colour, filled=True) # Gutter separator line renderer.draw_line((x + gutter_w, y), (x + gutter_w, y + h), colour=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.draw_rect((x + gutter_w, cur_y), (cw, lh), colour=self.current_line_colour, filled=True) # 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.draw_rect( (sel_x1, line_top), (sel_x2 - sel_x1, lh), colour=self.selection_colour, filled=True ) 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.draw_rect( (sel_x1, line_top), (max(sel_x2 - sel_x1, self.font_size * 0.5), lh), colour=self.selection_colour, filled=True, ) elif li == el: # Last line of selection sel_x2 = content_x + renderer.text_width(line_text[:ec], scale) renderer.draw_rect( (content_x, line_top), (sel_x2 - content_x, lh), colour=self.selection_colour, filled=True ) else: # Full line selected full_w = renderer.text_width(line_text, scale) renderer.draw_rect( (content_x, line_top), (max(full_w, self.font_size * 0.5), lh), colour=self.selection_colour, filled=True, ) # 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.draw_text(num_str, (num_x, num_y), colour=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.draw_text(line_text, (content_x, text_y), colour=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_screen_pos(renderer) cursor_top = cur_y + 2 cursor_bot = cur_y + lh - 2 renderer.draw_line((cur_x, cursor_top), (cur_x, cursor_bot), colour=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.draw_rect((tx, ty), (tw, th), colour=theme.scrollbar_track, filled=True) # 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.draw_rect((sx, sy), (sw, sh), colour=thumb_colour, filled=True)