Source code for simvx.core.ui.find_replace

"""Find and replace controller for the code editor widget.

Manages find bar state, search execution, match navigation,
replace operations, keyboard/mouse routing, and bar rendering.
"""

from __future__ import annotations

import re

from .core import Colour

# Find bar height constant (shared with code_edit draw logic)
_FIND_BAR_HEIGHT = 28.0


[docs] class FindReplaceMixin: """Mixin providing find/replace functionality for a code editor. Expects the host class to have: - ``_lines: list[str]`` - ``_cursor_line: int``, ``_cursor_col: int`` - ``_select_start``, ``_select_end`` - ``_has_selection()``, ``_get_selection_text()`` - ``_ensure_cursor_visible()`` - ``font_size: float`` - ``focused: bool`` - ``queue_redraw()`` - ``text_changed`` signal - ``find_opened`` / ``find_closed`` signals - ``_invalidate_cache(line)`` - ``get_global_rect()`` - ``get_theme()`` - ``text`` property """ def _init_find_replace(self): """Initialise find/replace state. Call from ``__init__``.""" self._find_active: bool = False self._find_text: str = "" self._find_matches: list[tuple[int, int, int]] = [] # (line, col_start, col_end) self._find_index: int = 0 self._find_cursor_blink: float = 0.0 # Replace bar state self._replace_active: bool = False self._replace_text: str = "" # Find options self._find_regex: bool = False self._find_case_sensitive: bool = False self._find_whole_word: bool = False # Which field has focus: "find" or "replace" self._find_focus: str = "find" # ================================================================ # Find bar toggle # ================================================================ def _toggle_find_bar(self): """Open or close the find bar.""" if self._find_active: self._close_find_bar() else: self._open_find_bar() def _toggle_replace_bar(self): """Open find bar with replace row, or close if replace is already active.""" if self._replace_active: self._close_find_bar() else: self._open_find_bar() self._replace_active = True def _open_find_bar(self): """Show the find bar.""" self._find_active = True self._find_text = "" self._find_matches = [] self._find_index = 0 self._find_cursor_blink = 0.0 self._replace_active = False self._find_focus = "find" # If there is selected text, use it as the initial search term if self._has_selection(): sel_text = self._get_selection_text() if "\n" not in sel_text: self._find_text = sel_text self._perform_find() self.find_opened.emit() def _close_find_bar(self): """Hide the find bar and replace bar.""" self._find_active = False self._find_text = "" self._find_matches = [] self._replace_active = False self._replace_text = "" self.find_closed.emit() # ================================================================ # Search logic # ================================================================ def _perform_find(self): """Search all lines for the current find text, respecting regex/case/word options.""" self._find_matches = [] if not self._find_text: return try: if self._find_regex: flags = 0 if self._find_case_sensitive else re.IGNORECASE pattern = self._find_text if self._find_whole_word: pattern = r"\b" + pattern + r"\b" regex = re.compile(pattern, flags) for li, line in enumerate(self._lines): for m in regex.finditer(line): self._find_matches.append((li, m.start(), m.end())) else: query = self._find_text if self._find_case_sensitive else self._find_text.lower() for li, line in enumerate(self._lines): search_line = line if self._find_case_sensitive else line.lower() start = 0 while True: idx = search_line.find(query, start) if idx == -1: break end = idx + len(query) if self._find_whole_word: before_ok = idx == 0 or not (line[idx - 1].isalnum() or line[idx - 1] == "_") after_ok = end >= len(line) or not (line[end].isalnum() or line[end] == "_") if not (before_ok and after_ok): start = idx + 1 continue self._find_matches.append((li, idx, end)) start = idx + 1 except re.error: self._find_matches = [] # Reset index if self._find_matches: best = 0 for mi, (ml, mc, _) in enumerate(self._find_matches): if (ml, mc) >= (self._cursor_line, self._cursor_col): best = mi break self._find_index = best def _find_next(self): """Jump to the next find match.""" if not self._find_matches: return self._find_index = (self._find_index + 1) % len(self._find_matches) ml, mc, me = self._find_matches[self._find_index] self._cursor_line = ml self._cursor_col = mc self._select_start = (ml, mc) self._select_end = (ml, me) self._ensure_cursor_visible() def _find_prev(self): """Jump to the previous find match.""" if not self._find_matches: return self._find_index = (self._find_index - 1) % len(self._find_matches) ml, mc, me = self._find_matches[self._find_index] self._cursor_line = ml self._cursor_col = mc self._select_start = (ml, mc) self._select_end = (ml, me) self._ensure_cursor_visible()
[docs] def replace_current(self): """Replace current match and advance to next.""" if not self._find_matches or self._find_index >= len(self._find_matches): return ml, mc, me = self._find_matches[self._find_index] line = self._lines[ml] self._lines[ml] = line[:mc] + self._replace_text + line[me:] self._invalidate_cache(ml) self.text_changed.emit(self.text) self._perform_find() if self._find_matches: self._find_index = min(self._find_index, len(self._find_matches) - 1)
[docs] def replace_all(self): """Replace all matches (iterate in reverse to preserve positions).""" if not self._find_matches: return for ml, mc, me in reversed(self._find_matches): line = self._lines[ml] self._lines[ml] = line[:mc] + self._replace_text + line[me:] self._invalidate_cache(0) self.text_changed.emit(self.text) self._perform_find()
# ================================================================ # Key / char / click routing # ================================================================ def _handle_find_key(self, key: str): """Process key events directed at the find/replace bar.""" if key == "escape": self._close_find_bar() elif key == "enter": if self._find_focus == "find": self._find_next() else: self.replace_current() elif key == "tab": if self._replace_active: self._find_focus = "replace" if self._find_focus == "find" else "find" elif key == "backspace": if self._find_focus == "find": if self._find_text: self._find_text = self._find_text[:-1] self._perform_find() else: if self._replace_text: self._replace_text = self._replace_text[:-1] def _handle_find_char(self, ch: str): """Process character input for the find/replace bar.""" if self._find_focus == "find": self._find_text += ch self._perform_find() else: self._replace_text += ch def _handle_find_bar_click(self, position) -> bool: """Handle mouse clicks on find/replace bar toggle buttons. Returns True if consumed.""" px = position.x if hasattr(position, "x") else position[0] py = position.y if hasattr(position, "y") else position[1] x, y, w, _ = self.get_global_rect() bar_h = _FIND_BAR_HEIGHT * (2 if self._replace_active else 1) # Only handle clicks within the bar area if py < y or py > y + bar_h: return False scale = self.font_size / 14.0 label_w = 35.0 * scale input_w = min(300.0, w - label_w - 120) count_w = 80.0 * scale btn_x = x + label_w + 16 + input_w + 8 + count_w + 8 btn_w = 24.0 btn_h = 18.0 btn_y = y + (_FIND_BAR_HEIGHT - btn_h) / 2 # [.*] regex toggle if btn_x <= px <= btn_x + btn_w and btn_y <= py <= btn_y + btn_h: self._find_regex = not self._find_regex self._perform_find() return True # [Aa] case toggle btn2_x = btn_x + btn_w + 4 if btn2_x <= px <= btn2_x + btn_w and btn_y <= py <= btn_y + btn_h: self._find_case_sensitive = not self._find_case_sensitive self._perform_find() return True # [W] whole word toggle btn3_x = btn2_x + btn_w + 4 if btn3_x <= px <= btn3_x + btn_w and btn_y <= py <= btn_y + btn_h: self._find_whole_word = not self._find_whole_word self._perform_find() return True # Replace row buttons (only when replace is active) if self._replace_active and py > y + _FIND_BAR_HEIGHT: repl_label_w = 55.0 * scale repl_input_x = x + repl_label_w + 16 repl_input_w = min(300.0, w - repl_label_w - 120) repl_btn_x = repl_input_x + repl_input_w + 8 repl_btn_w = 60.0 repl_btn_h = 18.0 repl_btn_y = y + _FIND_BAR_HEIGHT + (_FIND_BAR_HEIGHT - repl_btn_h) / 2 if repl_btn_x <= px <= repl_btn_x + repl_btn_w and repl_btn_y <= py <= repl_btn_y + repl_btn_h: self.replace_current() return True ra_x = repl_btn_x + repl_btn_w + 8 ra_w = 72.0 if ra_x <= px <= ra_x + ra_w and repl_btn_y <= py <= repl_btn_y + repl_btn_h: self.replace_all() return True if repl_input_x <= px <= repl_input_x + repl_input_w: self._find_focus = "replace" return True # Click on find input field -> focus find find_input_x = x + label_w + 16 if find_input_x <= px <= find_input_x + input_w and py <= y + _FIND_BAR_HEIGHT: self._find_focus = "find" return True return py <= y + bar_h # ================================================================ # Drawing # ================================================================ def _draw_find_highlights(self, renderer, content_x, scale, lh, first_visible, last_visible, y_offset): """Draw highlight rectangles for find matches.""" highlight_colour = (0.6, 0.5, 0.1, 0.35) active_colour = (0.8, 0.6, 0.0, 0.55) for mi, (ml, mc, me) in enumerate(self._find_matches): if ml < first_visible or ml >= last_visible: continue line_text = self._lines[ml] match_x = content_x + renderer.text_width(line_text[:mc], scale) match_w = renderer.text_width(line_text[mc:me], scale) match_y = self._line_y(ml) + y_offset colour = active_colour if mi == self._find_index else highlight_colour renderer.draw_filled_rect(match_x, match_y, match_w, lh, colour) def _draw_find_bar(self, renderer, x, y, w): """Draw the find bar (and optional replace row) at the top of the editor.""" bar_bg = (0.14, 0.14, 0.16, 1.0) bar_border = (0.32, 0.32, 0.37, 1.0) row_h = _FIND_BAR_HEIGHT bar_h = row_h * (2 if self._replace_active else 1) scale = self.font_size / 14.0 find_focus_border = (0.5, 0.7, 1.0, 1.0) unfocused_border = (0.38, 0.38, 0.48, 1.0) # Background renderer.draw_filled_rect(x, y, w, bar_h, bar_bg) renderer.draw_line_coloured(x, y + bar_h, x + w, y + bar_h, bar_border) # --- Find row --- label_text = "Find:" renderer.draw_text_coloured(label_text, x + 8, y + (row_h - self.font_size) / 2, scale, (0.68, 0.68, 0.70, 1.0)) label_w = renderer.text_width(label_text, scale) input_x = x + label_w + 16 input_w = min(300.0, w - label_w - 120) input_h = row_h - 6 input_y = y + 3 input_border = find_focus_border if self._find_focus == "find" else unfocused_border renderer.draw_filled_rect(input_x, input_y, input_w, input_h, (0.07, 0.07, 0.09, 1.0)) renderer.draw_rect_coloured(input_x, input_y, input_w, input_h, input_border) if self._find_text: renderer.draw_text_coloured( self._find_text, input_x + 4, input_y + (input_h - self.font_size) / 2, scale, (1.0, 1.0, 1.0, 1.0) ) # Cursor in find field if self._find_focus == "find" and self._find_cursor_blink < 0.5 and self.focused: cursor_x = input_x + 4 + renderer.text_width(self._find_text, scale) renderer.draw_line_coloured(cursor_x, input_y + 3, cursor_x, input_y + input_h - 3, Colour.WHITE) # Match count count_x = input_x + input_w + 8 if self._find_text: count_text = f"{len(self._find_matches)} matches" if self._find_matches: count_text = f"{self._find_index + 1}/{len(self._find_matches)}" renderer.draw_text_coloured( count_text, count_x, y + (row_h - self.font_size) / 2, scale, (0.56, 0.56, 0.58, 1.0) ) count_x += renderer.text_width(count_text, scale) + 8 else: count_x += 8 # Toggle buttons: [.*] [Aa] [W] btn_w = 24.0 btn_h = 18.0 btn_y = y + (row_h - btn_h) / 2 btn_on_bg = (0.28, 0.38, 0.58, 1.0) btn_off_bg = (0.11, 0.11, 0.14, 1.0) btn_text_colour = (0.86, 0.86, 0.88, 1.0) btn_scale = scale * 0.85 toggle_items = [(".*", self._find_regex), ("Aa", self._find_case_sensitive), ("W", self._find_whole_word)] for label, active in toggle_items: bg = btn_on_bg if active else btn_off_bg renderer.draw_filled_rect(count_x, btn_y, btn_w, btn_h, bg) renderer.draw_rect_coloured(count_x, btn_y, btn_w, btn_h, (0.48, 0.48, 0.52, 0.6)) tw = renderer.text_width(label, btn_scale) renderer.draw_text_coloured( label, count_x + (btn_w - tw) / 2, btn_y + (btn_h - self.font_size * 0.85) / 2, btn_scale, btn_text_colour, ) count_x += btn_w + 4 # --- Replace row --- if self._replace_active: ry = y + row_h repl_label = "Replace:" renderer.draw_text_coloured( repl_label, x + 8, ry + (row_h - self.font_size) / 2, scale, (0.68, 0.68, 0.70, 1.0) ) repl_label_w = renderer.text_width(repl_label, scale) repl_input_x = x + repl_label_w + 16 repl_input_w = min(300.0, w - repl_label_w - 120) repl_input_h = row_h - 6 repl_input_y = ry + 3 repl_border = find_focus_border if self._find_focus == "replace" else unfocused_border renderer.draw_filled_rect(repl_input_x, repl_input_y, repl_input_w, repl_input_h, (0.07, 0.07, 0.09, 1.0)) renderer.draw_rect_coloured(repl_input_x, repl_input_y, repl_input_w, repl_input_h, repl_border) if self._replace_text: renderer.draw_text_coloured( self._replace_text, repl_input_x + 4, repl_input_y + (repl_input_h - self.font_size) / 2, scale, (1.0, 1.0, 1.0, 1.0), ) # Cursor in replace field if self._find_focus == "replace" and self._find_cursor_blink < 0.5 and self.focused: cursor_x = repl_input_x + 4 + renderer.text_width(self._replace_text, scale) renderer.draw_line_coloured( cursor_x, repl_input_y + 3, cursor_x, repl_input_y + repl_input_h - 3, Colour.WHITE ) # Replace / Replace All buttons repl_btn_x = repl_input_x + repl_input_w + 8 repl_btn_h = 18.0 repl_btn_y = ry + (row_h - repl_btn_h) / 2 for label, bw in [("Replace", 60.0), ("All", 36.0)]: renderer.draw_filled_rect(repl_btn_x, repl_btn_y, bw, repl_btn_h, (0.20, 0.20, 0.24, 1.0)) renderer.draw_rect_coloured(repl_btn_x, repl_btn_y, bw, repl_btn_h, (0.48, 0.48, 0.52, 0.6)) tw = renderer.text_width(label, btn_scale) renderer.draw_text_coloured( label, repl_btn_x + (bw - tw) / 2, repl_btn_y + (repl_btn_h - self.font_size * 0.85) / 2, btn_scale, btn_text_colour, ) repl_btn_x += bw + 8