Skip to content

Commit

Permalink
Adds Settings Dialog (#38)
Browse files Browse the repository at this point in the history
* Introduces basic settings dialog

* Adds widgets to settings dialog tabs

* Connects settings dialog to program settings

* Adds reset button to dialog

* Removes window tab

* Disables elements on startup, enabling them when a project is created

* Adds tests for SettingsDialog

* Adds reset dialog

* Removes reset dialog

* Completes test suite

* Addresses some review comments

* Completes addressing review comments

* Removes "save_path" from view

* Removes logging tab

* Adjusts mock presenter in tests
  • Loading branch information
DrPaulSharp authored Oct 3, 2024
1 parent 02e9a17 commit 30def2c
Show file tree
Hide file tree
Showing 10 changed files with 386 additions and 59 deletions.
36 changes: 29 additions & 7 deletions rascal2/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import logging
from enum import IntEnum, StrEnum
from os import PathLike
from pathlib import PurePath
from pathlib import Path, PurePath
from typing import Any, TypeAlias

from pydantic import BaseModel, Field
Expand All @@ -22,6 +22,26 @@ def get_global_settings() -> QtCore.QSettings:
)


def delete_local_settings(path: str | PathLike) -> None:
"""Delete the "settings.json" file.
Parameters
----------
path: str or PathLike
The path to the folder where the settings are saved.
"""
file = Path(path, "settings.json")
file.unlink(missing_ok=True)


class SettingsGroups(StrEnum):
"""The groups of the RasCAL-2 settings, used to set tabs in the dialog"""

General = "General"
Logging = "Logging"
Windows = "Windows"


class Styles(StrEnum):
"""Visual styles for RasCAL-2."""

Expand Down Expand Up @@ -83,14 +103,16 @@ class Settings(BaseModel, validate_assignment=True, arbitrary_types_allowed=True

# The Settings object's own model fields contain the within-project settings.
# The global settings are read and written via this object using `set_global_settings`.
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)
style: Styles = Field(default=Styles.Light, title=SettingsGroups.General, description="Style")
editor_fontsize: int = Field(default=12, title=SettingsGroups.General, description="Editor Font Size", gt=0)
terminal_fontsize: int = Field(default=12, title=SettingsGroups.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")
log_path: str = Field(default="logs/rascal.log", title=SettingsGroups.Logging, description="Path to Log File")
log_level: LogLevels = Field(default=LogLevels.Info, title=SettingsGroups.Logging, description="Minimum Log Level")

mdi_defaults: MDIGeometries | None = Field(default=None, title="windows", description="Default Window Geometries")
mdi_defaults: MDIGeometries = Field(
default=None, title=SettingsGroups.Windows, description="Default Window Geometries"
)

def model_post_init(self, __context: Any):
global_settings = get_global_settings()
Expand Down
3 changes: 1 addition & 2 deletions rascal2/dialogs/project_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def __init__(self, parent):
self.create_buttons()
self.create_form()
self.add_widgets_to_layout()
self.setWindowTitle("New Project")

def add_widgets_to_layout(self) -> None:
"""
Expand Down Expand Up @@ -173,6 +174,4 @@ def create_project(self) -> None:
self.verify_folder()
if self.project_name_error.isHidden() and self.project_folder_error.isHidden():
self.parent().presenter.create_project(self.project_name.text(), self.project_folder.text())
if not self.parent().toolbar.isEnabled():
self.parent().toolbar.setEnabled(True)
self.accept()
108 changes: 108 additions & 0 deletions rascal2/dialogs/settings_dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
from PyQt6 import QtCore, QtWidgets

from rascal2.core.settings import Settings, SettingsGroups, delete_local_settings
from rascal2.widgets.inputs import ValidatedInputWidget


class SettingsDialog(QtWidgets.QDialog):
def __init__(self, parent):
"""
Dialog to adjust RasCAL-2 settings.
Parameters
----------
parent : MainWindowView
The view of the RasCAL-2 GUI
"""
super().__init__(parent)

self.setModal(True)
self.setMinimumWidth(600)
self.setMinimumHeight(400)

self.settings = parent.settings.copy()
self.reset_dialog = None

self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose)

tab_widget = QtWidgets.QTabWidget()
tab_widget.addTab(SettingsTab(self, SettingsGroups.General), SettingsGroups.General)

self.reset_button = QtWidgets.QPushButton("Reset to Defaults", self)
self.reset_button.clicked.connect(self.reset_default_settings)
self.accept_button = QtWidgets.QPushButton("OK", self)
self.accept_button.clicked.connect(self.update_settings)
self.cancel_button = QtWidgets.QPushButton("Cancel", self)
self.cancel_button.clicked.connect(self.reject)

button_layout = QtWidgets.QHBoxLayout()
button_layout.addWidget(self.reset_button)
button_layout.addStretch(1)
button_layout.addWidget(self.accept_button)
button_layout.addWidget(self.cancel_button)

main_layout = QtWidgets.QVBoxLayout()
main_layout.addWidget(tab_widget)
main_layout.addLayout(button_layout)
self.setLayout(main_layout)
self.setWindowTitle("Settings")

def update_settings(self) -> None:
"""Accept the changed settings"""
self.parent().settings = self.settings
self.parent().settings.save(self.parent().presenter.model.save_path)
self.accept()

def reset_default_settings(self) -> None:
"""Reset the settings to the global defaults"""
delete_local_settings(self.parent().presenter.model.save_path)
self.parent().settings = Settings()
self.accept()


class SettingsTab(QtWidgets.QWidget):
def __init__(self, parent: SettingsDialog, group: SettingsGroups):
"""A tab in the Settings Dialog tab layout.
Parameters
----------
parent : SettingsDialog
The dialog in which this tab lies
group : SettingsGroups
The set of settings with this value in "title" field of the
Settings object's "field_info" will be included in this tab.
"""
super().__init__(parent)

self.settings = parent.settings
self.widgets = {}
tab_layout = QtWidgets.QGridLayout()

field_info = self.settings.model_fields
group_settings = [key for (key, value) in field_info.items() if value.title == group]

for i, setting in enumerate(group_settings):
label_text = setting.replace("_", " ").title()
tab_layout.addWidget(QtWidgets.QLabel(label_text), i, 0)
self.widgets[setting] = ValidatedInputWidget(field_info[setting])
try:
self.widgets[setting].set_data(getattr(self.settings, setting))
except TypeError:
self.widgets[setting].set_data(str(getattr(self.settings, setting)))
self.widgets[setting].edited_signal.connect(lambda ignore=None, s=setting: self.modify_setting(s))
tab_layout.addWidget(self.widgets[setting], i, 1)

tab_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
self.setLayout(tab_layout)

def modify_setting(self, setting: str):
"""A slot that updates the given setting in the dialog's copy of the Settings object.
Connect this (via a lambda) to the "edited_signal" of the corresponding widget.
Parameters
----------
setting : str
The name of the setting to be modified by this slot
"""
setattr(self.settings, setting, self.widgets[setting].get_data())
Binary file added rascal2/static/images/settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions rascal2/ui/presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def create_project(self, name: str, save_path: str):
self.view.init_settings_and_log(save_path)
self.view.setup_mdi()
self.view.undo_stack.clear()
self.view.enable_elements()

def edit_controls(self, setting: str, value: Any):
"""Edit a setting in the Controls object.
Expand Down
84 changes: 68 additions & 16 deletions rascal2/ui/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
from PyQt6 import QtCore, QtGui, QtWidgets

from rascal2.config import path_for, setup_logging, setup_settings
from rascal2.core.settings import MDIGeometries
from rascal2.core.settings import MDIGeometries, Settings
from rascal2.dialogs.project_dialog import ProjectDialog
from rascal2.dialogs.settings_dialog import SettingsDialog
from rascal2.widgets import ControlsWidget
from rascal2.widgets.startup_widget import StartUpWidget

Expand Down Expand Up @@ -40,6 +41,8 @@ def __init__(self):
self.controls_widget = ControlsWidget(self)
self.project_widget = QtWidgets.QWidget()

self.disabled_elements = []

self.create_actions()
self.create_menus()
self.create_toolbar()
Expand All @@ -48,7 +51,9 @@ def __init__(self):
self.setMinimumSize(1024, 900)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose)

self.startup_dlg, self.project_dlg = StartUpWidget(self), ProjectDialog(self)
self.settings = Settings()
self.startup_dlg = StartUpWidget(self)
self.project_dlg = ProjectDialog(self)

self.setCentralWidget(self.startup_dlg)

Expand All @@ -63,6 +68,11 @@ def show_project_dialog(self):
):
self.startup_dlg.show()

def show_settings_dialog(self):
"""Shows the settings dialog to adjust program settings"""
settings_dlg = SettingsDialog(self)
settings_dlg.show()

def create_actions(self):
"""Creates the menu and toolbar actions"""

Expand All @@ -81,24 +91,47 @@ def create_actions(self):
self.save_project_action.setStatusTip("Save project")
self.save_project_action.setIcon(QtGui.QIcon(path_for("save-project.png")))
self.save_project_action.setShortcut(QtGui.QKeySequence.StandardKey.Save)
self.save_project_action.setEnabled(False)
self.disabled_elements.append(self.save_project_action)

self.undo_action = self.undo_stack.createUndoAction(self, "&Undo")
self.undo_action.setStatusTip("Undo the last action")
self.undo_action.setIcon(QtGui.QIcon(path_for("undo.png")))
self.undo_action.setShortcut(QtGui.QKeySequence.StandardKey.Undo)
self.undo_action.setEnabled(False)
self.disabled_elements.append(self.undo_action)

self.redo_action = self.undo_stack.createRedoAction(self, "&Redo")
self.redo_action.setStatusTip("Redo the last undone action")
self.redo_action.setIcon(QtGui.QIcon(path_for("redo.png")))
self.redo_action.setShortcut(QtGui.QKeySequence.StandardKey.Redo)
self.redo_action.setEnabled(False)
self.disabled_elements.append(self.redo_action)

self.undo_view_action = QtGui.QAction("Undo &History", self)
self.undo_view_action.setStatusTip("View undo history")
self.undo_view_action.triggered.connect(self.undo_view.show)
self.undo_view_action.setEnabled(False)
self.disabled_elements.append(self.undo_view_action)

self.export_plots_action = QtGui.QAction("Export", self)
self.export_plots_action.setStatusTip("Export Plots")
self.export_plots_action.setIcon(QtGui.QIcon(path_for("export-plots.png")))
self.export_plots_action.setEnabled(False)
self.disabled_elements.append(self.export_plots_action)

self.settings_action = QtGui.QAction("Settings", self)
self.settings_action.setStatusTip("Settings")
self.settings_action.setIcon(QtGui.QIcon(path_for("settings.png")))
self.settings_action.triggered.connect(self.show_settings_dialog)
self.settings_action.setEnabled(False)
self.disabled_elements.append(self.settings_action)

self.export_plots_action = QtGui.QAction("Export", self)
self.export_plots_action.setStatusTip("Export Plots")
self.export_plots_action.setIcon(QtGui.QIcon(path_for("export-plots.png")))
self.export_plots_action.setEnabled(False)
self.disabled_elements.append(self.export_plots_action)

self.open_help_action = QtGui.QAction("&Help", self)
self.open_help_action.setStatusTip("Open Documentation")
Expand All @@ -115,37 +148,49 @@ def create_actions(self):
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.tile_windows_action.setEnabled(False)
self.disabled_elements.append(self.tile_windows_action)

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.reset_windows_action.setEnabled(False)
self.disabled_elements.append(self.reset_windows_action)

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)
self.save_default_windows_action.setEnabled(False)
self.disabled_elements.append(self.save_default_windows_action)

def create_menus(self):
"""Creates the main menu and sub menus"""
main_menu = self.menuBar()
main_menu.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.PreventContextMenu)
self.main_menu = self.menuBar()
self.main_menu.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.PreventContextMenu)

file_menu = main_menu.addMenu("&File")
file_menu.addAction(self.new_project_action)
file_menu.addSeparator()
file_menu.addAction(self.exit_action)
self.file_menu = self.main_menu.addMenu("&File")
self.file_menu.addAction(self.new_project_action)
self.file_menu.addSeparator()
self.file_menu.addAction(self.settings_action)
self.file_menu.addSeparator()
self.file_menu.addAction(self.exit_action)

edit_menu = main_menu.addMenu("&Edit")
edit_menu = self.main_menu.addMenu("&Edit")
edit_menu.addAction(self.undo_action)
edit_menu.addAction(self.redo_action)
edit_menu.addAction(self.undo_view_action)

# tools_menu = main_menu.addMenu("&Tools")

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)
self.windows_menu = self.main_menu.addMenu("&Windows")
self.windows_menu.addAction(self.tile_windows_action)
self.windows_menu.addAction(self.reset_windows_action)
self.windows_menu.addAction(self.save_default_windows_action)
self.windows_menu.setEnabled(False)
self.disabled_elements.append(self.windows_menu)

help_menu = main_menu.addMenu("&Help")
help_menu.addAction(self.open_help_action)
self.help_menu = self.main_menu.addMenu("&Help")
self.help_menu.addAction(self.open_help_action)

def open_docs(self):
"""Opens the documentation"""
Expand All @@ -157,14 +202,14 @@ def create_toolbar(self):
self.toolbar = self.addToolBar("ToolBar")
self.toolbar.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.PreventContextMenu)
self.toolbar.setMovable(False)
self.toolbar.setEnabled(False)

self.toolbar.addAction(self.new_project_action)
self.toolbar.addAction(self.open_project_action)
self.toolbar.addAction(self.save_project_action)
self.toolbar.addAction(self.undo_action)
self.toolbar.addAction(self.redo_action)
self.toolbar.addAction(self.export_plots_action)
self.toolbar.addAction(self.settings_action)
self.toolbar.addAction(self.open_help_action)

def create_status_bar(self):
Expand Down Expand Up @@ -230,6 +275,7 @@ def init_settings_and_log(self, save_path: str):
The save path for the project.
"""
self.save_path = save_path
proj_path = pathlib.Path(save_path)
self.settings = setup_settings(proj_path)
log_path = pathlib.Path(self.settings.log_path)
Expand All @@ -238,3 +284,9 @@ def init_settings_and_log(self, save_path: str):

log_path.parents[0].mkdir(parents=True, exist_ok=True)
self.logging = setup_logging(log_path, level=self.settings.log_level)

def enable_elements(self):
"""Enable the elements that are disabled on startup."""
for element in self.disabled_elements:
element.setEnabled(True)
self.disabled_elements = []
2 changes: 1 addition & 1 deletion rascal2/widgets/inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def __init__(self, field_info: FieldInfo, parent=None):

if issubclass(field_info.annotation, Enum):
self.editor = QtWidgets.QComboBox(self)
self.editor.addItems(str(e.value) for e in field_info.annotation)
self.editor.addItems(str(e) for e in field_info.annotation)
self.get_data = self.editor.currentText
self.set_data = self.editor.setCurrentText
self.edited_signal = self.editor.currentTextChanged
Expand Down
Loading

0 comments on commit 30def2c

Please sign in to comment.