diff --git a/qt/applications/workbench/workbench/app/mainwindow.py b/qt/applications/workbench/workbench/app/mainwindow.py index 5bc70668d16fd7e4ced0d99dc70517f87c754cd2..24e2a836c4c908fe7ccc5c17bc05117d8221ad9b 100644 --- a/qt/applications/workbench/workbench/app/mainwindow.py +++ b/qt/applications/workbench/workbench/app/mainwindow.py @@ -44,7 +44,7 @@ requirements.check_qt() # ----------------------------------------------------------------------------- # Qt # ----------------------------------------------------------------------------- -from qtpy.QtCore import (QEventLoop, Qt, QCoreApplication) # noqa +from qtpy.QtCore import (QEventLoop, Qt, QCoreApplication, QSettings, QPoint, QSize) # noqa from qtpy.QtGui import (QColor, QPixmap) # noqa from qtpy.QtWidgets import (QApplication, QDesktopWidget, QFileDialog, QMainWindow, QSplashScreen) # noqa @@ -53,6 +53,8 @@ from mantidqt.utils.qt import plugins, widget_updates_disabled # noqa # Pre-application setup plugins.setup_library_paths() +from workbench.config import APPNAME, CONF, ORG_DOMAIN, ORGANIZATION # noqa + # ----------------------------------------------------------------------------- # Create the application instance early, set the application name for window @@ -67,7 +69,14 @@ def qapplication(): app = QApplication.instance() if app is None: QCoreApplication.setAttribute(Qt.AA_ShareOpenGLContexts) - app = QApplication(['Mantid Workbench']) + argv = sys.argv[:] + argv[0] = APPNAME # replace application name + app = QApplication(argv) + app.setOrganizationName(ORGANIZATION) + app.setOrganizationDomain(ORG_DOMAIN) + app.setApplicationName(APPNAME) + # not calling app.setApplicationVersion(mantid.kernel.version_str()) + # because it needs to happen after logging is monkey-patched in return app @@ -107,17 +116,12 @@ class MainWindow(QMainWindow): def __init__(self): QMainWindow.__init__(self) - qapp = QApplication.instance() - qapp.setAttribute(Qt.AA_UseHighDpiPixmaps) - if hasattr(Qt, 'AA_EnableHighDpiScaling'): - qapp.setAttribute(Qt.AA_EnableHighDpiScaling, True) - + # -- instance attributes -- self.setWindowTitle("Mantid Workbench") - # -- instance attributes -- - self.window_size = None - self.window_position = None - self.maximized_flag = None + # uses default configuration as necessary + self.readSettings(CONF) + # widgets self.messagedisplay = None self.ipythonconsole = None @@ -258,14 +262,8 @@ class MainWindow(QMainWindow): # ----------------------- Layout --------------------------------- def setup_layout(self): - self.setup_for_first_run() - - def setup_for_first_run(self): """Assume this is a first run of the application and set layouts accordingly""" - self.setWindowState(Qt.WindowMaximized) - desktop = QDesktopWidget() - self.window_size = desktop.screenGeometry().size() self.setup_default_layouts() def prep_window_for_reset(self): @@ -335,6 +333,8 @@ class MainWindow(QMainWindow): def closeEvent(self, event): # Close editors if self.editor.app_closing(): + self.writeSettings(CONF) # write current window information to global settings object + # Close all open plots # We don't want this at module scope here import matplotlib.pyplot as plt #noqa @@ -361,6 +361,46 @@ class MainWindow(QMainWindow): def open_manage_directories(self): ManageUserDirectories(self).exec_() + def readSettings(self, settings): + qapp = QApplication.instance() + qapp.setAttribute(Qt.AA_UseHighDpiPixmaps) + if hasattr(Qt, 'AA_EnableHighDpiScaling'): + qapp.setAttribute(Qt.AA_EnableHighDpiScaling, settings.get('main/high_dpi_scaling')) + + # get the saved window geometry + window_size = settings.get('main/window/size') + if not isinstance(window_size, QSize): + window_size = QSize(*window_size) + window_pos = settings.get('main/window/position') + if not isinstance(window_pos, QPoint): + window_pos = QPoint(*window_pos) + + # make sure main window is smaller than the desktop + desktop_size = QDesktopWidget().screenGeometry().size() + w = min(desktop_size.width(), window_size.width()) + h = min(desktop_size.height(), window_size.height()) + window_size = QSize(w, h) + + # and that it will be painted on screen + x = min(window_pos.x(), desktop_size.width() - window_size.width()) + y = min(window_pos.y(), desktop_size.height() - window_size.height()) + window_pos = QPoint(x, y) + + # set the geometry + self.resize(window_size) + self.move(window_pos) + + # restore window state + if settings.has('main/window/state'): + self.restoreState(settings.get('main/window/state')) + else: + self.setWindowState(Qt.WindowMaximized) + + def writeSettings(self, settings): + settings.set('main/window/size', self.size()) # QSize + settings.set('main/window/position', self.pos()) # QPoint + settings.set('main/window/state', self.saveState()) # QByteArray + def initialize(): """Perform an initialization of the application instance. Most notably @@ -402,12 +442,11 @@ def start_workbench(app): importlib.import_module('mantid') main_window.show() + if main_window.splash: main_window.splash.hide() # lift-off! - app.exec_() - - return main_window + return app.exec_() def main(): @@ -429,9 +468,9 @@ def main(): # the default sys check interval leads to long lags # when request scripts to be aborted sys.setcheckinterval(SYSCHECK_INTERVAL) - main_window = None + exit_value = 0 try: - main_window = start_workbench(app) + exit_value = start_workbench(app) except BaseException: # We count this as a crash import traceback @@ -439,12 +478,9 @@ def main(): # about. Prints to stderr as we can't really count on anything # else traceback.print_exc(file=ORIGINAL_STDERR) - - if main_window is None: - # An exception occurred don't exit here - return - - ORIGINAL_SYS_EXIT() + exit_value = -1 + finally: + ORIGINAL_SYS_EXIT(exit_value) if __name__ == '__main__': diff --git a/qt/applications/workbench/workbench/config/__init__.py b/qt/applications/workbench/workbench/config/__init__.py index 38b6452e68f2d819b646bccff5c213e92581ea5a..89f8d58fb99cadf829b135b6ccbf0d4e88c88922 100644 --- a/qt/applications/workbench/workbench/config/__init__.py +++ b/qt/applications/workbench/workbench/config/__init__.py @@ -14,3 +14,37 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +""" Main configuration module. + +A singleton instance called CONF is defined. Modules wishing to access the settings +should import the CONF object as + + from workbench.config import CONF + +and use it to access the settings +""" +from __future__ import (absolute_import, unicode_literals) + +from workbench.config.user import UserConfig + +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- +ORGANIZATION = 'mantidproject' +ORG_DOMAIN = 'mantidproject.org' +APPNAME = 'mantidworkbench' + +# Iterable containing defaults for each configurable section of the code +# General application settings are in the main section +DEFAULTS = { + 'main': { + 'high_dpi_scaling': True, + 'window/size': (1260, 740), + 'window/position': (10, 10), + } +} + +# ----------------------------------------------------------------------------- +# 'Singleton' instance +# ----------------------------------------------------------------------------- +CONF = UserConfig(ORGANIZATION, APPNAME, defaults=DEFAULTS) diff --git a/qt/applications/workbench/workbench/config/main.py b/qt/applications/workbench/workbench/config/main.py deleted file mode 100644 index 02373c0993723b403de5e1dad4f754f768a703b5..0000000000000000000000000000000000000000 --- a/qt/applications/workbench/workbench/config/main.py +++ /dev/null @@ -1,51 +0,0 @@ -# This file is part of the mantid workbench. -# -# Copyright (C) 2017 mantidproject -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. -""" Main configuration module. - -A singleton instance called CONF is defined. Modules wishing to access the settings -should import the CONF object as - - from workbench.config.main import CONF - -and use it to access the settings -""" -from __future__ import (absolute_import, unicode_literals) - -from workbench.config.user import UserConfig - -# ----------------------------------------------------------------------------- -# Constants -# ----------------------------------------------------------------------------- -ORGANIZATION = 'mantidproject' -APPNAME = 'workbench' - -# Iterable containing defaults for each configurable section of the code -# General application settings are in the main section -DEFAULTS = { - 'main': { - 'high_dpi_scaling': True, - 'window/size': (1260, 740), - 'window/position': (10, 10), - 'window/is_maximized': True, - 'window/is_fullscreen': False, - } -} - -# ----------------------------------------------------------------------------- -# 'Singleton' instance -# ----------------------------------------------------------------------------- -CONF = UserConfig(ORGANIZATION, APPNAME, defaults=DEFAULTS) diff --git a/qt/applications/workbench/workbench/config/test/test_user.py b/qt/applications/workbench/workbench/config/test/test_user.py index 1f2b353a0429e127c88668b21983194a88d29a30..6b600c3c421483cc1d19c6c8a41425e4e3d0ccc7 100644 --- a/qt/applications/workbench/workbench/config/test/test_user.py +++ b/qt/applications/workbench/workbench/config/test/test_user.py @@ -30,7 +30,8 @@ class ConfigUserTest(TestCase): defaults = { 'main': { 'a_default_key': 100, - 'bool_option': False + 'bool_option': False, + 'bool_option2': True }, } cls.cfg = UserConfig(cls.__name__, cls.__name__, defaults) @@ -53,28 +54,45 @@ class ConfigUserTest(TestCase): def test_value_not_in_settings_retrieves_default_if_it_exists(self): self.assertEqual(100, self.cfg.get('main', 'a_default_key')) + self.assertEqual(100, self.cfg.get('main/a_default_key')) def test_boolean_with_default_false_can_be_retrieved(self): self.assertEqual(False, self.cfg.get('main', 'bool_option')) + self.assertEqual(False, self.cfg.get('main/bool_option')) + + self.assertEqual(True, self.cfg.get('main/bool_option2')) + + def test_has_keys(self): + self.assertTrue(self.cfg.has('main', 'a_default_key')) + self.assertTrue(self.cfg.has('main/a_default_key')) + self.assertFalse(self.cfg.has('main', 'missing-key')) + self.assertFalse(self.cfg.has('main/missing-key')) + + def test_remove_key(self): + self.cfg.set('main', 'key1', 1) + self.assertTrue(self.cfg.has('main/key1')) + self.cfg.remove('main/key1') + self.assertFalse(self.cfg.has('main/key1')) # ---------------------------------------------- # Failure tests # ---------------------------------------------- def test_get_raises_error_with_invalid_section_type(self): - self.assertRaises(RuntimeError, self.cfg.get, 1, 'key1') + self.assertRaises(TypeError, self.cfg.get, 1, 'key1') def test_get_raises_error_with_invalid_option_type(self): - self.assertRaises(RuntimeError, self.cfg.get, 'section', 1) + self.assertRaises(TypeError, self.cfg.get, 'section', 1) def test_get_raises_keyerror_with_no_saved_setting_or_default(self): self.assertRaises(KeyError, self.cfg.get, 'main', 'missing-key') + self.assertRaises(KeyError, self.cfg.get, 'main/missing-key') def test_set_raises_error_with_invalid_section_type(self): - self.assertRaises(RuntimeError, self.cfg.set, 1, 'key1', 1) + self.assertRaises(TypeError, self.cfg.set, 1, 'key1', 1) def test_set_raises_error_with_invalid_option_type(self): - self.assertRaises(RuntimeError, self.cfg.set, 'section', 1, 1) + self.assertRaises(TypeError, self.cfg.set, 'section', 1, 1) if __name__ == '__main__': diff --git a/qt/applications/workbench/workbench/config/user.py b/qt/applications/workbench/workbench/config/user.py index b88450695712334abdf070fc1f41b8f2ea944e52..6d60ed6865ece272fadbf2ad463d26964dd6df96 100644 --- a/qt/applications/workbench/workbench/config/user.py +++ b/qt/applications/workbench/workbench/config/user.py @@ -18,7 +18,7 @@ from __future__ import (absolute_import, division, print_function, unicode_literals) from mantidqt.py3compat import is_text_string - +from posixpath import join as joinsettings from qtpy.QtCore import QSettings @@ -32,11 +32,9 @@ class UserConfig(object): # The raw QSettings instance qsettings = None - defaults = None def __init__(self, organization, application, defaults=None): """ - :param organization: A string name for the organization :param application: A string name for the application name :param defaults: Default configuration values for this instance in the @@ -45,77 +43,113 @@ class UserConfig(object): # Loads the saved settings if found self.qsettings = QSettings(QSettings.IniFormat, QSettings.UserScope, organization, application) - self.defaults = defaults - def all_keys(self): - return self.qsettings.allKeys() + # convert the defaults into something that qsettings can handle + default_settings = self._flatten_defaults(defaults) + + # put defaults into qsettings if they weren't there already + configFileKeys = self.qsettings.allKeys() + for key in default_settings.keys(): + if key not in configFileKeys: + self.qsettings.setValue(key, default_settings[key]) + + # fixup the values of booleans - they do not evaluate correctly when read from the config file + # TODO come up with a unit test for this + for key in self.all_keys(): + value = self.get(key) + if value == 'true': + self.set(key, True) + elif value == 'false': + self.set(key, False) + + def all_keys(self, group=None): + if group is not None: + self.qsettings.beginGroup(group) + result = self.qsettings.allKeys() + self.qsettings.endGroup() + else: + result = self.qsettings.allKeys() + + return result @property def filename(self): return self.qsettings.fileName() - def get(self, section, option): - """ - Return a value for an option in a given section. If not - specified in the saved settings then the initial - defaults are consulted. If no option is found then + def get(self, option, second=None): + """Return a value for an option. If two arguments are given the first + is the group/section and the second is the option within it. + ``config.get('main', 'window/size')`` is equivalent to + ``config.get('main/window/size')`` If no option is found then a KeyError is raised - :param section: A string section name - :param option: A string option name - :return: The value of the option """ - value = self.qsettings.value(self._settings_path(section, option)) - if not value: - value = self._get_default_or_raise(section, option) - - return value - - def set(self, section, option, value): + option = self._check_section_option_is_valid(option, second) + value = self.qsettings.value(option) + + # qsettings appears to return None if the option isn't found + if value is None: + raise KeyError('Unknown config item requested: "{}"'.format(option)) + else: + return value + + def has(self, option, second=None): + """Return a True if the key exists in the + settings. ``config.get('main', 'window/size')`` and + ``config.get('main/window/size')`` are equivalent. + """ + option = self._check_section_option_is_valid(option, second) + return option in self.all_keys() + + def set(self, option, value, extra=None): + """Set a value for an option in a given section. Can either supply + the fully qualified option or add the section as an additional + first argument. ``config.set('main', 'high_dpi_scaling', + True)`` is equivalent to ``config.set('main/high_dpi_scaling', + True)`` """ - Set a value for an option in a given section. - :param section: A string section name - :param option: A string option name - :param value: The value of the setting + if extra is None: + option = self._check_section_option_is_valid(option, extra) + # value is in the right place + else: + option = self._check_section_option_is_valid(option, value) + value = extra + self.qsettings.setValue(option, value) + + def remove(self, option, second=None): + """Removes a key from the settings. Key not existing returns without effect. """ - self.qsettings.setValue(self._settings_path(section, option), value) + option = self._check_section_option_is_valid(option, second) + if self.has(option): + self.qsettings.remove(option) # ------------------------------------------------------------------------- # "Private" methods # ------------------------------------------------------------------------- - def _check_section_option_is_valid(self, section, option): - """ - Sanity check the section and option are strings - """ - if not is_text_string(section): - raise RuntimeError("section is not a text string") - if not is_text_string(option): - raise RuntimeError("option is not a text string") - - def _get_default_or_raise(self, section, option): - """ - Returns the value listed in the defaults if it exists - :param section: A string denoting the section (not checked) - :param option: A string denoting the option name - :return: The value of the default - :raises KeyError: if the item does not exist - """ - value = None - if self.defaults and section in self.defaults: - try: - value = self.defaults[section][option] - except KeyError: - raise KeyError("Unknown config item requested: " + - self._settings_path(section, option)) - return value - - def _settings_path(self, section, option): + @staticmethod + def _flatten_defaults(input_dict): + result = {} + for key in input_dict: + value = input_dict[key] + if isinstance(value, dict): + value = UserConfig._flatten_defaults(value) + for key_inner in value.keys(): + result[joinsettings(key, key_inner)] = value[key_inner] + else: + result[key] = value + return result + + def _check_section_option_is_valid(self, option, second): """ - Private method to construct a path to the given option with the - section - :param section: The name of the section - :param option: The name of the option - :return: A path to the location within the QSettings instance + Sanity check the section and option are strings and return the flattened option key """ - self._check_section_option_is_valid(section, option) - return section + "/" + option + if second is None: + if not is_text_string(option): + raise TypeError('Found invalid type ({}) for option ({}) must be a string'.format(type(option), option)) + return option + else: # fist argument is actually the section/group + if not is_text_string(option): + raise TypeError('Found invalid type ({}) for section ({}) must be a string'.format(type(option), option)) + if not is_text_string(second): + raise TypeError('Found invalid type ({}) for option ({}) must be a string'.format(type(second), second)) + return joinsettings(option, second)