Coverage for src / ezqt_widgets / widgets / input / file_picker_input.py: 86.09%

103 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-01 22:46 +0000

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

2# FILE_PICKER_INPUT - File Picker Input Widget 

3# Project: ezqt_widgets 

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

5 

6""" 

7File picker input widget module. 

8 

9Provides a composite input widget combining a QLineEdit and a folder icon 

10button that opens a QFileDialog for file or directory selection. 

11""" 

12 

13from __future__ import annotations 

14 

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

16# IMPORTS 

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

18# Standard library imports 

19import base64 

20from typing import Literal 

21 

22# Third-party imports 

23from PySide6.QtCore import QByteArray, QSize, Qt, Signal 

24from PySide6.QtGui import QIcon, QPainter, QPixmap 

25from PySide6.QtSvg import QSvgRenderer 

26from PySide6.QtWidgets import ( 

27 QFileDialog, 

28 QHBoxLayout, 

29 QLineEdit, 

30 QSizePolicy, 

31 QToolButton, 

32 QWidget, 

33) 

34 

35# Local imports 

36from ...types import WidgetParent 

37from ..misc.theme_icon import ThemeIcon 

38from ..shared import SVG_FOLDER 

39 

40# /////////////////////////////////////////////////////////////// 

41# CLASSES 

42# /////////////////////////////////////////////////////////////// 

43 

44 

45class FilePickerInput(QWidget): 

46 """Composite input widget combining a QLineEdit and a folder icon button. 

47 

48 Clicking the folder button opens a QFileDialog (file or directory mode). 

49 The selected path is displayed in the QLineEdit. The widget supports 

50 theme-aware icon rendering via ThemeIcon. 

51 

52 Features: 

53 - File or directory selection via QFileDialog 

54 - Editable QLineEdit for manual path entry 

55 - Theme-aware folder icon via ThemeIcon 

56 - Signals for file selection and path text changes 

57 - Configurable placeholder, filter, and dialog title 

58 

59 Args: 

60 parent: The parent widget (default: None). 

61 placeholder: Placeholder text for the QLineEdit 

62 (default: "Select a file..."). 

63 mode: Selection mode, either "file" or "directory" 

64 (default: "file"). 

65 filter: File filter string for QFileDialog, e.g. "Images (*.png *.jpg)" 

66 (default: ""). 

67 dialog_title: Title for the QFileDialog window (default: ""). 

68 

69 Properties: 

70 path: Get or set the current file/directory path. 

71 mode: Get or set the selection mode ("file" or "directory"). 

72 placeholder_text: Get or set the QLineEdit placeholder text. 

73 filter: Get or set the file dialog filter string. 

74 dialog_title: Get or set the file dialog window title. 

75 

76 Signals: 

77 fileSelected(str): Emitted when a path is chosen via the dialog. 

78 pathChanged(str): Emitted on every text change in the QLineEdit. 

79 

80 Example: 

81 >>> from ezqt_widgets import FilePickerInput 

82 >>> picker = FilePickerInput(placeholder="Choose a CSV file...", 

83 ... filter="CSV files (*.csv)") 

84 >>> picker.fileSelected.connect(lambda p: print(f"Selected: {p}")) 

85 >>> picker.show() 

86 """ 

87 

88 fileSelected = Signal(str) 

89 pathChanged = Signal(str) 

90 

91 # /////////////////////////////////////////////////////////////// 

92 # INIT 

93 # /////////////////////////////////////////////////////////////// 

94 

95 def __init__( 

96 self, 

97 parent: WidgetParent = None, 

98 *, 

99 placeholder: str = "Select a file...", 

100 mode: Literal["file", "directory"] = "file", 

101 filter: str = "", # noqa: A002 

102 dialog_title: str = "", 

103 ) -> None: 

104 """Initialize the file picker input.""" 

105 super().__init__(parent) 

106 self.setProperty("type", "FilePickerInput") 

107 

108 # Initialize private state 

109 self._mode: Literal["file", "directory"] = mode 

110 self._filter: str = filter 

111 self._dialog_title: str = dialog_title 

112 self._folder_icon: ThemeIcon | None = None 

113 

114 # Setup UI 

115 self._setup_widget(placeholder) 

116 

117 # ------------------------------------------------ 

118 # PRIVATE METHODS 

119 # ------------------------------------------------ 

120 

121 def _setup_widget(self, placeholder: str) -> None: 

122 """Setup the widget layout and child components. 

123 

124 Args: 

125 placeholder: Initial placeholder text for the QLineEdit. 

126 """ 

127 # Build folder icon from inline SVG bytes 

128 self._folder_icon = self._build_folder_icon() 

129 

130 # QLineEdit 

131 self._line_edit = QLineEdit() 

132 self._line_edit.setPlaceholderText(placeholder) 

133 self._line_edit.setSizePolicy( 

134 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed 

135 ) 

136 self._line_edit.textChanged.connect(self.pathChanged.emit) 

137 

138 # Folder button 

139 self._btn = QToolButton() 

140 self._btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) 

141 self._btn.setCursor(Qt.CursorShape.PointingHandCursor) 

142 if self._folder_icon is not None: 142 ↛ 145line 142 didn't jump to line 145 because the condition on line 142 was always true

143 self._btn.setIcon(self._folder_icon) 

144 self._btn.setIconSize(QSize(16, 16)) 

145 self._btn.clicked.connect(self._open_dialog) 

146 

147 # Layout 

148 layout = QHBoxLayout(self) 

149 layout.setContentsMargins(0, 0, 0, 0) 

150 layout.setSpacing(4) 

151 layout.addWidget(self._line_edit) 

152 layout.addWidget(self._btn) 

153 

154 @staticmethod 

155 def _build_folder_icon() -> ThemeIcon | None: 

156 """Build a ThemeIcon from the shared folder SVG. 

157 

158 Returns: 

159 A ThemeIcon instance, or None if rendering fails. 

160 """ 

161 svg_bytes = base64.b64decode(base64.b64encode(SVG_FOLDER)) 

162 renderer = QSvgRenderer(QByteArray(svg_bytes)) 

163 if not renderer.isValid(): 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true

164 return None 

165 

166 pixmap = QPixmap(QSize(16, 16)) 

167 pixmap.fill(Qt.GlobalColor.transparent) 

168 painter = QPainter(pixmap) 

169 renderer.render(painter) 

170 painter.end() 

171 

172 icon = QIcon(pixmap) 

173 return ThemeIcon.from_source(icon) 

174 

175 def _open_dialog(self) -> None: 

176 """Open the QFileDialog and update the path on selection.""" 

177 title = self._dialog_title or ( 

178 "Select Directory" if self._mode == "directory" else "Select File" 

179 ) 

180 current = self._line_edit.text() 

181 

182 if self._mode == "directory": 

183 selected = QFileDialog.getExistingDirectory( 

184 self, 

185 title, 

186 current, 

187 ) 

188 else: 

189 selected, _ = QFileDialog.getOpenFileName( 

190 self, 

191 title, 

192 current, 

193 self._filter, 

194 ) 

195 

196 if selected: 

197 self._line_edit.setText(selected) 

198 self.fileSelected.emit(selected) 

199 

200 # /////////////////////////////////////////////////////////////// 

201 # PROPERTIES 

202 # /////////////////////////////////////////////////////////////// 

203 

204 @property 

205 def path(self) -> str: 

206 """Get the current path displayed in the QLineEdit. 

207 

208 Returns: 

209 The current path string. 

210 """ 

211 return self._line_edit.text() 

212 

213 @path.setter 

214 def path(self, value: str) -> None: 

215 """Set the path displayed in the QLineEdit. 

216 

217 Args: 

218 value: The new path string. 

219 """ 

220 self._line_edit.setText(str(value)) 

221 

222 @property 

223 def mode(self) -> Literal["file", "directory"]: 

224 """Get the file dialog selection mode. 

225 

226 Returns: 

227 The current mode ("file" or "directory"). 

228 """ 

229 return self._mode 

230 

231 @mode.setter 

232 def mode(self, value: Literal["file", "directory"]) -> None: 

233 """Set the file dialog selection mode. 

234 

235 Args: 

236 value: The new mode ("file" or "directory"). 

237 """ 

238 if value in ("file", "directory"): 

239 self._mode = value 

240 

241 @property 

242 def placeholder_text(self) -> str: 

243 """Get the QLineEdit placeholder text. 

244 

245 Returns: 

246 The current placeholder text. 

247 """ 

248 return self._line_edit.placeholderText() 

249 

250 @placeholder_text.setter 

251 def placeholder_text(self, value: str) -> None: 

252 """Set the QLineEdit placeholder text. 

253 

254 Args: 

255 value: The new placeholder text. 

256 """ 

257 self._line_edit.setPlaceholderText(str(value)) 

258 

259 @property 

260 def filter(self) -> str: # noqa: A003 

261 """Get the file dialog filter string. 

262 

263 Returns: 

264 The current filter string. 

265 """ 

266 return self._filter 

267 

268 @filter.setter 

269 def filter(self, value: str) -> None: # noqa: A003 

270 """Set the file dialog filter string. 

271 

272 Args: 

273 value: The new filter string (e.g. "Images (*.png *.jpg)"). 

274 """ 

275 self._filter = str(value) 

276 

277 @property 

278 def dialog_title(self) -> str: 

279 """Get the file dialog window title. 

280 

281 Returns: 

282 The current dialog title. 

283 """ 

284 return self._dialog_title 

285 

286 @dialog_title.setter 

287 def dialog_title(self, value: str) -> None: 

288 """Set the file dialog window title. 

289 

290 Args: 

291 value: The new dialog title. 

292 """ 

293 self._dialog_title = str(value) 

294 

295 # /////////////////////////////////////////////////////////////// 

296 # PUBLIC METHODS 

297 # /////////////////////////////////////////////////////////////// 

298 

299 def clear(self) -> None: 

300 """Clear the current path from the QLineEdit.""" 

301 self._line_edit.clear() 

302 

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

304 """Update the folder icon color for the given theme. 

305 

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

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

308 

309 Args: 

310 theme: The new theme (``"dark"`` or ``"light"``). 

311 """ 

312 if isinstance(self._folder_icon, ThemeIcon): 312 ↛ exitline 312 didn't return from function 'setTheme' because the condition on line 312 was always true

313 self._folder_icon.setTheme(theme) 

314 self._btn.setIcon(self._folder_icon) 

315 

316 # /////////////////////////////////////////////////////////////// 

317 # STYLE METHODS 

318 # /////////////////////////////////////////////////////////////// 

319 

320 def refreshStyle(self) -> None: 

321 """Refresh the widget style. 

322 

323 Useful after dynamic stylesheet changes. 

324 """ 

325 self.style().unpolish(self) 

326 self.style().polish(self) 

327 self.update() 

328 

329 

330# /////////////////////////////////////////////////////////////// 

331# PUBLIC API 

332# /////////////////////////////////////////////////////////////// 

333 

334__all__ = ["FilePickerInput"]