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