Source code for simvx.core.ui.code_edit

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