Example #1
0
    def __init__(
        self,
        urlPatchServer,
        prodCode,
        language,
        runDir,
        patchClient,
        wineProgram,
        hiResEnabled,
        iconFileIn,
        homeDir,
        winePrefix,
        wineDebug,
        osType,
        parent,
        data_folder,
        current_game,
        gameDocumentsDir,
    ):

        self.homeDir = homeDir
        self.osType = osType
        self.logger = logging.getLogger("OneLauncher")

        ui_file = QtCore.QFile(os.path.join(data_folder, "ui", "winPatch.ui"))
        ui_file.open(QtCore.QFile.ReadOnly)
        loader = QUiLoader()
        self.winLog = loader.load(ui_file, parentWidget=parent)
        ui_file.close()

        self.winLog.setWindowFlags(QtCore.Qt.Dialog
                                   | QtCore.Qt.FramelessWindowHint)

        if self.osType.usingWindows:
            self.winLog.setWindowTitle("Output")
        else:
            self.winLog.setWindowTitle("Patch - Wine output")

        self.winLog.btnSave.setText("Save Log")
        self.winLog.btnSave.setEnabled(False)
        self.winLog.progressBar.reset()
        self.winLog.btnStop.setText("Close")
        self.winLog.btnStart.setText("Patch")
        self.winLog.btnSave.clicked.connect(self.btnSaveClicked)
        self.winLog.btnStop.clicked.connect(self.btnStopClicked)
        self.winLog.btnStart.clicked.connect(self.btnStartClicked)

        self.aborted = False
        self.finished = True
        self.lastRun = False
        self.command = ""
        self.arguments = []

        self.process_status_timer = QtCore.QTimer()
        self.process_status_timer.timeout.connect(
            self.activelyShowProcessStatus)

        patchClient = os.path.join(runDir, patchClient)
        # Fix for the at least one person who has a title case patchclient.dll
        if os.path.split(patchClient)[
                1] == "patchclient.dll" and not os.path.exists(patchClient):
            patchClient = os.path.join(runDir, "PatchClient.dll")

        # Make sure patchClient exists
        if not os.path.exists(patchClient):
            self.winLog.txtLog.append(
                '<font color="Khaki">Patch client %s not found</font>' %
                (patchClient))
            self.logger.error("Patch client %s not found" % (patchClient))
            return

        self.progressMonitor = ProgressMonitor(self.winLog)

        self.process = QtCore.QProcess()
        self.process.readyReadStandardOutput.connect(self.readOutput)
        self.process.readyReadStandardError.connect(self.readErrors)
        self.process.finished.connect(self.processFinished)

        processEnvironment = QtCore.QProcessEnvironment.systemEnvironment()

        if self.osType.usingWindows:
            self.arguments = [
                patchClient,
                "Patch",
                urlPatchServer,
                "--language",
                language,
                "--productcode",
                prodCode,
            ]

            self.command = "rundll32.exe"

            # Get log file to read patching details from, since
            # rundll32 doesn't provide output on Windows
            log_folder_name = gameDocumentsDir

            game_logs_folder = os.path.join(
                os.path.split(os.environ.get("APPDATA"))[0],
                "Local",
                log_folder_name,
            )

            self.patch_log_file = os.path.join(game_logs_folder,
                                               "PatchClient.log")
            if os.path.exists(self.patch_log_file):
                os.remove(self.patch_log_file)
                open(self.patch_log_file, "x")
            self.patch_log_file = open(self.patch_log_file, "r")
        else:
            if winePrefix != "":
                processEnvironment.insert("WINEPREFIX", winePrefix)

            if wineDebug != "":
                processEnvironment.insert("WINEDEBUG", wineDebug)

            self.arguments = [
                "rundll32.exe",
                patchClient,
                "Patch",
                urlPatchServer,
                "--language",
                language,
                "--productcode",
                prodCode,
            ]

            self.command = wineProgram

        self.process.setProcessEnvironment(processEnvironment)
        self.process.setWorkingDirectory(runDir)

        if hiResEnabled:
            self.arguments.append("--highres")

        self.file_arguments = self.arguments.copy()
        self.file_arguments.append("--filesonly")
Example #2
0
    def __init__(
        self,
        appName,
        clientType,
        argTemplate,
        account,
        server,
        ticket,
        chatServer,
        language,
        runDir,
        wineProgram,
        wineDebug,
        winePrefix,
        hiResEnabled,
        builtInPrefixEnabled,
        osType,
        homeDir,
        iconFileIn,
        crashreceiver,
        DefaultUploadThrottleMbps,
        bugurl,
        authserverurl,
        supporturl,
        supportserviceurl,
        glsticketlifetime,
        worldName,
        accountText,
        parent,
        data_folder,
        startupScripts,
        gameConfigDir,
    ):

        # Fixes binary path for 64-bit client
        if clientType == "WIN64":
            appName = "x64" + os.sep + appName

        self.homeDir = homeDir
        self.osType = osType
        self.worldName = worldName
        self.accountText = accountText
        self.parent = parent
        self.logger = logging.getLogger("OneLauncher")
        self.startupScripts = startupScripts
        self.gameConfigDirPath = os.path.join(osType.documentsDir, gameConfigDir)

        ui_file = QtCore.QFile(os.path.join(data_folder, "ui", "winLog.ui"))
        ui_file.open(QtCore.QFile.ReadOnly)
        loader = QUiLoader()
        self.winLog = loader.load(ui_file, parentWidget=parent)
        ui_file.close()

        self.winLog.setWindowFlags(QtCore.Qt.Dialog | QtCore.Qt.FramelessWindowHint)

        if self.osType.usingWindows:
            self.winLog.setWindowTitle("Output")
        else:
            self.winLog.setWindowTitle("Launch Game - Wine output")

        # self.winLog.btnStart.setVisible(False)
        self.winLog.btnStart.setText("Back")
        self.winLog.btnStart.setEnabled(False)
        self.winLog.btnSave.setText("Save")
        self.winLog.btnSave.setEnabled(False)
        self.winLog.btnStop.setText("Exit")
        self.winLog.btnStart.clicked.connect(self.btnStartClicked)
        self.winLog.btnSave.clicked.connect(self.btnSaveClicked)
        self.winLog.btnStop.clicked.connect(self.btnStopClicked)

        self.aborted = False
        self.finished = False
        self.command = ""
        self.arguments = []

        gameParams = (
            argTemplate.replace("{SUBSCRIPTION}", account)
            .replace("{LOGIN}", server)
            .replace("{GLS}", ticket)
            .replace("{CHAT}", chatServer)
            .replace("{LANG}", language)
            .replace("{CRASHRECEIVER}", crashreceiver)
            .replace("{UPLOADTHROTTLE}", DefaultUploadThrottleMbps)
            .replace("{BUGURL}", bugurl)
            .replace("{AUTHSERVERURL}", authserverurl)
            .replace("{GLSTICKETLIFETIME}", glsticketlifetime)
            .replace("{SUPPORTURL}", supporturl)
            .replace("{SUPPORTSERVICEURL}", supportserviceurl)
        )

        if not hiResEnabled:
            gameParams = gameParams + " --HighResOutOfDate"

        self.process = QtCore.QProcess()
        self.process.readyReadStandardOutput.connect(self.readOutput)
        self.process.readyReadStandardError.connect(self.readErrors)
        self.process.finished.connect(self.resetButtons)

        if self.osType.usingWindows:
            self.command = appName
            self.process.setWorkingDirectory(runDir)

            os.chdir(runDir)

            for arg in gameParams.split(" "):
                self.arguments.append(arg)

        else:
            processEnvironment = QtCore.QProcessEnvironment.systemEnvironment()

            if wineDebug != "":
                processEnvironment.insert("WINEDEBUG", wineDebug)

            if winePrefix != "":
                processEnvironment.insert("WINEPREFIX", winePrefix)

            self.command = wineProgram
            self.process.setWorkingDirectory(runDir)

            self.arguments.append(appName)

            for arg in gameParams.split(" "):
                self.arguments.append(arg)

            # Applies needed settings for the builtin wine prefix
            if builtInPrefixEnabled:
                # Enables ESYNC if open file limit is high enough
                if os.path.exists("/proc/sys/fs/file-max"):
                    with open("/proc/sys/fs/file-max") as file:
                        file_data = file.read()
                        if int(file_data) >= 524288:
                            processEnvironment.insert("WINEESYNC", "1")
                
                # Enables FSYNC. It overides ESYNC and will only be used if 
                # the required kernel patches are installed.
                processEnvironment.insert("WINEFSYNC", "1")

                # Adds dll overrides for directx, so dxvk is used instead of wine3d
                processEnvironment.insert("WINEDLLOVERRIDES", "d3d11=n;dxgi=n;d3d10=n")

            self.process.setProcessEnvironment(processEnvironment)

        self.winLog.txtLog.append("Connecting to server: " + worldName)
        self.winLog.txtLog.append("Account: " + accountText)
        self.winLog.txtLog.append("Game Directory: " + runDir)
        self.winLog.txtLog.append("Game Client: " + appName)

        self.winLog.show()

        self.runStatupScripts()
Example #3
0
class QtFrontend(ViewSBFrontend):
    """ Qt Frontend that consumes packets for display. """

    UI_NAME = 'qt'
    UI_DESCRIPTION = 'unstable GUI in Qt'


    # So, Qt's tree widgets require that column 0 have the expand arrow, but you _can_ change
    # where column 0 is displayed.
    # We want the summary column to have the expand arrow, so we'll swap it
    # with the sequence column in __init__().
    COLUMN_SEQUENCE  = 6
    COLUMN_TIMESTAMP = 1
    COLUMN_DEVICE    = 2
    COLUMN_ENDPOINT  = 3
    COLUMN_DIRECTION = 4
    COLUMN_LENGTH    = 5
    COLUMN_SUMMARY   = 0
    COLUMN_STATUS    = 7
    COLUMN_DATA      = 8


    @staticmethod
    def reason_to_be_disabled():
        try:
            import PySide6
        except ImportError:
            return "PySide6 (Qt library) not available."

        return None


    @staticmethod
    def _stringify_list(lst):
        """
        Tiny helper than runs the str constructor on every item in a list, but specifically handles two cases:
        1) the object in question is None, which we instead want to display as an empty string,
        2) the resulting string contains a null character, which Qt doesn't like, so we'll
        represent it to the user as, literally, \0.
        """
        return [str(x).replace('\0', r'\0') if x is not None else '' for x in lst]


    def _create_item_for_packet(self, viewsb_packet):
        """ Creates a QTreeWidgetItem for a given ViewSBPacket.

        Args:
            viewsb_packet -- The ViewSBPacket to create the QTreeWidgetItem from.

        Returns a QTreeWidgetItem.
        """

        def get_packet_string_array(viewsb_packet):
            """ Tiny helper to return and stringify the common fields used for the columns of tree items. """

            direction = viewsb_packet.direction.name if viewsb_packet.direction is not None else ''

            length = len(viewsb_packet.data) if viewsb_packet.data is not None else ''

            return self._stringify_list([
                viewsb_packet.summarize(),
                viewsb_packet.timestamp,
                viewsb_packet.device_address,
                viewsb_packet.endpoint_number,
                direction,
                length,
                viewsb_packet.sequence,
                viewsb_packet.summarize_status(),
                viewsb_packet.summarize_data()
                ]) + [viewsb_packet]


        item = QTreeWidgetItem(get_packet_string_array(viewsb_packet))

        # Give the item a reference to the original packet object.
        item.setData(0, QtCore.Qt.UserRole, viewsb_packet)

        return item


    def _recursively_walk_packet(self, viewsb_packet):
        """ Recursively walks packet subordinates, batching QTreeWidgetItem.addChildren as much as possible.

        Args:
            viewsb_packet -- The top-level packet (as far as the caller's context is concerned).
        """

        packet_item = self._create_item_for_packet(viewsb_packet)

        packet_children_list = []

        for sub_packet in viewsb_packet.subordinate_packets:

            # Create the item for this packet, and recursively fill its children.
            packet_children_list.append(self._recursively_walk_packet(sub_packet))


        packet_item.addChildren(packet_children_list)

        return packet_item


    def __init__(self):
        """ Sets up the Qt UI. """

        super().__init__()
        QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts)

        signal.signal(signal.SIGINT, signal.SIG_DFL)  # fix SIGINT handling - cleanly exit on ctrl+c

        self.app = QApplication.instance() or QApplication([])

        try:
            import qt_material

            qt_material.apply_stylesheet(self.app, 'light_blue.xml')
        except ImportError:
            pass

        self.ui_file = QtCore.QFile(os.path.dirname(os.path.realpath(__file__)) + '/qt.ui')
        self.loader = QUiLoader()
        self.loader.registerCustomWidget(ViewSBQTreeWidget)
        self.loader.registerCustomWidget(ViewSBHexView)
        self.window = self.loader.load(self.ui_file) # type: QMainWindow

        # Swap columns 0 and 5 to put the expand arrow on the summary column.
        self.window.usb_tree_widget.header().swapSections(self.COLUMN_SUMMARY, self.COLUMN_SEQUENCE)

        self.window.usb_tree_widget.header().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)

        self.window.update_timer = QtCore.QTimer()
        self.window.update_timer.timeout.connect(self._update)

        self.window.usb_tree_widget.currentItemChanged.connect(self._tree_current_item_changed)

        self.window.usb_tree_widget = self.window.usb_tree_widget
        self.window.usb_tree_widget.sortByColumn(0, Qt.SortOrder.AscendingOrder)


    def ready(self):
        """ Called when the backend is ready to stream. """
        self.window.showMaximized()


    def _update(self):
        """ Called by the QTimer `update_timer`; collects packets the queue and adds them to the tree view.

        We use this instead of calling `handle_communications` and defining `handle_incoming_packet`,
        because adding items one at a time as we receive them is slower than batching them.

        Note: Since this is called via a QTimer signal, this method runs in the UI thread.
        """

        # Handle exceptions
        if self._exception_conn.poll():
            self.handle_exception(*self._exception_conn.recv())
            # TODO: overide handle_exception to show a Qt dialog message

        # If the process manager told us to stop (which might happen if e.g. the backend exits),
        # then stop and exit.
        if self.termination_event.is_set():
            self.app.closeAllWindows()

        packet_list = []

        try:

            # Get as many packets as we can as quick as we can.
            while True:

                packet = self.data_queue.get_nowait()
                packet_list.append(packet)

        # But the instant it's empty, don't wait for any more; just send them to be processed.
        except multiprocessing.queues.Empty:
            pass

        finally:
            self.add_packets(packet_list)


    def _tree_current_item_changed(self, current_item, _previous_item):
        """
        Handler for the QTreeWidget.currentItemChanged() signal that populates the side panels with
        detail fields and a hex representation of the current packet.
        """

        # Clear the details widget.
        self.window.usb_details_tree_widget.clear()

        current_packet = current_item.data(0, QtCore.Qt.UserRole)

        # A list of 2-tuples: first element is a table title, and the second is usually a string:string dict.
        detail_fields = current_packet.get_detail_fields()

        if detail_fields:
            self.update_detail_fields(detail_fields)

        self.window.usb_hex_view.populate(current_packet.get_raw_data())


    def update_detail_fields(self, detail_fields):
        """ Populates the detail view with the relevant fields for the selected packet. """

        # Each table will have a root item in the details view.
        root_items = []

        for table in detail_fields:
            title = table[0]

            root = QTreeWidgetItem([title])
            children = []

            fields = table[1]

            # The usual case: a str:str dict.
            if isinstance(fields, dict):
                for key, value in fields.items():
                    children.append(QTreeWidgetItem(self._stringify_list([key, value])))

            # Sometimes it'll just be a 1-column list.
            elif isinstance(fields, list):
                for item in fields:
                    children.append(QTreeWidgetItem(self._stringify_list([item])))

            # Sometimes it'll just be a string, or a `bytes` instance.
            else:
                children.append(QTreeWidgetItem(self._stringify_list([fields])))

            root.addChildren(children)

            # Add an empty "item" between each table.
            root_items.extend([root, QTreeWidgetItem([])])


        self.window.usb_details_tree_widget.addTopLevelItems(root_items)

        self.window.usb_details_tree_widget.expandAll()

        self.window.usb_details_tree_widget.resizeColumnToContents(0)
        self.window.usb_details_tree_widget.resizeColumnToContents(1)


    def add_packets(self, viewsb_packets):
        """ Adds a list of top-level ViewSB packets to the tree.

        We're in the UI thread; every bit of overhead counts, so let's batch as much as possible.
        """

        top_level_items_list = []

        for viewsb_packet in viewsb_packets:

            # Create the item for this packet, and recursively fill its children.
            top_level_items_list.append(self._recursively_walk_packet(viewsb_packet))


        self.window.usb_tree_widget.addTopLevelItems(top_level_items_list)


    def run(self):
        """ Overrides ViewSBFrontend.run(). """

        self.wait_for_backend_ready()

        # TODO: is there a better value than 100 ms? Should it be configurable by the Analyzer?
        self.window.update_timer.start(100)
        self.app.exec_()
        self.stop()

    def stop(self):
        self.app.closeAllWindows()
        self.termination_event.set()
Example #4
0
    def __init__(self):
        """"""
        super().__init__()
        self.main = QUiLoader().load('main_window.ui', self)

        self.add_menu_theme(self.main, self.main.menuStyles)
Example #5
0
    def __init__(self):
        """Initialize the class."""

        super().__init__()

        # Preflight
        self.check_for_updates(silent=True)

        self.ui = QUiLoader().load(resources.path("tailor.resources", "tailor.ui"))
        self.ui.setWindowIcon(
            QtGui.QIcon(str(resources.path("tailor.resources", "tailor.png")))
        )
        # store reference to this code in data tab
        self.ui.data.code = self

        # set up dirty timer
        self._dirty_timer = QtCore.QTimer()
        self._dirty_timer.timeout.connect(self.mark_project_dirty)

        # clear all program state
        self.clear_all()

        # Enable close buttons...
        self.ui.tabWidget.setTabsClosable(True)
        # ...but remove them for the table view
        for pos in QtWidgets.QTabBar.LeftSide, QtWidgets.QTabBar.RightSide:
            widget = self.ui.tabWidget.tabBar().tabButton(0, pos)
            if widget:
                widget.close()

        # connect button signals
        self.ui.add_column_button.clicked.connect(self.add_column)
        self.ui.add_calculated_column_button.clicked.connect(self.add_calculated_column)

        # connect menu items
        self.ui.actionQuit.triggered.connect(self.ui.close)
        self.ui.actionAbout_Tailor.triggered.connect(self.show_about_dialog)
        self.ui.actionNew.triggered.connect(self.new_project)
        self.ui.actionOpen.triggered.connect(self.open_project_dialog)
        self.ui.actionSave.triggered.connect(self.save_project_or_dialog)
        self.ui.actionSave_As.triggered.connect(self.save_as_project_dialog)
        self.ui.actionCheck_for_updates.triggered.connect(self.check_for_updates)
        self.ui.actionImport_CSV.triggered.connect(self.import_csv)
        self.ui.actionExport_CSV.triggered.connect(self.export_csv)
        self.ui.actionExport_Graph_to_PDF.triggered.connect(
            lambda: self.export_graph(".pdf")
        )
        self.ui.actionExport_Graph_to_PNG.triggered.connect(
            lambda: self.export_graph(".png")
        )
        self.ui.actionClose.triggered.connect(self.new_project)
        self.ui.actionAdd_column.triggered.connect(self.add_column)
        self.ui.actionAdd_calculated_column.triggered.connect(
            self.add_calculated_column
        )
        self.ui.actionAdd_row.triggered.connect(self.add_row)
        self.ui.actionRemove_column.triggered.connect(self.remove_column)
        self.ui.actionRemove_row.triggered.connect(self.remove_row)
        self.ui.actionClear_Cell_Contents.triggered.connect(self.clear_selected_cells)

        # set up the open recent menu
        self.ui._recent_files_separator = self.ui.menuOpen_Recent.insertSeparator(
            self.ui.actionClear_Menu
        )
        self.update_recent_files()
        self.ui.actionClear_Menu.triggered.connect(self.clear_recent_files_menu)

        # user interface events
        self.ui.data_view.horizontalHeader().sectionMoved.connect(self.column_moved)
        self.ui.tabWidget.currentChanged.connect(self.tab_changed)
        self.ui.tabWidget.tabCloseRequested.connect(self.close_tab)
        self.ui.name_edit.textEdited.connect(self.rename_column)
        self.ui.formula_edit.textEdited.connect(self.update_column_expression)
        self.ui.create_plot_button.clicked.connect(self.ask_and_create_plot_tab)

        # install event filter to capture UI events (which are not signals)
        # necessary to caputer closeEvent inside QMainWindow widget
        self.ui.installEventFilter(self)

        # Set standard shortcuts for menu items
        self.ui.actionNew.setShortcut(QtGui.QKeySequence.New)
        self.ui.actionOpen.setShortcut(QtGui.QKeySequence.Open)
        self.ui.actionClose.setShortcut(QtGui.QKeySequence.Close)
        self.ui.actionSave.setShortcut(QtGui.QKeySequence.Save)
        self.ui.actionSave_As.setShortcut(QtGui.QKeySequence.SaveAs)

        # Set other shortcuts for menu items
        self.ui.actionImport_CSV.setShortcut(QtGui.QKeySequence("Ctrl+I"))
        self.ui.actionImport_CSV_Into_Current_Project.setShortcut(
            QtGui.QKeySequence("Shift+Ctrl+I")
        )
        self.ui.actionExport_CSV.setShortcut(QtGui.QKeySequence("Ctrl+E"))
        self.ui.actionExport_Graph_to_PDF.setShortcut(QtGui.QKeySequence("Ctrl+G"))
        self.ui.actionExport_Graph_to_PNG.setShortcut(
            QtGui.QKeySequence("Shift+Ctrl+G")
        )

        # Create shortcut for return/enter keys
        for key in QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter:
            QtGui.QShortcut(
                QtGui.QKeySequence(key), self.ui.data_view, self.edit_or_move_down
            )
        # Shortcut for backspace and delete: clear cell contents
        for key in QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete:
            QtGui.QShortcut(
                QtGui.QKeySequence(key), self.ui.data_view, self.clear_selected_cells
            )

        # Start at (0, 0)
        self.ui.data_view.setCurrentIndex(self.data_model.createIndex(0, 0))
Example #6
0
class Application(QtCore.QObject):
    """Main user interface for the tailor app.

    The user interface centers on the table containing the data values. A single
    DataModel is instantiated to hold the data.

    """

    _project_filename = None
    _recent_files_actions = None
    _selected_col_idx = None
    _plot_num = 1

    _is_dirty = False

    def __init__(self):
        """Initialize the class."""

        super().__init__()

        # Preflight
        self.check_for_updates(silent=True)

        self.ui = QUiLoader().load(resources.path("tailor.resources", "tailor.ui"))
        self.ui.setWindowIcon(
            QtGui.QIcon(str(resources.path("tailor.resources", "tailor.png")))
        )
        # store reference to this code in data tab
        self.ui.data.code = self

        # set up dirty timer
        self._dirty_timer = QtCore.QTimer()
        self._dirty_timer.timeout.connect(self.mark_project_dirty)

        # clear all program state
        self.clear_all()

        # Enable close buttons...
        self.ui.tabWidget.setTabsClosable(True)
        # ...but remove them for the table view
        for pos in QtWidgets.QTabBar.LeftSide, QtWidgets.QTabBar.RightSide:
            widget = self.ui.tabWidget.tabBar().tabButton(0, pos)
            if widget:
                widget.close()

        # connect button signals
        self.ui.add_column_button.clicked.connect(self.add_column)
        self.ui.add_calculated_column_button.clicked.connect(self.add_calculated_column)

        # connect menu items
        self.ui.actionQuit.triggered.connect(self.ui.close)
        self.ui.actionAbout_Tailor.triggered.connect(self.show_about_dialog)
        self.ui.actionNew.triggered.connect(self.new_project)
        self.ui.actionOpen.triggered.connect(self.open_project_dialog)
        self.ui.actionSave.triggered.connect(self.save_project_or_dialog)
        self.ui.actionSave_As.triggered.connect(self.save_as_project_dialog)
        self.ui.actionCheck_for_updates.triggered.connect(self.check_for_updates)
        self.ui.actionImport_CSV.triggered.connect(self.import_csv)
        self.ui.actionExport_CSV.triggered.connect(self.export_csv)
        self.ui.actionExport_Graph_to_PDF.triggered.connect(
            lambda: self.export_graph(".pdf")
        )
        self.ui.actionExport_Graph_to_PNG.triggered.connect(
            lambda: self.export_graph(".png")
        )
        self.ui.actionClose.triggered.connect(self.new_project)
        self.ui.actionAdd_column.triggered.connect(self.add_column)
        self.ui.actionAdd_calculated_column.triggered.connect(
            self.add_calculated_column
        )
        self.ui.actionAdd_row.triggered.connect(self.add_row)
        self.ui.actionRemove_column.triggered.connect(self.remove_column)
        self.ui.actionRemove_row.triggered.connect(self.remove_row)
        self.ui.actionClear_Cell_Contents.triggered.connect(self.clear_selected_cells)

        # set up the open recent menu
        self.ui._recent_files_separator = self.ui.menuOpen_Recent.insertSeparator(
            self.ui.actionClear_Menu
        )
        self.update_recent_files()
        self.ui.actionClear_Menu.triggered.connect(self.clear_recent_files_menu)

        # user interface events
        self.ui.data_view.horizontalHeader().sectionMoved.connect(self.column_moved)
        self.ui.tabWidget.currentChanged.connect(self.tab_changed)
        self.ui.tabWidget.tabCloseRequested.connect(self.close_tab)
        self.ui.name_edit.textEdited.connect(self.rename_column)
        self.ui.formula_edit.textEdited.connect(self.update_column_expression)
        self.ui.create_plot_button.clicked.connect(self.ask_and_create_plot_tab)

        # install event filter to capture UI events (which are not signals)
        # necessary to caputer closeEvent inside QMainWindow widget
        self.ui.installEventFilter(self)

        # Set standard shortcuts for menu items
        self.ui.actionNew.setShortcut(QtGui.QKeySequence.New)
        self.ui.actionOpen.setShortcut(QtGui.QKeySequence.Open)
        self.ui.actionClose.setShortcut(QtGui.QKeySequence.Close)
        self.ui.actionSave.setShortcut(QtGui.QKeySequence.Save)
        self.ui.actionSave_As.setShortcut(QtGui.QKeySequence.SaveAs)

        # Set other shortcuts for menu items
        self.ui.actionImport_CSV.setShortcut(QtGui.QKeySequence("Ctrl+I"))
        self.ui.actionImport_CSV_Into_Current_Project.setShortcut(
            QtGui.QKeySequence("Shift+Ctrl+I")
        )
        self.ui.actionExport_CSV.setShortcut(QtGui.QKeySequence("Ctrl+E"))
        self.ui.actionExport_Graph_to_PDF.setShortcut(QtGui.QKeySequence("Ctrl+G"))
        self.ui.actionExport_Graph_to_PNG.setShortcut(
            QtGui.QKeySequence("Shift+Ctrl+G")
        )

        # Create shortcut for return/enter keys
        for key in QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter:
            QtGui.QShortcut(
                QtGui.QKeySequence(key), self.ui.data_view, self.edit_or_move_down
            )
        # Shortcut for backspace and delete: clear cell contents
        for key in QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete:
            QtGui.QShortcut(
                QtGui.QKeySequence(key), self.ui.data_view, self.clear_selected_cells
            )

        # Start at (0, 0)
        self.ui.data_view.setCurrentIndex(self.data_model.createIndex(0, 0))

        # tests
        # def test():
        #     print(self.get_column_ordering())

        # QtGui.QShortcut(QtGui.QKeySequence("Ctrl+X"), self.ui.data_view, test)
        # filename = "~/Desktop/meting1.csv"
        # dialog = CSVFormatDialog(filename)
        # dialog.exec()
        # sys.exit()
        # if dialog.exec() == QtWidgets.QDialog.Accepted:
        #     (
        #         delimiter,
        #         decimal,
        #         thousands,
        #         header,
        #         skiprows,
        #     ) = dialog.get_format_parameters()
        #     self._do_import_csv(
        #         filename, delimiter, decimal, thousands, header, skiprows
        #     )
        # self._do_import_csv(filename, ";", ",", ".", 0, 0)

        # import numpy as np
        # import pandas as pd

        # x = [1, 2, 3, 4, 5, np.nan]
        # y = [1, 4, np.nan, 8, 10, np.nan]
        # self.data_model.beginResetModel()
        # self.data_model._data = pd.DataFrame.from_dict({"x": x, "y": y})
        # self.data_model.endResetModel()
        # self.create_plot_tab("x", "y")
        # plot_tab = self.ui.tabWidget.currentWidget()
        # plot_tab.model_func.setText("a * x + b")
        # plot_tab.fit_button.clicked.emit()

        # np.random.seed(1)
        # x = np.linspace(0, 10, 11)
        # y = np.random.normal(loc=x, scale=0.1 * x, size=len(x))
        # self.data_model.beginResetModel()
        # self.data_model._data = pd.DataFrame.from_dict(
        #     {"U": x, "I": y, "dU": 0.1 * x + 0.01, "dI": 0.1 * y + 0.01}
        # )
        # self.data_model.endResetModel()
        # self.create_plot_tab("U", "I", "dU", "dI")

        # plot_tab = self.tabWidget.currentWidget()
        # plot_tab.model_func.setText("U0 * U + b")
        # plot_tab.fit_button.clicked.emit()

        # plot_tab.model_func.setText("(U0 * U + b")
        # plot_tab.fit_button.clicked.emit()

        # self.tabWidget.setCurrentIndex(0)
        # self.data_view.selectColumn(0)
        # self.name_edit.setText("U_0")
        # self.name_edit.textEdited.emit("U_0")
        # self.data_view.selectColumn(1)
        # self.name_edit.setText("I_0")
        # self.name_edit.textEdited.emit("I_0")
        # self.tabWidget.setCurrentIndex(1)
        # plot_tab.fit_button.clicked.emit()

        # self.add_calculated_column()
        # self.name_edit.setText("inv_U")
        # self.formula_edit.setText("1 / U")
        # self.create_plot_tab("inv_U", "I")

        # self.add_calculated_column()
        # self.name_edit.setText("P")
        # self.formula_edit.setText("U * I")
        # self.name_edit.textEdited.emit("P")
        # self.formula_edit.textEdited.emit("U * I")
        # self.data_view.selectColumn(0)
        # self.remove_column()
        # self.add_column()
        # self.name_edit.setText("P")
        # self.data_view.selectColumn(0)
        # # self.create_plot_tab("U", "Usq")
        # plot_tab = self.tabWidget.currentWidget()
        # # plot_tab.fit_start_box.setValue(4.5)
        # # plot_tab.fit_end_box.setValue(7.5)
        # # plot_tab.use_fit_domain.setChecked(True)
        # plot_tab.model_func.setText("a * U + b")
        # # for row in plot_tab._params.values():
        # #     row.itemAt(plot_tab._idx_value_box).widget().setValue(20)
        # plot_tab.fit_button.clicked.emit()
        # plot_tab.draw_curve_option.setCurrentIndex(2)
        # # plot_tab.use_fit_domain.setChecked(False)
        # # plot_tab.export_graph("test.png")
        # # plot_tab.export_graph("test.pdf")
        # # self.tabWidget.setCurrentIndex(0)
        # # self.data_model._data["I"] /= 2
        # # plot_tab.update_plot()
        # # plot_tab.model_func.setText("a * U ** 2 + c")
        # # plot_tab.model_func.textEdited.emit("")
        # self.save_project("test.tlr")
        # self.clear_all()
        # self.load_project(pathlib.Path.home() / "Desktop" / "analyse-radon220.tlr")
        # self.save_project("test.tlr")
        # self.clear_all()
        # self.load_project("test.tlr")

        # self._do_import_csv(
        #     "~/Desktop/importtest.csv", ",", ".", ",", 0, 0, create_new=False
        # )

    def _set_view_and_selection_model(self):
        """Set up data view and selection model.

        Connects the table widget to the data model, sets up various behaviours
        and resets visual column ordering.
        """
        self.ui.data_view.setModel(self.data_model)
        self.ui.data_view.setDragDropMode(self.ui.data_view.NoDragDrop)
        header = self.ui.data_view.horizontalHeader()
        header.setSectionsMovable(True)
        header.setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
        header.setMinimumSectionSize(header.defaultSectionSize())

        # reset column ordering. There is, apparently, no easy way to do this :'(
        for log_idx in range(self.data_model.columnCount()):
            # move sections in the correct position FROM LEFT TO RIGHT
            # so, logical indexes should be numbered [0, 1, 2, ... ]
            # >>> header.moveSection(from, to)
            vis_idx = header.visualIndex(log_idx)
            header.moveSection(vis_idx, log_idx)

        self.selection = self.ui.data_view.selectionModel()
        self.selection.selectionChanged.connect(self.selection_changed)

    def mark_project_dirty(self, is_dirty=True):
        """Mark project as dirty"""
        self._is_dirty = is_dirty
        self.update_window_title()
        if not is_dirty:
            # FIXME: this can be implemented much better by actually detecting changes.
            self._dirty_timer.start(DIRTY_TIMEOUT)

    def column_moved(self, logidx, oldidx, newidx):
        """Move column in reaction to UI signal.

        Dragging a column to a new location triggers execution of this method.
        Since the UI only reorders the column visually and does not change the
        underlying data, we will store the ordering in the data model.

        Args:
            logidx (int): the logical column index (index in the dataframe)
            oldidx (int): the old visual index
            newidx (int): the new visual index
        """
        self.data_model._column_order = self.get_column_ordering()
        self.data_model.recalculate_all_columns()

    def get_column_ordering(self):
        """Return the visual order of logical columns in the table view.

        Returns a list of column indexes. The first index is the first (visual)
        column in the table view. The index points to a colum in the underlying
        data. So, if the underlying data has columns col0, col1, col2, col3, but
        you visually rearrange them as col3, col1, col0, col2, then this method
        will return [3, 1, 0, 2].
        """
        header = self.ui.data_view.horizontalHeader()
        n_columns = self.data_model.columnCount()
        return [header.logicalIndex(i) for i in range(n_columns)]

    def eventFilter(self, watched, event):
        """Catch PySide6 events.

        Events are signals without slots. That is, signals which cannot be
        connected to predefined endpoints. They can, however, be captured by an
        event filter.

        Args:
            watched (QtCore.QObject): the object which generated the event.
            event (QtCore.QEvent): the event object.

        Returns:
            boolean: True if the event is ignored, False otherwise.
        """
        if watched is self.ui and event.type() == QtCore.QEvent.Close:
            if self.confirm_project_close_dialog():
                event.accept()
                return False
            else:
                event.ignore()
                return True
        else:
            return super().eventFilter(watched, event)

    def show_about_dialog(self):
        """Show about application dialog."""
        box = QtWidgets.QMessageBox()
        box.setIconPixmap(self.ui.windowIcon().pixmap(64, 64))
        box.setText("Tailor")
        box.setInformativeText(
            dedent(
                f"""
            <p>Version {__version__}.</p>

            <p>Tailor is written by David Fokkema for use in the physics lab courses at the Vrije Universiteit Amsterdam and the University of Amsterdam.</p>

            <p>Tailor is free software licensed under the GNU General Public License v3.0 or later.</p>

            <p>For more information, please visit:<br><a href="https://github.com/davidfokkema/tailor">https://github.com/davidfokkema/tailor</a></p>
        """
            )
        )
        box.exec()

    def edit_or_move_down(self):
        """Edit cell or move cursor down a row.

        Start editing a cell. If the cell was already being edited, move the
        cursor down a row, stopping the edit in the process. Trigger a
        recalculation of all calculated columns.
        """
        cur_index = self.ui.data_view.currentIndex()
        if not self.ui.data_view.isPersistentEditorOpen(cur_index):
            # is not yet editing, so start an edit
            self.ui.data_view.edit(cur_index)
        else:
            # is already editing, what index is below?
            new_index = self.get_index_below_selected_cell()
            if new_index == cur_index:
                # already on bottom row, create a new row and take that index
                self.add_row()
                new_index = self.get_index_below_selected_cell()
            # move to it (finishing editing in the process)
            self.ui.data_view.setCurrentIndex(new_index)

    def clear_selected_cells(self):
        """Clear contents of selected cells."""
        for index in self.selection.selectedIndexes():
            self.data_model.setData(index, "", skip_update=True)
        # signal that ALL values may have changed using an invalid index
        # this can be MUCH quicker than emitting signals for each cell
        self.data_model.dataChanged.emit(QtCore.QModelIndex(), QtCore.QModelIndex())
        # recalculate computed values once
        self.data_model.recalculate_all_columns()

    def get_index_below_selected_cell(self):
        """Get index directly below the selected cell."""
        return self.ui.data_view.moveCursor(
            self.ui.data_view.MoveDown, QtCore.Qt.NoModifier
        )

    def selection_changed(self, selected, deselected):
        """Handle selectionChanged events in the data view.

        When the selection is changed, the column index of the left-most cell in
        the first selection is used to identify the column name and the
        mathematical expression that is used to calculate the column values.
        These values are used to update the column information in the user
        interface.

        Args: selected: QItemSelection containing the newly selected events.
            deselected: QItemSelection containing previously selected, and now
            deselected, items.
        """
        if not selected.isEmpty():
            self.ui.nameLabel.setEnabled(True)
            self.ui.name_edit.setEnabled(True)
            first_selection = selected.first()
            col_idx = first_selection.left()
            self._selected_col_idx = col_idx
            self.ui.name_edit.setText(self.data_model.get_column_name(col_idx))
            self.ui.formula_edit.setText(self.data_model.get_column_expression(col_idx))
            if self.data_model.is_calculated_column(col_idx):
                self.ui.formulaLabel.setEnabled(True)
                self.ui.formula_edit.setEnabled(True)
            else:
                self.ui.formulaLabel.setEnabled(False)
                self.ui.formula_edit.setEnabled(False)
        else:
            self.ui.nameLabel.setEnabled(False)
            self.ui.name_edit.clear()
            self.ui.name_edit.setEnabled(False)
            self.ui.formulaLabel.setEnabled(False)
            self.ui.formula_edit.clear()
            self.ui.formula_edit.setEnabled(False)

    def tab_changed(self, idx):
        """Handle currentChanged events of the tab widget.

        When the tab widget changes to a plot tab, update the plot to reflect
        any changes to the data that might have occured.

        Args:
            idx: an integer index of the now-focused tab.
        """
        self.update_plot_tab(idx)

    def update_plot_tab(self, idx):
        """Update plot tab.

        Update the plot to reflect any changes to the data that might have
        occured.

        Args:
            idx: an integer index of the tab.
        """
        tab = self.ui.tabWidget.widget(idx).code
        if type(tab) == PlotTab:
            tab.update_plot()

    def update_all_plots(self):
        """Update all plot tabs.

        Update all plots to reflect any changes to the data that might have
        occured.
        """
        for idx in range(self.ui.tabWidget.count()):
            self.update_plot_tab(idx)

    def add_column(self):
        """Add column to data model and select it."""
        col_index = self.data_model.columnCount()
        self.data_model.insertColumn(col_index)
        self.ui.data_view.selectColumn(col_index)
        self.ui.name_edit.selectAll()
        self.ui.name_edit.setFocus()

    def add_calculated_column(self):
        """Add a calculated column to data model and select it."""
        col_index = self.data_model.columnCount()
        self.data_model.insert_calculated_column(col_index)
        self.ui.data_view.selectColumn(col_index)
        self.ui.name_edit.selectAll()
        self.ui.name_edit.setFocus()

    def add_row(self):
        """Add row to data model."""
        self.data_model.insertRow(self.data_model.rowCount())

    def remove_column(self):
        """Remove selected column(s) from data model."""
        selected_columns = [s.column() for s in self.selection.selectedColumns()]
        if selected_columns:
            # Remove columns in reverse order to avoid index shifting during
            # removal. WIP: It would be more efficient to merge ranges of
            # contiguous columns since they can be removed in one fell swoop.
            selected_columns.sort(reverse=True)
            for column in selected_columns:
                self.data_model.removeColumn(column)
        else:
            error_msg = QtWidgets.QMessageBox()
            error_msg.setText("You must select one or more columns.")
            error_msg.exec()

    def remove_row(self):
        """Remove selected row(s) from data model."""
        selected_rows = [s.row() for s in self.selection.selectedRows()]
        if selected_rows:
            # Remove rows in reverse order to avoid index shifting during
            # removal. WIP: It would be more efficient to merge ranges of
            # contiguous rows since they can be removed in one fell swoop.
            selected_rows.sort(reverse=True)
            for row in selected_rows:
                self.data_model.removeRow(row)
        else:
            error_msg = QtWidgets.QMessageBox()
            error_msg.setText("You must select one or more rows.")
            error_msg.exec()

    def rename_column(self, name):
        """Rename a column.

        Renames the currently selected column.

        Args:
            name: a QString containing the new name.
        """
        if self._selected_col_idx is not None:
            # Do not allow empty names or duplicate column names
            if name and name not in self.data_model.get_column_names():
                old_name = self.data_model.get_column_name(self._selected_col_idx)
                new_name = self.data_model.rename_column(self._selected_col_idx, name)
                self.rename_plot_variables(old_name, new_name)
                # set the normalized name to the name edit field
                self.ui.name_edit.setText(new_name)

    def rename_plot_variables(self, old_name, new_name):
        """Rename any plotted variables

        Args:
            old_name: the name that may be currently in use.
            new_name: the new column name
        """
        num_tabs = self.ui.tabWidget.count()
        tabs = [self.ui.tabWidget.widget(i).code for i in range(num_tabs)]
        for tab in tabs:
            if type(tab) == PlotTab:
                needs_info_update = False
                for var in ["x_var", "y_var", "x_err_var", "y_err_var"]:
                    if getattr(tab, var) == old_name:
                        needs_info_update = True
                        setattr(tab, var, new_name)
                        # The following creates problems with partial matches
                        # For now, the model function is *not* updated
                        #
                        # if var == "x_var":
                        # update model expression and model object
                        # expr = tab.model_func.text()
                        # new_expr = expr.replace(old_name, new_name)
                        # tab.model_func.setText(new_expr)
                        # tab.get_params_and_update_model()
                        if var == "y_var":
                            # update y-label for model expression
                            tab.update_function_label(new_name)
                if needs_info_update:
                    tab.update_info_box()

    def update_column_expression(self, expression):
        """Update a column expression.

        Tries to recalculate the values of the currently selected column in the
        data model.

        Args:
            expression: a QString containing the mathematical expression.
        """
        if self._selected_col_idx is not None:
            self.data_model.update_column_expression(self._selected_col_idx, expression)

    def ask_and_create_plot_tab(self):
        """Opens a dialog and create a new tab with a plot.

        First, a create plot dialog is opened to query the user for the columns
        to plot. When the dialog is accepted, creates a new tab containing the
        requested plot.
        """
        dialog = self.create_plot_dialog()
        if dialog.exec() == QtWidgets.QDialog.Accepted:
            x_var = dialog.x_axis_box.currentText()
            y_var = dialog.y_axis_box.currentText()
            x_err = dialog.x_err_box.currentText()
            y_err = dialog.y_err_box.currentText()
            if x_var and y_var:
                self.create_plot_tab(x_var, y_var, x_err, y_err)

    def create_plot_tab(self, x_var, y_var, x_err=None, y_err=None):
        """Create a new tab with a plot.

        After creating the plot, the tab containing the plot is focused.

        Args:
            x_var: the name of the variable to plot on the x-axis.
            y_var: the name of the variable to plot on the y-axis.
            x_err: the name of the variable to use for the x-error bars.
            y_err: the name of the variable to use for the y-error bars.
        """
        plot_tab = PlotTab(self.data_model, main_app=self)
        idx = self.ui.tabWidget.addTab(plot_tab.ui, f"Plot {self._plot_num}")
        self._plot_num += 1
        plot_tab.create_plot(x_var, y_var, x_err, y_err)

        self.ui.tabWidget.setCurrentIndex(idx)

    def create_plot_dialog(self):
        """Create a dialog to request variables for creating a plot."""
        create_dialog = QUiLoader().load(
            resources.path("tailor.resources", "create_plot_dialog.ui"),
            self.ui,
        )
        choices = [None] + self.data_model.get_column_names()
        create_dialog.x_axis_box.addItems(choices)
        create_dialog.y_axis_box.addItems(choices)
        create_dialog.x_err_box.addItems(choices)
        create_dialog.y_err_box.addItems(choices)
        return create_dialog

    def close_tab(self, idx):
        """Close a plot tab.

        Closes the requested tab, but do not close the table view.

        Args:
            idx: an integer tab index
        """
        if idx > 0:
            # Don't close the table view, only close plot tabs
            if self.confirm_close_dialog("Are you sure you want to close this plot?"):
                self.ui.tabWidget.removeTab(idx)

    def clear_all(self):
        """Clear all program state.

        Closes all tabs and data.
        """
        for idx in range(self.ui.tabWidget.count(), 0, -1):
            # close all plot tabs in reverse order, they are no longer valid
            self.ui.tabWidget.removeTab(idx)
        self._plot_num = 1
        self.data_model = DataModel(main_app=self)
        self._set_view_and_selection_model()
        self.ui.data_view.setCurrentIndex(self.data_model.createIndex(0, 0))
        self._set_project_path(None)
        self.mark_project_dirty(False)

    def new_project(self):
        """Close the current project and open a new one."""
        if self.confirm_project_close_dialog():
            self.clear_all()

    def save_project_or_dialog(self):
        """Save project or present a dialog.

        When you first save a project, present a dialog to select a filename. If
        you previously opened or saved this project, just save it without
        presenting the dialog.
        """
        if self._project_filename is None:
            self.save_as_project_dialog()
        else:
            self.save_project(self._project_filename)

    def save_as_project_dialog(self):
        """Present save project dialog and save project."""
        filename, _ = QtWidgets.QFileDialog.getSaveFileName(
            parent=self.ui,
            dir=self.get_recent_directory(),
            filter="Tailor project files (*.tlr);;All files (*)",
        )
        if filename:
            self.set_recent_directory(pathlib.Path(filename).parent)
            self.save_project(filename)

    def save_project(self, filename):
        """Save a Tailor project.

        Save all data and program state (i.e. plot tabs, fit parameters, etc.)
        to a Tailor project file.

        Args:
            filename: a string containing the filename to save to.
        """
        try:
            save_obj = {
                "application": __name__,
                "version": __version__,
                "data_model": {},
                "tabs": [],
                "plot_num": self._plot_num,
                "current_tab": self.ui.tabWidget.currentIndex(),
            }

            # save data for the data model
            self.data_model.save_state_to_obj(save_obj["data_model"])

            for idx in range(1, self.ui.tabWidget.count()):
                # save data for each tab
                tab = self.ui.tabWidget.widget(idx).code
                tab_data = {"label": self.ui.tabWidget.tabBar().tabText(idx)}
                tab.save_state_to_obj(tab_data)
                save_obj["tabs"].append(tab_data)
        except Exception as exc:
            self._show_exception(
                exc,
                title="Unable to save project.",
                text="This is a bug in the application.",
            )

        # save data to disk
        with gzip.open(filename, "w") as f:
            f.write(json.dumps(save_obj).encode("utf-8"))

        # remember filename for subsequent call to "Save"
        self._set_project_path(filename)

        self.update_recent_files(filename)
        self.mark_project_dirty(False)

    def open_project_dialog(self):
        """Present open project dialog and load project."""
        if self.confirm_project_close_dialog():
            filename, _ = QtWidgets.QFileDialog.getOpenFileName(
                parent=self.ui,
                dir=self.get_recent_directory(),
                filter="Tailor project files (*.tlr);;All files (*)",
            )
            if filename:
                self.set_recent_directory(pathlib.Path(filename).parent)
                self.load_project(filename)

    def get_recent_directory(self):
        """Get recent directory from config file.

        Returns:
            str: the most recently visited directory.
        """
        cfg = config.read_config()
        return cfg.get("recent_dir", None)

    def set_recent_directory(self, directory):
        """Save the most recently visited directory to the config file.

        Args:
            directory (str or pathlib.Path): the most recently visited directory.
        """
        cfg = config.read_config()
        cfg["recent_dir"] = str(directory)
        config.write_config(cfg)

    def confirm_project_close_dialog(self):
        """Present a confirmation dialog before closing a project.

        Present a dialog to confirm that the user really wants to close a
        project and lose possible changes.

        Returns:
            A boolean. If True, the user confirms closing the project. If False,
            the user wants to cancel the action.
        """
        if not self._is_dirty:
            # There are no changes, skip confirmation dialog
            return True
        else:
            msg = "This action will lose any changes in the current project. Discard the current project, or cancel?"
            button = QtWidgets.QMessageBox.warning(
                self.ui,
                "Please confirm",
                msg,
                buttons=QtWidgets.QMessageBox.Save
                | QtWidgets.QMessageBox.Discard
                | QtWidgets.QMessageBox.Cancel,
                defaultButton=QtWidgets.QMessageBox.Cancel,
            )
            if button == QtWidgets.QMessageBox.Discard:
                return True
            elif button == QtWidgets.QMessageBox.Save:
                self.save_project_or_dialog()
                return True
            else:
                return False

    def confirm_close_dialog(self, msg=None):
        """Present a confirmation dialog before closing.

        Present a dialog to confirm that the user really wants to close an
        object and lose possible changes.

        Args:
            msg: optional message to present to the user. If None, the default
                message asks for confirmation to discard the current project.

        Returns:
            A boolean. If True, the user confirms closing the project. If False,
            the user wants to cancel the action.
        """
        if msg is None:
            msg = "You might lose changes."
        button = QtWidgets.QMessageBox.warning(
            self.ui,
            "Please confirm",
            msg,
            buttons=QtWidgets.QMessageBox.Close | QtWidgets.QMessageBox.Cancel,
            defaultButton=QtWidgets.QMessageBox.Cancel,
        )
        if button == QtWidgets.QMessageBox.Close:
            return True
        else:
            return False

    def load_project(self, filename):
        """Load a Tailor project.

        Load all data and program state (i.e. plot tabs, fit parameters, etc.)
        from a Tailor project file.

        Args:
            filename: a string containing the filename to load from.
        """
        try:
            with gzip.open(filename) as f:
                save_obj = json.loads(f.read().decode("utf-8"))

            if save_obj["application"] == __name__:
                self.clear_all()

                # remember filename for subsequent call to "Save"
                self._set_project_path(filename)

                # load data for the data model
                self.data_model.load_state_from_obj(save_obj["data_model"])

                # create a tab and load data for each plot
                for tab_data in save_obj["tabs"]:
                    plot_tab = PlotTab(self.data_model, main_app=self)
                    self.ui.tabWidget.addTab(plot_tab.ui, tab_data["label"])
                    plot_tab.load_state_from_obj(tab_data)
                self._plot_num = save_obj["plot_num"]
                self.ui.tabWidget.setCurrentIndex(save_obj["current_tab"])
        except Exception as exc:
            self._show_exception(
                exc,
                title="Unable to open project.",
                text="This can happen if the file is corrupt or if there is a bug in the application.",
            )
        else:
            self.update_recent_files(filename)
            self.mark_project_dirty(False)
            self.ui.statusbar.showMessage(
                "Finished loading project.", timeout=MSG_TIMEOUT
            )

    def export_csv(self):
        """Export all data as CSV.

        Export all data in the table as a comma-separated values file.
        """
        filename, _ = QtWidgets.QFileDialog.getSaveFileName(
            parent=self.ui,
            dir=self.get_recent_directory(),
            filter="CSV files (*.csv);;Text files (*.txt);;All files (*)",
        )
        if filename:
            self.set_recent_directory(pathlib.Path(filename).parent)
            self.data_model.write_csv(filename)

    def import_csv(self):
        """Import data from a CSV file.

        After confirmation, erase all data and import from a comma-separated
        values file.
        """
        if self.confirm_project_close_dialog():
            filename, _ = QtWidgets.QFileDialog.getOpenFileName(
                parent=self.ui,
                dir=self.get_recent_directory(),
                filter="CSV files (*.csv);;Text files (*.txt);;All files (*)",
            )
            if filename:
                self.set_recent_directory(pathlib.Path(filename).parent)
                dialog = CSVFormatDialog(filename, parent=self.ui)
                if dialog.ui.exec() == QtWidgets.QDialog.Accepted:
                    (
                        delimiter,
                        decimal,
                        thousands,
                        header,
                        skiprows,
                    ) = dialog.get_format_parameters()
                    self._do_import_csv(
                        filename,
                        delimiter,
                        decimal,
                        thousands,
                        header,
                        skiprows,
                    )

    def _do_import_csv(self, filename, delimiter, decimal, thousands, header, skiprows):
        """Import CSV data from file.

        Args:
            filename: a string containing the path to the CSV file
            delimiter: a string containing the column delimiter
            decimal: a string containing the decimal separator
            thousands: a string containing the thousands separator
            header: an integer with the row number containing the column names,
                or None.
            skiprows: an integer with the number of rows to skip at start of file
        """
        if self.data_model.is_empty():
            # when the data only contains empty cells
            self.clear_all()
            import_func = self.data_model.read_csv
        else:
            import_func = self.data_model.read_and_concat_csv

        import_func(
            filename,
            delimiter=delimiter,
            decimal=decimal,
            thousands=thousands,
            header=header,
            skiprows=skiprows,
        )
        self.ui.data_view.setCurrentIndex(self.data_model.createIndex(0, 0))
        self.update_all_plots()

    def export_graph(self, suffix):
        """Export a graph to a file.

        If the user specifies a name with a different suffix an error will be
        displayed.

        Args:
            suffix: the required suffix of the file.
        """
        tab = self.ui.tabWidget.currentWidget().code
        if type(tab) == PlotTab:
            filename, _ = QtWidgets.QFileDialog.getSaveFileName(
                parent=self.ui,
                dir=self.get_recent_directory(),
                filter=f"Graphics (*{suffix});;All files (*)",
            )
            if filename:
                path = pathlib.Path(filename)
                self.set_recent_directory(path.parent)
                if path.suffix == suffix:
                    try:
                        tab.export_graph(path)
                    except Exception as exc:
                        self._show_exception(
                            exc,
                            title="Unable to export graph.",
                            text="This can happen if there is a bug in the application.",
                        )
                else:
                    error_msg = QtWidgets.QMessageBox()
                    error_msg.setText(f"You didn't select a {suffix} file.")
                    error_msg.exec()
        else:
            error_msg = QtWidgets.QMessageBox()
            error_msg.setText("You must select a plot tab first.")
            error_msg.exec()

    def _set_project_path(self, filename):
        """Set window title and project name."""
        self._project_filename = filename
        self.update_window_title()

    def update_window_title(self):
        """Update window title.

        Include project name and dirty flag in the title.
        """
        filename = self._project_filename
        title = "Tailor"
        if filename is not None:
            title += f": {pathlib.Path(filename).stem}"
        if self._is_dirty:
            title += "*"
        self.ui.setWindowTitle(title)

    def _show_exception(self, exc, title, text):
        """Show a messagebox with detailed exception information.

        Args:
            exc: the exception.
            title: short header text.
            text: longer informative text describing the problem.
        """
        msg = QtWidgets.QMessageBox(parent=self.ui)
        msg.setText(title)
        msg.setInformativeText(text)
        msg.setDetailedText(traceback.format_exc())
        msg.setStyleSheet("QLabel{min-width: 400px;}")
        msg.exec()

    def update_recent_files(self, file=None):
        """Update open recent files list.

        Update open recent files list in menu and in configuration file.

        Args:
            file (pathlib.Path or str): the most recent file which will be added to the list.
        """
        cfg = config.read_config()
        recents = cfg.get("recent_files", [])
        if file:
            path = str(file)
            if path in recents:
                recents.remove(path)
            recents.insert(0, path)
            recents = recents[:MAX_RECENT_FILES]
            cfg["recent_files"] = recents
            config.write_config(cfg)
        self.populate_recent_files_menu(recents)

    def populate_recent_files_menu(self, recents):
        """Populate the open recent files menu.

        Populate the recent files with a list of recent file names.

        Args:
            recents (list): A list of recent file names.
        """
        if self._recent_files_actions:
            for action in self._recent_files_actions:
                self.ui.menuOpen_Recent.removeAction(action)
        if recents:
            actions = [QtGui.QAction(f) for f in recents]
            self.ui.menuOpen_Recent.insertActions(
                self.ui._recent_files_separator, actions
            )
            for action in actions:
                action.triggered.connect(
                    partial(self.open_recent_project_action, action.text())
                )
            self.ui.actionClear_Menu.setEnabled(True)
            self._recent_files_actions = actions

    def clear_recent_files_menu(self):
        """Clear the open recent files menu."""
        for action in self._recent_files_actions:
            self.ui.menuOpen_Recent.removeAction(action)
        self._recent_files_actions = None
        cfg = config.read_config()
        cfg["recent_files"] = []
        config.write_config(cfg)
        self.ui.actionClear_Menu.setEnabled(False)

    def open_recent_project_action(self, filename):
        if self.confirm_project_close_dialog():
            self.load_project(filename)

    def check_for_updates(self, silent=False):
        """Check for new releases of Tailor.

        Args:
            silent (bool, optional): If there are no updates available, should
                this method return silently? Defaults to False.
        """
        latest_version, update_link = self.get_latest_version_and_update_link()
        if latest_version is None:
            msg = "You appear to have no internet connection or GitHub is down."
        elif update_link is None:
            msg = f"You appear to be on the latest version ({__version__}), great!"
        else:
            msg = dedent(
                f"""\
                <p>There is a new version available. You have version {__version__} and the latest
                version is {latest_version}. You can download the new version using the link below.</p>

                <p><a href={update_link}>Download update.</a></p>
                """
            )
        if silent and update_link is None:
            # no updates, and asked to be silent
            return
        else:
            box = QtWidgets.QMessageBox()
            box.setText("Updates")
            box.setInformativeText(msg)
            box.setStyleSheet("QLabel{min-width: 300px;}")
            box.exec()

    def get_latest_version_and_update_link(self):
        """Get latest version and link to latest release, if available.

        Get the latest version of Tailor. If a new release is available, returns
        a platform-specific download link. If there is no new release, returns
        None.

        Returns:
            str: URL to download link or None.
        """
        try:
            r = urllib.request.urlopen(RELEASE_API_URL, timeout=HTTP_TIMEOUT)
        except urllib.error.URLError:
            # no internet connection?
            return None, None
        else:
            release_info = json.loads(r.read())
            latest_version = release_info["name"]
            update_link = None
            if packaging.version.parse(latest_version) > packaging.version.parse(
                __version__
            ):
                urls = {
                    pathlib.Path(a["name"]).suffix: a["browser_download_url"]
                    for a in release_info["assets"]
                }
                system = platform.system()
                try:
                    if system == "Darwin":
                        update_link = urls[".dmg"]
                    elif system == "Windows":
                        update_link = urls[".msi"]
                except KeyError:
                    # installer not available, no update link
                    pass
            return latest_version, update_link
Example #7
0

if __name__ == "__main__":
    import sys

    QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts)
    app = QApplication(sys.argv)

    ui_file_name = "calculator_layout.ui"
    ui_file = QFile(ui_file_name)

    if not ui_file.open(QIODevice.ReadOnly):
        print("Cannot open {}: {}".format(ui_file_name, ui_file.errorString()))
        sys.exit(-1)

    loader = QUiLoader()
    dialog = loader.load(ui_file)
    ui_file.close()

    if not dialog:
        print(loader.errorString())
        sys.exit(-1)

    #loader.load(ui_file).show()

    dialog.show()

    sys.exit(app.exec())
"""
    app = QtWidgets.QApplication(sys.argv)
    Dialog = QtWidgets.QDialog()
Example #8
0
 def __init__(self):
     """"""
     super().__init__()
     self.main = QUiLoader().load('main_window.ui', self)
     self.main.pushButton_2.setProperty('class', 'big_button')