Source code for simvx.ide.panels.search_panel

"""Search panel -- project-wide text search with regex support."""


from __future__ import annotations

import logging
import os
import re
from pathlib import Path
from typing import TYPE_CHECKING

from simvx.core import Signal
from simvx.core.math.types import Vec2
from simvx.core.ui.core import Control
from simvx.core.ui.theme import get_theme
from simvx.core.ui.widgets import Button, TextEdit

if TYPE_CHECKING:
    from ..state import IDEState

log = logging.getLogger(__name__)

_HEADER_H = 28.0
_INPUT_H = 26.0
_TOOLBAR_H = 26.0
_ROW_H = 20.0
_MAX_RESULTS = 2000

_SEARCH_EXTENSIONS = {
    ".py", ".json", ".toml", ".yaml", ".yml", ".md", ".html", ".css",
    ".js", ".ts", ".glsl", ".rs", ".go", ".c", ".cpp", ".h", ".hpp",
    ".sh", ".bash", ".txt", ".cfg", ".ini", ".xml",
}
_SKIP_DIRS = {".git", "__pycache__", ".venv", "node_modules", "build", "dist", ".egg-info", ".mypy_cache"}


[docs] class SearchPanel(Control): """Project-wide search panel with regex and case-sensitivity toggles.""" def __init__(self, state: IDEState, **kwargs): super().__init__(**kwargs) self.name = "Search" self._state = state theme = get_theme() self.result_selected = Signal() # Search input self._input = TextEdit(placeholder="Search...") self._input.size = Vec2(200, _INPUT_H) self._input.font_size = 12.0 self._input.bg_colour = theme.bg_input self._input.border_colour = theme.border self._input.text_submitted.connect(self._do_search) self.add_child(self._input) # Case sensitive toggle self._btn_case = Button("Aa") self._btn_case.size = Vec2(28, 22) self._btn_case.font_size = 11.0 self._btn_case.bg_colour = theme.btn_bg self._btn_case.hover_colour = theme.btn_hover self._btn_case.border_colour = theme.border_light self._btn_case.text_colour = theme.text_dim self._btn_case.pressed.connect(self._toggle_case) self.add_child(self._btn_case) # Regex toggle self._btn_regex = Button(".*") self._btn_regex.size = Vec2(28, 22) self._btn_regex.font_size = 11.0 self._btn_regex.bg_colour = theme.btn_bg self._btn_regex.hover_colour = theme.btn_hover self._btn_regex.border_colour = theme.border_light self._btn_regex.text_colour = theme.text_dim self._btn_regex.pressed.connect(self._toggle_regex) self.add_child(self._btn_regex) # Whole word toggle self._btn_word = Button("W") self._btn_word.size = Vec2(28, 22) self._btn_word.font_size = 11.0 self._btn_word.bg_colour = theme.btn_bg self._btn_word.hover_colour = theme.btn_hover self._btn_word.border_colour = theme.border_light self._btn_word.text_colour = theme.text_dim self._btn_word.pressed.connect(self._toggle_word) self.add_child(self._btn_word) # State self._case_sensitive = False self._use_regex = False self._whole_word = False self._results: list[tuple[str, int, str]] = [] # (path, line_num, line_text) self._selected_index: int = -1 self._scroll_y: float = 0.0 self._result_count_text: str = ""
[docs] def refresh_theme(self): """Re-apply theme colours after a theme change.""" theme = get_theme() self._input.bg_colour = theme.bg_input self._input.border_colour = theme.border for btn in (self._btn_case, self._btn_regex, self._btn_word): btn.bg_colour = theme.btn_bg btn.hover_colour = theme.btn_hover btn.border_colour = theme.border_light # Restore toggle indicator colours self._btn_case.text_colour = theme.warning if self._case_sensitive else theme.text_dim self._btn_regex.text_colour = theme.warning if self._use_regex else theme.text_dim self._btn_word.text_colour = theme.warning if self._whole_word else theme.text_dim
# -- Internal -------------------------------------------------------------- def _toggle_case(self): self._case_sensitive = not self._case_sensitive theme = get_theme() self._btn_case.text_colour = theme.warning if self._case_sensitive else theme.text_dim if self._input.text: self._do_search(self._input.text) def _toggle_regex(self): self._use_regex = not self._use_regex theme = get_theme() self._btn_regex.text_colour = theme.warning if self._use_regex else theme.text_dim if self._input.text: self._do_search(self._input.text) def _toggle_word(self): self._whole_word = not self._whole_word theme = get_theme() self._btn_word.text_colour = theme.warning if self._whole_word else theme.text_dim if self._input.text: self._do_search(self._input.text) def _do_search(self, query: str): """Run the search across project files.""" self._results.clear() self._selected_index = -1 self._scroll_y = 0.0 if not query or not self._state.project_root: self._result_count_text = "" return # Build regex pattern try: if self._use_regex: pat = r"\b" + query + r"\b" if self._whole_word else query flags = 0 if self._case_sensitive else re.IGNORECASE pattern = re.compile(pat, flags) else: escaped = re.escape(query) if self._whole_word: escaped = r"\b" + escaped + r"\b" flags = 0 if self._case_sensitive else re.IGNORECASE pattern = re.compile(escaped, flags) except re.error: self._result_count_text = "Invalid regex" return root = self._state.project_root count = 0 for dirpath, dirnames, filenames in os.walk(root): # Filter directories in-place dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS and not d.endswith(".egg-info")] for fname in filenames: if count >= _MAX_RESULTS: break suffix = Path(fname).suffix.lower() if suffix not in _SEARCH_EXTENSIONS: continue filepath = str(Path(dirpath) / fname) try: with open(filepath, encoding="utf-8", errors="replace") as f: for line_num, line in enumerate(f, start=1): if count >= _MAX_RESULTS: break if pattern.search(line): text = line.rstrip("\n\r") if len(text) > 200: text = text[:197] + "..." self._results.append((filepath, line_num, text)) count += 1 except OSError: continue if count >= _MAX_RESULTS: break suffix = " (limit reached)" if count >= _MAX_RESULTS else "" self._result_count_text = f"{count} results{suffix}" def _on_gui_input(self, event): if event.key == "scroll_up": self._scroll_y = max(0, self._scroll_y - 20) return if event.key == "scroll_down": _, _, _, h = self.get_rect() content_h = _HEADER_H + _INPUT_H + _TOOLBAR_H + 8 max_scroll = max(0, len(self._results) * _ROW_H - (h - content_h)) self._scroll_y = min(max_scroll, self._scroll_y + 20) return if event.button == 1 and event.pressed: _, y, _, _ = self.get_global_rect() py = event.position.y if hasattr(event.position, "y") else event.position[1] content_top = y + _HEADER_H + _INPUT_H + _TOOLBAR_H + 8 if py < content_top: return row = int((py - content_top + self._scroll_y) / _ROW_H) if 0 <= row < len(self._results): self._selected_index = row path, line_num, _ = self._results[row] self.result_selected.emit(path, line_num) self._state.goto_requested.emit(path, line_num - 1, 0) # -- Layout / Draw ---------------------------------------------------------
[docs] def process(self, dt: float): _, _, w, h = self.get_rect() # Auto-focus search input when panel becomes visible with valid dimensions if self.visible and not self._input.focused and w > 50: self._input.set_focus() # Search input input_y = _HEADER_H + 4 btn_total_w = 28 * 3 + 8 * 2 # three buttons + two gaps self._input.position = Vec2(4, input_y) self._input.size = Vec2(w - btn_total_w - 16, _INPUT_H) # Toggle buttons: case | regex | word self._btn_case.position = Vec2(w - btn_total_w - 4, input_y + 2) self._btn_regex.position = Vec2(w - 28 * 2 - 8 - 4, input_y + 2) self._btn_word.position = Vec2(w - 28 - 4, input_y + 2)
[docs] def draw(self, renderer): theme = get_theme() x, y, w, h = self.get_global_rect() renderer.draw_filled_rect(x, y, w, h, theme.panel_bg) scale = 12.0 / 14.0 small_scale = scale * 0.85 # Header renderer.draw_filled_rect(x, y, w, _HEADER_H, theme.header_bg) renderer.draw_text_coloured("Search", x + 8, y + (_HEADER_H - 12) / 2, scale, theme.text) renderer.draw_line_coloured(x, y + _HEADER_H, x + w, y + _HEADER_H, theme.border) # Result count / status below toolbar area toolbar_y = y + _HEADER_H + _INPUT_H + 6 if self._result_count_text: renderer.draw_text_coloured(self._result_count_text, x + 8, toolbar_y + 4, small_scale, theme.text_dim) # Results list content_top = toolbar_y + _TOOLBAR_H content_h = max(0, y + h - content_top) renderer.push_clip(x, content_top, w, content_h) if not self._results and self._input.text: renderer.draw_text_coloured("No results found", x + 8, content_top + 8, scale, theme.text_dim) elif not self._results: renderer.draw_text_coloured("Enter a search query and press Enter", x + 8, content_top + 8, scale, theme.text_dim) for i, (path, line_num, text) in enumerate(self._results): ry = content_top + i * _ROW_H - self._scroll_y if ry + _ROW_H < content_top or ry > content_top + content_h: continue # Selection highlight if i == self._selected_index: renderer.draw_filled_rect(x, ry, w, _ROW_H, theme.selection) # File path and line number short = self._state.relative_path(path) loc = f"{short}:{line_num}" renderer.draw_text_coloured(loc, x + 4, ry + 2, small_scale, theme.warning) # Matching text (truncated) loc_w = renderer.text_width(loc, small_scale) + 12 avail = w - loc_w - 8 trimmed = text.strip() if renderer.text_width(trimmed, small_scale) > avail: while trimmed and renderer.text_width(trimmed + "...", small_scale) > avail: trimmed = trimmed[:-1] trimmed += "..." renderer.draw_text_coloured(trimmed, x + loc_w, ry + 2, small_scale, theme.text) renderer.pop_clip()