Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Filedialog widget for magicgui #23

Merged
merged 41 commits into from
Jul 3, 2020
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
52578ca
Add mapping between pathllib.Path and QFileDialog
GenevieveBuckley Jun 8, 2020
2ae87ae
Partial step towards implementation
GenevieveBuckley Jun 22, 2020
96502d5
Working filedialog, but opened immediately
GenevieveBuckley Jun 22, 2020
b3eb790
update lineedit filedialog
tlambert03 Jun 23, 2020
7c54a35
update docstring
tlambert03 Jun 23, 2020
a6d4b1b
update example
tlambert03 Jun 23, 2020
cdc6608
Merge branch 'master' into filedialog-widget
GenevieveBuckley Jun 24, 2020
23dff3d
Merge pull request #1 from tlambert03/tjl-filedialog
GenevieveBuckley Jul 1, 2020
7f7473f
Rename to MagicFileDialog for clarity
GenevieveBuckley Jul 1, 2020
9e59bec
Add basic MagicFileDialog test
GenevieveBuckley Jul 1, 2020
2ace939
Test MagicFileDialog mode set with improperly capitalized Enum
GenevieveBuckley Jul 1, 2020
f076201
Fix FileDialogMode enum test
GenevieveBuckley Jul 1, 2020
13d6f5c
Add test for MagicFileDialog popup file chooser
GenevieveBuckley Jul 2, 2020
868c05a
Merge branch 'master' into filedialog-widget
GenevieveBuckley Jul 2, 2020
71f4558
Skip test_magicfiledialog_open_chooser on Mac because filedialog won'…
GenevieveBuckley Jul 2, 2020
d1f69a4
Test unsupported type for MagicFileDialog set_path
GenevieveBuckley Jul 2, 2020
10dc21e
Add 'r' and 'w' aliases to FileDialogMode enumeration
GenevieveBuckley Jul 2, 2020
771ac6e
Use read mode 'r' in file dialog example code
GenevieveBuckley Jul 2, 2020
86831a8
Parametrize magicfiledialog popup chooser test
GenevieveBuckley Jul 2, 2020
4ae4831
test_get_set_change with MagicFileDialog widget too
GenevieveBuckley Jul 2, 2020
740fd9b
Test MagicFileDialog creation with kwargs 'mode' and 'filter'
GenevieveBuckley Jul 2, 2020
bfd8478
Improve test coverage in test_magicfiledialog_opens_chooser by settin…
GenevieveBuckley Jul 2, 2020
0d6ddfd
Test MagicFileDialog get/set multiple paths
GenevieveBuckley Jul 2, 2020
a366db5
Change MagicFileDialog default 'r' mode to getExistingFileNames (mult…
GenevieveBuckley Jul 2, 2020
ddeafd0
Unneeded else clause in type2widget
GenevieveBuckley Jul 2, 2020
680c574
Improve code coverage of MagicFileDialog popup chooser test
GenevieveBuckley Jul 2, 2020
520854d
Test get/set multiple filepaths in MagicFileDialog properly
GenevieveBuckley Jul 2, 2020
0d7587c
Test MagicFileDialog multiple filepaths set with iterable
GenevieveBuckley Jul 2, 2020
e48e6fa
Ooops, turns out this else clause is very important
GenevieveBuckley Jul 2, 2020
9037abb
Add test that would have prevented my silly error
GenevieveBuckley Jul 2, 2020
0e489de
Fix space issue if multiple files selected in MagicFileDialog
GenevieveBuckley Jul 2, 2020
1f70acf
Add test docstrings for linter compliance
GenevieveBuckley Jul 2, 2020
e25bc01
Update magicgui/_tests/test_ qt.py
GenevieveBuckley Jul 3, 2020
5e715c4
Test invalid string when seting mode of MagicFileDialog
GenevieveBuckley Jul 3, 2020
77dc265
Black formatting for test_qt.py
GenevieveBuckley Jul 3, 2020
77b2a04
Fix linter error
GenevieveBuckley Jul 3, 2020
de3d9af
Merge branch 'master' into filedialog-widget
GenevieveBuckley Jul 3, 2020
df09f1f
Add clarification to prevent future misunderstandings of MagicFileDia…
GenevieveBuckley Jul 3, 2020
78aa56a
Update magicgui/_tests/test_ qt.py
GenevieveBuckley Jul 3, 2020
0ed63e0
Update magicgui/_qt.py
tlambert03 Jul 3, 2020
5ec9874
Fix test so MagicFileDialog 'r' mode opens a single existing file
GenevieveBuckley Jul 3, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions examples/file_dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""FileDialog with magicgui."""
from pathlib import Path
from magicgui import event_loop, magicgui


# may also add Qt-style filter to filename options:
# e.g. {"filter": "Images (*.tif *.tiff)"}
@magicgui(filename={"mode": "r"})
def filepicker(filename=Path("~")):
"""Take a filename and do something with it."""
print("The filename is:", filename)
return filename


with event_loop():
gui = filepicker.Gui(show=True)
gui.filename_changed.connect(print)
133 changes: 132 additions & 1 deletion magicgui/_qt.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
# -*- coding: utf-8 -*-
"""Widgets and type-to-widget conversion for the Qt backend."""

import os
import sys
from contextlib import contextmanager
import datetime
from enum import Enum, EnumMeta
from typing import Any, Callable, Dict, Iterable, NamedTuple, Optional, Tuple, Type
from pathlib import Path
from typing import (
Any,
Callable,
Dict,
Iterable,
List,
NamedTuple,
Optional,
Tuple,
Type,
Union,
)

from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import (
Expand All @@ -17,6 +30,7 @@
QComboBox,
QDateTimeEdit,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QGridLayout,
QGroupBox,
Expand Down Expand Up @@ -153,13 +167,18 @@ def type2widget(type_: type) -> Optional[Type[WidgetType]]:
int: QSpinBox,
float: QDoubleSpinBox,
str: QLineEdit,
Path: MagicFileDialog,
datetime.datetime: QDateTimeEdit,
type(None): QLineEdit,
}
if type_ in simple:
return simple[type_]
elif isinstance(type_, EnumMeta):
return QDataComboBox
else:
for key in simple.keys():
if issubclass(type_, key):
return simple[key]
return None


Expand Down Expand Up @@ -219,6 +238,10 @@ def getter():
)
elif isinstance(widget, QSplitter):
return GetSetOnChange(widget.sizes, widget.setSizes, widget.splitterMoved)
elif isinstance(widget, MagicFileDialog):
return GetSetOnChange(
widget.get_path, widget.set_path, widget.line_edit.textChanged
)
raise ValueError(f"Unrecognized widget Type: {widget}")


Expand Down Expand Up @@ -286,6 +309,11 @@ def make_widget(
if setter:
setter(val)

if isinstance(widget, MagicFileDialog):
if "mode" in kwargs:
widget.mode = kwargs["mode"]
if "filter" in kwargs:
widget.filter = kwargs["filter"]
return widget


Expand All @@ -312,3 +340,106 @@ def setValue(self, value):
def setMaximum(self, value):
"""Set maximum position of slider in float units."""
super().setMaximum(value * self.PRECISION)


class FileDialogMode(Enum):
"""FileDialog mode options.

EXISTING_FILE - returns one existing file.
EXISTING_FILES - return one or more existing files.
OPTIONAL_FILE - return one file name that does not have to exist.
EXISTING_DIRECTORY - returns one existing directory.
R - read mode, returns one or more existing files.
Alias of EXISTING_FILES.
W - write mode, returns one file name that does not have to exist.
Alias of OPTIONAL_FILE.
"""

EXISTING_FILE = "getOpenFileName"
EXISTING_FILES = "getOpenFileNames"
OPTIONAL_FILE = "getSaveFileName"
EXISTING_DIRECTORY = "getExistingDirectory"
# Aliases
R = "getOpenFileNames"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
R = "getOpenFileNames"
R = "getOpenFileName"

let's make this an alias for a single file. and in a followup (or here if you want to) we can do something like this:

@magicgui
def filepicker(filename=Sequence[Path("~")]):
    ...

and then in type2widget return functools.partial(MagicFileDialog, mode='EXISTING_FILES')

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whoops, sorry. I committed this and it broke the test. can you fix that? Then I think this is good to go

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok

W = "getSaveFileName"


class MagicFileDialog(QWidget):
"""A LineEdit widget with a QFileDialog button."""

def __init__(
self,
parent=None,
mode: Union[FileDialogMode, str] = FileDialogMode.OPTIONAL_FILE,
filter: str = "",
):
super().__init__(parent)
self.line_edit = QLineEdit(self)
self.choose_btn = QPushButton("Choose file", self)
self.choose_btn.clicked.connect(self._on_choose_clicked)
self.mode = mode
self.filter: str = filter
layout = QHBoxLayout(self)
layout.addWidget(self.line_edit)
layout.addWidget(self.choose_btn)

def _help_text(self):
if self.mode is FileDialogMode.EXISTING_DIRECTORY:
return "Choose directory"
else:
return "Select file" + ("s" if self.mode.name.endswith("S") else "")

@property
def mode(self):
"""Mode for the FileDialog."""
return self._mode

@mode.setter
def mode(self, value: Union[FileDialogMode, str]):
mode: Union[FileDialogMode, str] = value
if isinstance(value, str):
try:
mode = FileDialogMode(value)
except ValueError:
try:
mode = FileDialogMode[value.upper()]
except KeyError:
pass
GenevieveBuckley marked this conversation as resolved.
Show resolved Hide resolved
if not isinstance(mode, FileDialogMode):
raise ValueError(
f"{mode!r} is not a valid FileDialogMode. "
f"Options include {set(i.name.lower() for i in FileDialogMode)}"
)
self._mode = mode
self.choose_btn.setText(self._help_text())

def _on_choose_clicked(self):
show_dialog = getattr(QFileDialog, self.mode.value)
start_path = self.get_path()
if isinstance(start_path, tuple):
start_path = start_path[0]
start_path = os.fspath(os.path.abspath(os.path.expanduser(start_path)))
caption = self._help_text()
if self.mode is FileDialogMode.EXISTING_DIRECTORY:
result = show_dialog(self, caption, start_path)
else:
result, _ = show_dialog(self, caption, start_path, self.filter)
if result:
self.set_path(result)

def get_path(self) -> Union[Tuple[Path, ...], Path]:
"""Get current file path."""
text = self.line_edit.text()
if self.mode is FileDialogMode.EXISTING_FILES:
return tuple(Path(p) for p in text.split(", "))
return Path(text)

def set_path(self, value: Union[List[str], Tuple[str, ...], str, Path]):
"""Set current file path."""
if isinstance(value, (list, tuple)):
value = ", ".join([os.fspath(p) for p in value])
if not isinstance(value, (str, Path)):
raise TypeError(
f"value must be a string, or list/tuple of strings, got {type(value)}"
)
self.line_edit.setText(str(value))
76 changes: 75 additions & 1 deletion magicgui/_tests/test_ qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

"""Tests for `magicgui._qt` module."""

from enum import Enum
import datetime
from enum import Enum
from pathlib import Path, PosixPath, WindowsPath

import pytest
from qtpy import QtCore
Expand Down Expand Up @@ -40,6 +41,7 @@ def test_event():
QtW.QSplitter,
QtW.QSlider,
_qt.QDoubleSlider,
_qt.MagicFileDialog,
],
)
def test_get_set_change(qtbot, WidgetClass):
Expand Down Expand Up @@ -76,6 +78,15 @@ def test_setters(qtbot):
assert w.maximum() == 10


def test_make_widget_magicfiledialog(qtbot):
"""Test MagicFileDialog creation with kwargs 'mode' and 'filter'."""
w = _qt.make_widget(_qt.MagicFileDialog, "magicfiledialog",
mode="r",
filter="Images (*.tif *.tiff)")
assert w.mode == _qt.FileDialogMode.EXISTING_FILES
assert w.filter == "Images (*.tif *.tiff)"


def test_datetimeedit(qtbot):
"""Test the datetime getter."""
getter, setter, onchange = _qt.getter_setter_onchange(QtW.QDateTimeEdit())
Expand All @@ -90,3 +101,66 @@ def test_set_categorical(qtbot):
assert [w.itemText(i) for i in range(w.count())] == ["a", "b"]
_qt.set_categorical_choices(w, (("a", 1), ("c", 3)))
assert [w.itemText(i) for i in range(w.count())] == ["a", "c"]


def test_magicfiledialog(qtbot):
"""Test the MagicFileDialog class."""
filewidget = _qt.MagicFileDialog()

# check default values
assert filewidget.get_path() == Path('.')
assert filewidget.mode == _qt.FileDialogMode.OPTIONAL_FILE

# set the mode
filewidget.mode = _qt.FileDialogMode.EXISTING_FILES # Enum input
assert filewidget.mode == _qt.FileDialogMode.EXISTING_FILES
filewidget.mode = "oPtioNal_FiLe" # improper capitalization
assert filewidget.mode == _qt.FileDialogMode.OPTIONAL_FILE
filewidget.mode = "EXISTING_DIRECTORY" # string input
assert filewidget.mode == _qt.FileDialogMode.EXISTING_DIRECTORY
with pytest.raises(ValueError):
filewidget.mode = 123 # invalid mode
GenevieveBuckley marked this conversation as resolved.
Show resolved Hide resolved
filewidget.mode = 'invalid_string'

# set the path
filewidget.set_path('my/example/path/')
assert filewidget.get_path() == Path('my/example/path/')

filewidget.mode = _qt.FileDialogMode.EXISTING_FILES
filewidget.set_path(['path/one.txt', 'path/two.txt'])
assert filewidget.get_path() == (Path('path/one.txt'), Path('path/two.txt'))
filewidget.set_path(['path/3.txt, path/4.txt'])
assert filewidget.get_path() == (Path('path/3.txt'), Path('path/4.txt'))

with pytest.raises(TypeError):
filewidget.set_path(123) # invalid type, only str/Path accepted


@pytest.mark.skipif("sys.platform == 'darwin'") # dialog box hangs on Mac
@pytest.mark.parametrize("mode", [
('r'), # existing file
GenevieveBuckley marked this conversation as resolved.
Show resolved Hide resolved
('EXISTING_DIRECTORY'),
])
def test_magicfiledialog_opens_chooser(qtbot, mode):
"""Test the choose button opens a popup file dialog."""
filewidget = _qt.MagicFileDialog()
filewidget.set_path(('.',)) # set_path with tuple for better code coverage
filewidget.mode = mode

def handle_dialog():
popup_filedialog = next(
child
for child in filewidget.children()
if isinstance(child, QtW.QFileDialog)
)
assert isinstance(popup_filedialog, QtW.QFileDialog)
popup_filedialog.reject()

QtCore.QTimer().singleShot(400, handle_dialog)
filewidget._on_choose_clicked()


@pytest.mark.parametrize("pathtype", [PosixPath, WindowsPath, Path])
def test_linux_mac_magifiledialog(pathtype):
"""Test we get a MagicFileDialog from a various Path types."""
assert _qt.type2widget(pathtype) == _qt.MagicFileDialog