"""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).
"""
from __future__ import annotations
import logging
from ..descriptors import Property, Signal
from .core import Colour, ThemeColour
from .find_replace import FindReplaceMixin, _FIND_BAR_HEIGHT
from .folding import FoldingMixin
from .markers import MarkerMixin, TextMarker, _MARKER_COLORS # noqa: F401 -- re-export
from .menu import MenuItem, PopupMenu
from .multiline import MultiLineTextEdit
from .syntax_highlighter import SyntaxHighlighterMixin
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")
# 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)
# Override defaults for code editing appearance
self.show_line_numbers = True
self.font_size = 14.0
# 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", lambda: (self._open_find_bar(), setattr(self, "_replace_active", True)), "Ctrl+H"
),
]
)
self.add_child(self._context_menu)
# 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, key: str, shift: bool = False):
"""Override key handling for code editing features."""
self._cursor_blink = 0.0
# Ctrl+H: Toggle replace bar
if key in ("ctrl+h", "ctrl_h"):
self._toggle_replace_bar()
return
# Ctrl+F: Toggle find bar
if key in ("ctrl+f", "ctrl_f"):
self._toggle_find_bar()
return
# Ctrl+Space: Manually request LSP code completion
if key in ("ctrl+space", "ctrl_space"):
self.completion_requested.emit()
return
# Route keys to find/replace bar when active
if self._find_active:
if key in ("escape", "enter", "backspace", "tab"):
self._handle_find_key(key)
return
# Code folding
if key == "ctrl+shift+[":
self.fold_at_line(self._cursor_line)
return
if key == "ctrl+shift+]":
self.unfold_at_line(self._cursor_line)
return
# Tab / Shift+Tab
if key == "tab" and not self.read_only:
if shift:
self._dedent_lines()
else:
self._indent_or_insert_tab()
return
# Enter with auto-indent (or plain newline when auto_indent is disabled)
if key == "enter" and not self.read_only:
if self.auto_indent:
self._handle_enter_autoindent()
else:
super()._handle_key(key, shift)
self._invalidate_cache(max(0, self._cursor_line - 1))
return
# Default key handling
super()._handle_key(key, shift)
# 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 ("backspace", "delete", "enter", "tab"):
self._invalidate_cache(max(0, self._cursor_line - 1))
def _on_gui_input(self, event):
"""Override input to catch character input for cache invalidation and find bar routing."""
# Route character input to find/replace bar when active
if self._find_active and event.char and len(event.char) == 1:
self._handle_find_char(event.char)
self.queue_redraw()
return
# Handle mouse clicks on find/replace bar buttons
if self._find_active and event.button == 1 and event.pressed:
if self._handle_find_bar_click(event.position):
self.queue_redraw()
return
# Detect gutter clicks for fold toggling and breakpoint toggling
if event.button == 1 and event.pressed and self.show_line_numbers:
if self.is_point_inside(event.position):
px = event.position.x if hasattr(event.position, "x") else event.position[0]
py = event.position.y if hasattr(event.position, "y") else event.position[1]
gx, gy, _, _ = self.get_global_rect()
local_x = px - gx
# Gutter width is computed in draw; approximate it the same way
digits = max(2, len(str(len(self._lines))))
self._font_scale()
# Estimate gutter width (matches _gutter_width logic without renderer)
approx_gutter = digits * self.font_size * 0.6 + 16.0 # _LINE_NUMBER_PAD * 2
if local_x < approx_gutter:
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 - gy - 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
self.gutter_clicked.emit(line)
self.queue_redraw()
return
# Right-click context menu
if event.button == 3 and event.pressed:
if self.is_point_inside(event.position):
px = event.position.x if hasattr(event.position, "x") else event.position[0]
py = event.position.y if hasattr(event.position, "y") else event.position[1]
self._context_menu.show(px, py)
self.queue_redraw()
return
# Mouse move hover detection for marker tooltips
if event.button == 0 and not event.key and not event.char and event.position:
if self.is_point_inside(event.position):
px = event.position.x if hasattr(event.position, "x") else event.position[0]
py = event.position.y if hasattr(event.position, "y") else event.position[1]
gx, gy, _, _ = self.get_global_rect()
find_bar_offset = (
(_FIND_BAR_HEIGHT * (2 if self._replace_active else 1)) if self._find_active else 0.0
)
lh = self._line_height()
digits = max(2, len(str(len(self._lines))))
approx_gutter = digits * self.font_size * 0.6 + 16.0
# Approximate line from y position
vis_row_f = (py - gy - 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))
# Approximate column from x position
local_x = px - gx - approx_gutter - 6.0 # _PADDING_LEFT
char_w = self.font_size * 0.6
col = max(0, int(local_x / char_w)) if char_w > 0 else 0
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()
super()._on_gui_input(event)
# Invalidate cache on character input
if event.char and len(event.char) == 1 and not self.read_only:
self._invalidate_cache(self._cursor_line)
# ================================================================
# Tab handling
# ================================================================
def _indent_or_insert_tab(self):
"""Insert 4 spaces at cursor, or indent selected lines."""
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):
"""Remove up to 4 leading spaces from the current or selected lines."""
if self._has_selection():
(sl, _), (el, _) = self._ordered_selection()
else:
sl = el = self._cursor_line
any_changed = False
for li in range(sl, el + 1):
line = self._lines[li]
# Count leading spaces (up to tab_size)
remove_count = 0
for ci in range(min(self.tab_size, len(line))):
if line[ci] == " ":
remove_count += 1
else:
break
if remove_count > 0:
self._lines[li] = line[remove_count:]
any_changed = True
# Adjust cursor if on this line
if li == self._cursor_line:
self._cursor_col = max(0, self._cursor_col - remove_count)
if any_changed:
self._invalidate_cache(sl)
self.text_changed.emit(self.text)
# ================================================================
# 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 process(self, dt: float):
"""Update cursor blink timers and word highlighting."""
super().process(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 draw(self, renderer):
x, y, w, h = self.get_global_rect()
scale = self._font_scale()
lh = self._line_height()
gutter_w = self._gutter_width(renderer)
content_x = x + gutter_w + 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, renderer)
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, renderer)
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.draw_filled_rect(x, y, w, h, self.bg_colour)
# 2. Border
border = self.focus_colour if self.focused else self.border_colour
renderer.draw_rect_coloured(x, y, w, h, border)
# 3. Line numbers gutter
if self.show_line_numbers and gutter_w > 0:
renderer.draw_filled_rect(x, y + find_bar_offset, gutter_w, h - find_bar_offset, self.gutter_bg_colour)
renderer.draw_line_coloured(x + gutter_w, y + find_bar_offset, x + gutter_w, y + h, 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.draw_filled_rect(x + gutter_w, cur_y, w - gutter_w - 12.0, lh, self.current_line_colour)
elif first_visible <= self._cursor_line < last_visible:
cur_y = self._line_y(self._cursor_line) + find_bar_offset
renderer.draw_filled_rect(x + gutter_w, cur_y, w - gutter_w - 12.0, lh, 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.draw_filled_rect(bm_x, bm_y, bm_ch_w, lh, 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.draw_filled_rect(hw_x, hw_y, hw_w, lh, 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.draw_line_coloured(gx, guide_y, gx, guide_y + lh, 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.draw_text_coloured(num_str, num_x, num_y, scale, colour)
# 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.draw_text_coloured(">", fold_x, fold_y, scale * 0.85, fold_gutter_colour)
elif self._is_foldable(li):
renderer.draw_text_coloured("v", fold_x, fold_y, scale * 0.85, fold_gutter_colour)
# 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.draw_text_coloured(token_text, tx, text_y, scale, colour)
tx += renderer.text_width(token_text, scale)
# Show fold indicator after line text
if li in self._folded_regions:
renderer.draw_text_coloured(" ...", tx, text_y, scale, (0.5, 0.5, 0.5, 0.7))
# 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_screen_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.draw_line_coloured(cur_x, cursor_top, cur_x, cursor_bot, 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.draw_filled_rect(tx_s, ty_s, tw_s, th_s, 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.draw_filled_rect(sx_s, sy_s, sw_s, sh_s, 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.draw_filled_rect(sel_x1, line_top, sel_x2 - sel_x1, lh, 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.draw_filled_rect(
sel_x1, line_top, max(sel_x2 - sel_x1, self.font_size * 0.5), lh, self.selection_colour
)
elif li == el:
sel_x2 = content_x + renderer.text_width(line_text[:ec], scale)
renderer.draw_filled_rect(content_x, line_top, sel_x2 - content_x, lh, self.selection_colour)
else:
full_w = renderer.text_width(line_text, scale)
renderer.draw_filled_rect(
content_x, line_top, max(full_w, self.font_size * 0.5), lh, self.selection_colour
)
# ================================================================
# Text property override for cache invalidation
# ================================================================
@property
def text(self) -> str:
"""Get full text content."""
return "\n".join(self._lines)
@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