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
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")