Source code for magscope.ui.search

from __future__ import annotations

from dataclasses import dataclass, field
from difflib import SequenceMatcher

from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import QWidget

from magscope.ui.theme import get_accent_color


[docs] def normalize_search_text(text: str) -> str: return " ".join(text.casefold().replace("-", " ").replace("_", " ").split())
@dataclass(frozen=True)
[docs] class SearchTarget:
[docs] label: str
[docs] aliases: tuple[str, ...] = ()
[docs] context: str = ""
[docs] description: str = ""
[docs] keywords: tuple[str, ...] = ()
[docs] guide_only: bool = True
@property
[docs] def display_label(self) -> str: return f"{self.label} - {self.context}" if self.context else self.label
@property
[docs] def search_values(self) -> tuple[str, ...]: return (self.label, self.display_label, *self.aliases, *self.keywords)
@dataclass(frozen=True)
[docs] class PanelControlTarget(SearchTarget):
[docs] panel_id: str = ""
[docs] widget_path: tuple[str, ...] = ()
@dataclass(frozen=True)
[docs] class PreferencesSettingTarget(SearchTarget):
[docs] setting_key: str = ""
[docs] tab_name: str = "MagScope"
@dataclass(frozen=True)
[docs] class PreferencesWidgetTarget(SearchTarget):
[docs] tab_name: str = ""
[docs] widget_attr: str = ""
@dataclass(frozen=True) @dataclass(frozen=True)
[docs] class SearchMatch:
[docs] target: SearchTarget
[docs] rank: int
[docs] score: float = field(default=0.0)
[docs] class SearchRegistry:
[docs] RANK_EXACT_LABEL = 0
[docs] RANK_EXACT_DISPLAY = 1
[docs] RANK_EXACT_ALIAS = 2
[docs] RANK_PREFIX = 3
[docs] RANK_CONTAINS = 4
[docs] RANK_FUZZY = 5
[docs] RANK_EMPTY_QUERY = 10
def __init__(self, targets: list[SearchTarget] | None = None) -> None:
[docs] self._targets: list[SearchTarget] = list(targets or [])
@property
[docs] def targets(self) -> list[SearchTarget]: return list(self._targets)
[docs] def clear(self) -> None: self._targets.clear()
[docs] def register(self, target: SearchTarget) -> None: if target.display_label in {existing.display_label for existing in self._targets}: return self._targets.append(target)
[docs] def register_many(self, targets: list[SearchTarget] | tuple[SearchTarget, ...]) -> None: for target in targets: self.register(target)
[docs] def matches(self, text: str) -> list[SearchMatch]: query = normalize_search_text(text) if not query: return [SearchMatch(target, self.RANK_EMPTY_QUERY, 0.0) for target in self._targets] query_terms = query.split() matches: list[SearchMatch] = [] fuzzy_matches: list[SearchMatch] = [] for target in self._targets: normalized_label = normalize_search_text(target.label) normalized_display = normalize_search_text(target.display_label) normalized_aliases = [normalize_search_text(alias) for alias in target.aliases] normalized_keywords = [normalize_search_text(keyword) for keyword in target.keywords] normalized_values = [ normalized_label, normalized_display, *normalized_aliases, *normalized_keywords, ] if normalized_label == query: matches.append(SearchMatch(target, self.RANK_EXACT_LABEL, 1.0)) continue if normalized_display == query: matches.append(SearchMatch(target, self.RANK_EXACT_DISPLAY, 1.0)) continue if any(alias == query for alias in normalized_aliases): matches.append(SearchMatch(target, self.RANK_EXACT_ALIAS, 1.0)) continue if any(value.startswith(query) for value in normalized_values): matches.append(SearchMatch(target, self.RANK_PREFIX, 0.9)) continue if any(query in value or all(term in value for term in query_terms) for value in normalized_values): matches.append(SearchMatch(target, self.RANK_CONTAINS, 0.75)) continue fuzzy_score = max( (SequenceMatcher(None, query, value).ratio() for value in normalized_values), default=0.0, ) if fuzzy_score >= 0.68: fuzzy_matches.append(SearchMatch(target, self.RANK_FUZZY, fuzzy_score)) matches_to_sort = matches if matches else fuzzy_matches return sorted(matches_to_sort, key=lambda match: (match.rank, -match.score, match.target.display_label))
[docs] def labels(self, text: str, *, limit: int = 20) -> list[str]: labels: list[str] = [] for match in self.matches(text): label = match.target.display_label if label not in labels: labels.append(label) if len(labels) >= limit: break return labels
[docs] def best(self, text: str) -> SearchTarget | None: if not normalize_search_text(text): return None matches = self.matches(text) return matches[0].target if matches else None
[docs] class SearchHighlighter: def __init__(self) -> None:
[docs] self._original_styles: dict[QWidget, str] = {}
[docs] def clear(self) -> None: for widget, style in list(self._original_styles.items()): try: widget.setStyleSheet(style) except RuntimeError: pass self._original_styles.clear()
[docs] def highlight(self, widget: QWidget, *, duration_ms: int = 2500) -> None: self.clear() self._original_styles[widget] = widget.styleSheet() widget.setStyleSheet( f"border: 2px solid {get_accent_color()}; border-radius: 4px; padding: 2px;" ) QTimer.singleShot(duration_ms, lambda w=widget: self.clear_widget(w))
[docs] def clear_widget(self, widget: QWidget) -> None: original_style = self._original_styles.pop(widget, None) if original_style is None: return try: widget.setStyleSheet(original_style) except RuntimeError: pass