Coverage for src / ezqt_widgets / widgets / input / search_input.py: 65.81%
117 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-01 22:46 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-01 22:46 +0000
1# ///////////////////////////////////////////////////////////////
2# SEARCH_INPUT - Search Input Widget
3# Project: ezqt_widgets
4# ///////////////////////////////////////////////////////////////
6"""
7Search input widget module.
9Provides a QLineEdit subclass for search input with integrated history
10and optional search icon for PySide6 applications.
11"""
13from __future__ import annotations
15# ///////////////////////////////////////////////////////////////
16# IMPORTS
17# ///////////////////////////////////////////////////////////////
18# Standard library imports
19from typing import Any
21# Third-party imports
22from PySide6.QtCore import Qt, Signal
23from PySide6.QtGui import QIcon, QKeyEvent, QPixmap
24from PySide6.QtWidgets import QLineEdit
26from ...types import IconSourceExtended, WidgetParent
28# Local imports
29from ..misc.theme_icon import ThemeIcon
31# ///////////////////////////////////////////////////////////////
32# CLASSES
33# ///////////////////////////////////////////////////////////////
36class SearchInput(QLineEdit):
37 """QLineEdit subclass for search input with integrated history.
39 Features:
40 - Maintains a history of submitted searches
41 - Navigate history with up/down arrows
42 - Emits a searchSubmitted(str) signal on validation (Enter)
43 - Optional search icon (left or right)
44 - Optional clear button
46 Args:
47 parent: The parent widget (default: None).
48 max_history: Maximum number of history entries to keep
49 (default: 20).
50 search_icon: Icon to display as search icon
51 (ThemeIcon, QIcon, QPixmap, str, or None, default: None).
52 icon_position: Icon position, 'left' or 'right' (default: 'left').
53 clear_button: Whether to show a clear button (default: True).
54 *args: Additional arguments passed to QLineEdit.
55 **kwargs: Additional keyword arguments passed to QLineEdit.
57 Properties:
58 search_icon: Get or set the search icon.
59 icon_position: Get or set the icon position ('left' or 'right').
60 clear_button: Get or set whether the clear button is shown.
61 max_history: Get or set the maximum history size.
63 Signals:
64 searchSubmitted(str): Emitted when a search is submitted (Enter key).
66 Example:
67 >>> from ezqt_widgets import SearchInput
68 >>> search = SearchInput(max_history=10, clear_button=True)
69 >>> search.searchSubmitted.connect(lambda q: print(f"Search: {q}"))
70 >>> search.setPlaceholderText("Type and press Enter...")
71 >>> search.show()
72 """
74 searchSubmitted = Signal(str)
76 # ///////////////////////////////////////////////////////////////
77 # INIT
78 # ///////////////////////////////////////////////////////////////
80 def __init__(
81 self,
82 parent: WidgetParent = None,
83 max_history: int = 20,
84 search_icon: IconSourceExtended = None,
85 icon_position: str = "left",
86 clear_button: bool = True,
87 *args: Any,
88 **kwargs: Any,
89 ) -> None:
90 """Initialize the search input."""
91 super().__init__(parent, *args, **kwargs)
93 # Initialize properties
94 self._search_icon: QIcon | None = None
95 self._icon_position: str = icon_position
96 self._clear_button: bool = clear_button
97 self._history: list[str] = []
98 self._history_index: int = -1
99 self._max_history: int = max_history
100 self._current_text: str = ""
102 # Setup UI
103 self._setup_ui()
105 # Set icon if provided
106 if search_icon:
107 # Setter accepts ThemeIcon | QIcon | QPixmap | str | None, but mypy sees return type
108 self.search_icon = search_icon
110 # ------------------------------------------------
111 # PRIVATE METHODS
112 # ------------------------------------------------
114 def _setup_ui(self) -> None:
115 """Setup the user interface components."""
116 self.setPlaceholderText("Search...")
117 self.setClearButtonEnabled(self._clear_button)
119 # ///////////////////////////////////////////////////////////////
120 # PROPERTIES
121 # ///////////////////////////////////////////////////////////////
123 @property
124 def search_icon(self) -> QIcon | None:
125 """Get the search icon.
127 Returns:
128 The current search icon, or None if not set.
129 """
130 return self._search_icon
132 @search_icon.setter
133 def search_icon(self, value: IconSourceExtended) -> None:
134 """Set the search icon.
136 Args:
137 value: The icon source (ThemeIcon, QIcon, QPixmap, path string, or None).
138 """
139 if isinstance(value, str):
140 # Load icon from path
141 icon = QIcon(value)
142 elif isinstance(value, QPixmap): 142 ↛ 143line 142 didn't jump to line 143 because the condition on line 142 was never true
143 icon = QIcon(value)
144 else:
145 icon = value
147 self._search_icon = ThemeIcon.from_source(icon)
149 # Update display
150 if self._search_icon:
151 self.setStyleSheet(f"""
152 QLineEdit {{
153 padding-{self._icon_position}: 20px;
154 }}
155 """)
156 else:
157 self.setStyleSheet("")
159 @property
160 def icon_position(self) -> str:
161 """Get the icon position.
163 Returns:
164 The current icon position ('left' or 'right').
165 """
166 return self._icon_position
168 @icon_position.setter
169 def icon_position(self, value: str) -> None:
170 """Set the icon position.
172 Args:
173 value: The icon position ('left' or 'right').
174 """
175 if value in ["left", "right"]:
176 self._icon_position = value
177 # Update icon display
178 if self._search_icon:
179 self.search_icon = self._search_icon
181 @property
182 def clear_button(self) -> bool:
183 """Get whether the clear button is shown.
185 Returns:
186 True if clear button is shown, False otherwise.
187 """
188 return self._clear_button
190 @clear_button.setter
191 def clear_button(self, value: bool) -> None:
192 """Set whether the clear button is shown.
194 Args:
195 value: Whether to show the clear button.
196 """
197 self._clear_button = bool(value)
198 self.setClearButtonEnabled(self._clear_button)
200 @property
201 def max_history(self) -> int:
202 """Get the maximum history size.
204 Returns:
205 The maximum number of history entries.
206 """
207 return self._max_history
209 @max_history.setter
210 def max_history(self, value: int) -> None:
211 """Set the maximum history size.
213 Args:
214 value: The maximum number of history entries.
215 """
216 self._max_history = max(1, int(value))
217 self._trim_history()
219 # ///////////////////////////////////////////////////////////////
220 # PUBLIC METHODS
221 # ///////////////////////////////////////////////////////////////
223 def addToHistory(self, text: str) -> None:
224 """Add a search term to history.
226 Args:
227 text: The search term to add.
228 """
229 if not text.strip(): 229 ↛ 230line 229 didn't jump to line 230 because the condition on line 229 was never true
230 return
232 # Remove if already exists
233 if text in self._history:
234 self._history.remove(text)
236 # Add to beginning
237 self._history.insert(0, text)
238 self._trim_history()
239 self._history_index = -1
241 def getHistory(self) -> list[str]:
242 """Get the search history.
244 Returns:
245 A copy of the search history list.
246 """
247 return self._history.copy()
249 def clearHistory(self) -> None:
250 """Clear the search history."""
251 self._history.clear()
252 self._history_index = -1
254 def setHistory(self, history_list: list[str]) -> None:
255 """Set the search history.
257 Args:
258 history_list: List of history entries to set.
259 """
260 self._history = [
261 str(item).strip() for item in history_list if str(item).strip()
262 ]
263 self._trim_history()
264 self._history_index = -1
266 # ------------------------------------------------
267 # PRIVATE METHODS
268 # ------------------------------------------------
270 def _trim_history(self) -> None:
271 """Trim history to maximum size."""
272 while len(self._history) > self._max_history:
273 self._history.pop()
275 # ///////////////////////////////////////////////////////////////
276 # EVENT HANDLERS
277 # ///////////////////////////////////////////////////////////////
279 def keyPressEvent(self, event: QKeyEvent) -> None:
280 """Handle key press events.
282 Args:
283 event: The key event.
284 """
285 if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
286 # Submit search
287 text = self.text().strip()
288 if text:
289 self.addToHistory(text)
290 self.searchSubmitted.emit(text)
291 elif event.key() == Qt.Key.Key_Up:
292 # Navigate history up
293 if self._history:
294 if self._history_index < len(self._history) - 1:
295 self._history_index += 1
296 self.setText(self._history[self._history_index])
297 event.accept()
298 return
299 elif event.key() == Qt.Key.Key_Down:
300 # Navigate history down
301 if self._history_index > 0:
302 self._history_index -= 1
303 self.setText(self._history[self._history_index])
304 event.accept()
305 return
306 elif self._history_index == 0:
307 self._history_index = -1
308 self.setText(self._current_text)
309 event.accept()
310 return
312 # Store current text for history navigation
313 if event.key() not in [Qt.Key.Key_Up, Qt.Key.Key_Down]:
314 self._current_text = self.text()
316 super().keyPressEvent(event)
318 def setTheme(self, theme: str) -> None:
319 """Update the search icon color for the given theme.
321 Can be connected directly to a theme-change signal to keep
322 the icon in sync with the application's color scheme.
324 Args:
325 theme: The new theme (``"dark"`` or ``"light"``).
326 """
327 if isinstance(self._search_icon, ThemeIcon):
328 self._search_icon.setTheme(theme)
329 self.update()
331 # ///////////////////////////////////////////////////////////////
332 # STYLE METHODS
333 # ///////////////////////////////////////////////////////////////
335 def refreshStyle(self) -> None:
336 """Refresh the widget style.
338 Useful after dynamic stylesheet changes.
339 """
340 self.style().unpolish(self)
341 self.style().polish(self)
342 self.update()
345# ///////////////////////////////////////////////////////////////
346# PUBLIC API
347# ///////////////////////////////////////////////////////////////
349__all__ = ["SearchInput"]