"""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
import time
from collections import deque
from contextlib import contextmanager
from ..descriptors import Property
from ..input.enums import MouseButton
from ..input.state import Input
from ..signals import Signal
from ..text.measure import LOGICAL_EM, measure_text_width, text_prefix_widths
from .core import Colour, Control, ThemeColour
from .enums import FocusMode
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
#: Keys whose ``ctrl+shift+<key>`` press is a shifted variant of the
#: ``ctrl+<key>`` binding rather than a binding of its own: the four selecting
#: motions, plus ``z`` for redo. The router spells any such press as the single
#: string ``ctrl+shift+<key>``, so ``_handle_key`` folds it back into
#: ``shift=True`` plus ``ctrl+<key>`` before matching. Everything else spelled
#: ``ctrl+shift+…`` (delete-line, duplicate-line, the fold pair in CodeTextEdit)
#: owns that name and is left alone.
_CTRL_SHIFT_FOLDED_KEYS = ("home", "end", "left", "right", "z")
def _is_word_char(ch: str) -> bool:
"""True for characters that word-wise motion and select-word treat as one word."""
return ch.isalnum() or ch == "_"
[docs]
class MultiLineTextEdit(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
"""
_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")
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
# 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.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) -> float:
"""X coordinate where text content starts (after gutter)."""
x, _, _, _ = self.get_global_rect()
return x + 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()))
# ----------------------------------------------------------------
# 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()
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 = 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))
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.
Widths come from the font the text is drawn with, so the caret lands
between the glyphs the click fell between whatever the typeface.
"""
# One walk of the line for every column, rather than re-measuring a
# longer prefix per column: that is quadratic in the line length, and it
# leaves a cached width behind for every column of every line clicked.
widths = text_prefix_widths(line_text, self._font_scale())
col = len(line_text)
for i, tw in enumerate(widths):
if tw >= offset:
col = i - 1 if i > 0 and offset - widths[i - 1] < tw - offset else i
break
return 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)
@contextmanager
def _cursor_motion(self, shift: bool):
"""Wrap a cursor move so *shift* extends the selection instead of dropping it.
Every navigation binding shares the same sandwich: anchor or drop the
selection, move, then carry the selection end along and scroll the new
cursor into view.
"""
if shift:
self._start_selection()
else:
self._clear_selection()
yield
if shift:
self._update_selection_end()
self._ensure_cursor_visible()
# ----------------------------------------------------------------
# Word-wise motion
# ----------------------------------------------------------------
def _move_word_left(self):
"""Move the cursor to the start of the word before it.
Skips any run of separators first, so a cursor sitting after ``foo, `` lands
on the ``f``. At column zero it steps to the end of the previous line.
"""
line = self._lines[self._cursor_line]
col = self._cursor_col
if col == 0:
if self._cursor_line > 0:
self._cursor_line -= 1
self._cursor_col = len(self._lines[self._cursor_line])
return
while col > 0 and not _is_word_char(line[col - 1]):
col -= 1
while col > 0 and _is_word_char(line[col - 1]):
col -= 1
self._cursor_col = 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]
col = self._cursor_col
if col >= len(line):
if self._cursor_line < len(self._lines) - 1:
self._cursor_line += 1
self._cursor_col = 0
return
while col < len(line) and not _is_word_char(line[col]):
col += 1
while col < len(line) and _is_word_char(line[col]):
col += 1
self._cursor_col = 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 _is_word_char(line[col]):
return
start = col
while start > 0 and _is_word_char(line[start - 1]):
start -= 1
end = col
while end < len(line) - 1 and _is_word_char(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 == MouseButton.LEFT and event.pressed:
if self.is_point_inside(event.position):
self.set_focus()
# Mouse wheel scrolling. Claimed here so the wheel moves this view
# rather than the container it sits in.
if event.key == "scroll_up":
self._scroll_y -= 3.0
self._clamp_scroll()
self.queue_redraw()
event.handled = True
return
if event.key == "scroll_down":
self._scroll_y += 3.0
self._clamp_scroll()
self.queue_redraw()
event.handled = True
return
# Scrollbar thumb drag
if event.button == MouseButton.LEFT:
if event.pressed:
sx, sy, sw, sh = self._scrollbar_thumb_rect()
if sw > 0 and sh > 0:
px = event.position.x
py = event.position.y
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
event.handled = True
return
else:
if self._dragging_scrollbar:
self._dragging_scrollbar = False
event.handled = True
return
if self._dragging_text:
self._dragging_text = False
self.release_mouse()
event.handled = True
return
# Scrollbar drag motion
if self._dragging_scrollbar and event.position:
py = 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
# Text drag motion (mouse grabbed, button=0 motion events)
if self._dragging_text and event.button is None and event.position:
self._pending_drag = event.position
self._resolve_pending_pointer()
self.queue_redraw()
return
if not self.focused:
return
# 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 == MouseButton.LEFT and event.pressed and not self._dragging_scrollbar:
if self.is_point_inside(event.position):
self.set_focus()
event.handled = True
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
self._pending_click = event.position
self._pending_click_extend = False
self._pending_double_click = True
self._resolve_pending_pointer()
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._resolve_pending_pointer()
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:
if self._handle_key(event.key, shift):
event.handled = True
self.queue_redraw()
return
# Character input
if event.char and len(event.char) == 1 and not self.read_only:
# A newline arriving as a CHARACTER (a driver or IME path that never
# synthesises an enter key) must split the line like enter does --
# embedding it would break the invariant that no line contains a
# newline, leaving a buffer whose ``text`` looks right while the
# widget draws, counts, and selects it as one line.
if event.char in ("\n", "\r"):
if self._handle_key("enter"):
event.handled = True
self.queue_redraw()
return
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) -> bool:
"""Process a key press event.
Returns True when the key was consumed, so the caller can mark the event
handled and keep router-level fallbacks (Tab focus cycling, Escape) from
acting on a key this widget has already used.
"""
self._cursor_blink = 0.0
# Fold the shift modifier out of a combo into the flag, so each binding is
# named once. The router builds combos in ctrl, shift, alt order, so a
# shift combo that also carries ctrl arrives as "ctrl+shift+<key>" and never
# as "shift+ctrl+<key>"; the two forms are folded separately below.
# "shift+tab" is a binding in its own right and keeps its name.
if key.startswith("shift+") and key != "shift+tab":
shift = True
key = key[6:] # strip "shift+"
elif key.startswith("ctrl+shift+") and key[len("ctrl+shift+") :] in _CTRL_SHIFT_FOLDED_KEYS:
shift = True
key = "ctrl+" + key[len("ctrl+shift+") :]
# Ctrl+ shortcuts (key names are prefixed with "ctrl+" by input layer)
if key == "ctrl+z":
if shift:
self.redo()
else:
self.undo()
return True
if key == "ctrl+y":
self.redo()
return True
if key == "ctrl+c":
self.copy()
return True
if key == "ctrl+x":
if not self.read_only:
self.cut()
return True
if key == "ctrl+v":
if not self.read_only:
self.paste()
return True
if key == "ctrl+a":
self.select_all()
return True
if key == "ctrl+/":
if not self.read_only:
self.toggle_comment()
return True
if key == "ctrl+shift+k":
if not self.read_only:
self.delete_line()
return True
if key == "ctrl+d":
self.select_word()
return True
if key == "ctrl+shift+d":
if not self.read_only:
self.duplicate_line()
return True
if key == "alt+up":
if not self.read_only:
self.move_line_up()
return True
if key == "alt+down":
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 == "left":
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 == "right":
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 == "ctrl+left":
with self._cursor_motion(shift):
self._move_word_left()
return True
if key == "ctrl+right":
with self._cursor_motion(shift):
self._move_word_right()
return True
if key == "up":
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 == "down":
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 == "home":
with self._cursor_motion(shift):
self._cursor_col = 0
return True
if key == "end":
with self._cursor_motion(shift):
self._cursor_col = len(self._lines[self._cursor_line])
return True
if key == "ctrl+home":
with self._cursor_motion(shift):
self._cursor_line = 0
self._cursor_col = 0
return True
if key == "ctrl+end":
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 == "page_up":
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 == "page_down":
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 == "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 True
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 True
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 True
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 True
if key == "shift+tab":
# 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.get_global_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.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)