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