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, mouse click positioning, text selection
via shift+arrow keys, vertical scrolling via mouse wheel and scrollbar
thumb drag, undo/redo history, clipboard cut/copy/paste, line comments,
and select-word (ctrl+d).
"""


from __future__ import annotations

import logging
import time
from collections import deque

from ..input.state import Input
from ..descriptors import Property, Signal
from ..math.types import Vec2
from .core import Colour, Control, ThemeColour

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(Control): """Multi-line text editor with selection, scrolling, and line numbers. Features: - Multi-line text buffer with cursor navigation - Text selection via shift+arrow keys - 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") 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 # Appearance self.font_size = 14.0 # Configuration self.show_line_numbers = False self.read_only = False self.tab_size = 4 # 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() # Default size self.size = Vec2(400, 300) # 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) @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 / 14.0 def _gutter_width(self, renderer) -> float: """Width of the line number gutter (0 if disabled).""" if not self.show_line_numbers: return 0.0 # Width based on number of digits in line count digits = max(2, len(str(len(self._lines)))) return renderer.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, renderer) -> float: """X coordinate where text content starts (after gutter).""" x, _, _, _ = self.get_global_rect() return x + self._gutter_width(renderer) + _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(renderer) 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 _pos_to_cursor(self, screen_pos, renderer) -> 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)) # Determine column from x coordinate cx = self._content_x(renderer) target_x = px - cx line_text = self._lines[line] scale = self._font_scale() # Find closest column by measuring text widths col = 0 for i in range(len(line_text) + 1): tw = renderer.text_width(line_text[:i], scale) if tw >= target_x: # Check if previous column was closer if i > 0: prev_tw = renderer.text_width(line_text[: i - 1], scale) if target_x - prev_tw < tw - target_x: col = i - 1 else: col = i else: col = 0 break col = i return (line, 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) 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 (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 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 == 1 and event.pressed: if self.is_point_inside(event.position): self.set_focus() # Mouse wheel scrolling if event.key == "scroll_up": self._scroll_y -= 3.0 self._clamp_scroll() self.queue_redraw() return if event.key == "scroll_down": self._scroll_y += 3.0 self._clamp_scroll() self.queue_redraw() return # Scrollbar thumb drag if event.button == 1: if event.pressed: sx, sy, sw, sh = self._scrollbar_thumb_rect() if sw > 0 and sh > 0: px = event.position.x if hasattr(event.position, "x") else event.position[0] py = event.position.y if hasattr(event.position, "y") else event.position[1] 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 else: if self._dragging_scrollbar: self._dragging_scrollbar = False return if self._dragging_text: self._dragging_text = False self.release_mouse() return # Scrollbar drag motion if self._dragging_scrollbar and event.position: py = event.position.y if hasattr(event.position, "y") else event.position[1] _, 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 == 0 and event.position: self._pending_drag = event.position self.queue_redraw() return if not self.focused: return # Mouse click: position cursor, start drag selection, shift+click extends, double-click selects word if event.button == 1 and event.pressed and not self._dragging_scrollbar: if self.is_point_inside(event.position): self.set_focus() 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 (handled in draw via flag) self._pending_click = event.position self._pending_click_extend = False self._pending_double_click = True 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._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: self._handle_key(event.key, shift) self.queue_redraw() return # Character input if event.char and len(event.char) == 1 and not self.read_only: 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): """Process a key press event.""" self._cursor_blink = 0.0 # Detect shift modifier from combo key (e.g. "shift+right" -> shift=True, key="right") if key.startswith("shift+") and not key.startswith("shift+ctrl") and key not in ("shift+tab",): shift = True key = key[6:] # strip "shift+" # Ctrl+ shortcuts (key names are prefixed with "ctrl+" by input layer) if key == "ctrl+z": if shift: self.redo() else: self.undo() return if key == "ctrl+y": self.redo() return if key == "ctrl+c": self.copy() return if key == "ctrl+x": if not self.read_only: self.cut() return if key == "ctrl+v": if not self.read_only: self.paste() return if key == "ctrl+a": self.select_all() return if key == "ctrl+/": if not self.read_only: self.toggle_comment() return if key == "ctrl+shift+k": if not self.read_only: self.delete_line() return if key == "ctrl+d": self.select_word() return if key == "ctrl+shift+d": if not self.read_only: self.duplicate_line() return if key == "alt+up": if not self.read_only: self.move_line_up() return if key == "alt+down": if not self.read_only: self.move_line_down() return # Arrow keys if key == "left": if shift: self._start_selection() else: self._clear_selection() 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]) if shift: self._update_selection_end() self._ensure_cursor_visible() return if key == "right": if shift: self._start_selection() else: self._clear_selection() 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 if shift: self._update_selection_end() self._ensure_cursor_visible() return if key == "up": if shift: self._start_selection() else: self._clear_selection() if self._cursor_line > 0: self._cursor_line -= 1 self._cursor_col = min(self._cursor_col, len(self._lines[self._cursor_line])) if shift: self._update_selection_end() self._ensure_cursor_visible() return if key == "down": if shift: self._start_selection() else: self._clear_selection() 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])) if shift: self._update_selection_end() self._ensure_cursor_visible() return if key == "home": if shift: self._start_selection() else: self._clear_selection() self._cursor_col = 0 if shift: self._update_selection_end() self._ensure_cursor_visible() return if key == "end": if shift: self._start_selection() else: self._clear_selection() self._cursor_col = len(self._lines[self._cursor_line]) if shift: self._update_selection_end() self._ensure_cursor_visible() return if key == "page_up": if shift: self._start_selection() else: self._clear_selection() 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() if shift: self._update_selection_end() self._ensure_cursor_visible() return if key == "page_down": if shift: self._start_selection() else: self._clear_selection() 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() if shift: self._update_selection_end() self._ensure_cursor_visible() return # Editing keys (skip if read-only) if self.read_only: return 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 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 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 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 # ---------------------------------------------------------------- # Process (cursor blink timer) # ----------------------------------------------------------------
[docs] def process(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 draw(self, renderer): x, y, w, h = self.get_global_rect() scale = self._font_scale() lh = self._line_height() gutter_w = self._gutter_width(renderer) content_x = x + gutter_w + _PADDING_LEFT w - gutter_w - _PADDING_LEFT - _PADDING_RIGHT - _SCROLLBAR_WIDTH visible = self._visible_lines() # Handle deferred mouse click (needs renderer for text_width) if self._pending_click is not None: line, col = self._pos_to_cursor(self._pending_click, renderer) 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 # Handle deferred mouse drag (needs renderer for text_width) if self._pending_drag is not None: line, col = self._pos_to_cursor(self._pending_drag, renderer) self._cursor_line = line self._cursor_col = col self._update_selection_end() self._ensure_cursor_visible() self._pending_drag = None # 1. Background renderer.draw_filled_rect(x, y, w, h, self.bg_colour) # 2. Border border = self.focus_colour if self.focused else self.border_colour renderer.draw_rect_coloured(x, y, w, h, border) # 3. Line numbers gutter if self.show_line_numbers and gutter_w > 0: renderer.draw_filled_rect(x, y, gutter_w, h, self.gutter_bg_colour) # Gutter separator line renderer.draw_line_coloured(x + gutter_w, y, x + gutter_w, y + h, 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_filled_rect(x + gutter_w, cur_y, cw, lh, 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.draw_filled_rect(sel_x1, line_top, sel_x2 - sel_x1, lh, 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.draw_filled_rect( sel_x1, line_top, max(sel_x2 - sel_x1, self.font_size * 0.5), lh, self.selection_colour ) elif li == el: # Last line of selection sel_x2 = content_x + renderer.text_width(line_text[:ec], scale) renderer.draw_filled_rect(content_x, line_top, sel_x2 - content_x, lh, self.selection_colour) else: # Full line selected full_w = renderer.text_width(line_text, scale) renderer.draw_filled_rect( content_x, line_top, max(full_w, self.font_size * 0.5), lh, 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.draw_text_coloured(num_str, num_x, num_y, scale, self.line_number_colour) # 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_coloured(line_text, content_x, text_y, scale, self.text_colour) # 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_coloured(cur_x, cursor_top, cur_x, cursor_bot, 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_filled_rect(tx, ty, tw, th, 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.draw_filled_rect(sx, sy, sw, sh, thumb_colour)