From cf158d30e64c6435d37a5aa9112091b14dc95101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Tue, 7 Oct 2025 11:35:30 -0400 Subject: [PATCH 1/6] Fix code logic in 'set_path' --- qtapputils/widgets/path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qtapputils/widgets/path.py b/qtapputils/widgets/path.py index 83dbc0f..9509468 100644 --- a/qtapputils/widgets/path.py +++ b/qtapputils/widgets/path.py @@ -75,7 +75,7 @@ def path(self): def set_path(self, path: str): """Set the path to the specified value.""" - if path == self.path: + if path == self.path(): return self.path_lineedit.setText(path) From f10f0ea52b2603688e288b4724140b30bd29a6ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Tue, 7 Oct 2025 11:35:51 -0400 Subject: [PATCH 2/6] PathBoxWidget: remove 'path' and 'directory' args from __init__ --- qtapputils/widgets/path.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/qtapputils/widgets/path.py b/qtapputils/widgets/path.py index 9509468..086b45f 100644 --- a/qtapputils/widgets/path.py +++ b/qtapputils/widgets/path.py @@ -26,8 +26,8 @@ class PathBoxWidget(QFrame): """ sig_path_changed = Signal(str) - def __init__(self, parent: QWidget = None, path: str = '', - directory: str = '', path_type: str = 'getExistingDirectory', + def __init__(self, parent: QWidget = None, + path_type: str = 'getExistingDirectory', filters: str = None, gettext: Callable = None): super().__init__(parent) @@ -39,7 +39,7 @@ def __init__(self, parent: QWidget = None, path: str = '', elif path_type == 'getSaveFileName': self._caption = _('Save File') - self._directory = directory + self._directory = osp.expanduser('~') self.filters = filters self._path_type = path_type @@ -50,8 +50,6 @@ def __init__(self, parent: QWidget = None, path: str = '', self.path_lineedit = QLineEdit() self.path_lineedit.setReadOnly(True) - self.path_lineedit.setText(path) - self.path_lineedit.setToolTip(path) self.path_lineedit.setFixedHeight( self.browse_btn.sizeHint().height() - 2) From 892a7a48442d442868d2da99d79ee24e4925777b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Tue, 7 Oct 2025 11:37:50 -0400 Subject: [PATCH 3/6] Add a 'browse_icon' option to use a QToolbutton instead of a Pushbutton --- qtapputils/widgets/path.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/qtapputils/widgets/path.py b/qtapputils/widgets/path.py index 086b45f..8b9e377 100644 --- a/qtapputils/widgets/path.py +++ b/qtapputils/widgets/path.py @@ -15,10 +15,13 @@ # ---- Third party imports from qtpy.QtCore import Signal +from qtpy.QtGui import QIcon from qtpy.QtWidgets import ( QCheckBox, QFrame, QLineEdit, QLabel, QFileDialog, QPushButton, QGridLayout, QWidget) +# ---- Local imports +from qtapputils.qthelpers import create_toolbutton class PathBoxWidget(QFrame): """ @@ -28,7 +31,8 @@ class PathBoxWidget(QFrame): def __init__(self, parent: QWidget = None, path_type: str = 'getExistingDirectory', - filters: str = None, gettext: Callable = None): + filters: str = None, gettext: Callable = None, + browse_icon: QIcon = None): super().__init__(parent) _ = gettext if gettext else lambda x: x @@ -43,15 +47,25 @@ def __init__(self, parent: QWidget = None, self.filters = filters self._path_type = path_type - self.browse_btn = QPushButton(_("Browse...")) - self.browse_btn.setDefault(False) - self.browse_btn.setAutoDefault(False) - self.browse_btn.clicked.connect(self.browse_path) - self.path_lineedit = QLineEdit() self.path_lineedit.setReadOnly(True) - self.path_lineedit.setFixedHeight( - self.browse_btn.sizeHint().height() - 2) + if browse_icon is None: + self.browse_btn = QPushButton(_("Browse...")) + self.browse_btn.setDefault(False) + self.browse_btn.setAutoDefault(False) + self.browse_btn.clicked.connect(self.browse_path) + + # We need to do this to align vertically the height of the + # browse button with the line edit. + self.path_lineedit.setFixedHeight( + self.browse_btn.sizeHint().height() - 2) + else: + self.browse_btn = create_toolbutton( + self, + icon=browse_icon, + text=_("Browse..."), + triggered=self.browse_path, + ) layout = QGridLayout(self) layout.setContentsMargins(0, 0, 0, 0) From 55963a2b207161bb7bd6ff049c5375366ef72d89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Tue, 7 Oct 2025 11:45:16 -0400 Subject: [PATCH 4/6] Code style improvement and documentation --- qtapputils/widgets/path.py | 77 ++++++++++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 20 deletions(-) diff --git a/qtapputils/widgets/path.py b/qtapputils/widgets/path.py index 8b9e377..f34cdce 100644 --- a/qtapputils/widgets/path.py +++ b/qtapputils/widgets/path.py @@ -23,16 +23,41 @@ # ---- Local imports from qtapputils.qthelpers import create_toolbutton + class PathBoxWidget(QFrame): """ - A widget to display and select a directory or file location. + a widget to display and select a directory or file location. + + Features + -------- + - Read-only line edit showing the current path. + - Browse button to open a QFileDialog for selecting a path. + - Optionally, uses a custom icon for the browse button. + - Emits `sig_path_changed` when the path changes. + + Parameters + ---------- + parent : QWidget, optional + Parent widget. + path_type : str, optional + Type of path dialog: 'getExistingDirectory', 'getOpenFileName', or + 'getSaveFileName'. + filters : str, optional + Filter string for file dialogs. + gettext : Callable, optional + Translation function for GUI strings. + browse_icon : QIcon, optional + Custom icon for the browse button. """ sig_path_changed = Signal(str) - def __init__(self, parent: QWidget = None, - path_type: str = 'getExistingDirectory', - filters: str = None, gettext: Callable = None, - browse_icon: QIcon = None): + def __init__( + self, + parent: QWidget = None, + path_type: str = 'getExistingDirectory', + filters: str = None, + gettext: Callable = None, + browse_icon: QIcon = None): super().__init__(parent) _ = gettext if gettext else lambda x: x @@ -54,9 +79,7 @@ def __init__(self, parent: QWidget = None, self.browse_btn.setDefault(False) self.browse_btn.setAutoDefault(False) self.browse_btn.clicked.connect(self.browse_path) - - # We need to do this to align vertically the height of the - # browse button with the line edit. + # Align line edit height with button. self.path_lineedit.setFixedHeight( self.browse_btn.sizeHint().height() - 2) else: @@ -73,20 +96,27 @@ def __init__(self, parent: QWidget = None, layout.addWidget(self.path_lineedit, 0, 0) layout.addWidget(self.browse_btn, 0, 1) - def is_valid(self): - """Return whether path is valid.""" + def is_valid(self) -> bool: + """Return True if the current path exists on disk.""" return osp.exists(self.path()) - def is_empty(self): - """Return whether the path is empty.""" - return self.path_lineedit.text() == '' + def is_empty(self) -> bool: + """Return True if the current path is empty.""" + return not self.path_lineedit.text().strip() - def path(self): - """Return the path of this pathbox widget.""" + def path(self) -> str: + """Return the currently displayed path.""" return self.path_lineedit.text() def set_path(self, path: str): - """Set the path to the specified value.""" + """ + Set the path to the specified value. + + Parameters + ---------- + path : str + The new path to display and set as default directory. + """ if path == self.path(): return @@ -102,22 +132,29 @@ def browse_path(self): self, self._caption, self.directory(), options=QFileDialog.ShowDirsOnly) elif self._path_type == 'getOpenFileName': - path, ext = QFileDialog.getOpenFileName( + path, _ = QFileDialog.getOpenFileName( self, self._caption, self.directory(), self.filters) elif self._path_type == 'getSaveFileName': - path, ext = QFileDialog.getSaveFileName( + path, _ = QFileDialog.getSaveFileName( self, self._caption, self.directory(), self.filters) if path: self.set_path(path) - def directory(self): + def directory(self) -> str: """Return the directory that is used by the QFileDialog.""" return (self._directory if osp.exists(self._directory) else osp.expanduser('~')) def set_directory(self, directory: str = path): - """Set the default directory that will be used by the QFileDialog.""" + """ + Set the default directory for file dialogs. + + Parameters + ---------- + directory : str or None + Directory path to set as default. + """ if directory is not None and osp.exists(directory): self._directory = directory From 11667eeb73322bd0d21db2e1a80901194aacd22b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Tue, 7 Oct 2025 11:45:32 -0400 Subject: [PATCH 5/6] Expands options in get_standard_iconsize --- qtapputils/icons.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/qtapputils/icons.py b/qtapputils/icons.py index c2a0b78..2578772 100644 --- a/qtapputils/icons.py +++ b/qtapputils/icons.py @@ -50,6 +50,12 @@ def get_standard_iconsize(constant: 'str') -> int: return style.pixelMetric(QStyle.PM_MessageBoxIconSize) elif constant == 'small': return style.pixelMetric(QStyle.PM_SmallIconSize) + elif constant == 'large': + return style.pixelMetric(QStyle.PM_LargeIconSize) + elif constant == 'toolbar': + return style.pixelMetric(QStyle.PM_ToolBarIconSize) + elif constant == 'button': + return style.pixelMetric(QStyle.PM_ButtonIconSize) class IconManager: From 7fbca1b7aabda9f8464284409a819beb8acb7fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Tue, 7 Oct 2025 11:59:19 -0400 Subject: [PATCH 6/6] Update test_path.py --- qtapputils/widgets/tests/test_path.py | 141 ++++++++++++++++++++++---- 1 file changed, 123 insertions(+), 18 deletions(-) diff --git a/qtapputils/widgets/tests/test_path.py b/qtapputils/widgets/tests/test_path.py index d4287d3..ba0f0af 100644 --- a/qtapputils/widgets/tests/test_path.py +++ b/qtapputils/widgets/tests/test_path.py @@ -17,6 +17,7 @@ # ---- Third party imports import pytest from qtpy.QtCore import Qt +from qtpy.QtGui import QIcon # ---- Local imports from qtapputils.widgets.path import PathBoxWidget, QFileDialog @@ -29,8 +30,6 @@ def pathbox(qtbot): pathbox = PathBoxWidget( parent=None, - path='', - directory='', path_type='getSaveFileName', filters=None ) @@ -42,32 +41,138 @@ def pathbox(qtbot): # ============================================================================= # ---- Tests for the PathBoxWidget # ============================================================================= -def test_getopen_filename(qtbot, pathbox, mocker, tmp_path): - """Test that getting a file name is working as expected.""" +def test_initialization_no_icon(qtbot): + pathbox = PathBoxWidget() + qtbot.addWidget(pathbox) + + assert pathbox.is_empty() assert not pathbox.is_valid() + assert pathbox.path() == "" + assert pathbox.directory() + + +def test_initialization_with_icon(qtbot): + icon = QIcon() + pathbox = PathBoxWidget(browse_icon=icon) + qtbot.addWidget(pathbox) + assert pathbox.is_empty() - assert pathbox.path() == '' - assert osp.samefile(pathbox.directory(), osp.expanduser('~')) + assert not pathbox.is_valid() - # Create an empty file. - selectedfilter = 'Text File (*.txt)' - selectedfilename = osp.join(tmp_path, 'pathbox_testfile.txt') - with open(selectedfilename, 'w') as txtfile: - txtfile.write('test') - # Patch the open file dialog and select the test file. +def test_set_and_get_path_valid(tmp_path, qtbot): + # Create a valid file + f = tmp_path / "myfile.txt" + f.write_text("content") + + pathbox = PathBoxWidget(path_type="getOpenFileName") + qtbot.addWidget(pathbox) + pathbox.set_path(str(f)) + + assert not pathbox.is_empty() + assert pathbox.is_valid() + assert pathbox.path() == str(f) + assert pathbox.directory() == str(tmp_path) + + +def test_set_and_get_path_invalid(qtbot): + pathbox = PathBoxWidget() + qtbot.addWidget(pathbox) + pathbox.set_path("/tmp/nonexistent_file.txt") + + assert pathbox.path() == "/tmp/nonexistent_file.txt" + assert pathbox.directory() == osp.expanduser('~') + assert not pathbox.is_valid() + + +def test_set_directory_valid(tmp_path, qtbot): + d = tmp_path / "dir" + d.mkdir() + + pathbox = PathBoxWidget() + qtbot.addWidget(pathbox) + pathbox.set_directory(str(d)) + + assert pathbox.directory() == str(d) + + +def test_set_directory_invalid(qtbot): + pathbox = PathBoxWidget() + qtbot.addWidget(pathbox) + pathbox.set_directory("/tmp/nonexistent_dir") + + # Should fallback to home if directory is invalid + assert pathbox.directory() == osp.expanduser('~') + + +def test_signal_emitted_on_path_change(tmp_path, qtbot): + f = tmp_path / "file.txt" + f.write_text("abc") + + pathbox = PathBoxWidget() + qtbot.addWidget(pathbox) + + with qtbot.waitSignal(pathbox.sig_path_changed) as blocker: + pathbox.set_path(str(f)) + assert blocker.args == [str(f)] + + +def test_browse_path_get_existing_directory(mocker, tmp_path, qtbot): + d = tmp_path / "bro_dir" + d.mkdir() + + pathbox = PathBoxWidget(path_type="getExistingDirectory") + qtbot.addWidget(pathbox) + qfdialog_patcher = mocker.patch.object( QFileDialog, - 'getSaveFileName', - return_value=(selectedfilename, selectedfilter) + 'getExistingDirectory', + return_value=str(d) ) - qtbot.mouseClick(pathbox.browse_btn, Qt.LeftButton) + pathbox.browse_path() assert qfdialog_patcher.call_count == 1 + assert pathbox.path() == str(d) + assert pathbox.directory() == str(tmp_path) assert pathbox.is_valid() - assert not pathbox.is_empty() - assert pathbox.path() == selectedfilename - assert osp.samefile(pathbox.directory(), tmp_path) + + +def test_browse_path_get_open_file_name(mocker, tmp_path, qtbot): + f = tmp_path / "bro_file.txt" + f.write_text("abc") + + pathbox = PathBoxWidget(path_type="getOpenFileName") + qtbot.addWidget(pathbox) + + qfdialog_patcher = mocker.patch.object( + QFileDialog, + 'getOpenFileName', + return_value=(str(f), "") + ) + + pathbox.browse_path() + assert qfdialog_patcher.call_count == 1 + assert pathbox.path() == str(f) + assert pathbox.directory() == str(tmp_path) + assert pathbox.is_valid() + + +def test_browse_path_get_save_file_name(mocker, tmp_path, qtbot): + f = tmp_path / "save_file.txt" + + pathbox = PathBoxWidget(path_type="getSaveFileName") + qtbot.addWidget(pathbox) + + qfdialog_patcher = mocker.patch.object( + QFileDialog, + 'getSaveFileName', + return_value=(str(f), "") + ) + + pathbox.browse_path() + assert qfdialog_patcher.call_count == 1 + assert pathbox.path() == str(f) + assert pathbox.directory() == str(tmp_path) if __name__ == "__main__":