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

import logging
import time
from collections import deque

from ..descriptors import Property, Signal
from ..input.state import Input
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)
[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 / 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_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)