diff --git a/conda/meta.yaml b/conda/meta.yaml index 6362ef24da7..2065f4161c3 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -37,8 +37,11 @@ requirements: - jenkspy=0.2.0 - pyqt=5.15.* - pyqtgraph=0.13.3 + - qt-material=2.14 + - darkdetect=0.8.0 - qt-gtk-platformtheme # [linux] + build: number: 1 entry_points: diff --git a/conftest.py b/conftest.py index 84c3e884958..e23c48f76c3 100644 --- a/conftest.py +++ b/conftest.py @@ -5,6 +5,8 @@ from collections import Counter import pytest +from PyQt5 import QtCore +from PyQt5.QtGui import QFont from mantidimaging.core.utility.leak_tracker import leak_tracker @@ -63,3 +65,20 @@ def leak_test_stats(): # leak_tracker.clear() # Uncomment to clear after each test # print(leak_tracker.pretty_print(debug_owners=True)) # uncomment to track leaks + + +@pytest.fixture(autouse=True) +def setup_QSettings(): + settings = QtCore.QSettings('mantidproject', 'Mantid Imaging') + default_font = QFont() + extra_style_default = { + + # Density Scale + 'density_scale': '-5', + + # font + 'font_size': str(default_font.pointSize()) + 'px', + } + settings.setValue('extra_style_default', extra_style_default) + if settings.value('extra_style') is None: + settings.setValue('extra_style', extra_style_default) diff --git a/docs/release_notes/next/feature-2144-UI-theme-settings b/docs/release_notes/next/feature-2144-UI-theme-settings new file mode 100644 index 00000000000..def6f3f86e9 --- /dev/null +++ b/docs/release_notes/next/feature-2144-UI-theme-settings @@ -0,0 +1 @@ +#2144: MI UI theme can be changed in runtime via a settings menu \ No newline at end of file diff --git a/mantidimaging/gui/mvp_base/view.py b/mantidimaging/gui/mvp_base/view.py index 95902f4ed03..1a67fcab80d 100644 --- a/mantidimaging/gui/mvp_base/view.py +++ b/mantidimaging/gui/mvp_base/view.py @@ -5,13 +5,18 @@ import time from logging import getLogger +from PyQt5 import QtCore from PyQt5.QtCore import Qt, QTimer -from PyQt5.QtWidgets import QMainWindow, QMessageBox, QDialog +from PyQt5.QtWidgets import QMainWindow, QMessageBox, QDialog, QApplication from mantidimaging.gui.utility import compile_ui LOG = getLogger(__name__) perf_logger = getLogger("perf." + __name__) +settings = QtCore.QSettings('mantidproject', 'Mantid Imaging') + +if not settings.contains("theme_selection") or settings.value("theme_selection") is None: + settings.setValue('theme_selection', 'Fusion') class BaseMainWindowView(QMainWindow): @@ -25,6 +30,8 @@ def __init__(self, parent, ui_file=None): if ui_file is not None: compile_ui(ui_file, self) + QApplication.instance().setStyle(settings.value('theme_selection')) + def closeEvent(self, e): LOG.debug('UI window closed') self.cleanup() diff --git a/mantidimaging/gui/ui/main_window.ui b/mantidimaging/gui/ui/main_window.ui index 34715b3c458..e9e5f67babb 100644 --- a/mantidimaging/gui/ui/main_window.ui +++ b/mantidimaging/gui/ui/main_window.ui @@ -27,7 +27,7 @@ 0 0 1100 - 25 + 22 @@ -46,6 +46,8 @@ + + @@ -239,6 +241,11 @@ Open Live Viewer + + + Settings + + diff --git a/mantidimaging/gui/ui/settings_window.ui b/mantidimaging/gui/ui/settings_window.ui new file mode 100644 index 00000000000..d305c616750 --- /dev/null +++ b/mantidimaging/gui/ui/settings_window.ui @@ -0,0 +1,157 @@ + + + SettingsWindow + + + + 0 + 0 + 454 + 444 + + + + + 456 + 449 + + + + MainWindow + + + + + 0 + 0 + + + + + + 10 + 10 + 1921 + 951 + + + + + 1 + 1 + + + + + + + + 1 + 1 + + + + 0 + + + + Appearance + + + + + 10 + 10 + 411 + 361 + + + + + + + Theme: + + + + + + + + 0 + 0 + + + + + + + + Menu font size: + + + + + + + + 0 + 0 + + + + + + + + Dark mode: + + + + + + + + + + + + + + Use OS defaults: + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + + + + diff --git a/mantidimaging/gui/windows/main/presenter.py b/mantidimaging/gui/windows/main/presenter.py index 751e4e56fd6..1e9043373d7 100644 --- a/mantidimaging/gui/windows/main/presenter.py +++ b/mantidimaging/gui/windows/main/presenter.py @@ -10,7 +10,10 @@ from collections.abc import Iterable import numpy as np +from PyQt5.QtCore import QSettings, Qt +from PyQt5.QtGui import QFont, QPalette, QColor from PyQt5.QtWidgets import QTabBar, QApplication, QTreeWidgetItem +from qt_material import apply_stylesheet from mantidimaging.core.data import ImageStack from mantidimaging.core.data.dataset import StrictDataset, MixedDataset, _get_stack_data_type @@ -29,6 +32,8 @@ RECON_TEXT = "Recon" +settings = QSettings('mantidproject', 'Mantid Imaging') + class StackId(NamedTuple): id: uuid.UUID @@ -828,3 +833,61 @@ def _create_strict_dataset_stack_name(stack_type: str, dataset_name: str) -> str def is_dataset_strict(self, ds_id: uuid.UUID) -> bool: return self.model.is_dataset_strict(ds_id) + + def do_update_UI(self) -> None: + if settings.value('use_os_defaults', defaultValue='True') == 'True': + extra_style = settings.value('extra_style_default') + theme = 'Fusion' + override_os_theme = 'False' + else: + extra_style = settings.value('extra_style') + use_dark_mode = settings.value('use_dark_mode') + theme = settings.value('theme_selection') + override_os_theme = settings.value('override_os_theme') + os_theme = settings.value('os_theme') + font = QFont(settings.value('default_font_family'), int(extra_style['font_size'].replace('px', ''))) + for window in [ + self.view, self.view.recon, self.view.live_viewer, self.view.spectrum_viewer, self.view.filters, + self.view.settings_window + ]: + if window: + QApplication.instance().setFont(font) + window.setStyleSheet(theme) + if theme == 'Fusion': + if override_os_theme == 'False': + if os_theme == 'Light': + self.use_fusion_light_mode() + elif os_theme == 'Dark': + self.use_fusion_dark_mode() + else: + if use_dark_mode == 'True': + self.use_fusion_dark_mode() + else: + self.use_fusion_light_mode() + QApplication.instance().setFont(font) + window.setStyleSheet(theme) + else: + apply_stylesheet(window, theme=theme, invert_secondary=False, extra=extra_style) + + @staticmethod + def use_fusion_dark_mode() -> None: + palette = QPalette() + palette.setColor(QPalette.Window, QColor(53, 53, 53)) + palette.setColor(QPalette.WindowText, Qt.white) + palette.setColor(QPalette.Base, QColor(25, 25, 25)) + palette.setColor(QPalette.AlternateBase, QColor(53, 53, 53)) + palette.setColor(QPalette.ToolTipBase, Qt.black) + palette.setColor(QPalette.ToolTipText, Qt.white) + palette.setColor(QPalette.Text, Qt.white) + palette.setColor(QPalette.Button, QColor(53, 53, 53)) + palette.setColor(QPalette.ButtonText, Qt.white) + palette.setColor(QPalette.BrightText, Qt.red) + palette.setColor(QPalette.Link, QColor(42, 130, 218)) + palette.setColor(QPalette.Highlight, QColor(42, 130, 218)) + palette.setColor(QPalette.HighlightedText, Qt.black) + QApplication.instance().setPalette(palette) + + @staticmethod + def use_fusion_light_mode() -> None: + palette = QPalette() + QApplication.instance().setPalette(palette) diff --git a/mantidimaging/gui/windows/main/view.py b/mantidimaging/gui/windows/main/view.py index 73dfc52d0f3..cb08d811ffc 100644 --- a/mantidimaging/gui/windows/main/view.py +++ b/mantidimaging/gui/windows/main/view.py @@ -37,6 +37,7 @@ from mantidimaging.gui.windows.nexus_load_dialog.view import NexusLoadDialog from mantidimaging.gui.windows.operations import FiltersWindowView from mantidimaging.gui.windows.recon import ReconstructWindowView +from mantidimaging.gui.windows.settings.view import SettingsWindowView from mantidimaging.gui.windows.spectrum_viewer.view import SpectrumViewerWindowView from mantidimaging.gui.windows.live_viewer.view import LiveViewerWindowView from mantidimaging.gui.windows.stack_choice.compare_presenter import StackComparePresenter @@ -94,12 +95,14 @@ class MainWindowView(BaseMainWindowView): actionLoadNeXusFile: QAction actionSaveImages: QAction actionSaveNeXusFile: QAction + actionSettings: QAction actionExit: QAction filters: FiltersWindowView | None = None recon: ReconstructWindowView | None = None spectrum_viewer: SpectrumViewerWindowView | None = None live_viewer: LiveViewerWindowView | None = None + settings_window: SettingsWindowView | None = None image_load_dialog: ImageLoadDialog | None = None image_save_dialog: ImageSaveDialog | None = None @@ -108,6 +111,8 @@ class MainWindowView(BaseMainWindowView): add_to_dataset_dialog: AddImagesToDatasetDialog | None = None move_stack_dialog: MoveStackDialog | None = None + default_theme_enabled: int = 1 + def __init__(self, open_dialogs: bool = True): super().__init__(None, "gui/ui/main_window.ui") @@ -167,6 +172,8 @@ def __init__(self, open_dialogs: bool = True): self.tabifiedDockWidgetActivated.connect(self._on_tab_bar_clicked) + self.presenter.do_update_UI() + def _window_ready(self) -> None: if perf_logger.isEnabledFor(1): perf_logger.info(f"Mantid Imaging ready in {time.monotonic() - process_start_time}") @@ -193,6 +200,7 @@ def setup_shortcuts(self): self.actionRecon.triggered.connect(self.show_recon_window) self.actionSpectrumViewer.triggered.connect(self.show_spectrum_viewer_window) self.actionLiveViewer.triggered.connect(self.live_view_choose_directory) + self.actionSettings.triggered.connect(self.show_settings_window) self.actionCompareImages.triggered.connect(self.show_stack_select_dialog) @@ -372,6 +380,15 @@ def show_nexus_save_dialog(self): self.nexus_save_dialog = NexusSaveDialog(self, self.strict_dataset_list) self.nexus_save_dialog.show() + def show_settings_window(self): + if not self.settings_window: + self.settings_window = SettingsWindowView(self) + self.settings_window.show() + else: + self.settings_window.activateWindow() + self.settings_window.raise_() + self.settings_window.show() + def show_recon_window(self): if not self.recon: self.recon = ReconstructWindowView(self) @@ -507,6 +524,8 @@ def closeEvent(self, event): self.spectrum_viewer.close() if self.filters: self.filters.close() + if self.settings_window: + self.settings_window.close() else: # Ignore the close event, keeping window open diff --git a/mantidimaging/gui/windows/settings/__init__.py b/mantidimaging/gui/windows/settings/__init__.py new file mode 100644 index 00000000000..ce6ef2fb43a --- /dev/null +++ b/mantidimaging/gui/windows/settings/__init__.py @@ -0,0 +1,3 @@ +# Copyright (C) 2024 ISIS Rutherford Appleton Laboratory UKRI +# SPDX - License - Identifier: GPL-3.0-or-later +from __future__ import annotations diff --git a/mantidimaging/gui/windows/settings/presenter.py b/mantidimaging/gui/windows/settings/presenter.py new file mode 100644 index 00000000000..229026a9143 --- /dev/null +++ b/mantidimaging/gui/windows/settings/presenter.py @@ -0,0 +1,92 @@ +# Copyright (C) 2024 ISIS Rutherford Appleton Laboratory UKRI +# SPDX - License - Identifier: GPL-3.0-or-later +from __future__ import annotations + +from logging import getLogger +from typing import TYPE_CHECKING + +from PyQt5.QtCore import QSettings, QSignalBlocker + +from mantidimaging.gui.mvp_base import BasePresenter + +LOG = getLogger(__name__) + +if TYPE_CHECKING: + from mantidimaging.gui.windows.settings.view import SettingsWindowView # pragma: no cover + from mantidimaging.gui.windows.main import MainWindowView + +settings = QSettings('mantidproject', 'Mantid Imaging') + + +class SettingsWindowPresenter(BasePresenter): + view: SettingsWindowView + + def __init__(self, view: SettingsWindowView, main_window: MainWindowView): + super().__init__(view) + self.view = view + self.main_window = main_window + self.current_theme = settings.value('theme_selection') + if settings.value('selected_font_size') is None: + self.current_menu_font_size = settings.value('default_font_size') + else: + self.current_menu_font_size = settings.value('selected_font_size') + + def set_theme(self): + self.current_theme = self.view.current_theme + settings.setValue('theme_selection', self.current_theme) + if self.current_theme == 'Fusion': + self.view.darkModeCheckBox.setEnabled(True) + else: + self.view.darkModeCheckBox.setEnabled(False) + self.main_window.presenter.do_update_UI() + + def set_extra_style(self): + extra_style = settings.value('extra_style') + settings.setValue('selected_font_size', self.view.current_menu_font_size) + extra_style.update({'font_size': self.view.current_menu_font_size + 'px'}) + settings.setValue('extra_style', extra_style) + self.main_window.presenter.do_update_UI() + + def set_dark_mode(self): + if self.view.darkModeCheckBox.isChecked(): + use_dark_mode = 'True' + else: + use_dark_mode = 'False' + + settings.setValue('use_dark_mode', use_dark_mode) + settings.setValue('override_os_theme', 'True') + self.main_window.presenter.do_update_UI() + + def set_to_os_defaults(self): + if self.view.osDefaultsCheckBox.isChecked(): + settings.setValue('use_os_defaults', 'True') + theme_text = 'Fusion' + theme_enabled = False + font_text = settings.value('default_font_size') + font_enabled = False + if settings.value('os_theme') == 'Dark': + dark_mode_checked = True + else: + dark_mode_checked = False + dark_mode_enabled = False + else: + settings.setValue('use_os_defaults', 'False') + theme_text = settings.value('theme_selection') + theme_enabled = True + font_text = settings.value('selected_font_size') + font_enabled = True + if settings.value('use_dark_mode') == 'True': + dark_mode_checked = True + else: + dark_mode_checked = False + dark_mode_enabled = True + with QSignalBlocker(self.view.themeName): + self.view.themeName.setCurrentText(theme_text) + self.view.themeName.setEnabled(theme_enabled) + with QSignalBlocker(self.view.menuFontSizeChoice): + self.view.menuFontSizeChoice.setCurrentText(font_text) + self.view.menuFontSizeChoice.setEnabled(font_enabled) + with QSignalBlocker(self.view.darkModeCheckBox): + self.view.darkModeCheckBox.setChecked(dark_mode_checked) + self.view.darkModeCheckBox.setEnabled(dark_mode_enabled) + self.main_window.presenter.do_update_UI() diff --git a/mantidimaging/gui/windows/settings/view.py b/mantidimaging/gui/windows/settings/view.py new file mode 100644 index 00000000000..41931f7123f --- /dev/null +++ b/mantidimaging/gui/windows/settings/view.py @@ -0,0 +1,70 @@ +# Copyright (C) 2024 ISIS Rutherford Appleton Laboratory UKRI +# SPDX - License - Identifier: GPL-3.0-or-later +from __future__ import annotations +from logging import getLogger +from typing import TYPE_CHECKING + +from PyQt5.QtCore import QSettings, QSignalBlocker +from PyQt5.QtWidgets import QTabWidget, QWidget, QComboBox, QLabel, QCheckBox + +from mantidimaging.gui.mvp_base import BaseMainWindowView + +from qt_material import list_themes, QtStyleTools + +from mantidimaging.gui.windows.settings.presenter import SettingsWindowPresenter + +if TYPE_CHECKING: + from mantidimaging.gui.windows.main import MainWindowView # noqa:F401 # pragma: no cover + +LOG = getLogger(__name__) + +settings = QSettings('mantidproject', 'Mantid Imaging') + + +class SettingsWindowView(BaseMainWindowView, QtStyleTools): + settingsTabWidget: QTabWidget + appearanceTab: QWidget + themeName: QComboBox + themeLabel: QLabel + menuFontSizeLabel: QLabel + menuFontSizeChoice: QComboBox + darkModeCheckBox: QCheckBox + osDefaultsCheckBox: QCheckBox + + def __init__(self, main_window: MainWindowView): + super().__init__(None, 'gui/ui/settings_window.ui') + self.setWindowTitle('Settings') + self.main_window = main_window + self.presenter = SettingsWindowPresenter(self, main_window) + + self.themeName.addItem('Fusion') + self.themeName.addItems(list_themes()) + self.themeName.setCurrentText(self.presenter.current_theme) + + self.menuFontSizeChoice.addItems([str(font_size) for font_size in range(4, 21)]) + self.menuFontSizeChoice.setCurrentText(self.presenter.current_menu_font_size.replace('px', '')) + + self.themeName.currentTextChanged.connect(self.presenter.set_theme) + self.menuFontSizeChoice.currentTextChanged.connect(self.presenter.set_extra_style) + self.darkModeCheckBox.stateChanged.connect(self.presenter.set_dark_mode) + self.osDefaultsCheckBox.stateChanged.connect(self.presenter.set_to_os_defaults) + + if self.current_theme != 'Fusion': + self.darkModeCheckBox.setEnabled(False) + with (QSignalBlocker(self.darkModeCheckBox)): + if settings.value('use_dark_mode') == 'True' or (settings.value('os_theme') == 'Dark' + and settings.value('override_os_theme') == 'False'): + self.darkModeCheckBox.setChecked(True) + else: + self.darkModeCheckBox.setChecked(False) + + if settings.value('use_os_defaults') == 'True' or settings.value('use_os_defaults') is None: + self.osDefaultsCheckBox.setChecked(True) + + @property + def current_theme(self) -> str: + return self.themeName.currentText() + + @property + def current_menu_font_size(self) -> str: + return self.menuFontSizeChoice.currentText() diff --git a/mantidimaging/main.py b/mantidimaging/main.py index 22270d42ac2..8d62e78e833 100755 --- a/mantidimaging/main.py +++ b/mantidimaging/main.py @@ -8,10 +8,11 @@ import warnings import os +import darkdetect from PyQt5.QtCore import QSettings from PyQt5.QtWidgets import QApplication from PyQt5 import QtCore -from PyQt5.QtGui import QGuiApplication +from PyQt5.QtGui import QGuiApplication, QFont, QFontInfo import mantidimaging.core.parallel.manager as pm @@ -22,6 +23,8 @@ warnings.formatwarning = lambda message, category, filename, lineno, line=None: formatwarning_orig( message, category, filename, lineno, line="") +settings = QtCore.QSettings('mantidproject', 'Mantid Imaging') + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Mantid Imaging GUI") @@ -58,7 +61,26 @@ def parse_args() -> argparse.Namespace: def setup_application() -> QApplication: QGuiApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling) q_application = QApplication(sys.argv) - q_application.setStyle('Fusion') + + default_font = QFont() + default_font_info = QFontInfo(default_font) + + extra_style_default = { + + # Density Scale + 'density_scale': '-5', + + # font + 'font_size': str(default_font.pointSize()) + 'px', + } + settings.setValue('extra_style_default', extra_style_default) + if settings.value('extra_style') is None: + settings.setValue('extra_style', extra_style_default) + + settings.setValue('os_theme', darkdetect.theme()) + settings.setValue('default_font_size', str(default_font.pointSize())) + settings.setValue('default_font_family', str(default_font_info.family())) + q_application.setApplicationName("Mantid Imaging") q_application.setOrganizationName("mantidproject") q_application.setOrganizationDomain("mantidproject.org")