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-03-31 10:03 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-31 10:03 +0000
1# ///////////////////////////////////////////////////////////////
2# FILE_PICKER_INPUT - File Picker Input Widget
3# Project: ezqt_widgets
4# ///////////////////////////////////////////////////////////////
6"""
7File picker input widget module.
9Provides a composite input widget combining a QLineEdit and a folder icon
10button that opens a QFileDialog for file or directory selection.
11"""
13from __future__ import annotations
15# ///////////////////////////////////////////////////////////////
16# IMPORTS
17# ///////////////////////////////////////////////////////////////
18# Standard library imports
19import base64
20from typing import Literal
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)
35# Local imports
36from ...types import WidgetParent
37from ..misc.theme_icon import ThemeIcon
38from ..shared import SVG_FOLDER
40# ///////////////////////////////////////////////////////////////
41# CLASSES
42# ///////////////////////////////////////////////////////////////
45class FilePickerInput(QWidget):
46 """Composite input widget combining a QLineEdit and a folder icon button.
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.
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
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: "").
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.
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.
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 """
88 fileSelected = Signal(str)
89 pathChanged = Signal(str)
91 # ///////////////////////////////////////////////////////////////
92 # INIT
93 # ///////////////////////////////////////////////////////////////
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")
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
114 # Setup UI
115 self._setup_widget(placeholder)
117 # ------------------------------------------------
118 # PRIVATE METHODS
119 # ------------------------------------------------
121 def _setup_widget(self, placeholder: str) -> None:
122 """Setup the widget layout and child components.
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()
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)
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)
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)
154 @staticmethod
155 def _build_folder_icon() -> ThemeIcon | None:
156 """Build a ThemeIcon from the shared folder SVG.
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
166 pixmap = QPixmap(QSize(16, 16))
167 pixmap.fill(Qt.GlobalColor.transparent)
168 painter = QPainter(pixmap)
169 renderer.render(painter)
170 painter.end()
172 icon = QIcon(pixmap)
173 return ThemeIcon.from_source(icon)
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()
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 )
196 if selected:
197 self._line_edit.setText(selected)
198 self.fileSelected.emit(selected)
200 # ///////////////////////////////////////////////////////////////
201 # PROPERTIES
202 # ///////////////////////////////////////////////////////////////
204 @property
205 def path(self) -> str:
206 """Get the current path displayed in the QLineEdit.
208 Returns:
209 The current path string.
210 """
211 return self._line_edit.text()
213 @path.setter
214 def path(self, value: str) -> None:
215 """Set the path displayed in the QLineEdit.
217 Args:
218 value: The new path string.
219 """
220 self._line_edit.setText(str(value))
222 @property
223 def mode(self) -> Literal["file", "directory"]:
224 """Get the file dialog selection mode.
226 Returns:
227 The current mode ("file" or "directory").
228 """
229 return self._mode
231 @mode.setter
232 def mode(self, value: Literal["file", "directory"]) -> None:
233 """Set the file dialog selection mode.
235 Args:
236 value: The new mode ("file" or "directory").
237 """
238 if value in ("file", "directory"):
239 self._mode = value
241 @property
242 def placeholder_text(self) -> str:
243 """Get the QLineEdit placeholder text.
245 Returns:
246 The current placeholder text.
247 """
248 return self._line_edit.placeholderText()
250 @placeholder_text.setter
251 def placeholder_text(self, value: str) -> None:
252 """Set the QLineEdit placeholder text.
254 Args:
255 value: The new placeholder text.
256 """
257 self._line_edit.setPlaceholderText(str(value))
259 @property
260 def filter(self) -> str: # noqa: A003
261 """Get the file dialog filter string.
263 Returns:
264 The current filter string.
265 """
266 return self._filter
268 @filter.setter
269 def filter(self, value: str) -> None: # noqa: A003
270 """Set the file dialog filter string.
272 Args:
273 value: The new filter string (e.g. "Images (*.png *.jpg)").
274 """
275 self._filter = str(value)
277 @property
278 def dialog_title(self) -> str:
279 """Get the file dialog window title.
281 Returns:
282 The current dialog title.
283 """
284 return self._dialog_title
286 @dialog_title.setter
287 def dialog_title(self, value: str) -> None:
288 """Set the file dialog window title.
290 Args:
291 value: The new dialog title.
292 """
293 self._dialog_title = str(value)
295 # ///////////////////////////////////////////////////////////////
296 # PUBLIC METHODS
297 # ///////////////////////////////////////////////////////////////
299 def clear(self) -> None:
300 """Clear the current path from the QLineEdit."""
301 self._line_edit.clear()
303 def setTheme(self, theme: str) -> None:
304 """Update the folder icon color for the given theme.
306 Can be connected directly to a theme-change signal to keep
307 the icon in sync with the application's color scheme.
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)
316 # ///////////////////////////////////////////////////////////////
317 # STYLE METHODS
318 # ///////////////////////////////////////////////////////////////
320 def refreshStyle(self) -> None:
321 """Refresh the widget style.
323 Useful after dynamic stylesheet changes.
324 """
325 self.style().unpolish(self)
326 self.style().polish(self)
327 self.update()
330# ///////////////////////////////////////////////////////////////
331# PUBLIC API
332# ///////////////////////////////////////////////////////////////
334__all__ = ["FilePickerInput"]