From 23cfcbdf5fad02a4ff6c5b4c6077aeb2da84f52c Mon Sep 17 00:00:00 2001 From: "Alex H. Room" <69592136+alexhroom@users.noreply.github.com> Date: Wed, 11 Sep 2024 12:22:26 +0100 Subject: [PATCH] Adds ability to save and reset layouts (#27) * working window save/load * removed debug print and added tests * changed test to adaptive width/height --- rascal2/core/settings.py | 19 +++++++++++- rascal2/ui/view.py | 44 ++++++++++++++++++++++++--- tests/test_view.py | 64 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 tests/test_view.py diff --git a/rascal2/core/settings.py b/rascal2/core/settings.py index f21bd68..8e3a779 100644 --- a/rascal2/core/settings.py +++ b/rascal2/core/settings.py @@ -4,7 +4,7 @@ from enum import IntEnum, StrEnum from os import PathLike from pathlib import PurePath -from typing import Any +from typing import Any, TypeAlias from pydantic import BaseModel, Field from PyQt6 import QtCore @@ -54,6 +54,20 @@ def _missing_(cls, value): raise ValueError("Not a known logging level.") +# WindowGeometry is a tuple (x, y, width, height, minimized) +# where 'minimized' is True iff the window is minimized +WindowGeometry: TypeAlias = tuple[int, int, int, int, bool] + + +class MDIGeometries(BaseModel): + """Model for storing window positions and sizes.""" + + plots: WindowGeometry = Field(max_length=5, min_length=5) + project: WindowGeometry = Field(max_length=5, min_length=5) + terminal: WindowGeometry = Field(max_length=5, min_length=5) + controls: WindowGeometry = Field(max_length=5, min_length=5) + + class Settings(BaseModel, validate_assignment=True, arbitrary_types_allowed=True): """Model for system settings. @@ -72,9 +86,12 @@ class Settings(BaseModel, validate_assignment=True, arbitrary_types_allowed=True style: Styles = Field(default=Styles.Light, title="general", description="Style") editor_fontsize: int = Field(default=12, title="general", description="Editor Font Size", gt=0) terminal_fontsize: int = Field(default=12, title="general", description="Terminal Font Size", gt=0) + log_path: str = Field(default="logs/rascal.log", title="logging", description="Path to Log File") log_level: LogLevels = Field(default=LogLevels.Info, title="logging", description="Minimum Log Level") + mdi_defaults: MDIGeometries | None = Field(default=None, title="windows", description="Default Window Geometries") + def model_post_init(self, __context: Any): global_settings = get_global_settings() unset_settings = [s for s in self.model_fields if s not in self.model_fields_set] diff --git a/rascal2/ui/view.py b/rascal2/ui/view.py index eae4152..aacbc5d 100644 --- a/rascal2/ui/view.py +++ b/rascal2/ui/view.py @@ -3,6 +3,7 @@ from PyQt6 import QtCore, QtGui, QtWidgets from rascal2.config import path_for, setup_logging, setup_settings +from rascal2.core.settings import MDIGeometries from rascal2.dialogs.project_dialog import ProjectDialog from rascal2.widgets import ControlsWidget from rascal2.widgets.startup_widget import StartUpWidget @@ -107,9 +108,15 @@ def create_actions(self): # Window menu actions self.tile_windows_action = QtGui.QAction("Tile Windows", self) - self.tile_windows_action.setStatusTip("Arrange windows in the default grid") + self.tile_windows_action.setStatusTip("Arrange windows in the default grid.") self.tile_windows_action.setIcon(QtGui.QIcon(path_for("tile.png"))) self.tile_windows_action.triggered.connect(self.mdi.tileSubWindows) + self.reset_windows_action = QtGui.QAction("Reset to Default") + self.reset_windows_action.setStatusTip("Reset the windows to their default arrangement.") + self.reset_windows_action.triggered.connect(self.reset_mdi_layout) + self.save_default_windows_action = QtGui.QAction("Save Current Window Positions") + self.save_default_windows_action.setStatusTip("Set the current window positions as default.") + self.save_default_windows_action.triggered.connect(self.save_mdi_layout) def create_menus(self): """Creates the main menu and sub menus""" @@ -129,6 +136,8 @@ def create_menus(self): windows_menu = main_menu.addMenu("&Windows") windows_menu.addAction(self.tile_windows_action) + windows_menu.addAction(self.reset_windows_action) + windows_menu.addAction(self.save_default_windows_action) help_menu = main_menu.addMenu("&Help") help_menu.addAction(self.open_help_action) @@ -174,12 +183,39 @@ def setup_mdi(self): widget, QtCore.Qt.WindowType.WindowMinMaxButtonsHint | QtCore.Qt.WindowType.WindowTitleHint ) window.setWindowTitle(title) - # TODO implement user save for layouts, this should default to use saved layout and fallback to tile - # https://github.com/RascalSoftware/RasCAL-2/issues/15 - self.mdi.tileSubWindows() + self.reset_mdi_layout() self.startup_dlg = self.takeCentralWidget() self.setCentralWidget(self.mdi) + def reset_mdi_layout(self): + """Reset MDI layout to the default.""" + if self.settings.mdi_defaults is None: + for window in self.mdi.subWindowList(): + window.showNormal() + self.mdi.tileSubWindows() + else: + for window in self.mdi.subWindowList(): + # get corresponding MDIGeometries entry for the widget + widget_name = window.windowTitle().lower().split(" ")[-1] + x, y, width, height, minimized = getattr(self.settings.mdi_defaults, widget_name) + if minimized: + window.showMinimized() + else: + window.showNormal() + + window.setGeometry(x, y, width, height) + + def save_mdi_layout(self): + """Set current MDI geometries as the default.""" + geoms = {} + for window in self.mdi.subWindowList(): + # get corresponding MDIGeometries entry for the widget + widget_name = window.windowTitle().lower().split(" ")[-1] + geom = window.geometry() + geoms[widget_name] = (geom.x(), geom.y(), geom.width(), geom.height(), window.isMinimized()) + + self.settings.mdi_defaults = MDIGeometries.model_validate(geoms) + def init_settings_and_log(self, save_path: str): """Initialise settings and logging for the project. diff --git a/tests/test_view.py b/tests/test_view.py new file mode 100644 index 0000000..0469da4 --- /dev/null +++ b/tests/test_view.py @@ -0,0 +1,64 @@ +"""Unit tests for the main window view.""" + +from unittest.mock import patch + +import pytest + +from rascal2.core.settings import MDIGeometries, Settings +from rascal2.ui.view import MainWindowView + + +@pytest.mark.parametrize( + "geometry", + [ + ((1, 2, 196, 24, True), (1, 2, 196, 24, True), (1, 2, 196, 24, True), (1, 2, 196, 24, True)), + ((1, 2, 196, 24, True), (3, 78, 196, 24, True), (1, 2, 204, 66, False), (12, 342, 196, 24, True)), + ], +) +@patch("rascal2.ui.view.MainWindowPresenter") +@patch("rascal2.ui.view.ControlsWidget.setup_controls") +class TestMDISettings: + def test_reset_mdi(self, mock1, mock2, geometry): + """Test that resetting the MDI works.""" + view = MainWindowView() + view.settings = Settings() + view.setup_mdi() + view.settings.mdi_defaults = MDIGeometries( + plots=geometry[0], project=geometry[1], terminal=geometry[2], controls=geometry[3] + ) + view.reset_mdi_layout() + for window in view.mdi.subWindowList(): + # get corresponding MDIGeometries entry for the widget + widget_name = window.windowTitle().lower().split(" ")[-1] + wgeom = window.geometry() + assert getattr(view.settings.mdi_defaults, widget_name) == ( + wgeom.x(), + wgeom.y(), + wgeom.width(), + wgeom.height(), + window.isMinimized(), + ) + + def test_set_mdi(self, mock1, mock2, geometry): + """Test that setting the MDI adds the expected object to settings.""" + view = MainWindowView() + view.settings = Settings() + view.setup_mdi() + widgets_in_order = [] + + for i, window in enumerate(view.mdi.subWindowList()): + widgets_in_order.append(window.windowTitle().lower().split(" ")[-1]) + window.setGeometry(*geometry[i][0:4]) + if geometry[i][4] is True: + window.showMinimized() + + view.save_mdi_layout() + for i, widget in enumerate(widgets_in_order): + window = view.mdi.subWindowList()[i] + assert getattr(view.settings.mdi_defaults, widget) == ( + window.x(), + window.y(), + window.width(), + window.height(), + window.isMinimized(), + )