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

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