mborgerson / pyside6_qtads

Python bindings to Qt Advanced Docking System for PySide6
Other
22 stars 9 forks source link

self.dock_manager.restoreState doesn't delete auto-hidden docks if it has the same name as the new one #22

Open mehdi-d-b opened 1 year ago

mehdi-d-b commented 1 year ago

Hi, I'm writing an application where a user can create multiple charts to plot data. For each chart dock he creates, there is a corresponding "settings" dock created. These "settings" docks are auto hidden in the bottom sidebar so it's not always displayed. When the user has created his workspace, he can save it and be able to restore it later. The problem I'm facing is, when a user wants to load a workspace with docks having the same name as the current ones, the auto-hidden one is not deleted. Here is how to reproduce the bug:

  1. Load the app.
  2. Add a new chart named "chart1". It creates two docks named "chart1 (Chart)" and "chart1 (Controls)".
  3. Save the workspace with any name.
  4. Load the workspace. The regular dock "chart1 (Chart)" has been replaced by the new one, but the auto-hidden dock "chart1 (Controls)" hasn't, so we have now 2 docks "chart1 (Controls)" with the QWidget inside, even if the parent has been deleted.

I've only had this bug when adding the AutoHide feature, and if the dock "chart1 (Controls)" has been moved from auto-hidden to floating, it is deleted accordingly, so the bugs appears only on hidden docks.

Here is a minimal code to reproduce the bug:

import sys
import re
import json
import os
from functools import partial
from glob import glob
from os import makedirs, path, listdir

from PySide6.QtGui import QAction

import PySide6QtAds as QtAds

from PySide6.QtCore import (
    QSettings,
    QObject
)

from PySide6.QtWidgets import (
    QApplication,
    QMainWindow,
    QInputDialog,
    QMessageBox,
    QDialog,
    QLineEdit,
    QVBoxLayout,
    QLabel,
    QWidget,
    QMenu,
    QDialogButtonBox,
)

class GManager(QObject):
    def __init__(self, settings_file, panel_name):
        self.panel_name = panel_name
        if settings_file == "":
            self.settings = {
               "some_key": "some_default_setting"
            }
        else:
            self.import_settings(settings_file)

        self.chart = QWidget()
        self.settings_window = QWidget()

    def export_settings(self, workspace_name):
        try:
            if not os.path.exists(f"workspaces/{workspace_name}/vues/"):
                os.makedirs(f"workspaces/{workspace_name}/vues/")
            with open(f"workspaces/{workspace_name}/vues/{self.panel_name}.json", 'w') as file:
                json.dump(self.settings, file)
            return True
        except Exception as e:
            print(f"Erreur lors de l'exportation de la représentation vers le fichier JSON : {str(e)}")
            return False

    def import_settings(self, file_name):
        try:
            with open(file_name, 'r') as file:
                representation_data = json.load(file)

            self.settings = representation_data
            return True
        except Exception as e:
            print(f"Erreur lors de l'importation de la représentation depuis le fichier JSON : {str(e)}")
            return False

class PopupCreateGM(QDialog):
    def __init__(self):
        super().__init__()
        layout = QVBoxLayout()

        self.window_name = ""
        name_label = QLabel("Dock label:")
        layout.addWidget(name_label)

        name_input = QLineEdit()
        layout.addWidget(name_input)

        button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        button_box.accepted.connect(lambda: self.accept_with_validation(name_input.text()))
        button_box.rejected.connect(self.reject)
        layout.addWidget(button_box)

        self.setLayout(layout)

    def accept_with_validation(self, window_name):
        if not window_name:
            QMessageBox.warning(self, "Error", "Please enter a name for the Dock.")
            return

        if not self.is_valid_filename(window_name):
            invalid_chars = ''.join(re.findall(r'[^a-zA-Z0-9-_]', window_name))
            QMessageBox.warning(self, "Error", f"The dock label contains invalid characters: {invalid_chars}")
            return

        self.window_name = window_name
        super().accept()

    def is_valid_filename(self, filename):
        return re.match(r'^[a-zA-Z0-9_-]+$', filename) is not None

class MainWindow(QMainWindow):

    def __init__(self, parent=None):
        super().__init__(parent)

        # AutoHide
        QtAds.CDockManager.setAutoHideConfigFlag(QtAds.CDockManager.DefaultAutoHideConfig)

        # Create the dock manager. Because the parent parameter is a QMainWindow
        # the dock manager registers itself as the central widget.
        self.dock_manager = QtAds.CDockManager(self)

        self.chart_managers=[]

        self.create_toolbar()

    def create_toolbar(self):
        menubar = self.menuBar()

        save_workspace_action = QAction("Save Workspace", self)
        save_workspace_action.triggered.connect(self.save_workspace)

        self.load_workspace_action = QAction("Load Workspace", self)
        load_workspace_menu = QMenu(self)
        self.load_workspace_action.setMenu(load_workspace_menu)
        load_workspace_menu.aboutToShow.connect(self.populate_workspace_menu)

        create_chart_action = QAction("Create Chart", self)
        create_chart_action.triggered.connect(self.add_chart_manager)

        file_menu = menubar.addMenu("File")
        file_menu.addAction(save_workspace_action)
        file_menu.addAction(self.load_workspace_action)

        chart_menu = menubar.addMenu("Chart")
        chart_menu.addAction(create_chart_action)

    def populate_workspace_menu(self):
        """
        Populates the "Load Workspace" menu with existing workspaces.
        """
        load_workspace_menu = self.load_workspace_action.menu()
        load_workspace_menu.clear()  # Clear previous entries
        workspace_path = "workspaces/"
        if path.exists(workspace_path):
            workspaces = [name for name in listdir(workspace_path) if path.isdir(path.join(workspace_path, name))]
            for workspace in workspaces:
                action = load_workspace_menu.addAction(workspace)
                action.triggered.connect(partial(self.restore_workspace, workspace))

    def add_chart_manager(self, panel_name="", settings_file=""):
        if settings_file == "":
            dialog = PopupCreateGM()
            if dialog.exec() != QDialog.Accepted:  
                return  
            panel_name = dialog.window_name
        new_gm = GManager(settings_file, panel_name)
        self.chart_managers.append(new_gm)

        # Create docks
        chart_dock = QtAds.CDockWidget(panel_name + " (Chart)")
        chart_dock.setWidget(new_gm.chart)
        self.dock_manager.addDockWidget(QtAds.DockWidgetArea.CenterDockWidgetArea, chart_dock)

        settings_dock = QtAds.CDockWidget(panel_name + " (Settings)")
        settings_dock.setWidget(
            new_gm.settings_window
        )

        self.dock_manager.addAutoHideDockWidget(
            QtAds.PySide6QtAds.ads.SideBarLocation.SideBarBottom,
            settings_dock
        )

        chart_dock.setFeature(QtAds.CDockWidget.CustomCloseHandling, True)
        settings_dock.setFeature(QtAds.CDockWidget.CustomCloseHandling, True)

        chart_dock.setFeature(QtAds.CDockWidget.DockWidgetDeleteOnClose, True)
        settings_dock.setFeature(QtAds.CDockWidget.DockWidgetDeleteOnClose, True)

        chart_dock.closeRequested.connect(lambda manager=new_gm: self.on_dock_close_requested([
            chart_dock,
            settings_dock
            ],
            manager)
        )

        settings_dock.closeRequested.connect(lambda manager=new_gm: self.on_dock_close_requested([
            chart_dock,
            settings_dock
            ],
            manager)
        )

    def restore_workspace(self, workspace_name):
        self.chart_managers = []
        file_pattern = f"workspaces/{workspace_name}/vues/*.json"
        file_list = glob(file_pattern)
        for file_name in file_list:
            try:
                panel_name = file_name.split(f"workspaces/{workspace_name}/vues\\")[1].split(".json")[0]
                self.add_chart_manager(panel_name=panel_name,settings_file=file_name)
            except Exception as e:
                print(f"An error occurred in restore_workspace: {str(e)}")
                QMessageBox.critical(self, "Error", f"An error occurred in restore_workspace: {str(e)}")
        # Load states
        self.restore_workspace_state(workspace_name)

    def save_workspace_state(self, workspace_name):
        '''
        Saves the dock manager state and the main window geometry.
        '''
        settings = QSettings("Settings.ini", QSettings.IniFormat)
        settings.setValue(f"{workspace_name}/Geometry", self.saveGeometry())
        settings.setValue(f"{workspace_name}/State", self.saveState())
        settings.setValue(f"{workspace_name}/DockingState", self.dock_manager.saveState())

    def restore_workspace_state(self, workspace_name):
        '''
        Restores the dock manager state
        '''
        try:
            settings = QSettings("Settings.ini", QSettings.IniFormat)
            geom = settings.value(f"{workspace_name}/Geometry")
            if geom is not None:
                self.restoreGeometry(geom)

            state = settings.value(f"{workspace_name}/State")
            if state is not None:
                self.restoreState(state)

            state = settings.value(f"{workspace_name}/DockingState")
            if state is not None:
                self.dock_manager.restoreState(state)
        except Exception as e:
            print(f"An error occurred in restore_workspace_state: {str(e)}")
            QMessageBox.critical(self, "Error", f"An error occurred in restore_workspace_state: {str(e)}")

    def save_workspace(self, workspace_name):
        if not path.exists("workspaces"):
            makedirs("workspaces")

        workspace_name, ok = QInputDialog.getText(self, "Save perspective", "Enter unique name:")
        if ok and workspace_name:
            if not path.exists(f"workspaces/{workspace_name}"):
                makedirs(f"workspaces/{workspace_name}/vues")
            for i, graph_manager in enumerate(self.chart_managers):
                graph_manager.export_settings(workspace_name)
                # Save shapes
                self.save_workspace_state(workspace_name)
            QMessageBox.information(self, "Success", f"Workspace '{workspace_name}' created successfully!")

    def on_dock_close_requested(self, list_of_docks, manager):
        try:
            result = QMessageBox.question(None, "Close Editor",
                    "Close this window? The associated window will also be deleted")
            if result == QMessageBox.Yes:
                for dock_widget in list_of_docks:
                    dock_widget.closeDockWidget()
                    del dock_widget
                self.chart_managers.remove(manager)
        except RuntimeError as e:
            QMessageBox.critical(None, "Error", f"An error occurred in on_dock_close_requested: {str(e)}")

if __name__ == "__main__":
    # pyQt app
    app = QApplication(sys.argv)
    app.setStyle('Fusion')
    widget = MainWindow()
    widget.show()
    exit(app.exec())

Thanks in advance.