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