Repository: biolab/orange3

Issue:
Improve the layout of OWSave
I nominate this widget for the ugliest widget in Orange core. Suggestions for improvements:

- Compression doesn't need a checkbox; "None" (or "No compression") can be an option in the combo. (Don't forget to migrate settings).
- Form layout shouldn't override the default system alignment of labels.
- Save buttons are too wide.
- The checkbox beside the save button is rather unimpressive, and does not have the same meaning as elsewhere. It tells whether the widget reacts after receiving new data and not (as is assumed elsewhere) to changes in the widget. This can be resolved by replacing it with a checkbox with a label, something like "Autosave upon receiving new data".

##### Expected behavior

Looks nice.

##### Actual behavior

Looks ugly. 

##### Steps to reproduce the behavior

Open it.

##### Additional info (worksheets, data, screenshots, ...)

Rather not.


Discussion:

Resolving patch:
diff --git a/Orange/data/io.py b/Orange/data/io.py
--- a/Orange/data/io.py
+++ b/Orange/data/io.py
@@ -987,7 +987,7 @@ class ExcelReader(FileFormat):
     """Reader for excel files"""
     EXTENSIONS = ('.xlsx',)
     DESCRIPTION = 'Microsoft Excel spreadsheet'
-    SUPPORT_COMPRESSED = True
+    SUPPORT_COMPRESSED = False
     SUPPORT_SPARSE_DATA = False
 
     def __init__(self, filename):
diff --git a/Orange/widgets/data/owsave.py b/Orange/widgets/data/owsave.py
--- a/Orange/widgets/data/owsave.py
+++ b/Orange/widgets/data/owsave.py
@@ -1,31 +1,16 @@
 import os.path
-import pathlib
+import sys
+import re
 
-from AnyQt.QtWidgets import QFormLayout
-from AnyQt.QtCore import Qt
+from AnyQt.QtWidgets import QFileDialog, QGridLayout, QMessageBox
 
 from Orange.data.table import Table
-from Orange.data.io import Compression, FileFormat, TabReader, CSVReader, PickleReader, \
-    ExcelReader
+from Orange.data.io import TabReader, CSVReader, PickleReader, ExcelReader
 from Orange.widgets import gui, widget
 from Orange.widgets.settings import Setting
-from Orange.widgets.utils import filedialogs
 from Orange.widgets.utils.widgetpreview import WidgetPreview
 from Orange.widgets.widget import Input
 
-FILE_TYPES = [
-    ("{} ({})".format(w.DESCRIPTION, w.EXTENSIONS[0]),
-     w.EXTENSIONS[0],
-     w.SUPPORT_SPARSE_DATA)
-    for w in (TabReader, CSVReader, PickleReader, ExcelReader)
-]
-
-COMPRESSIONS = [
-    ("gzip ({})".format(Compression.GZIP), Compression.GZIP),
-    ("bzip2 ({})".format(Compression.BZIP2), Compression.BZIP2),
-    ("lzma ({})".format(Compression.XZ), Compression.XZ),
-]
-
 
 class OWSave(widget.OWWidget):
     name = "Save Data"
@@ -34,170 +19,274 @@ class OWSave(widget.OWWidget):
     category = "Data"
     keywords = []
 
+    settings_version = 2
+
+    writers = [TabReader, CSVReader, PickleReader, ExcelReader]
+    filters = {
+        **{f"{w.DESCRIPTION} (*{w.EXTENSIONS[0]})": w
+           for w in writers},
+        **{f"Compressed {w.DESCRIPTION} (*{w.EXTENSIONS[0]}.gz)": w
+           for w in writers if w.SUPPORT_COMPRESSED}
+    }
+    userhome = os.path.expanduser(f"~{os.sep}")
+
     class Inputs:
         data = Input("Data", Table)
 
     class Error(widget.OWWidget.Error):
-        unsupported_extension = widget.Msg("Selected extension is not supported.")
+        unsupported_sparse = widget.Msg("Use Pickle format for sparse data.")
+        no_file_name = widget.Msg("File name is not set.")
+        general_error = widget.Msg("{}")
 
     want_main_area = False
     resizing_enabled = False
 
     last_dir = Setting("")
-    auto_save = Setting(False)
-    filetype = Setting(FILE_TYPES[0][0])
-    compression = Setting(COMPRESSIONS[0][0])
-    compress = Setting(False)
+    filter = Setting(next(iter(filters)))
+    filename = Setting("", schema_only=True)
     add_type_annotations = Setting(True)
+    auto_save = Setting(False)
 
     def __init__(self):
         super().__init__()
         self.data = None
-        self.filename = ""
-        self.basename = ""
-        self.type_ext = ""
-        self.compress_ext = ""
-
-        form = QFormLayout(
-            labelAlignment=Qt.AlignLeft,
-            formAlignment=Qt.AlignLeft,
-            rowWrapPolicy=QFormLayout.WrapLongRows,
-            verticalSpacing=10,
-        )
-
-        box = gui.vBox(self.controlArea, "Format")
-
-        gui.comboBox(
-            box, self, "filetype",
-            callback=self._update_text,
-            items=[item for item, _, _ in FILE_TYPES],
-            sendSelectedValue=True,
-        )
-        form.addRow("File type", self.controls.filetype, )
-
-        gui.comboBox(
-            box, self, "compression",
-            callback=self._update_text,
-            items=[item for item, _ in COMPRESSIONS],
-            sendSelectedValue=True,
-        )
-        gui.checkBox(
-            box, self, "compress", label="Use compression",
-            callback=self._update_text,
-        )
-
-        form.addRow(self.controls.compress, self.controls.compression)
-
-        box.layout().addLayout(form)
-
-        self.annotations_cb = gui.checkBox(
-            None, self, "add_type_annotations", label="Add type annotations",
-        )
-        form.addRow(self.annotations_cb, None)
-
-        self.save = gui.auto_commit(
-            self.controlArea, self, "auto_save", "Save", box=False,
-            commit=self.save_file, callback=self.adjust_label,
-            disabled=True, addSpace=True
-        )
-        self.save_as = gui.button(
-            self.controlArea, self, "Save As...",
-            callback=self.save_file_as, disabled=True
-        )
-        self.save_as.setMinimumWidth(220)
-        self.adjustSize()
-
-    def get_writer_selected(self):
-        writer = FileFormat.get_reader(self.type_ext)
 
-        ext = self.type_ext + self.compress_ext
-        if ext not in writer.EXTENSIONS:
-            self.Error.unsupported_extension()
-            return None
-        writer.EXTENSIONS = [ext]
-        return writer
+        grid = QGridLayout()
+        gui.widgetBox(self.controlArea, orientation=grid)
+        grid.addWidget(
+            gui.checkBox(
+                None, self, "add_type_annotations",
+                "Add type annotations to header",
+                tooltip=
+                "Some formats (Tab-delimited, Comma-separated) can include \n"
+                "additional information about variables types in header rows.",
+                callback=self._update_messages),
+            0, 0, 1, 2)
+        grid.setRowMinimumHeight(1, 8)
+        grid.addWidget(
+            gui.checkBox(
+                None, self, "auto_save", "Autosave when receiving new data",
+                callback=self._update_messages),
+            2, 0, 1, 2)
+        grid.setRowMinimumHeight(3, 8)
+        self.bt_save = gui.button(None, self, "Save", callback=self.save_file)
+        grid.addWidget(self.bt_save, 4, 0)
+        grid.addWidget(
+            gui.button(None, self, "Save as ...", callback=self.save_file_as),
+            4, 1)
 
-    @classmethod
-    def remove_extensions(cls, filename):
-        if not filename:
-            return None
-        for ext in pathlib.PurePosixPath(filename).suffixes:
-            filename = filename.replace(ext, '')
-        return filename
+        self.adjustSize()
+        self._update_messages()
 
-    def adjust_label(self):
-        if self.filename:
-            text = "Auto save as '{}'" if self.auto_save else "Save as '{}'"
-            self.save.button.setText(
-                text.format(self.basename + self.type_ext + self.compress_ext))
+    @property
+    def writer(self):
+        return self.filters[self.filter]
 
     @Inputs.data
     def dataset(self, data):
+        self.Error.clear()
         self.data = data
-        self.save.setDisabled(data is None)
-        self.save_as.setDisabled(data is None)
-        if data is None:
-            return
+        self._update_status()
+        self._update_messages()
+        if self.auto_save and self.filename:
+            self.save_file()
 
-        items = [item for item, _, supports_sparse in FILE_TYPES
-                 if supports_sparse or not data.is_sparse()]
-        if items != [self.controls.filetype.itemText(i) for i in
-                     range(self.controls.filetype.count())]:
-            self.controls.filetype.clear()
-            self.controls.filetype.insertItems(0, items)
-            self.update_extension()
-
-        self.save_file()
+    def save_file(self):
+        if not self.filename:
+            self.save_file_as()
+            return
 
-    def save_file_as(self):
-        file_name = self.remove_extensions(self.filename) or os.path.join(
-            self.last_dir or os.path.expanduser("~"),
-            getattr(self.data, 'name', ''))
-        self.update_extension()
-        writer = self.get_writer_selected()
-        if not writer:
+        self.Error.general_error.clear()
+        if self.data is None \
+                or not self.filename \
+                or (self.data.is_sparse()
+                        and not self.writer.SUPPORT_SPARSE_DATA):
             return
+        try:
+            self.writer.write(
+                self.filename, self.data, self.add_type_annotations)
+        except IOError as err_value:
+            self.Error.general_error(str(err_value))
 
-        filename, writer, _ = filedialogs.open_filename_dialog_save(
-            file_name, '', [writer],
-        )
+    def save_file_as(self):
+        filename, selected_filter = self.get_save_filename()
         if not filename:
             return
-
         self.filename = filename
+        self.filter = selected_filter
         self.last_dir = os.path.split(self.filename)[0]
-        self.basename = os.path.basename(self.remove_extensions(filename))
-        self.unconditional_save_file()
-        self.adjust_label()
+        self.bt_save.setText(f"Save as {os.path.split(filename)[1]}")
+        self._update_messages()
+        self.save_file()
 
-    def save_file(self):
+    def _update_messages(self):
+        self.Error.no_file_name(
+            shown=not self.filename and self.auto_save)
+        self.Error.unsupported_sparse(
+            shown=self.data is not None and self.data.is_sparse()
+            and self.filename and not self.writer.SUPPORT_SPARSE_DATA)
+
+    def _update_status(self):
         if self.data is None:
-            return
-        if not self.filename:
-            self.save_file_as()
+            self.info.set_input_summary(self.info.NoInput)
+        else:
+            self.info.set_input_summary(
+                str(len(self.data)),
+                f"Data set {self.data.name or '(no name)'} "
+                f"with {len(self.data)} instances")
+
+    def send_report(self):
+        self.report_data_brief(self.data)
+        writer = self.writer
+        noyes = ["No", "Yes"]
+        self.report_items((
+            ("File name", self.filename or "not set"),
+            ("Format", writer.DESCRIPTION),
+            ("Type annotations",
+             writer.OPTIONAL_TYPE_ANNOTATIONS
+             and noyes[self.add_type_annotations])
+        ))
+
+    @classmethod
+    def migrate_settings(cls, settings, version=0):
+        def migrate_to_version_2():
+            # Set the default; change later if possible
+            settings.pop("compression", None)
+            settings["filter"] = next(iter(cls.filters))
+            filetype = settings.pop("filetype", None)
+            if filetype is None:
+                return
+
+            ext = cls._extension_from_filter(filetype)
+            if settings.pop("compress", False):
+                for afilter in cls.filters:
+                    if ext + ".gz" in afilter:
+                        settings["filter"] = afilter
+                        return
+                # If not found, uncompressed may have been erroneously set
+                # for a writer that didn't support if (such as .xlsx), so
+                # fall through to uncompressed
+            for afilter in cls.filters:
+                if ext in afilter:
+                    settings["filter"] = afilter
+                    return
+
+        if version < 2:
+            migrate_to_version_2()
+
+    def _initial_start_dir(self):
+        if self.filename and os.path.exists(os.path.split(self.filename)[0]):
+            return self.filename
         else:
-            try:
-                self.get_writer_selected().write(
-                    os.path.join(
-                        self.last_dir,
-                        self.basename + self.type_ext + self.compress_ext),
-                    self.data, self.add_type_annotations)
-
-            except Exception as err_value:
-                self.error(str(err_value))
+            data_name = getattr(self.data, 'name', '')
+            if data_name:
+                data_name += self.writer.EXTENSIONS[0]
+            return os.path.join(self.last_dir or self.userhome, data_name)
+
+    @staticmethod
+    def _replace_extension(filename, extension):
+        known_extensions = map(OWSave._extension_from_filter, OWSave.filters)
+        for known_ext in sorted(known_extensions, key=len, reverse=True):
+            if filename.endswith(known_ext):
+                filename = filename[:-len(known_ext)]
+                break
+        return filename + extension
+
+    @staticmethod
+    def _extension_from_filter(selected_filter):
+        return re.search(r".*\(\*?(\..*)\)$", selected_filter).group(1)
+
+    def _valid_filters(self):
+        if self.data is None or not self.data.is_sparse():
+            return self.filters
+        else:
+            return {filt: writer for filt, writer in self.filters.items()
+                    if writer.SUPPORT_SPARSE_DATA}
+
+    def _default_valid_filter(self):
+        if self.data is None or not self.data.is_sparse() \
+                or self.filters[self.filter].SUPPORT_SPARSE_DATA:
+            return self.filter
+        for filt, writer in self.filters.items():
+            if writer.SUPPORT_SPARSE_DATA:
+                return filt
+        # This shouldn't happen and it will trigger an error in tests
+        return None   # pragma: no cover
+
+    # As of Qt 5.9, QFileDialog.setDefaultSuffix does not support double
+    # suffixes, not even in non-native dialogs. We handle each OS separately.
+    if sys.platform in ("darwin", "win32"):
+        # macOS and Windows native dialogs do not correctly handle double
+        # extensions. We thus don't pass any suffixes to the dialog and add
+        # the correct suffix after closing the dialog and only then check
+        # if the file exists and ask whether to override.
+        # It is a bit confusing that the user does not see the final name in the
+        # dialog, but I see no better solution.
+        def get_save_filename(self):  # pragma: no cover
+            if sys.platform == "darwin":
+                def remove_star(filt):
+                    return filt.replace(" (*.", " (.")
+            else:
+                def remove_star(filt):
+                    return filt
+
+            no_ext_filters = {remove_star(f): f for f in self._valid_filters()}
+            filename = self._initial_start_dir()
+            while True:
+                dlg = QFileDialog(
+                    None, "Save File", filename, ";;".join(no_ext_filters))
+                dlg.setAcceptMode(dlg.AcceptSave)
+                dlg.selectNameFilter(remove_star(self._default_valid_filter()))
+                dlg.setOption(QFileDialog.DontConfirmOverwrite)
+                if dlg.exec() == QFileDialog.Rejected:
+                    return "", ""
+                filename = dlg.selectedFiles()[0]
+                selected_filter = no_ext_filters[dlg.selectedNameFilter()]
+                filename = self._replace_extension(
+                    filename, self._extension_from_filter(selected_filter))
+                if not os.path.exists(filename) or QMessageBox.question(
+                        self, "Overwrite file?",
+                        f"File {os.path.split(filename)[1]} already exists.\n"
+                        "Overwrite?") == QMessageBox.Yes:
+                    return filename, selected_filter
+
+    else:  # Linux and any unknown platforms
+        # Qt does not use a native dialog on Linux, so we can connect to
+        # filterSelected and to overload selectFile to change the extension
+        # while the dialog is open.
+        # For unknown platforms (which?), we also use the non-native dialog to
+        # be sure we know what happens.
+        class SaveFileDialog(QFileDialog):
+            # pylint: disable=protected-access
+            def __init__(self, *args, **kwargs):
+                super().__init__(*args, **kwargs)
+                self.setAcceptMode(QFileDialog.AcceptSave)
+                self.setOption(QFileDialog.DontUseNativeDialog)
+                self.filterSelected.connect(self.updateDefaultExtension)
+
+            def selectNameFilter(self, selected_filter):
+                super().selectNameFilter(selected_filter)
+                self.updateDefaultExtension(selected_filter)
+
+            def updateDefaultExtension(self, selected_filter):
+                self.suffix = OWSave._extension_from_filter(selected_filter)
+                files = self.selectedFiles()
+                if files and not os.path.isdir(files[0]):
+                    self.selectFile(files[0])
+
+            def selectFile(self, filename):
+                filename = OWSave._replace_extension(filename, self.suffix)
+                super().selectFile(filename)
+
+        def get_save_filename(self):
+            dlg = self.SaveFileDialog(
+                None, "Save File", self._initial_start_dir(),
+                ";;".join(self._valid_filters()))
+            dlg.selectNameFilter(self._default_valid_filter())
+            if dlg.exec() == QFileDialog.Rejected:
+                return "", ""
             else:
-                self.error()
-
-    def update_extension(self):
-        self.type_ext = [ext for name, ext, _ in FILE_TYPES if name == self.filetype][0]
-        self.annotations_cb.setEnabled(False)
-        if self.get_writer_selected().OPTIONAL_TYPE_ANNOTATIONS:
-            self.annotations_cb.setEnabled(True)
-        self.compress_ext = dict(COMPRESSIONS)[self.compression] if self.compress else ''
-
-    def _update_text(self):
-        self.update_extension()
-        self.adjust_label()
+                return dlg.selectedFiles()[0], dlg.selectedNameFilter()
 
 
 if __name__ == "__main__":  # pragma: no cover


Test patch:
diff --git a/Orange/widgets/data/tests/test_owsave.py b/Orange/widgets/data/tests/test_owsave.py
--- a/Orange/widgets/data/tests/test_owsave.py
+++ b/Orange/widgets/data/tests/test_owsave.py
@@ -1,145 +1,592 @@
-# Test methods with long descriptive names can omit docstrings
-# pylint: disable=missing-docstring
+# pylint: disable=missing-docstring, protected-access, unsubscriptable-object
+import unittest
 from unittest.mock import patch, Mock
-import itertools
+import os
+import sys
+
+import scipy.sparse as sp
+from AnyQt.QtWidgets import QFileDialog
 
 from Orange.data import Table
-from Orange.data.io import Compression, CSVReader, TabReader, PickleReader, \
-    ExcelReader, FileFormat
+from Orange.data.io import TabReader, PickleReader, ExcelReader
 from Orange.tests import named_file
 from Orange.widgets.tests.base import WidgetTest
-from Orange.widgets.utils.filedialogs import format_filter
 from Orange.widgets.data.owsave import OWSave
 
-FILE_TYPES = [
-    ("{} ({})".format(w.DESCRIPTION, w.EXTENSIONS[0]),
-     w.EXTENSIONS[0],
-     w.SUPPORT_SPARSE_DATA)
-    for w in (TabReader, CSVReader, PickleReader, ExcelReader)
-]
 
-COMPRESSIONS = [
-    ("gzip ({})".format(Compression.GZIP), Compression.GZIP),
-    ("bzip2 ({})".format(Compression.BZIP2), Compression.BZIP2),
-    ("lzma ({})".format(Compression.XZ), Compression.XZ),
-]
+# Yay, MS Windows!
+# This is not the proper general way to do it, but it's simplest and sufficient
+# Short name is suitable for the function's purpose
+def _w(s):  # pylint: disable=invalid-name
+    return s.replace("/", os.sep)
+
+
+class TestOWSaveBase(WidgetTest):
+    def setUp(self):
+        class OWSaveMockWriter(OWSave):
+            writer = Mock()
+            writer.EXTENSIONS = [".csv"]
+            writer.SUPPORT_COMPRESSED = True
+            writer.SUPPORT_SPARSE_DATA = False
+            writer.OPTIONAL_TYPE_ANNOTATIONS = False
+
+        self.widget = self.create_widget(OWSaveMockWriter)  # type: OWSave
+        self.iris = Table("iris")
+
+
+class TestOWSave(TestOWSaveBase):
+    def test_dataset(self):
+        widget = self.widget
+        widget.auto_save = True
+        insum = widget.info.set_input_summary = Mock()
+        savefile = widget.save_file = Mock()
+
+        datasig = widget.Inputs.data
+        self.send_signal(datasig, self.iris)
+        self.assertEqual(insum.call_args[0][0], "150")
+        insum.reset_mock()
+        savefile.reset_mock()
+
+        widget.filename = "foo.tab"
+        widget.writer = TabReader
+        widget.auto_save = False
+        self.send_signal(datasig, self.iris)
+        self.assertEqual(insum.call_args[0][0], "150")
+        savefile.assert_not_called()
+
+        widget.auto_save = True
+        self.send_signal(datasig, self.iris)
+        self.assertEqual(insum.call_args[0][0], "150")
+        savefile.assert_called()
+
+        self.send_signal(datasig, None)
+        insum.assert_called_with(widget.info.NoInput)
+
+    def test_initial_start_dir(self):
+        widget = self.widget
+        widget.filename = _w("/usr/foo/bar.csv")
+        self.assertEqual(widget._initial_start_dir(),
+                         _w(os.path.expanduser("~/")))
+
+        with patch("os.path.exists", return_value=True):
+            widget.filename = _w("/usr/foo/bar.csv")
+            self.assertEqual(widget._initial_start_dir(), widget.filename)
+
+            widget.filename = ""
+            widget.last_dir = _w("/usr/bar")
+            self.assertEqual(widget._initial_start_dir(), _w("/usr/bar/"))
+
+            widget.last_dir = _w("/usr/bar")
+            self.send_signal(widget.Inputs.data, self.iris)
+            self.assertEqual(widget._initial_start_dir(),
+                             _w("/usr/bar/iris.csv"))
+
+            widget.last_dir = ""
+            self.assertEqual(widget._initial_start_dir(),
+                             os.path.expanduser(_w("~/iris.csv")))
+
+    @patch("Orange.widgets.data.owsave.QFileDialog.getSaveFileName")
+    def test_save_file_sets_name(self, filedialog):
+        widget = self.widget
+        filters = iter(widget.filters)
+        filter1 = next(filters)
+        filter2 = next(filters)
+
+        widget.filename = _w("/usr/foo/bar.csv")
+        widget.last_dir = _w("/usr/foo/")
+        widget.filter = filter1
+
+        widget._update_messages = Mock()
+        widget.save_file = Mock()
+
+        widget.get_save_filename = Mock(return_value=("", filter2))
+        widget.save_file_as()
+        self.assertEqual(widget.filename, _w("/usr/foo/bar.csv"))
+        self.assertEqual(widget.last_dir, _w("/usr/foo/"))
+        self.assertEqual(widget.filter, filter1)
+        widget._update_messages.assert_not_called()
+        widget.save_file.assert_not_called()
+
+        widget.get_save_filename = \
+            Mock(return_value=(_w("/bar/bar.csv"), filter2))
+        widget.save_file_as()
+        self.assertEqual(widget.filename, _w("/bar/bar.csv"))
+        self.assertEqual(widget.last_dir, _w("/bar"))
+        self.assertEqual(widget.filter, filter2)
+        self.assertIn("bar.csv", widget.bt_save.text())
+        widget._update_messages.assert_called()
+        widget.save_file.assert_called()
+
+        widget.get_save_filename = Mock(return_value=("", filter2))
+        widget.save_file_as()
+        self.assertEqual(widget.filename, _w("/bar/bar.csv"))
+        self.assertEqual(widget.last_dir, _w("/bar"))
+        self.assertEqual(widget.filter, filter2)
+        self.assertIn("bar.csv", widget.bt_save.text())
+        widget._update_messages.assert_called()
+        widget.save_file.assert_called()
+
+    def test_save_file_calls_save_as(self):
+        widget = self.widget
+        widget.save_file_as = Mock()
+
+        self.send_signal(widget.Inputs.data, self.iris)
+
+        widget.filename = ""
+        widget.save_file()
+        widget.save_file_as.assert_called()
+        widget.save_file_as.reset_mock()
+
+        widget.filename = "bar.csv"
+        widget.save_file()
+        widget.save_file_as.assert_not_called()
+
+    def test_save_file_checks_can_save(self):
+        widget = self.widget
+        widget.get_save_filename = Mock(return_value=("", 0))
+
+        widget.save_file()
+        widget.writer.write.assert_not_called()
+
+        widget.filename = "foo"
+        widget.save_file()
+        widget.writer.write.assert_not_called()
+
+        widget.filename = ""
+        self.send_signal(widget.Inputs.data, self.iris)
+        widget.save_file()
+        widget.writer.write.assert_not_called()
+
+        widget.filename = "foo"
+        widget.save_file()
+        widget.writer.write.assert_called()
+        widget.writer.reset_mock()
+
+        self.iris.X = sp.csr_matrix(self.iris.X)
+        widget.save_file()
+        widget.writer.write.assert_not_called()
+
+        widget.writer.SUPPORT_SPARSE_DATA = True
+        widget.save_file()
+        widget.writer.write.assert_called()
+
+    def test_save_file_write_errors(self):
+        widget = self.widget
+        datasig = widget.Inputs.data
+
+        widget.auto_save = True
+        widget.filename = _w("bar/foo")
+
+        widget.writer.write.side_effect = IOError
+        self.send_signal(datasig, self.iris)
+        self.assertTrue(widget.Error.general_error.is_shown())
+
+        widget.writer.write.side_effect = None
+        self.send_signal(datasig, self.iris)
+        self.assertFalse(widget.Error.general_error.is_shown())
+
+        widget.writer.write.side_effect = IOError
+        self.send_signal(datasig, self.iris)
+        self.assertTrue(widget.Error.general_error.is_shown())
+
+        widget.writer.write.side_effect = None
+        self.send_signal(datasig, None)
+        self.assertFalse(widget.Error.general_error.is_shown())
+
+        widget.writer.write.side_effect = ValueError
+        self.assertRaises(ValueError, self.send_signal, datasig, self.iris)
+
+    def test_save_file_write(self):
+        widget = self.widget
+        datasig = widget.Inputs.data
+
+        widget.auto_save = True
+
+        widget.filename = _w("bar/foo.csv")
+        widget.add_type_annotations = True
+        self.send_signal(datasig, self.iris)
+        widget.writer.write.assert_called_with(
+            _w("bar/foo.csv"), self.iris, True)
+
+    def test_file_name_label(self):
+        widget = self.widget
+
+        widget.filename = ""
+        widget._update_messages()
+        self.assertFalse(widget.Error.no_file_name.is_shown())
+
+        widget.auto_save = True
+        widget._update_messages()
+        self.assertTrue(widget.Error.no_file_name.is_shown())
 
+        widget.filename = _w("/foo/bar/baz.csv")
+        widget._update_messages()
+        self.assertFalse(widget.Error.no_file_name.is_shown())
 
-class AddedFormat(FileFormat):
-    EXTENSIONS = ('.234',)
-    DESCRIPTION = "Test if a dialog format works after reading OWSave"
+    def test_sparse_error(self):
+        widget = self.widget
+        err = widget.Error.unsupported_sparse
+
+        widget.writer = ExcelReader
+        widget.filename = "foo.xlsx"
+        widget.data = self.iris
+
+        widget._update_messages()
+        self.assertFalse(err.is_shown())
+
+        widget.data.X = sp.csr_matrix(widget.data.X)
+        widget._update_messages()
+        self.assertTrue(err.is_shown())
+
+        widget.writer = PickleReader
+        widget._update_messages()
+        self.assertFalse(err.is_shown())
+
+        widget.writer = ExcelReader
+        widget._update_messages()
+        self.assertTrue(err.is_shown())
+
+        widget.data = None
+        widget._update_messages()
+        self.assertFalse(err.is_shown())
+
+    def test_valid_filters_for_sparse(self):
+        widget = self.widget
+
+        widget.data = None
+        self.assertEqual(widget.filters, widget._valid_filters())
+
+        widget.data = self.iris
+        self.assertEqual(widget.filters, widget._valid_filters())
+
+        widget.data.X = sp.csr_matrix(widget.data.X)
+        valid = widget._valid_filters()
+        self.assertNotEqual(widget.filters, {})
+        self.assertTrue(all(v.SUPPORT_SPARSE_DATA for v in valid.values()))
+
+    def test_valid_default_filter(self):
+        widget = self.widget
+        for widget.filter, writer in widget.filters.items():
+            if not writer.SUPPORT_SPARSE_DATA:
+                break
 
-    def write_file(self):
-        pass
+        widget.data = None
+        self.assertIs(widget.filter, widget._default_valid_filter())
 
+        widget.data = self.iris
+        self.assertIs(widget.filter, widget._default_valid_filter())
 
-class TestOWSave(WidgetTest):
+        widget.data.X = sp.csr_matrix(widget.data.X)
+        self.assertTrue(
+            widget.filters[widget._default_valid_filter()].SUPPORT_SPARSE_DATA)
 
+    def test_send_report(self):
+        widget = self.widget
+
+        widget.report_items = Mock()
+        for writer in widget.filters.values():
+            widget.writer = writer
+            for widget.add_type_annotations in (False, True):
+                widget.filename = f"foo.{writer.EXTENSIONS[0]}"
+                widget.send_report()
+                items = dict(widget.report_items.call_args[0][0])
+                msg = f"for {writer}, annotations={widget.add_type_annotations}"
+                self.assertEqual(items["File name"], widget.filename, msg=msg)
+                if writer.OPTIONAL_TYPE_ANNOTATIONS:
+                    self.assertEqual(
+                        items["Type annotations"],
+                        ["No", "Yes"][widget.add_type_annotations], msg=msg)
+                else:
+                    self.assertFalse(items["Type annotations"], msg=msg)
+
+    def test_migration_to_version_2(self):
+        const_settings = {
+            'add_type_annotations': True, 'auto_save': False,
+            'controlAreaVisible': True, 'last_dir': '/home/joe/Desktop',
+            '__version__': 1}
+
+        # No compression, Tab-separated values
+        settings = {**const_settings,
+                    'compress': False, 'compression': 'gzip (.gz)',
+                    'filetype': 'Tab-separated values (.tab)'}
+        OWSave.migrate_settings(settings)
+        self.assertEqual(
+            settings,
+            {**const_settings,
+             "filter": "Tab-separated values (*.tab)"})
+
+        # Compression; ignore compression format (.xz is no longer supported)
+        settings = {**const_settings,
+                    'compress': True, 'compression': 'lzma (.xz)',
+                    'filetype': 'Tab-separated values (.tab)'}
+        OWSave.migrate_settings(settings)
+        self.assertEqual(
+            settings,
+            {**const_settings,
+             "filter": "Compressed Tab-separated values (*.tab.gz)"})
+
+        # No compression, Excel
+        settings = {**const_settings,
+                    'compress': False, 'compression': 'lzma (.xz)',
+                    'filetype': 'Microsoft Excel spreadsheet (.xlsx)'}
+        OWSave.migrate_settings(settings)
+        self.assertEqual(
+            settings,
+            {**const_settings,
+             "filter": "Microsoft Excel spreadsheet (*.xlsx)"})
+
+        # Excel with compression - compression must be ignored
+        settings = {**const_settings,
+                    'compress': True, 'compression': 'lzma (.xz)',
+                    'filetype': 'Microsoft Excel spreadsheet (.xlsx)'}
+        OWSave.migrate_settings(settings)
+        self.assertEqual(
+            settings,
+            {**const_settings,
+             "filter": "Microsoft Excel spreadsheet (*.xlsx)"})
+
+        # Missing filetype (is this possible?)
+        settings = {**const_settings,
+                    'compress': True, 'compression': 'lzma (.xz)'}
+        OWSave.migrate_settings(settings)
+        self.assertTrue(settings["filter"] in OWSave.filters)
+
+        # Unsupported file format (is this possible?)
+        settings = {**const_settings,
+                    'compress': True, 'compression': 'lzma (.xz)',
+                    'filetype': 'Bar file (.bar)'}
+        OWSave.migrate_settings(settings)
+        self.assertTrue(settings["filter"] in OWSave.filters)
+
+
+class TestFunctionalOWSave(WidgetTest):
     def setUp(self):
         self.widget = self.create_widget(OWSave)  # type: OWSave
+        self.iris = Table("iris")
+
+    def test_save_uncompressed(self):
+        widget = self.widget
+        widget.auto_save = False
+
+        spiris = Table("iris")
+        spiris.X = sp.csr_matrix(spiris.X)
 
-    def test_writer(self):
-        compressions = [self.widget.controls.compression.itemText(i) for i in
-                        range(self.widget.controls.compression.count())]
-        types = [self.widget.controls.filetype.itemText(i)
-                 for i in range(self.widget.controls.filetype.count())]
-        for t, c, d in itertools.product(types, compressions, [True, False]):
-            self.widget.compression = c
-            self.widget.compress = d
-            self.widget.filetype = t
-            self.widget.update_extension()
-            self.assertEqual(
-                len(self.widget.get_writer_selected().EXTENSIONS), 1)
-
-    def test_ordinary_save(self):
-        self.send_signal(self.widget.Inputs.data, Table("iris"))
-
-        for ext, suffix, _ in FILE_TYPES:
-            self.widget.filetype = ext
-            self.widget.update_extension()
-            writer = self.widget.get_writer_selected()
-            with named_file("", suffix=suffix) as filename:
-                def choose_file(a, b, c, d, e, fn=filename, w=writer):
-                    return fn, format_filter(w)
-
-                with patch("AnyQt.QtWidgets.QFileDialog.getSaveFileName", choose_file):
-                    self.widget.save_file_as()
+        for selected_filter, writer in widget.filters.items():
+            widget.write = writer
+            ext = writer.EXTENSIONS[0]
+            with named_file("", suffix=ext) as filename:
+                widget.get_save_filename = Mock(
+                    return_value=(filename, selected_filter))
+
+                self.send_signal(widget.Inputs.data, self.iris)
+                widget.save_file_as()
                 self.assertEqual(len(Table(filename)), 150)
 
-    @patch('Orange.data.io.FileFormat.write')
-    def test_annotations(self, write):
+                if writer.SUPPORT_SPARSE_DATA:
+                    self.send_signal(widget.Inputs.data, spiris)
+                    widget.save_file()
+                    self.assertEqual(len(Table(filename)), 150)
+
+
+@unittest.skipUnless(sys.platform == "linux", "Tests for dialog on Linux")
+class TestOWSaveLinuxDialog(TestOWSaveBase):
+    def test_get_save_filename_linux(self):
         widget = self.widget
+        widget._initial_start_dir = lambda: "baz"
+        widget.filters = dict.fromkeys("abc")
+        widget.filter = "b"
+        dlg = widget.SaveFileDialog = Mock()  # pylint: disable=invalid-name
+        instance = dlg.return_value
+        instance.selectedFiles.return_value = ["foo"]
+        instance.selectedNameFilter.return_value = "bar"
+        self.assertEqual(widget.get_save_filename(), ("foo", "bar"))
+        self.assertEqual(dlg.call_args[0][2], "baz")
+        self.assertEqual(dlg.call_args[0][3], "a;;b;;c")
+        instance.selectNameFilter.assert_called_with("b")
 
-        self.send_signal(widget.Inputs.data, Table("iris"))
-        widget.filetype = FILE_TYPES[1][0]
-        widget.filename = 'foo.csv'
-        widget.update_extension()
+        instance.exec.return_value = QFileDialog.Rejected
+        self.assertEqual(widget.get_save_filename(), ("", ""))
 
-        widget.add_type_annotations = False
-        widget.unconditional_save_file()
-        write.assert_called()
-        self.assertFalse(write.call_args[0][2])
+    def test_save_file_dialog_enforces_extension_linux(self):
+        dialog = OWSave.SaveFileDialog(
+            None, "Save File", "foo.bar",
+            "Bar files (*.tab);;Low files (*.csv)")
 
-        widget.add_type_annotations = True
-        widget.unconditional_save_file()
-        self.assertTrue(write.call_args[0][2])
-
-    def test_disable_checkbox(self):
-        widget = self.widget
-        for type_ in FILE_TYPES:
-            widget.filetype = type_[0]
-            widget.update_extension()
-            if widget.get_writer_selected().OPTIONAL_TYPE_ANNOTATIONS:
-                self.assertTrue(widget.annotations_cb.isEnabled())
-            else:
-                self.assertFalse(widget.annotations_cb.isEnabled())
-
-    def test_compression(self):
-        self.send_signal(self.widget.Inputs.data, Table("iris"))
-
-        self.widget.compress = True
-        for type, compression in itertools.product([[x, ext] for x, ext, _ in FILE_TYPES],
-                                                   COMPRESSIONS):
-            self.widget.filetype = type[0]
-            self.widget.compression = compression[0]
-            self.widget.update_extension()
-            writer = self.widget.get_writer_selected()
-            with named_file("",
-                            suffix=type[1] + compression[1]) as filename:
-                def choose_file(a, b, c, d, e, fn=filename, w=writer):
-                    return fn, format_filter(w)
-
-                with patch("AnyQt.QtWidgets.QFileDialog.getSaveFileName", choose_file):
-                    self.widget.save_file_as()
-                self.assertEqual(len(Table(filename)), 150)
+        dialog.selectNameFilter("Low files (*.csv)")
+        self.assertTrue(dialog.selectedFiles()[0].endswith("/foo.csv"))
+
+        dialog.selectFile("high.bar")
+        self.assertTrue(dialog.selectedFiles()[0].endswith("/high.bar.csv"))
+
+        dialog.selectNameFilter("Bar files (*.tab)")
+        self.assertTrue(dialog.selectedFiles()[0].endswith("/high.bar.tab"))
 
-    def test_format_combo(self):
+        dialog.selectFile("middle.pkl")
+        self.assertTrue(dialog.selectedFiles()[0].endswith("/middle.tab"))
+
+        dialog.filterSelected.emit("Low files (*.csv)")
+        self.assertTrue(dialog.selectedFiles()[0].endswith("/middle.csv"))
+
+        dialog.selectFile("high.tab.gz")
+        self.assertTrue(dialog.selectedFiles()[0].endswith("/high.csv"))
+
+    def test_save_file_dialog_uses_valid_filters_linux(self):
         widget = self.widget
-        filetype = widget.controls.filetype
+        widget._valid_filters = lambda: ["a (*.a)", "b (*.b)"]
+        widget._default_valid_filter = lambda: "a (*.a)"
+        dlg = widget.SaveFileDialog = Mock()  # pylint: disable=invalid-name
+        instance = dlg.return_value
+        instance.exec.return_value = dlg.Rejected = QFileDialog.Rejected
+        widget.get_save_filename()
+        self.assertEqual(dlg.call_args[0][3], "a (*.a);;b (*.b)")
+        instance.selectNameFilter.assert_called_with("a (*.a)")
 
-        widget.save_file = Mock()
 
-        data = Table("iris")
-        sparse_data = Table("iris")
-        sparse_data.is_sparse = Mock(return_value=True)
+@unittest.skipUnless(sys.platform in ("darwin", "win32"),
+                     "Test for native dialog on Windows and macOS")
+class TestOWSaveDarwinDialog(TestOWSaveBase):  # pragma: no cover
+    if sys.platform == "darwin":
+        @staticmethod
+        def remove_star(filt):
+            return filt.replace(" (*.", " (.")
+    else:
+        @staticmethod
+        def remove_star(filt):
+            return filt
+
+    @patch("Orange.widgets.data.owsave.QFileDialog")
+    def test_get_save_filename_darwin(self, dlg):
+        widget = self.widget
+        widget._initial_start_dir = lambda: "baz"
+        widget.filters = dict.fromkeys(("aa (*.a)", "bb (*.b)", "cc (*.c)"))
+        widget.filter = "bb (*.b)"
+        instance = dlg.return_value
+        instance.exec.return_value = dlg.Accepted = QFileDialog.Accepted
+        instance.selectedFiles.return_value = ["foo"]
+        instance.selectedNameFilter.return_value = self.remove_star("aa (*.a)")
+        self.assertEqual(widget.get_save_filename(), ("foo.a", "aa (*.a)"))
+        self.assertEqual(dlg.call_args[0][2], "baz")
+        self.assertEqual(
+            dlg.call_args[0][3],
+            self.remove_star("aa (*.a);;bb (*.b);;cc (*.c)"))
+        instance.selectNameFilter.assert_called_with(
+            self.remove_star("bb (*.b)"))
+
+        instance.exec.return_value = dlg.Rejected = QFileDialog.Rejected
+        self.assertEqual(widget.get_save_filename(), ("", ""))
+
+    @patch("Orange.widgets.data.owsave.QFileDialog")
+    def test_save_file_dialog_enforces_extension_darwin(self, dlg):
+        widget = self.widget
+        for filter1 in widget.filters:
+            if OWSave._extension_from_filter(filter1) == ".tab":
+                break
+        for filter2 in widget.filters:
+            if OWSave._extension_from_filter(filter2) == ".csv.gz":
+                break
+
+        widget.filter = filter1
+        instance = dlg.return_value
+        instance.exec.return_value = QFileDialog.Accepted
+
+        instance.selectedNameFilter.return_value = self.remove_star(filter1)
+        instance.selectedFiles.return_value = ["foo"]
+        self.assertEqual(widget.get_save_filename()[0], "foo.tab")
+        instance.selectedFiles.return_value = ["foo.pkl"]
+        self.assertEqual(widget.get_save_filename()[0], "foo.tab")
+        instance.selectedFiles.return_value = ["foo.tab.gz"]
+        self.assertEqual(widget.get_save_filename()[0], "foo.tab")
+        instance.selectedFiles.return_value = ["foo.csv.gz"]
+        self.assertEqual(widget.get_save_filename()[0], "foo.tab")
+        instance.selectedFiles.return_value = ["foo.bar"]
+        self.assertEqual(widget.get_save_filename()[0], "foo.bar.tab")
+
+        instance.selectedNameFilter.return_value = self.remove_star(filter2)
+        instance.selectedFiles.return_value = ["foo"]
+        self.assertEqual(widget.get_save_filename()[0], "foo.csv.gz")
+        instance.selectedFiles.return_value = ["foo.pkl"]
+        self.assertEqual(widget.get_save_filename()[0], "foo.csv.gz")
+        instance.selectedFiles.return_value = ["foo.tab.gz"]
+        self.assertEqual(widget.get_save_filename()[0], "foo.csv.gz")
+        instance.selectedFiles.return_value = ["foo.csv.gz"]
+        self.assertEqual(widget.get_save_filename()[0], "foo.csv.gz")
+        instance.selectedFiles.return_value = ["foo.bar"]
+        self.assertEqual(widget.get_save_filename()[0], "foo.bar.csv.gz")
+
+    @patch("Orange.widgets.data.owsave.QFileDialog")
+    @patch("os.path.exists", new=lambda x: x == "old.tab")
+    @patch("Orange.widgets.data.owsave.QMessageBox")
+    def test_save_file_dialog_asks_for_overwrite_darwin(self, msgbox, dlg):
+        def selected_files():
+            nonlocal attempts
+            attempts += 1
+            return [["old.tab", "new.tab"][attempts]]
+
+        widget = self.widget
+        widget._initial_start_dir = lambda: "baz"
+        for filter1 in widget.filters:
+            if OWSave._extension_from_filter(filter1) == ".tab":
+                break
+
+        widget.filter = filter1
+        instance = dlg.return_value
+        instance.exec.return_value = QFileDialog.Accepted
+        instance.selectedFiles = selected_files
+        instance.selectedNameFilter.return_value = self.remove_star(filter1)
+
+        attempts = -1
+        msgbox.question.return_value = msgbox.Yes = 1
+        self.assertEqual(widget.get_save_filename()[0], "old.tab")
+
+        attempts = -1
+        msgbox.question.return_value = msgbox.No = 0
+        self.assertEqual(widget.get_save_filename()[0], "new.tab")
+
+    @patch("Orange.widgets.data.owsave.QFileDialog")
+    def test_save_file_dialog_uses_valid_filters_darwin(self, dlg):
+        widget = self.widget
+        widget._valid_filters = lambda: ["aa (*.a)", "bb (*.b)"]
+        widget._default_valid_filter = lambda: "aa (*.a)"
+        instance = dlg.return_value
+        instance.exec.return_value = dlg.Rejected = QFileDialog.Rejected
+        widget.get_save_filename()
+        self.assertEqual(
+            dlg.call_args[0][3], self.remove_star("aa (*.a);;bb (*.b)"))
+        instance.selectNameFilter.assert_called_with(
+            self.remove_star("aa (*.a)"))
+
 
-        self.send_signal(widget.Inputs.data, data)
-        n_nonsparse = filetype.count()
+class TestOWSaveUtils(unittest.TestCase):
+    def test_replace_extension(self):
+        replace = OWSave._replace_extension
+        fname = "/bing.bada.boom/foo.1942.tab"
+        self.assertEqual(
+            replace(fname, ".tab"), "/bing.bada.boom/foo.1942.tab")
+        self.assertEqual(
+            replace(fname, ".tab.gz"), "/bing.bada.boom/foo.1942.tab.gz")
+        self.assertEqual(
+            replace(fname, ".xlsx"), "/bing.bada.boom/foo.1942.xlsx")
 
-        self.send_signal(widget.Inputs.data, sparse_data)
-        n_sparse = filetype.count()
-        self.assertGreater(n_nonsparse, n_sparse)
+        fname = "foo.tab.gz"
+        self.assertEqual(replace(fname, ".tab"), "foo.tab")
+        self.assertEqual(replace(fname, ".tab.gz"), "foo.tab.gz")
+        self.assertEqual(replace(fname, ".csv"), "foo.csv")
+        self.assertEqual(replace(fname, ".csv.gz"), "foo.csv.gz")
 
-        self.send_signal(widget.Inputs.data, sparse_data)
-        self.assertEqual(filetype.count(), n_sparse)
+        fname = "/bing.bada.boom/foo"
+        self.assertEqual(replace(fname, ".tab"), fname + ".tab")
+        self.assertEqual(replace(fname, ".tab.gz"), fname + ".tab.gz")
 
-        self.send_signal(widget.Inputs.data, data)
-        self.assertEqual(filetype.count(), n_nonsparse)
+    def test_extension_from_filter(self):
+        self.assertEqual(
+            OWSave._extension_from_filter("Description (*.ext)"), ".ext")
+        self.assertEqual(
+            OWSave._extension_from_filter("Description (*.foo.ba)"), ".foo.ba")
+        self.assertEqual(
+            OWSave._extension_from_filter("Description (.ext)"), ".ext")
+        self.assertEqual(
+            OWSave._extension_from_filter("Description (.foo.bar)"), ".foo.bar")
 
-        self.send_signal(widget.Inputs.data, None)
-        self.send_signal(widget.Inputs.data, data)
-        self.assertEqual(filetype.count(), n_nonsparse)
 
-        self.send_signal(widget.Inputs.data, None)
-        self.send_signal(widget.Inputs.data, sparse_data)
-        self.assertEqual(filetype.count(), n_sparse)
+if __name__ == "__main__":
+    unittest.main()