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-03-31 10:03 +0000

1# /////////////////////////////////////////////////////////////// 

2# SEARCH_INPUT - Search Input Widget 

3# Project: ezqt_widgets 

4# /////////////////////////////////////////////////////////////// 

5 

6""" 

7Search input widget module. 

8 

9Provides a QLineEdit subclass for search input with integrated history 

10and optional search icon for PySide6 applications. 

11""" 

12 

13from __future__ import annotations 

14 

15# /////////////////////////////////////////////////////////////// 

16# IMPORTS 

17# /////////////////////////////////////////////////////////////// 

18# Standard library imports 

19from typing import Any 

20 

21# Third-party imports 

22from PySide6.QtCore import Qt, Signal 

23from PySide6.QtGui import QIcon, QKeyEvent, QPixmap 

24from PySide6.QtWidgets import QLineEdit 

25 

26from ...types import IconSourceExtended, WidgetParent 

27 

28# Local imports 

29from ..misc.theme_icon import ThemeIcon 

30 

31# /////////////////////////////////////////////////////////////// 

32# CLASSES 

33# /////////////////////////////////////////////////////////////// 

34 

35 

36class SearchInput(QLineEdit): 

37 """QLineEdit subclass for search input with integrated history. 

38 

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 

45 

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. 

56 

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. 

62 

63 Signals: 

64 searchSubmitted(str): Emitted when a search is submitted (Enter key). 

65 

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

73 

74 searchSubmitted = Signal(str) 

75 

76 # /////////////////////////////////////////////////////////////// 

77 # INIT 

78 # /////////////////////////////////////////////////////////////// 

79 

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) 

92 

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

101 

102 # Setup UI 

103 self._setup_ui() 

104 

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 

109 

110 # ------------------------------------------------ 

111 # PRIVATE METHODS 

112 # ------------------------------------------------ 

113 

114 def _setup_ui(self) -> None: 

115 """Setup the user interface components.""" 

116 self.setPlaceholderText("Search...") 

117 self.setClearButtonEnabled(self._clear_button) 

118 

119 # /////////////////////////////////////////////////////////////// 

120 # PROPERTIES 

121 # /////////////////////////////////////////////////////////////// 

122 

123 @property 

124 def search_icon(self) -> QIcon | None: 

125 """Get the search icon. 

126 

127 Returns: 

128 The current search icon, or None if not set. 

129 """ 

130 return self._search_icon 

131 

132 @search_icon.setter 

133 def search_icon(self, value: IconSourceExtended) -> None: 

134 """Set the search icon. 

135 

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 

146 

147 self._search_icon = ThemeIcon.from_source(icon) 

148 

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

158 

159 @property 

160 def icon_position(self) -> str: 

161 """Get the icon position. 

162 

163 Returns: 

164 The current icon position ('left' or 'right'). 

165 """ 

166 return self._icon_position 

167 

168 @icon_position.setter 

169 def icon_position(self, value: str) -> None: 

170 """Set the icon position. 

171 

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 

180 

181 @property 

182 def clear_button(self) -> bool: 

183 """Get whether the clear button is shown. 

184 

185 Returns: 

186 True if clear button is shown, False otherwise. 

187 """ 

188 return self._clear_button 

189 

190 @clear_button.setter 

191 def clear_button(self, value: bool) -> None: 

192 """Set whether the clear button is shown. 

193 

194 Args: 

195 value: Whether to show the clear button. 

196 """ 

197 self._clear_button = bool(value) 

198 self.setClearButtonEnabled(self._clear_button) 

199 

200 @property 

201 def max_history(self) -> int: 

202 """Get the maximum history size. 

203 

204 Returns: 

205 The maximum number of history entries. 

206 """ 

207 return self._max_history 

208 

209 @max_history.setter 

210 def max_history(self, value: int) -> None: 

211 """Set the maximum history size. 

212 

213 Args: 

214 value: The maximum number of history entries. 

215 """ 

216 self._max_history = max(1, int(value)) 

217 self._trim_history() 

218 

219 # /////////////////////////////////////////////////////////////// 

220 # PUBLIC METHODS 

221 # /////////////////////////////////////////////////////////////// 

222 

223 def addToHistory(self, text: str) -> None: 

224 """Add a search term to history. 

225 

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 

231 

232 # Remove if already exists 

233 if text in self._history: 

234 self._history.remove(text) 

235 

236 # Add to beginning 

237 self._history.insert(0, text) 

238 self._trim_history() 

239 self._history_index = -1 

240 

241 def getHistory(self) -> list[str]: 

242 """Get the search history. 

243 

244 Returns: 

245 A copy of the search history list. 

246 """ 

247 return self._history.copy() 

248 

249 def clearHistory(self) -> None: 

250 """Clear the search history.""" 

251 self._history.clear() 

252 self._history_index = -1 

253 

254 def setHistory(self, history_list: list[str]) -> None: 

255 """Set the search history. 

256 

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 

265 

266 # ------------------------------------------------ 

267 # PRIVATE METHODS 

268 # ------------------------------------------------ 

269 

270 def _trim_history(self) -> None: 

271 """Trim history to maximum size.""" 

272 while len(self._history) > self._max_history: 

273 self._history.pop() 

274 

275 # /////////////////////////////////////////////////////////////// 

276 # EVENT HANDLERS 

277 # /////////////////////////////////////////////////////////////// 

278 

279 def keyPressEvent(self, event: QKeyEvent) -> None: 

280 """Handle key press events. 

281 

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 

311 

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

315 

316 super().keyPressEvent(event) 

317 

318 def setTheme(self, theme: str) -> None: 

319 """Update the search icon color for the given theme. 

320 

321 Can be connected directly to a theme-change signal to keep 

322 the icon in sync with the application's color scheme. 

323 

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

330 

331 # /////////////////////////////////////////////////////////////// 

332 # STYLE METHODS 

333 # /////////////////////////////////////////////////////////////// 

334 

335 def refreshStyle(self) -> None: 

336 """Refresh the widget style. 

337 

338 Useful after dynamic stylesheet changes. 

339 """ 

340 self.style().unpolish(self) 

341 self.style().polish(self) 

342 self.update() 

343 

344 

345# /////////////////////////////////////////////////////////////// 

346# PUBLIC API 

347# /////////////////////////////////////////////////////////////// 

348 

349__all__ = ["SearchInput"]