"""MultiLineTextEdit -- Multi-line text editor widget with selection,
scrollbar, line numbers, cursor blink, undo/redo, and clipboard.
Supports keyboard navigation (including word-wise ctrl+left/right and
document-wise ctrl+home/end), mouse click positioning, text selection via
shift+arrow keys, vertical scrolling via mouse wheel and scrollbar thumb
drag, undo/redo history (ctrl+z, and ctrl+shift+z or ctrl+y to redo),
clipboard cut/copy/paste, line comments, and select-word (ctrl+d).
"""
import logging
from collections import deque
from contextlib import contextmanager
from ..descriptors import Property
from ..input.controls import Key, Pointer
from ..input.enums import CursorShape, PointerPhase
from ..input.events import KeyEvent, PointerEvent, ScrollEvent, TextEvent
from ..signals import Signal
from ..text.measure import LOGICAL_EM, caret_index_at, measure_text_width
from .core import Colour, Control, ThemeColour, _cursor_shape
from .enums import FocusMode, PointerFilter
from .text_selection import (
_ALT,
_CTRL,
_CTRL_SHIFT,
_MOTION,
_PLAIN,
_SHIFT,
_WORD_MOTION,
TextSelectionMixin,
_word_left,
_word_right,
_word_span,
)
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(TextSelectionMixin, Control):
"""Multi-line text editor with selection, scrolling, and line numbers.
Features:
- Multi-line text buffer with cursor navigation, word-wise (ctrl+left,
ctrl+right) and document-wise (ctrl+home, ctrl+end)
- Text selection via shift+arrow keys, and by holding shift with any of
the ctrl motions above
- Undo (ctrl+z) and redo (ctrl+shift+z or ctrl+y)
- Vertical scrollbar with thumb drag and mouse wheel
- Optional line number gutter
- Cursor blink animation
- Read-only mode
Example:
editor = MultiLineTextEdit()
editor.text = "Hello\\nWorld"
editor.text_changed.connect(lambda t: print("Changed:", t))
editor.show_line_numbers = True
"""
pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test")
cursor_shape = Property(CursorShape.IBEAM, coerce=_cursor_shape, hint="The pointer's shape over this control")
_draw_caching = True
_draws_children = True
# Theme-aware colours
text_colour = ThemeColour("text")
bg_colour = ThemeColour("bg_darker")
border_colour = ThemeColour("border")
focus_colour = ThemeColour("accent")
selection_colour = ThemeColour("selection")
line_number_colour = ThemeColour("line_number")
current_line_colour = ThemeColour("current_line")
gutter_bg_colour = ThemeColour("gutter_bg")
# Editor-visible settings
font_size = Property(14.0, range=(8, 72), hint="Font size")
show_line_numbers = Property(False, hint="Show line number gutter")
read_only = Property(False, hint="Read-only mode")
tab_size = Property(4, range=(1, 8), hint="Tab width in spaces")
size_x = Property(400.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed")
size_y = Property(300.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed")
# Clicking one focuses it, but Tab is the editor's own (indent), so it is a
# focus source and never a Tab destination. Declared rather than assigned in
# ``__init__``, so a caller's ``focus_mode=`` survives construction.
focus_mode = Property(FocusMode.CLICK, hint="Whether this control can take keyboard focus")
[docs]
@property
def wants_text_input(self) -> bool:
"""Whether this view takes typing: an editable one does, a read-only one only selects and copies."""
return not self.read_only
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
# Deferred pointer positions, resolved at draw time when the glyph
# metrics that turn a position into a caret are available.
self._pending_click: tuple[float, float] | None = None
self._pending_drag: tuple[float, float] | None = None
self._pending_click_extend: bool = False
# A double click selects the word under it; the press says which click
# of the run it is.
self._pending_double_click: bool = False
# Cursor blink
self._cursor_blink: float = 0.0
# Undo/redo history (snapshots of lines + cursor)
self._undo_stack: deque[tuple[list[str], int, int]] = deque(maxlen=100)
self._redo_stack: deque[tuple[list[str], int, int]] = deque(maxlen=100)
# Signals
self.text_changed = Signal()
# Apply initial text
if text:
self.text = text
# ----------------------------------------------------------------
# text property
# ----------------------------------------------------------------
@property
def text(self) -> str:
"""Get full text content (lines joined with newlines)."""
return "\n".join(self._lines)
[docs]
@text.setter
def text(self, value: str):
"""Set text content (splits by newlines into line buffer)."""
self._lines = value.split("\n") if value else [""]
# Clamp cursor to valid position
self._cursor_line = min(self._cursor_line, len(self._lines) - 1)
self._cursor_col = min(self._cursor_col, len(self._lines[self._cursor_line]))
self._clear_selection()
# ----------------------------------------------------------------
# Geometry helpers
# ----------------------------------------------------------------
def _line_height(self) -> float:
"""Height of a single line in pixels."""
return self.font_size * 1.4
def _font_scale(self) -> float:
"""Font scale factor for renderer calls."""
return self.font_size / LOGICAL_EM
def _gutter_width(self) -> float:
"""Width of the line number gutter (0 if disabled).
Measured rather than taken from a renderer, so input arriving before the
next draw classifies a click against the same gutter that will be drawn.
"""
if not self.show_line_numbers:
return 0.0
digits = max(2, len(str(len(self._lines))))
return measure_text_width("0" * digits, self._font_scale()) + _LINE_NUMBER_PAD * 2
def _visible_lines(self) -> int:
"""Number of lines visible in the content area."""
_, _, _, h = self._local_rect()
lh = self._line_height()
if lh <= 0:
return 1
return max(1, int((h - _PADDING_TOP * 2) / lh))
def _content_x(self) -> float:
"""Local x where text content starts (after gutter)."""
return self._gutter_width() + _PADDING_LEFT
def _max_scroll(self) -> float:
"""Maximum scroll_y value (in lines)."""
visible = self._visible_lines()
return max(0.0, len(self._lines) - visible)
def _clamp_scroll(self):
"""Keep scroll within valid range."""
self._scroll_y = max(0.0, min(self._scroll_y, self._max_scroll()))
@property
def scroll_y(self) -> float:
"""How far the view is scrolled, in lines from the top. Assigning it scrolls, clamped to the text."""
return self._scroll_y
[docs]
@scroll_y.setter
def scroll_y(self, value: float):
self._scroll_y = float(value)
self._clamp_scroll()
self.queue_redraw()
# ----------------------------------------------------------------
# Coordinate conversion helpers
# ----------------------------------------------------------------
def _line_y(self, line_index: int) -> float:
"""Local y for a given line (accounts for scroll)."""
lh = self._line_height()
return _PADDING_TOP + (line_index - self._scroll_y) * lh
def _cursor_draw_pos(self, renderer) -> tuple[float, float]:
"""Local (x, y) the cursor is drawn at."""
cx = self._content_x()
line_text = self._lines[self._cursor_line][: self._cursor_col]
text_offset = renderer.text_width(line_text, self._font_scale())
sx = cx + text_offset
sy = self._line_y(self._cursor_line)
return (sx, sy)
def _resolve_pending_pointer(self) -> None:
"""Apply a queued click/drag to the caret and selection.
Every measurement this needs (line height, gutter, glyph widths) is
renderer-free, so the input handler resolves the caret the moment the
event arrives -- a headless test sees the same caret a windowed run
does. The draw path calls it too, as a no-op fallback.
"""
if self._pending_click is not None:
line, col = self._pos_to_cursor(self._pending_click)
if getattr(self, "_pending_double_click", False):
# Double-click: position cursor then select word
self._cursor_line = line
self._cursor_col = col
self._clear_selection()
self._select_word_at_cursor()
self._pending_double_click = False
elif self._pending_click_extend:
# Shift+click: extend selection to clicked position
self._cursor_line = line
self._cursor_col = col
self._update_selection_end()
else:
# Normal click: move cursor and start new selection anchor
self._cursor_line = line
self._cursor_col = col
self._clear_selection()
self._start_selection()
self._cursor_blink = 0.0
self._pending_click = None
if self._pending_drag is not None:
line, col = self._pos_to_cursor(self._pending_drag)
self._cursor_line = line
self._cursor_col = col
self._update_selection_end()
self._ensure_cursor_visible()
self._pending_drag = None
def _pos_to_cursor(self, screen_pos) -> tuple[int, int]:
"""Convert screen position to (line, col) in text buffer."""
px, py = self.to_local(screen_pos)
lh = self._line_height()
# Determine line from y coordinate
line_f = (py - _PADDING_TOP) / lh + self._scroll_y
line = int(line_f)
line = max(0, min(line, len(self._lines) - 1))
col = self._column_at_offset(self._lines[line], px - self._content_x())
return (line, col)
def _column_at_offset(self, line_text: str, offset: float) -> int:
"""Column of *line_text* nearest *offset* pixels from where the text starts."""
return caret_index_at(line_text, offset, self._font_scale())
# ----------------------------------------------------------------
# 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
# ----------------------------------------------------------------
@property
def _caret(self) -> tuple[int, int]:
"""The caret as ``(line, column)``, the position the shared selection methods order."""
return (self._cursor_line, self._cursor_col)
@_caret.setter
def _caret(self, pos: tuple[int, int]) -> None:
self._cursor_line, self._cursor_col = pos
def _text_between(self, start: tuple[int, int], end: tuple[int, int]) -> str:
"""The text from *start* to *end*, lines joined with ``\\n``."""
(sl, sc), (el, ec) = start, end
if sl == el:
return self._lines[sl][sc:ec]
return "\n".join([self._lines[sl][sc:], *self._lines[sl + 1 : el], self._lines[el][:ec]])
def _remove_between(self, start: tuple[int, int], end: tuple[int, int]) -> None:
"""Remove the text from *start* to *end*, joining the lines either side."""
(sl, sc), (el, ec) = start, end
self._lines[sl : el + 1] = [self._lines[sl][:sc] + self._lines[el][ec:]]
@contextmanager
def _cursor_motion(self, shift: bool):
"""Move the caret as the shared motion does, then scroll it into view."""
with super()._cursor_motion(shift):
yield
self._ensure_cursor_visible()
# ----------------------------------------------------------------
# Word-wise motion
# ----------------------------------------------------------------
def _move_word_left(self):
"""Move the cursor to the start of the word before it.
At column zero it steps to the end of the previous line.
"""
if self._cursor_col == 0:
if self._cursor_line > 0:
self._cursor_line -= 1
self._cursor_col = len(self._lines[self._cursor_line])
return
self._cursor_col = _word_left(self._lines[self._cursor_line], self._cursor_col)
def _move_word_right(self):
"""Move the cursor past the end of the word after it.
At the end of a line it steps to the start of the next one.
"""
line = self._lines[self._cursor_line]
if self._cursor_col >= len(line):
if self._cursor_line < len(self._lines) - 1:
self._cursor_line += 1
self._cursor_col = 0
return
self._cursor_col = _word_right(line, self._cursor_col)
# ----------------------------------------------------------------
# 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
# ----------------------------------------------------------------
[docs]
def cut(self) -> None:
"""Cut the selected text to the clipboard, as one undoable step."""
if self._has_selection():
self._record_undo()
super().cut()
[docs]
def insert(self, text: str) -> None:
"""Insert *text* at the caret, replacing the selection, and move the caret past it.
Typing, a paste and an IME commit all arrive here, whatever their
length. Line breaks (``\\n``, ``\\r\\n`` or ``\\r``) split the line. The
insertion is one undo step and ``text_changed`` fires once for it, and
not at all for empty text.
"""
if not text:
return
self._record_undo()
if self._has_selection():
self._delete_selection()
new_lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
line = self._lines[self._cursor_line]
before = line[: self._cursor_col]
after = line[self._cursor_col :]
if len(new_lines) == 1:
self._lines[self._cursor_line] = before + new_lines[0] + after
self._cursor_col += len(new_lines[0])
else:
self._lines[self._cursor_line] = before + new_lines[0]
for i, pl in enumerate(new_lines[1:-1], start=1):
self._lines.insert(self._cursor_line + i, pl)
last = new_lines[-1]
self._lines.insert(self._cursor_line + len(new_lines) - 1, last + after)
self._cursor_line += len(new_lines) - 1
self._cursor_col = len(last)
self._clear_selection()
self._cursor_blink = 0.0
self._ensure_cursor_visible()
self.queue_redraw()
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:
self._select_word_at_cursor()
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 ""
span = _word_span(line, self._cursor_col)
if span is None:
return
start, end = span
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 local space."""
x, y, w, h = self._local_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
# ----------------------------------------------------------------
[docs]
def on_gui_input(self, event):
# Mouse wheel scrolling. Claimed here so the wheel moves this view
# rather than the container it sits in.
if isinstance(event, ScrollEvent):
self.scroll_y += -3.0 if event.scroll[1] > 0 else 3.0
return True
if isinstance(event, (KeyEvent, TextEvent)):
return self._on_text_key(event)
if not isinstance(event, PointerEvent):
return None
# Focus on click
if event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN:
if self.is_point_inside(event.position):
self.grab_focus()
# Scrollbar thumb drag
if event.button in Pointer.PRIMARY:
if event.phase is PointerPhase.DOWN:
sx, sy, sw, sh = self._scrollbar_thumb_rect()
if sw > 0 and sh > 0:
px, py = self.to_local(event.position)
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 True
elif event.phase in (PointerPhase.UP, PointerPhase.CANCEL):
# A cancel ends either drag the same way a release does. The
# selection and the scroll position stand; what stops is the
# view following a pointer it no longer has.
if self._dragging_scrollbar:
self._dragging_scrollbar = False
return True
if self._dragging_text:
self._dragging_text = False
self.release_pointer()
return True
# Scrollbar drag motion
if self._dragging_scrollbar:
py = self.to_local(event.position).y
_, track_y, _, track_h = self._scrollbar_track_rect()
total_lines = len(self._lines)
visible = self._visible_lines()
if total_lines > visible and track_h > 0:
ratio = visible / total_lines
thumb_h = max(20.0, track_h * ratio)
usable_track = track_h - thumb_h
if usable_track > 0:
delta_px = py - self._drag_start_y
max_scroll = self._max_scroll()
delta_scroll = (delta_px / usable_track) * max_scroll
self._scroll_y = self._drag_start_scroll + delta_scroll
self._clamp_scroll()
self.queue_redraw()
return None
# Text drag motion, fed by the grab this view took on the press.
if self._dragging_text and event.phase is PointerPhase.MOVE:
self._pending_drag = event.position
self._resolve_pending_pointer()
self.queue_redraw()
return None
if not self.focused:
return None
# Mouse click: position cursor, start drag selection, shift+click extends,
# double-click selects word. Claimed: the click placed a caret in this view.
if event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN and not self._dragging_scrollbar:
if self.is_point_inside(event.position):
self.grab_focus()
shift = event.shift
if event.click_count >= 2 and not shift:
# Double-click: position cursor then select word
self._pending_click = event.position
self._pending_click_extend = False
self._pending_double_click = True
self._resolve_pending_pointer()
return True
elif shift:
# Shift+click: extend selection
if self._select_start is None:
self._select_start = (self._cursor_line, self._cursor_col)
self._select_end = (self._cursor_line, self._cursor_col)
self._pending_click = event.position
self._pending_click_extend = True
else:
# Normal click: start new selection, begin drag
self._pending_click = event.position
self._pending_click_extend = False
self._resolve_pending_pointer()
self._dragging_text = True
self.capture_pointer()
return True
return None
def _on_text_key(self, event: KeyEvent | TextEvent) -> bool | None:
"""The keyboard half: editing keys and typed characters."""
if not self.focused:
return None
if isinstance(event, KeyEvent):
if not event.pressed:
return None
claimed = self._handle_key(event)
self.queue_redraw()
return claimed
# Character input. A lone newline arriving as a CHARACTER (a driver or
# IME path that never synthesises an enter key) acts as Enter, so it
# gets Enter's auto-indent; any other text goes through ``insert``.
if self.read_only or event.composing:
return None
if event.text in ("\n", "\r", "\r\n"):
claimed = self._handle_key(KeyEvent(key=Key.ENTER, pressed=True, time=event.time))
self.queue_redraw()
return claimed
self.insert(event.text)
return True
def _handle_key(self, event: KeyEvent) -> bool:
"""Process a key press event.
Returns True when the key was consumed, so the caller can claim the
event and keep router-level fallbacks (Tab focus cycling, Escape) from
acting on a key this widget has already used.
Every binding names the modifiers it wants and no others, so AltGr
(which reports Ctrl and Alt together on Windows and Linux) composes a
character instead of firing the Ctrl shortcut on the same key. The one
exception is Shift on a motion, which decides whether that motion
extends the selection rather than which motion it is.
"""
self._cursor_blink = 0.0
key = event.key
mods = (event.ctrl, event.shift, event.alt)
shift = event.shift
if key is Key.Z and mods in (_CTRL, _CTRL_SHIFT):
if shift:
self.redo()
else:
self.undo()
return True
if key is Key.Y and mods == _CTRL:
self.redo()
return True
if key is Key.C and mods == _CTRL:
self.copy()
return True
if key is Key.X and mods == _CTRL:
if not self.read_only:
self.cut()
return True
if key is Key.V and mods == _CTRL:
if not self.read_only:
self.paste()
return True
if key is Key.A and mods == _CTRL:
self.select_all()
return True
if key is Key.SLASH and mods == _CTRL:
if not self.read_only:
self.toggle_comment()
return True
if key is Key.K and mods == _CTRL_SHIFT:
if not self.read_only:
self.delete_line()
return True
if key is Key.D and mods == _CTRL:
self.select_word()
return True
if key is Key.D and mods == _CTRL_SHIFT:
if not self.read_only:
self.duplicate_line()
return True
if key is Key.UP and mods == _ALT:
if not self.read_only:
self.move_line_up()
return True
if key is Key.DOWN and mods == _ALT:
if not self.read_only:
self.move_line_down()
return True
# Navigation. Each binding only has to move the cursor: the context manager
# anchors or drops the selection around it and scrolls the result into view.
if key is Key.LEFT and mods in _MOTION:
with self._cursor_motion(shift):
if self._cursor_col > 0:
self._cursor_col -= 1
elif self._cursor_line > 0:
self._cursor_line -= 1
self._cursor_col = len(self._lines[self._cursor_line])
return True
if key is Key.RIGHT and mods in _MOTION:
with self._cursor_motion(shift):
line_len = len(self._lines[self._cursor_line])
if self._cursor_col < line_len:
self._cursor_col += 1
elif self._cursor_line < len(self._lines) - 1:
self._cursor_line += 1
self._cursor_col = 0
return True
if key is Key.LEFT and mods in _WORD_MOTION:
with self._cursor_motion(shift):
self._move_word_left()
return True
if key is Key.RIGHT and mods in _WORD_MOTION:
with self._cursor_motion(shift):
self._move_word_right()
return True
if key is Key.UP and mods in _MOTION:
with self._cursor_motion(shift):
if self._cursor_line > 0:
self._cursor_line -= 1
self._cursor_col = min(self._cursor_col, len(self._lines[self._cursor_line]))
return True
if key is Key.DOWN and mods in _MOTION:
with self._cursor_motion(shift):
if self._cursor_line < len(self._lines) - 1:
self._cursor_line += 1
self._cursor_col = min(self._cursor_col, len(self._lines[self._cursor_line]))
return True
if key is Key.HOME and mods in _MOTION:
with self._cursor_motion(shift):
self._cursor_col = 0
return True
if key is Key.END and mods in _MOTION:
with self._cursor_motion(shift):
self._cursor_col = len(self._lines[self._cursor_line])
return True
if key is Key.HOME and mods in _WORD_MOTION:
with self._cursor_motion(shift):
self._cursor_line = 0
self._cursor_col = 0
return True
if key is Key.END and mods in _WORD_MOTION:
with self._cursor_motion(shift):
self._cursor_line = len(self._lines) - 1
self._cursor_col = len(self._lines[self._cursor_line])
return True
if key is Key.PAGE_UP and mods in _MOTION:
with self._cursor_motion(shift):
visible = self._visible_lines()
self._cursor_line = max(0, self._cursor_line - visible)
self._cursor_col = min(self._cursor_col, len(self._lines[self._cursor_line]))
self._scroll_y = max(0.0, self._scroll_y - visible)
self._clamp_scroll()
return True
if key is Key.PAGE_DOWN and mods in _MOTION:
with self._cursor_motion(shift):
visible = self._visible_lines()
self._cursor_line = min(len(self._lines) - 1, self._cursor_line + visible)
self._cursor_col = min(self._cursor_col, len(self._lines[self._cursor_line]))
self._scroll_y += visible
self._clamp_scroll()
return True
# Editing keys (skip if read-only)
if self.read_only:
return False
if key is Key.BACKSPACE and mods in (_PLAIN, _SHIFT):
self._record_undo()
if self._has_selection():
self._delete_selection()
self.text_changed.emit(self.text)
elif self._cursor_col > 0:
line = self._lines[self._cursor_line]
self._lines[self._cursor_line] = line[: self._cursor_col - 1] + line[self._cursor_col :]
self._cursor_col -= 1
self.text_changed.emit(self.text)
elif self._cursor_line > 0:
# Merge with previous line
prev_len = len(self._lines[self._cursor_line - 1])
self._lines[self._cursor_line - 1] += self._lines[self._cursor_line]
del self._lines[self._cursor_line]
self._cursor_line -= 1
self._cursor_col = prev_len
self.text_changed.emit(self.text)
self._ensure_cursor_visible()
return True
if key is Key.DELETE and mods in (_PLAIN, _SHIFT):
self._record_undo()
if self._has_selection():
self._delete_selection()
self.text_changed.emit(self.text)
elif self._cursor_col < len(self._lines[self._cursor_line]):
line = self._lines[self._cursor_line]
self._lines[self._cursor_line] = line[: self._cursor_col] + line[self._cursor_col + 1 :]
self.text_changed.emit(self.text)
elif self._cursor_line < len(self._lines) - 1:
# Merge next line into current
self._lines[self._cursor_line] += self._lines[self._cursor_line + 1]
del self._lines[self._cursor_line + 1]
self.text_changed.emit(self.text)
self._ensure_cursor_visible()
return True
if key is Key.ENTER and mods in (_PLAIN, _SHIFT):
self._record_undo()
if self._has_selection():
self._delete_selection()
line = self._lines[self._cursor_line]
before = line[: self._cursor_col]
after = line[self._cursor_col :]
self._lines[self._cursor_line] = before
self._lines.insert(self._cursor_line + 1, after)
self._cursor_line += 1
self._cursor_col = 0
self._ensure_cursor_visible()
self.text_changed.emit(self.text)
return True
if key is Key.TAB and mods == _PLAIN:
self._record_undo()
if self._has_selection():
self._delete_selection()
spaces = " " * self.tab_size
line = self._lines[self._cursor_line]
self._lines[self._cursor_line] = line[: self._cursor_col] + spaces + line[self._cursor_col :]
self._cursor_col += self.tab_size
self._ensure_cursor_visible()
self.text_changed.emit(self.text)
return True
if key is Key.TAB and mods == _SHIFT:
# An editable editor owns both halves of the indent key; a read-only
# instance released them at the gate above, so focus can leave it.
self._record_undo()
self._dedent_lines()
self._ensure_cursor_visible()
return True
return False
def _dedent_lines(self):
"""Remove up to ``tab_size`` leading spaces from the current or selected lines."""
(sl, _), (el, _) = self._ordered_selection()
changed = False
for li in range(sl, el + 1):
line = self._lines[li]
removed = 0
for ch in line[: self.tab_size]:
if ch != " ":
break
removed += 1
if removed:
self._lines[li] = line[removed:]
changed = True
if li == self._cursor_line:
self._cursor_col = max(0, self._cursor_col - removed)
if changed:
self.text_changed.emit(self.text)
# ----------------------------------------------------------------
# Process (cursor blink timer)
# ----------------------------------------------------------------
[docs]
def on_update(self, dt: float):
"""Update cursor blink timer."""
old_visible = self._cursor_blink < 0.5
self._cursor_blink += dt
if self._cursor_blink > 1.0:
self._cursor_blink = 0.0
if old_visible != (self._cursor_blink < 0.5):
self.queue_redraw()
# ----------------------------------------------------------------
# Drawing
# ----------------------------------------------------------------
[docs]
def on_draw(self, renderer):
x, y, w, h = self._local_rect()
scale = self._font_scale()
lh = self._line_height()
gutter_w = self._gutter_width()
content_x = x + gutter_w + _PADDING_LEFT
w - gutter_w - _PADDING_LEFT - _PADDING_RIGHT - _SCROLLBAR_WIDTH
visible = self._visible_lines()
# Any still-pending click/drag (queued by a path that skipped the
# immediate resolution) lands before this frame draws the caret.
self._resolve_pending_pointer()
# 1. Background
renderer.rect((x, y), (w, h), fill=self.bg_colour)
# 2. Border
border = self.focus_colour if self.focused else self.border_colour
renderer.rect((x, y), (w, h), stroke=border)
# 3. Line numbers gutter
if self.show_line_numbers and gutter_w > 0:
renderer.rect((x, y), (gutter_w, h), fill=self.gutter_bg_colour)
# Gutter separator line
renderer.line((x + gutter_w, y), (x + gutter_w, y + h), stroke=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.rect((x + gutter_w, cur_y), (cw, lh), fill=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.rect((sel_x1, line_top), (sel_x2 - sel_x1, lh), fill=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.rect(
(sel_x1, line_top), (max(sel_x2 - sel_x1, self.font_size * 0.5), lh), fill=self.selection_colour
)
elif li == el:
# Last line of selection
sel_x2 = content_x + renderer.text_width(line_text[:ec], scale)
renderer.rect((content_x, line_top), (sel_x2 - content_x, lh), fill=self.selection_colour)
else:
# Full line selected
full_w = renderer.text_width(line_text, scale)
renderer.rect(
(content_x, line_top), (max(full_w, self.font_size * 0.5), lh), fill=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.text(num_str, (num_x, num_y), fill=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.text(line_text, (content_x, text_y), fill=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_draw_pos(renderer)
cursor_top = cur_y + 2
cursor_bot = cur_y + lh - 2
renderer.line((cur_x, cursor_top), (cur_x, cursor_bot), stroke=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.rect((tx, ty), (tw, th), fill=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.rect((sx, sy), (sw, sh), fill=thumb_colour)