"""CodeTextEdit -- Syntax-highlighted code editor widget.
Extends MultiLineTextEdit with Python syntax highlighting, auto-indent,
bracket matching, tab/dedent handling, current-line highlight, and an
inline find bar (Ctrl+F).
"""
import logging
from ..descriptors import Property
from ..input.controls import Key, Pointer
from ..input.enums import PointerPhase
from ..input.events import KeyEvent, PointerEvent, TextEvent
from ..signals import Signal
from .core import Colour, ThemeColour
from .find_replace import _FIND_BAR_HEIGHT, FindReplaceMixin
from .folding import FoldingMixin
from .markers import MarkerMixin, TextMarker
from .menu import MenuItem, PopupMenu
from .multiline import MultiLineTextEdit
from .syntax_highlighter import SyntaxHighlighterMixin
from .text_selection import _CTRL, _CTRL_SHIFT, _PLAIN, _SHIFT
log = logging.getLogger(__name__)
__all__ = ["CodeTextEdit", "TextMarker"]
# ============================================================================
# CodeTextEdit
# ============================================================================
[docs]
class CodeTextEdit(SyntaxHighlighterMixin, FindReplaceMixin, FoldingMixin, MarkerMixin, MultiLineTextEdit):
"""Multi-line code editor with Python syntax highlighting.
Features:
- Keyword, string, comment, number, decorator, and builtin colouring
- Auto-indent on Enter (matches previous indentation; adds 4 after colon)
- Bracket matching for (), [], {}
- Tab inserts 4 spaces; Shift+Tab dedents selected lines
- Current line highlight
- Find bar (Ctrl+F) with Enter to cycle matches
Example:
editor = CodeTextEdit()
editor.text = "def hello():\\n print('Hello')"
editor.show_line_numbers = True
"""
language = Property("python", enum=["python"], hint="Syntax language")
# Code reads with its gutter on, unlike the plain multi-line editor.
show_line_numbers = Property(True, hint="Show line number gutter")
# Theme-aware colours (override parent where needed)
current_line_colour = ThemeColour("current_line")
bracket_match_colour = ThemeColour("bracket_match")
bracket_mismatch_colour = ThemeColour("bracket_mismatch")
def __init__(self, text: str = "", **kwargs):
# Init mixin state before super().__init__ because the text setter
# (called by MultiLineTextEdit.__init__) references it.
self._init_syntax_highlighter()
super().__init__(text=text, **kwargs)
# Syntax highlighting toggle (disable for very large files)
self.syntax_highlighting = True
# Auto-indent on Enter (can be disabled for programmatic/paste input)
self.auto_indent = True
# Init mixin state (after super().__init__ so signals work)
self._init_find_replace()
self._init_folding()
self._init_markers()
# Signals
self.find_opened = Signal()
self.find_closed = Signal()
self.gutter_clicked = Signal()
self.completion_requested = Signal()
# Indent guides
self.show_indent_guides = True
# Current-word highlighting
self._highlight_word: str = ""
self._highlight_timer: float = 0.0
# Right-click context menu
self._context_menu = PopupMenu(
[
MenuItem("Cut", self.cut, "Ctrl+X"),
MenuItem("Copy", self.copy, "Ctrl+C"),
MenuItem("Paste", self.paste, "Ctrl+V"),
MenuItem(separator=True),
MenuItem("Select All", self.select_all, "Ctrl+A"),
MenuItem(separator=True),
MenuItem("Undo", self.undo, "Ctrl+Z"),
MenuItem("Redo", self.redo, "Ctrl+Y"),
MenuItem(separator=True),
MenuItem("Toggle Comment", self.toggle_comment, "Ctrl+/"),
MenuItem("Find", self._open_find_bar, "Ctrl+F"),
MenuItem("Find & Replace", self._open_find_replace, "Ctrl+H"),
]
)
self.add_child(self._context_menu)
def _open_find_replace(self) -> None:
"""Open the find bar with the replace row expanded."""
self._open_find_bar()
self._replace_active = True
# Alias: CodeEditorPanel delegates to "select_next_occurrence"
select_next_occurrence = MultiLineTextEdit.select_word
# ================================================================
# Auto-indent
# ================================================================
def _get_indent(self, line: str) -> str:
"""Return the leading whitespace of a line."""
stripped = line.lstrip()
return line[: len(line) - len(stripped)]
def _should_increase_indent(self, line: str) -> bool:
"""Check if a line ends with a colon (after stripping comments)."""
stripped = line.rstrip()
# Remove trailing comment
in_str = False
str_char = ""
last_code_idx = -1
for idx, ch in enumerate(stripped):
if in_str:
if ch == "\\":
continue
if ch == str_char:
in_str = False
continue
if ch in ('"', "'"):
in_str = True
str_char = ch
continue
if ch == "#":
break
last_code_idx = idx
if last_code_idx >= 0 and stripped[last_code_idx] == ":":
return True
return False
# ================================================================
# Key handling override
# ================================================================
def _handle_key(self, event: KeyEvent) -> bool:
"""Override key handling for code editing features.
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 editor has already used.
"""
self._cursor_blink = 0.0
key = event.key
mods = (event.ctrl, event.shift, event.alt)
# Ctrl+H: Toggle replace bar
if key is Key.H and mods == _CTRL:
self._toggle_replace_bar()
return True
# Ctrl+F: Toggle find bar
if key is Key.F and mods == _CTRL:
self._toggle_find_bar()
return True
# Ctrl+Space: Manually request LSP code completion
if key is Key.SPACE and mods == _CTRL:
self.completion_requested.emit()
return True
# Route keys to find/replace bar when active
if self._find_active and key in self.FIND_BAR_KEYS and mods in (_PLAIN, _SHIFT):
self._handle_find_key(key)
return True
# Code folding
if key is Key.LEFT_BRACKET and mods == _CTRL_SHIFT:
self.fold_at_line(self._cursor_line)
return True
if key is Key.RIGHT_BRACKET and mods == _CTRL_SHIFT:
self.unfold_at_line(self._cursor_line)
return True
# Tab / Shift+Tab: indent and dedent. A read-only editor does not act on
# either, so the key falls through and focus can leave the widget.
if key is Key.TAB and mods == _SHIFT:
if self.read_only:
return False
self._record_undo()
self._dedent_lines()
self._ensure_cursor_visible()
return True
if key is Key.TAB and mods == _PLAIN:
if self.read_only:
return False
self._indent_or_insert_tab()
return True
# Enter with auto-indent (or plain newline when auto_indent is disabled)
if key is Key.ENTER and mods in (_PLAIN, _SHIFT) and not self.read_only:
if self.auto_indent:
self._handle_enter_autoindent()
else:
super()._handle_key(event)
self._invalidate_cache(max(0, self._cursor_line - 1))
return True
# Default key handling
consumed = super()._handle_key(event)
# Ensure cursor doesn't land on folded lines
if self._folded_regions:
self._adjust_cursor_for_folds()
# Invalidate cache after any edit key
if key in (Key.BACKSPACE, Key.DELETE, Key.ENTER, Key.TAB):
self._invalidate_cache(max(0, self._cursor_line - 1))
return consumed
[docs]
def on_gui_input(self, event):
"""Override input to catch character input for cache invalidation and find bar routing."""
if isinstance(event, PointerEvent):
return self._on_pointer(event)
if not isinstance(event, (KeyEvent, TextEvent)):
return None
# Route typed text to the find/replace bar when it is open
if self._find_active and isinstance(event, TextEvent):
if not event.composing:
self._handle_find_char(event.text)
self.queue_redraw()
return True
return super().on_gui_input(event)
[docs]
def insert(self, text: str) -> None:
"""Insert *text* at the caret, as :meth:`MultiLineTextEdit.insert`, and re-highlight from its line."""
first_line = self._ordered_selection()[0][0]
super().insert(text)
self._invalidate_cache(first_line)
def _on_pointer(self, event) -> bool | None:
"""The pointer half: the find bar's buttons, the gutter, and hover."""
# Handle mouse clicks on find/replace bar buttons
if self._find_active and event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN:
if self._handle_find_bar_click(event.position):
self.queue_redraw()
return True
# Detect gutter clicks for fold toggling and breakpoint toggling
if event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN and self.show_line_numbers:
if self.is_point_inside(event.position):
px, py = self.to_local(event.position)
local_x = px
if local_x < self._gutter_width():
find_bar_offset = (
(_FIND_BAR_HEIGHT * (2 if self._replace_active else 1)) if self._find_active else 0.0
)
lh = self._line_height()
# Compute clicked line (accounting for folds)
vis_row_f = (py - find_bar_offset - 4.0) / lh + self._scroll_y
vis_row = int(vis_row_f)
if self._folded_regions:
all_vis = self._get_visible_lines()
vis_row = max(0, min(vis_row, len(all_vis) - 1))
line = all_vis[vis_row]
else:
line = max(0, min(vis_row, len(self._lines) - 1))
# Fold gutter area (leftmost ~14px)
if self._show_fold_gutter and local_x < 14.0:
self.toggle_fold(line)
self.queue_redraw()
return True
self.gutter_clicked.emit(line)
self.queue_redraw()
return True
# Right-click context menu
if event.button in Pointer.SECONDARY and event.phase is PointerPhase.DOWN:
if self.is_point_inside(event.position):
self._context_menu.show(event.position[0], event.position[1])
self.queue_redraw()
return True
# Mouse move hover detection for marker tooltips
if event.phase is PointerPhase.MOVE:
if self.is_point_inside(event.position):
px, py = self.to_local(event.position)
find_bar_offset = (_FIND_BAR_HEIGHT * (2 if self._replace_active else 1)) if self._find_active else 0.0
lh = self._line_height()
gutter_w = self._gutter_width()
# Approximate line from y position
vis_row_f = (py - find_bar_offset - 4.0) / lh + self._scroll_y
vis_row = int(vis_row_f)
if self._folded_regions:
all_vis = self._get_visible_lines()
vis_row = max(0, min(vis_row, len(all_vis) - 1))
line = all_vis[vis_row] if all_vis else 0
else:
line = max(0, min(vis_row, len(self._lines) - 1))
# Column from x position, measured with the font being drawn
local_x = px - gutter_w - 6.0 # _PADDING_LEFT
col = self._column_at_offset(self._lines[line] if line < len(self._lines) else "", local_x)
if line < len(self._lines):
col = min(col, len(self._lines[line]))
if line != self._last_hover_line or col != self._last_hover_col:
self._last_hover_line = line
self._last_hover_col = col
self._hover_time = 0.0
self._hover_tooltip = ""
for marker in self._markers:
if marker.line == line and marker.col_start <= col < marker.col_end and marker.tooltip:
self._hover_tooltip = marker.tooltip
self._hover_tooltip_line = line
self._hover_tooltip_col = col
break
self.queue_redraw()
return super().on_gui_input(event)
# ================================================================
# Tab handling
# ================================================================
def _indent_or_insert_tab(self):
"""Insert 4 spaces at cursor, or indent selected lines."""
self._record_undo()
spaces = " " * self.tab_size
if self._has_selection():
# Indent all selected lines
(sl, _), (el, _) = self._ordered_selection()
for li in range(sl, el + 1):
self._lines[li] = spaces + self._lines[li]
# Adjust cursor and selection
if self._select_start and self._select_end:
s_line, s_col = self._select_start
e_line, e_col = self._select_end
self._select_start = (s_line, s_col + self.tab_size)
self._select_end = (e_line, e_col + self.tab_size)
self._cursor_col += self.tab_size
self._invalidate_cache(sl)
self.text_changed.emit(self.text)
else:
# Insert spaces at cursor
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._invalidate_cache(self._cursor_line)
self._ensure_cursor_visible()
self.text_changed.emit(self.text)
def _dedent_lines(self):
"""Dedent as the base editor does, then drop highlighting for the affected lines."""
(sl, _), _ = self._ordered_selection()
super()._dedent_lines()
self._invalidate_cache(sl)
# ================================================================
# Enter with auto-indent
# ================================================================
def _handle_enter_autoindent(self):
"""Handle Enter key with automatic indentation."""
if self._has_selection():
self._delete_selection()
line = self._lines[self._cursor_line]
before = line[: self._cursor_col]
after = line[self._cursor_col :]
# Get current line indentation
indent = self._get_indent(before)
# Increase indent if line ends with colon
if self._should_increase_indent(before):
indent += " " * self.tab_size
self._lines[self._cursor_line] = before
self._lines.insert(self._cursor_line + 1, indent + after)
self._cursor_line += 1
self._cursor_col = len(indent)
self._invalidate_cache(self._cursor_line - 1)
self._ensure_cursor_visible()
self.text_changed.emit(self.text)
# ================================================================
# Process override (cursor blink for both editor and find bar)
# ================================================================
[docs]
def on_update(self, dt: float):
"""Update cursor blink timers and word highlighting."""
super().on_update(dt)
if self._find_active:
old_visible = self._find_cursor_blink < 0.5
self._find_cursor_blink += dt
if self._find_cursor_blink > 1.0:
self._find_cursor_blink = 0.0
if old_visible != (self._find_cursor_blink < 0.5):
self.queue_redraw()
# Hover tooltip delay
if self._hover_tooltip and self._hover_time < 0.3:
self._hover_time += dt
if self._hover_time >= 0.3:
self.queue_redraw()
# Debounced current-word highlight update
self._highlight_timer += dt
if self._highlight_timer >= 0.15:
self._highlight_timer = 0.0
self._update_highlight_word()
def _update_highlight_word(self):
"""Update the word under the cursor for occurrence highlighting."""
if self._has_selection() or not self._lines:
if self._highlight_word:
self._highlight_word = ""
return
line = self._lines[self._cursor_line] if self._cursor_line < len(self._lines) else ""
col = self._cursor_col
# Extract word at cursor
if col >= len(line) or not (line[col].isalnum() or line[col] == "_"):
# Try char before cursor
if col > 0 and (line[col - 1].isalnum() or line[col - 1] == "_"):
col -= 1
else:
if self._highlight_word:
self._highlight_word = ""
return
start = col
while start > 0 and (line[start - 1].isalnum() or line[start - 1] == "_"):
start -= 1
end = col
while end < len(line) and (line[end].isalnum() or line[end] == "_"):
end += 1
word = line[start:end]
if len(word) < 2:
word = ""
if word != self._highlight_word:
self._highlight_word = word
# ================================================================
# Drawing override -- syntax-highlighted text rendering
# ================================================================
[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 + 6.0 # _PADDING_LEFT
w - gutter_w - 6.0 - 4.0 - 12.0 # padding - scrollbar
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)
if getattr(self, "_pending_double_click", False):
self._cursor_line = line
self._cursor_col = col
self._clear_selection()
self._select_word_at_cursor()
self._pending_double_click = False
elif getattr(self, "_pending_click_extend", False):
self._cursor_line = line
self._cursor_col = col
self._update_selection_end()
else:
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)
self._cursor_line = line
self._cursor_col = col
self._update_selection_end()
self._ensure_cursor_visible()
self._pending_drag = None
# Adjust visible area if find bar is active (doubles when replace row shown)
find_bar_offset = (_FIND_BAR_HEIGHT * (2 if self._replace_active else 1)) if self._find_active else 0.0
# 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 + find_bar_offset), (gutter_w, h - find_bar_offset), fill=self.gutter_bg_colour)
renderer.line((x + gutter_w, y + find_bar_offset), (x + gutter_w, y + h), stroke=self.border_colour)
# 4. Clip to content area
clip_x = x
clip_w = w - 12.0 # scrollbar width
renderer.push_clip(clip_x, y + find_bar_offset, clip_w, h - find_bar_offset)
# Determine visible line range (accounting for folds)
all_visible = self._get_visible_lines() if self._folded_regions else None
first_visible = int(self._scroll_y)
last_visible = min(len(self._lines), first_visible + visible + 1)
# Build multiline string state for visible range (cached; skip if highlighting disabled)
ml_states = self._get_multiline_states() if self.syntax_highlighting else None
# Helper: compute Y position for a visual row index
def _visual_y(visual_row: int) -> float:
return y + 4.0 + (visual_row - self._scroll_y) * lh # _PADDING_TOP=4.0
if all_visible:
# Build set of visible buffer lines and visual row mapping
vis_set = set(all_visible)
buf_to_vis = {buf_line: vis_row for vis_row, buf_line in enumerate(all_visible)}
# Determine visual row range for rendering
first_vis_row = int(self._scroll_y)
last_vis_row = min(len(all_visible), first_vis_row + visible + 1)
render_lines = all_visible[first_vis_row:last_vis_row]
else:
vis_set = None
buf_to_vis = None
render_lines = list(range(first_visible, last_visible))
first_vis_row = first_visible
def _line_render_y(buf_line: int) -> float:
"""Y position for a buffer line, accounting for folds."""
if buf_to_vis is not None and buf_line in buf_to_vis:
return _visual_y(buf_to_vis[buf_line])
return self._line_y(buf_line)
# 5. Current line highlight
if buf_to_vis is not None:
if self._cursor_line in buf_to_vis:
cur_vis = buf_to_vis[self._cursor_line]
if first_vis_row <= cur_vis < last_vis_row:
cur_y = _visual_y(cur_vis) + find_bar_offset
renderer.rect((x + gutter_w, cur_y), (w - gutter_w - 12.0, lh), fill=self.current_line_colour)
elif first_visible <= self._cursor_line < last_visible:
cur_y = self._line_y(self._cursor_line) + find_bar_offset
renderer.rect((x + gutter_w, cur_y), (w - gutter_w - 12.0, lh), fill=self.current_line_colour)
# 6. Selection highlight
if self._has_selection():
self._draw_selection(
renderer, x, gutter_w, content_x, scale, lh, first_visible, last_visible, find_bar_offset
)
# 7. Find match highlights
if self._find_active and self._find_matches:
self._draw_find_highlights(renderer, content_x, scale, lh, first_visible, last_visible, find_bar_offset)
# 8. Bracket match highlight
bracket_match = self._find_matching_bracket()
if bracket_match is not None:
bm_line, bm_col = bracket_match
if vis_set is None or bm_line in vis_set:
bm_render_y = _line_render_y(bm_line)
bm_text_before = self._lines[bm_line][:bm_col]
bm_x = content_x + renderer.text_width(bm_text_before, scale)
bm_ch_w = renderer.text_width(self._lines[bm_line][bm_col : bm_col + 1], scale)
bm_y = bm_render_y + find_bar_offset
renderer.rect((bm_x, bm_y), (bm_ch_w, lh), fill=self.bracket_match_colour)
# 8b. Current-word occurrence highlighting
if self._highlight_word:
theme = self.get_theme()
hw = self._highlight_word
hw_len = len(hw)
for vis_idx, li in enumerate(render_lines):
line_text = self._lines[li] if li < len(self._lines) else ""
pos = 0
while True:
pos = line_text.find(hw, pos)
if pos < 0:
break
# Only highlight whole-word matches
before_ok = pos == 0 or not (line_text[pos - 1].isalnum() or line_text[pos - 1] == "_")
after_ok = pos + hw_len >= len(line_text) or not (
line_text[pos + hw_len].isalnum() or line_text[pos + hw_len] == "_"
)
if before_ok and after_ok:
hw_x = content_x + renderer.text_width(line_text[:pos], scale)
hw_w = renderer.text_width(hw, scale)
hw_y = _visual_y(first_vis_row + vis_idx) + find_bar_offset
renderer.rect((hw_x, hw_y), (hw_w, lh), fill=theme.highlight)
pos += hw_len
# 8c. Indent guides
if self.show_indent_guides:
theme = self.get_theme()
guide_colour = (theme.border[0], theme.border[1], theme.border[2], 0.3)
tab_size = int(getattr(self, "tab_size", 4))
space_w = renderer.text_width(" " * tab_size, scale)
for vis_idx, li in enumerate(render_lines):
line_text = self._lines[li] if li < len(self._lines) else ""
if not line_text.strip():
continue
indent = len(line_text) - len(line_text.lstrip())
guide_y = _visual_y(first_vis_row + vis_idx) + find_bar_offset
n_guides = indent // tab_size
for g in range(n_guides):
gx = content_x + g * space_w
renderer.line((gx, guide_y), (gx, guide_y + lh), stroke=guide_colour)
# 9. Markers (squiggles + gutter dots)
if self._markers:
self._draw_markers(
renderer, content_x, scale, lh, first_visible, last_visible, find_bar_offset, x, gutter_w
)
# 10. Line numbers + fold gutter
if self.show_line_numbers and gutter_w > 0:
fold_gutter_colour = (0.52, 0.52, 0.57, 0.8)
for vis_idx, li in enumerate(render_lines):
row_y = _visual_y(first_vis_row + vis_idx) + find_bar_offset
# Line number
num_str = str(li + 1)
num_w = renderer.text_width(num_str, scale)
num_x = x + gutter_w - num_w - 8.0
num_y = row_y + (lh - self.font_size) / 2
colour = self.line_number_colour if li != self._cursor_line else (0.8, 0.8, 0.8, 1.0)
renderer.text(num_str, (num_x, num_y), fill=colour, scale=scale)
# Fold gutter indicators
if self._show_fold_gutter:
fold_x = x + 2
fold_y = row_y + (lh - self.font_size) / 2
if li in self._folded_regions:
renderer.text(">", (fold_x, fold_y), fill=fold_gutter_colour, scale=scale * 0.85)
elif self._is_foldable(li):
renderer.text("v", (fold_x, fold_y), fill=fold_gutter_colour, scale=scale * 0.85)
# 11. Syntax-highlighted text content
for vis_idx, li in enumerate(render_lines):
tokens = self._get_line_tokens(li, ml_states)
if not tokens:
continue
row_y = _visual_y(first_vis_row + vis_idx) + find_bar_offset
text_y = row_y + (lh - self.font_size) / 2
tx = content_x
for token_text, token_type in tokens:
if not token_text:
continue
colour = getattr(self.get_theme().syntax, token_type, self.get_theme().syntax.normal)
renderer.text(token_text, (tx, text_y), fill=colour, scale=scale)
tx += renderer.text_width(token_text, scale)
# Show fold indicator after line text
if li in self._folded_regions:
renderer.text(" ...", (tx, text_y), fill=(0.5, 0.5, 0.5, 0.7), scale=scale)
# 12. Cursor (blinking vertical line)
if self.focused and self._cursor_blink < 0.5:
cursor_visible = False
if buf_to_vis is not None:
if self._cursor_line in buf_to_vis:
cur_vis = buf_to_vis[self._cursor_line]
if first_vis_row <= cur_vis < last_vis_row:
cursor_visible = True
elif first_visible <= self._cursor_line < last_visible:
cursor_visible = True
if cursor_visible:
cur_x, cur_y_raw = self._cursor_draw_pos(renderer)
# Adjust for fold offset if needed
if buf_to_vis is not None and self._cursor_line in buf_to_vis:
cur_y_adj = _visual_y(buf_to_vis[self._cursor_line]) + find_bar_offset
else:
cur_y_adj = cur_y_raw + find_bar_offset
cursor_top = cur_y_adj + 2
cursor_bot = cur_y_adj + lh - 2
renderer.line((cur_x, cursor_top), (cur_x, cursor_bot), stroke=Colour.WHITE)
renderer.pop_clip()
# 13. Scrollbar
total_lines = len(self._lines)
if total_lines > visible:
theme = self.get_theme()
tx_s, ty_s, tw_s, th_s = self._scrollbar_track_rect()
renderer.rect((tx_s, ty_s), (tw_s, th_s), fill=theme.scrollbar_track)
sx_s, sy_s, sw_s, sh_s = self._scrollbar_thumb_rect()
if sw_s > 0 and sh_s > 0:
thumb_colour = theme.scrollbar_hover if self._dragging_scrollbar else theme.scrollbar_fg
renderer.rect((sx_s, sy_s), (sw_s, sh_s), fill=thumb_colour)
# 14. Find bar (drawn on top if active)
if self._find_active:
self._draw_find_bar(renderer, x, y, w)
# 15. Hover tooltip
if self._hover_tooltip and self._hover_time >= 0.3:
self._draw_hover_tooltip(renderer)
# ================================================================
# Drawing helpers
# ================================================================
def _draw_selection(self, renderer, x, gutter_w, content_x, scale, lh, first_visible, last_visible, y_offset):
"""Draw selection highlight rectangles."""
(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) + y_offset
if li == sl and li == el:
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:
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:
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_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
)
# ================================================================
# Text property override for cache invalidation
# ================================================================
@property
def text(self) -> str:
"""Get full text content."""
return "\n".join(self._lines)
[docs]
@text.setter
def text(self, value: str):
"""Set text content and invalidate token cache."""
self._lines = value.split("\n") if value else [""]
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._token_cache.clear()
self._ml_states_cache = None