Skip to content

Commit 1aae063

Browse files
committed
OWSave: Separate dialogs for different platforms
1 parent 3bd171c commit 1aae063

File tree

3 files changed

+319
-241
lines changed

3 files changed

+319
-241
lines changed

Orange/widgets/data/owsave.py

+177-118
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import os.path
2+
import sys
3+
import re
24

3-
from AnyQt.QtWidgets import QFileDialog, QGridLayout, QWidget
5+
from AnyQt.QtWidgets import QFileDialog, QGridLayout, QMessageBox
46

57
from Orange.data.table import Table
68
from Orange.data.io import TabReader, CSVReader, PickleReader, ExcelReader
@@ -9,10 +11,6 @@
911
from Orange.widgets.utils.widgetpreview import WidgetPreview
1012
from Orange.widgets.widget import Input
1113

12-
class FileDialog(QFileDialog):
13-
def changeEvent(self, e):
14-
print(e)
15-
super().selectFile(e)
1614

1715
class OWSave(widget.OWWidget):
1816
name = "Save Data"
@@ -24,170 +22,122 @@ class OWSave(widget.OWWidget):
2422
settings_version = 2
2523

2624
writers = [TabReader, CSVReader, PickleReader, ExcelReader]
27-
filters = [f"{w.DESCRIPTION} (*.*)" for w in writers]
28-
filt_ext = {filter: w.EXTENSIONS[0] for filter, w in zip(filters, writers)}
25+
filters = {
26+
**{f"{w.DESCRIPTION} (*{w.EXTENSIONS[0]})": w
27+
for w in writers},
28+
**{f"Compressed {w.DESCRIPTION} (*{w.EXTENSIONS[0]}.gz)": w
29+
for w in writers}
30+
}
2931
userhome = os.path.expanduser(f"~{os.sep}")
3032

3133
class Inputs:
3234
data = Input("Data", Table)
3335

3436
class Error(widget.OWWidget.Error):
35-
unsupported_sparse = widget.Msg("Use .pkl format for sparse data.")
37+
unsupported_sparse = widget.Msg("Use Pickle format for sparse data.")
3638
no_file_name = widget.Msg("File name is not set.")
3739
general_error = widget.Msg("{}")
3840

3941
class Warning(widget.OWWidget.Warning):
40-
ignored_flag = widget.Msg("{} ignored for this format.")
42+
type_annotation_ignored = widget.Msg(
43+
"Type annotation setting is ignored for this format.")
4144

4245
want_main_area = False
4346
resizing_enabled = False
4447

45-
compress: bool
46-
add_type_annotations: bool
47-
4848
last_dir = Setting("")
49-
filter = Setting(filters[0])
50-
compress = Setting(False)
49+
filter = Setting(next(iter(filters)))
50+
filename = Setting("", schema_only=True)
5151
add_type_annotations = Setting(True)
5252
auto_save = Setting(False)
5353

5454
def __init__(self):
5555
super().__init__()
5656
self.data = None
57-
self.filename = ""
58-
self.writer = self.writers[0]
5957

6058
grid = QGridLayout()
61-
gui.widgetBox(self.controlArea, box=True, orientation=grid)
62-
grid.setSpacing(8)
63-
self.bt_save = gui.button(None, self, "Save", callback=self.save_file)
64-
grid.addWidget(self.bt_save, 0, 0)
65-
grid.addWidget(
66-
gui.button(None, self, "Save as ...", callback=self.save_file_as),
67-
0, 1)
68-
grid.addWidget(
69-
gui.checkBox(None, self, "auto_save",
70-
"Autosave when receiving new data",
71-
callback=self._update_controls),
72-
1, 0, 1, 2)
73-
grid.addWidget(QWidget(), 2, 0, 1, 2)
74-
59+
gui.widgetBox(self.controlArea, orientation=grid)
7560
grid.addWidget(
7661
gui.checkBox(
7762
None, self, "add_type_annotations",
78-
"Save with type annotations", callback=self._update_controls),
79-
3, 0, 1, 2)
63+
"Save with type annotations", callback=self._update_messages),
64+
0, 0, 1, 2)
65+
grid.setRowMinimumHeight(1, 8)
8066
grid.addWidget(
8167
gui.checkBox(
82-
None, self, "compress", "Compress file (gzip)",
83-
callback=self._update_controls),
84-
4, 0, 1, 2)
68+
None, self, "auto_save", "Autosave when receiving new data",
69+
callback=self._update_messages),
70+
2, 0, 1, 2)
71+
grid.setRowMinimumHeight(3, 8)
72+
self.bt_save = gui.button(None, self, "Save", callback=self.save_file)
73+
grid.addWidget(self.bt_save, 4, 0)
74+
grid.addWidget(
75+
gui.button(None, self, "Save as ...", callback=self.save_file_as),
76+
4, 1)
8577

8678
self.adjustSize()
87-
self._update_controls()
79+
self._update_messages()
80+
81+
@property
82+
def writer(self):
83+
return self.filters[self.filter]
8884

8985
@Inputs.data
9086
def dataset(self, data):
9187
self.Error.clear()
9288
self.data = data
93-
94-
self._update_controls()
95-
if self.data is None:
96-
self.info.set_input_summary(self.info.NoInput)
97-
else:
98-
self.info.set_input_summary(
99-
str(len(self.data)),
100-
f"Data set {self.data.name or '(no name)'} "
101-
f"with {len(self.data)} instances")
102-
89+
self._update_status()
90+
self._update_messages()
10391
if self.auto_save and self.filename:
10492
self.save_file()
10593

106-
def save_file_as(self):
107-
if self.filename:
108-
start_dir = self.filename
109-
else:
110-
data_name = getattr(self.data, 'name', '')
111-
if data_name:
112-
data_name += self.filt_ext[self.filter]
113-
start_dir = os.path.join(self.last_dir or self.userhome, data_name)
114-
115-
dlg = FileDialog(None, "Set File", start_dir, ";;".join(self.filters))
116-
dlg.setLabelText(dlg.Accept, "Select")
117-
dlg.setAcceptMode(dlg.AcceptSave)
118-
dlg.setSupportedSchemes(["file"])
119-
dlg.selectNameFilter(self.filter)
120-
dlg.setOption(QFileDialog.HideNameFilterDetails)
121-
dlg.currentChanged.connect(print)
122-
if dlg.exec() == dlg.Rejected:
123-
return
124-
125-
filename = dlg.selectedFiles()[0]
126-
selected_filter = dlg.selectedNameFilter()
127-
128-
# filename, selected_filter = QFileDialog.getSaveFileName(
129-
# self, "Save data", start_dir, ";;".join(self.filters), self.filter,
130-
# QFileDialog.HideNameFilterDetails)
131-
if not filename:
132-
return
133-
134-
self.filename = filename
135-
self.last_dir = os.path.split(filename)[0]
136-
self.filter = selected_filter
137-
self.writer = self.writers[self.filters.index(self.filter)]
138-
self._update_controls()
139-
self.save_file()
140-
14194
def save_file(self):
14295
if not self.filename:
14396
self.save_file_as()
14497
return
98+
14599
self.Error.general_error.clear()
146-
if not self._can_save():
100+
if self.data is None \
101+
or not self.filename \
102+
or (self.data.is_sparse()
103+
and not self.writer.SUPPORT_SPARSE_DATA):
147104
return
148105
try:
149106
self.writer.write(
150-
self._fullname(), self.data, self.add_type_annotations)
107+
self.filename, self.data, self.add_type_annotations)
151108
except IOError as err_value:
152109
self.Error.general_error(str(err_value))
153110

154-
def _fullname(self):
155-
return self.filename \
156-
+ ".gz" * self.writer.SUPPORT_COMPRESSED * self.compress
157-
158-
def _update_controls(self):
159-
if self.filename:
160-
self.bt_save.setText(
161-
f"Save as {os.path.split(self._fullname())[1]}")
162-
else:
163-
self.bt_save.setText("Save")
164-
self.Error.no_file_name(shown=not self.filename and self.auto_save)
111+
def save_file_as(self):
112+
filename, selected_filter = self.get_save_filename()
113+
if not filename:
114+
return
115+
self.filename = filename
116+
self.filter = selected_filter
117+
self.last_dir = os.path.split(self.filename)[0]
118+
self.bt_save.setText(f"Save as {os.path.split(filename)[1]}")
119+
self._update_messages()
120+
self.save_file()
165121

122+
def _update_messages(self):
123+
self.Error.no_file_name(
124+
shown=not self.filename and self.auto_save)
166125
self.Error.unsupported_sparse(
167126
shown=self.data is not None and self.data.is_sparse()
168127
and self.filename and not self.writer.SUPPORT_SPARSE_DATA)
128+
self.Warning.type_annotation_ignored(
129+
shown=self.data is not None and self.filename
130+
and self.add_type_annotations
131+
and not self.writer.OPTIONAL_TYPE_ANNOTATIONS)
169132

170-
if self.data is None or not self.filename:
171-
self.Warning.ignored_flag.clear()
133+
def _update_status(self):
134+
if self.data is None:
135+
self.info.set_input_summary(self.info.NoInput)
172136
else:
173-
no_compress = self.compress \
174-
and not self.writer.SUPPORT_COMPRESSED
175-
no_anotation = self.add_type_annotations \
176-
and not self.writer.OPTIONAL_TYPE_ANNOTATIONS
177-
ignored = [
178-
"",
179-
"Compression flag is",
180-
"Type annotation flag is",
181-
"Compression and type annotation flags are"
182-
][no_compress + 2 * no_anotation]
183-
self.Warning.ignored_flag(ignored, shown=bool(ignored))
184-
185-
def _can_save(self):
186-
return not (
187-
self.data is None
188-
or not self.filename
189-
or self.data.is_sparse() and not self.writer.SUPPORT_SPARSE_DATA
190-
)
137+
self.info.set_input_summary(
138+
str(len(self.data)),
139+
f"Data set {self.data.name or '(no name)'} "
140+
f"with {len(self.data)} instances")
191141

192142
def send_report(self):
193143
self.report_data_brief(self.data)
@@ -196,17 +146,126 @@ def send_report(self):
196146
self.report_items((
197147
("File name", self.filename or "not set"),
198148
("Format", writer.DESCRIPTION),
199-
("Compression", writer.SUPPORT_COMPRESSED and noyes[self.compress]),
200149
("Type annotations",
201150
writer.OPTIONAL_TYPE_ANNOTATIONS
202151
and noyes[self.add_type_annotations])
203152
))
204153

205154
@classmethod
206155
def migrate_settings(cls, settings, version=0):
207-
settings.filter = next(iter(cls.filt_ext))
208-
# if version < 2:
209-
# settings["filter"] = settings.pop("filetype")
156+
if version < 2:
157+
prev_filter = settings.pop("filetype", None)
158+
if prev_filter is None:
159+
settings["filter"] = next(iter(cls.filters))
160+
else:
161+
prev_ext = cls._extension_from_filter(prev_filter)
162+
prev_filter = prev_filter.split("(")[0]
163+
if settings.pop("compress", False):
164+
prev_filter = f"Compressed {prev_filter} (*{prev_ext}.gz)"
165+
else:
166+
prev_filter = f"{prev_filter} (*{prev_ext})"
167+
settings["filter"] = prev_filter
168+
169+
def _initial_start_dir(self):
170+
if self.filename and os.path.exists(os.path.split(self.filename)[0]):
171+
return self.filename
172+
else:
173+
data_name = getattr(self.data, 'name', '')
174+
if data_name:
175+
data_name += self.writer.EXTENSIONS[0]
176+
return os.path.join(self.last_dir or self.userhome, data_name)
177+
178+
@staticmethod
179+
def _replace_extension(filename, extension):
180+
if filename.endswith(extension): # it may contain dots before extension
181+
return filename
182+
last_fn = None
183+
while last_fn != filename:
184+
last_fn, filename = filename, os.path.splitext(filename)[0]
185+
return filename + extension
186+
187+
@staticmethod
188+
def _extension_from_filter(selected_filter):
189+
return re.search(r".*\(\*?(\..*)\)$", selected_filter).group(1)
190+
191+
# As of Qt 5.9, QFileDialog.setDefaultSuffix does not support double
192+
# suffixes, not even in non-native dialogs. We handle each OS separately.
193+
if sys.platform == "darwin":
194+
# On macOS, is double suffixes are passed to the dialog, they are
195+
# appended multiple times even if already present (QTBUG-44227).
196+
# The only known workaround with native dialog is to use suffix *.*.
197+
# We add the correct suffix after closing the dialog and only then check
198+
# if the file exists and ask whether to override.
199+
# It is a bit confusing that the user does not see the final name in the
200+
# dialog, but I see no better solution.
201+
def get_save_filename(self):
202+
def no_suffix(filt):
203+
return filt.split("(")[0] + "(*.*)"
204+
205+
mac_filters = {no_suffix(f): f for f in self.filters}
206+
filename = self._initial_start_dir()
207+
while True:
208+
dlg = QFileDialog(
209+
None, "Save File", filename, ";;".join(mac_filters))
210+
dlg.setAcceptMode(dlg.AcceptSave)
211+
dlg.selectNameFilter(no_suffix(self.filter))
212+
dlg.setOption(QFileDialog.HideNameFilterDetails)
213+
dlg.setOption(QFileDialog.DontConfirmOverwrite)
214+
if dlg.exec() == dlg.Rejected:
215+
return "", ""
216+
filename = dlg.selectedFiles()[0]
217+
selected_filter = mac_filters[dlg.selectedNameFilter()]
218+
filename = self._replace_extension(
219+
filename, self._extension_from_filter(selected_filter))
220+
if not os.path.exists(filename) or QMessageBox.question(
221+
self, "Overwrite file?",
222+
f"File {os.path.split(filename)[1]} already exists.\n"
223+
"Overwrite?") == QMessageBox.Yes:
224+
return filename, selected_filter
225+
226+
elif sys.platform == "win32":
227+
# TODO: This is not tested!!!
228+
# Windows native dialog may work correctly; if not, we may do the same
229+
# as for macOS?
230+
def get_save_filename(self):
231+
return QFileDialog.getSaveFileName(
232+
self, "Save File", self._initial_start_dir(),
233+
";;".join(self.filters), self.filter)
234+
235+
else: # Linux and any unknown platforms
236+
# Qt does not use a native dialog on Linux, so we can connect to
237+
# filterSelected and to overload selectFile to change the extension
238+
# while the dialog is open.
239+
# For unknown platforms (which?), we also use the non-native dialog to
240+
# be sure we know what happens.
241+
class SaveFileDialog(QFileDialog):
242+
# pylint: disable=protected-access
243+
def __init__(self, *args, **kwargs):
244+
super().__init__(*args, **kwargs)
245+
self.setAcceptMode(QFileDialog.AcceptSave)
246+
self.setOption(QFileDialog.DontUseNativeDialog)
247+
self.filterSelected.connect(self.updateDefaultExtension)
248+
249+
def updateDefaultExtension(self, selected_filter):
250+
self.suffix = OWSave._extension_from_filter(selected_filter)
251+
files = self.selectedFiles()
252+
if files and not os.path.isdir(files[0]):
253+
self.selectFile(files[0].split(".")[0])
254+
255+
def selectFile(self, filename):
256+
filename = OWSave._replace_extension(filename, self.suffix)
257+
super().selectFile(filename)
258+
259+
def get_save_filename(self):
260+
dlg = self.SaveFileDialog(
261+
None, "Save File", self._initial_start_dir(),
262+
";;".join(self.filters))
263+
dlg.selectNameFilter(self.filter)
264+
dlg.updateDefaultExtension(self.filter)
265+
if dlg.exec() == QFileDialog.Rejected:
266+
return "", ""
267+
else:
268+
return dlg.selectedFiles()[0], dlg.selectedNameFilter()
210269

211270

212271
if __name__ == "__main__": # pragma: no cover

0 commit comments

Comments
 (0)