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