diff --git a/openlp/core/common/settings.py b/openlp/core/common/settings.py index 4da8e8679..b167ed3ab 100644 --- a/openlp/core/common/settings.py +++ b/openlp/core/common/settings.py @@ -334,6 +334,7 @@ class Settings(QtCore.QSettings): 'themes/last directory import': None, 'themes/theme level': ThemeLevel.Global, 'themes/item transitions': False, + 'themes/hot reload': False, 'user interface/live panel': True, 'user interface/live splitter geometry': QtCore.QByteArray(), 'user interface/lock panel': True, diff --git a/openlp/core/lib/theme.py b/openlp/core/lib/theme.py index 208ee0592..2b711d277 100644 --- a/openlp/core/lib/theme.py +++ b/openlp/core/lib/theme.py @@ -301,6 +301,7 @@ class Theme(object): json_path = AppLocation.get_directory(AppLocation.AppDir) / 'core' / 'lib' / 'json' / 'theme.json' jsn = get_text_file_string(json_path) self.load_theme(jsn) + self.set_default_header_footer() self.background_filename = None self.background_source = None self.version = 2 @@ -334,13 +335,18 @@ class Theme(object): def set_default_header_footer(self): """ - Set the header and footer size into the current primary screen. - 10 px on each side is removed to allow for a border. + Set the default header and footer size to match the current primary screen. + Obeys theme override variables. """ - self.set_default_header() - self.set_default_footer() + if not self.font_main_override: + self.set_default_header() + if not self.font_footer_override: + self.set_default_footer() def set_default_header(self): + """ + Sets the default header position and size, ignores font_main_override + """ current_screen_geometry = ScreenList().current.display_geometry self.font_main_x = 10 self.font_main_y = 0 @@ -348,6 +354,9 @@ class Theme(object): self.font_main_height = current_screen_geometry.height() * 9 / 10 def set_default_footer(self): + """ + Sets the default footer position and size, ignores font_footer_override + """ current_screen_geometry = ScreenList().current.display_geometry self.font_footer_x = 10 self.font_footer_y = current_screen_geometry.height() * 9 / 10 diff --git a/openlp/core/ui/servicemanager.py b/openlp/core/ui/servicemanager.py index c05c31243..55c6016e2 100644 --- a/openlp/core/ui/servicemanager.py +++ b/openlp/core/ui/servicemanager.py @@ -1355,7 +1355,7 @@ class ServiceManager(QtWidgets.QWidget, RegistryBase, Ui_ServiceManager, LogMixi """ self.service_theme = self.theme_combo_box.currentText() self.settings.setValue('servicemanager/service theme', self.service_theme) - self.regenerate_service_items(True) + Registry().execute('theme_update_service') def theme_change(self): """ diff --git a/openlp/core/ui/slidecontroller.py b/openlp/core/ui/slidecontroller.py index 79f64246d..61f04eb10 100644 --- a/openlp/core/ui/slidecontroller.py +++ b/openlp/core/ui/slidecontroller.py @@ -502,6 +502,10 @@ class SlideController(QtWidgets.QWidget, LogMixin, RegistryProperties): self.mediacontroller_live_stop.connect(self.media_controller.on_media_stop) else: getattr(self, 'slidecontroller_preview_clear').connect(self.on_clear) + # Update the theme whenever global or service theme updated + # theme_update_list catches changes to themes AND if the global theme changes + Registry().register_function('theme_update_list', self.theme_updated) + Registry().register_function('theme_update_service', self.theme_updated) def new_song_menu(self): """ @@ -868,6 +872,16 @@ class SlideController(QtWidgets.QWidget, LogMixin, RegistryProperties): for display in self.displays: display.set_background_image(bg_color, image_path) + def theme_updated(self, var=None): + """ + Reloads the service item + + :param var: Unused but needed to catch the theme_update_list event + """ + if self.service_item and self.settings.value('themes/hot reload'): + slide_num = self.preview_widget.current_slide_number() + self._process_item(self.service_item, slide_num) + def reload_theme(self): """ Reload the theme on displays. diff --git a/openlp/core/ui/thememanager.py b/openlp/core/ui/thememanager.py index 7b9eb5349..a1e31646a 100644 --- a/openlp/core/ui/thememanager.py +++ b/openlp/core/ui/thememanager.py @@ -144,6 +144,7 @@ class ThemeManager(QtWidgets.QWidget, RegistryBase, Ui_ThemeManager, LogMixin, R # Variables self._theme_list = {} self.old_background_image_path = None + Registry().register_function('config_screen_changed', self.screen_changed) def get_global_theme(self): return self.get_theme_data(self.global_theme) @@ -184,6 +185,14 @@ class ThemeManager(QtWidgets.QWidget, RegistryBase, Ui_ThemeManager, LogMixin, R self.load_themes() Registry().register_function('theme_update_global', self.change_global_from_tab) + def screen_changed(self): + """ + Update the default theme location and size for when screen size changed + """ + for theme_name in self._theme_list: + theme_object = self._theme_list[theme_name] + theme_object.set_default_header_footer() + def upgrade_themes(self): """ Upgrade the xml files to json. @@ -292,7 +301,6 @@ class ThemeManager(QtWidgets.QWidget, RegistryBase, Ui_ThemeManager, LogMixin, R :param field: """ theme = Theme() - theme.set_default_header_footer() self.theme_form.theme = theme self.theme_form.exec() self.load_themes() @@ -566,7 +574,9 @@ class ThemeManager(QtWidgets.QWidget, RegistryBase, Ui_ThemeManager, LogMixin, R if not theme_data: self.log_debug('No theme data - using default theme') return Theme() - return self._create_theme_from_json(theme_data, self.theme_path) + theme_object = self._create_theme_from_json(theme_data, self.theme_path) + theme_object.set_default_header_footer() + return theme_object def over_write_message_box(self, theme_name): """ @@ -675,7 +685,6 @@ class ThemeManager(QtWidgets.QWidget, RegistryBase, Ui_ThemeManager, LogMixin, R Writes the theme to the disk and including the background image and thumbnail if necessary :param Theme theme: The theme data object. - :param image: The theme thumbnail. Optionally. :param background_override: Background to use rather than background_source. Optionally. :rtype: None """ diff --git a/openlp/core/ui/themestab.py b/openlp/core/ui/themestab.py index 5c09e1624..b3497029d 100644 --- a/openlp/core/ui/themestab.py +++ b/openlp/core/ui/themestab.py @@ -69,6 +69,9 @@ class ThemesTab(SettingsTab): self.item_transitions_check_box = QtWidgets.QCheckBox(self.universal_group_box) self.item_transitions_check_box.setObjectName('item_transitions_check_box') self.universal_group_box_layout.addWidget(self.item_transitions_check_box) + self.theme_hot_reload = QtWidgets.QCheckBox(self.universal_group_box) + self.theme_hot_reload.setObjectName('theme_hot_reload') + self.universal_group_box_layout.addWidget(self.theme_hot_reload) self.left_layout.addWidget(self.universal_group_box) self.left_layout.addStretch() self.level_group_box = QtWidgets.QGroupBox(self.right_column) @@ -115,6 +118,7 @@ class ThemesTab(SettingsTab): self.global_group_box.setTitle(translate('OpenLP.ThemesTab', 'Global Theme')) self.universal_group_box.setTitle(translate('OpenLP.ThemesTab', 'Universal Settings')) self.item_transitions_check_box.setText(translate('OpenLP.ThemesTab', '&Transition between service items')) + self.theme_hot_reload.setText(translate('OpenLP.ThemesTab', '&Reload live theme when changed')) self.level_group_box.setTitle(translate('OpenLP.ThemesTab', 'Theme Level')) self.song_level_radio_button.setText(translate('OpenLP.ThemesTab', 'S&ong Level')) self.song_level_label.setText( @@ -138,6 +142,7 @@ class ThemesTab(SettingsTab): self.theme_level = self.settings.value('themes/theme level') self.global_theme = self.settings.value('themes/global theme') self.item_transitions_check_box.setChecked(self.settings.value('themes/item transitions')) + self.theme_hot_reload.setChecked(self.settings.value('themes/hot reload')) if self.theme_level == ThemeLevel.Global: self.global_level_radio_button.setChecked(True) elif self.theme_level == ThemeLevel.Service: @@ -152,6 +157,7 @@ class ThemesTab(SettingsTab): self.settings.setValue('themes/theme level', self.theme_level) self.settings.setValue('themes/global theme', self.global_theme) self.settings.setValue('themes/item transitions', self.item_transitions_check_box.isChecked()) + self.settings.setValue('themes/hot reload', self.theme_hot_reload.isChecked()) self.renderer.set_theme_level(self.theme_level) if self.tab_visited: self.settings_form.register_post_process('theme_update_global') diff --git a/tests/functional/openlp_core/display/test_window.py b/tests/functional/openlp_core/display/test_window.py index 49b039eb5..82033aff8 100644 --- a/tests/functional/openlp_core/display/test_window.py +++ b/tests/functional/openlp_core/display/test_window.py @@ -23,6 +23,7 @@ Package to test the openlp.core.display.window package. """ import sys import time +import pytest from unittest.mock import MagicMock, patch @@ -37,6 +38,18 @@ from openlp.core.lib.theme import Theme from openlp.core.ui import HideMode +@pytest.fixture +def mock_geometry(): + mocked_screen = MagicMock() + mocked_screen.display_geometry = QtCore.QRect(10, 20, 400, 600) + mocked_screenlist = MagicMock() + mocked_screenlist.current = mocked_screen + screenlist_patcher = patch('openlp.core.lib.theme.ScreenList', return_value=mocked_screenlist) + screenlist_patcher.start() + yield + screenlist_patcher.stop() + + @patch('openlp.core.display.window.QtWidgets.QVBoxLayout') @patch('openlp.core.display.webengine.WebEngineView') def test_x11_override_on(mocked_webengine, mocked_addWidget, mock_settings): @@ -209,7 +222,7 @@ def test_run_javascript_sync_no_wait(mock_time, mocked_webengine, mocked_addWidg @patch('openlp.core.display.window.QtWidgets.QVBoxLayout') @patch('openlp.core.display.webengine.WebEngineView') -def test_set_theme_is_display_video(mocked_webengine, mocked_addWidget, mock_settings): +def test_set_theme_is_display_video(mocked_webengine, mocked_addWidget, mock_settings, mock_geometry): """ Test the set_theme function """ @@ -233,7 +246,7 @@ def test_set_theme_is_display_video(mocked_webengine, mocked_addWidget, mock_set @patch('openlp.core.display.window.QtWidgets.QVBoxLayout') @patch('openlp.core.display.webengine.WebEngineView') -def test_set_theme_not_display_video(mocked_webengine, mocked_addWidget, mock_settings): +def test_set_theme_not_display_video(mocked_webengine, mocked_addWidget, mock_settings, mock_geometry): """ Test the set_theme function """ @@ -264,7 +277,7 @@ def test_set_theme_not_display_video(mocked_webengine, mocked_addWidget, mock_se @patch('openlp.core.display.window.QtWidgets.QVBoxLayout') @patch('openlp.core.display.webengine.WebEngineView') -def test_set_theme_not_display_live(mocked_webengine, mocked_addWidget, mock_settings): +def test_set_theme_not_display_live(mocked_webengine, mocked_addWidget, mock_settings, mock_geometry): """ Test the set_theme function """ diff --git a/tests/functional/openlp_core/lib/test_theme.py b/tests/functional/openlp_core/lib/test_theme.py index 17fe7974c..5078f0e09 100644 --- a/tests/functional/openlp_core/lib/test_theme.py +++ b/tests/functional/openlp_core/lib/test_theme.py @@ -21,12 +21,26 @@ """ Package to test the openlp.core.lib.theme package. """ +import pytest +from PyQt5 import QtCore from pathlib import Path from unittest.mock import MagicMock, patch from openlp.core.lib.theme import BackgroundType, BackgroundGradientType, TransitionType, TransitionSpeed, Theme +@pytest.fixture +def mock_geometry(): + mocked_screen = MagicMock() + mocked_screen.display_geometry = QtCore.QRect(10, 20, 400, 600) + mocked_screenlist = MagicMock() + mocked_screenlist.current = mocked_screen + screenlist_patcher = patch('openlp.core.lib.theme.ScreenList', return_value=mocked_screenlist) + screenlist_patcher.start() + yield + screenlist_patcher.stop() + + def test_background_type_to_string(): """ Test the to_string method of :class:`BackgroundType` @@ -183,7 +197,7 @@ def test_transition_speed_from_string(): assert TransitionSpeed.from_string(transition_speed_slow) == TransitionSpeed.Slow -def test_new_theme(): +def test_new_theme(mock_geometry): """ Test the Theme constructor """ @@ -195,7 +209,7 @@ def test_new_theme(): check_theme(default_theme) -def test_expand_json(): +def test_expand_json(mock_geometry): """ Test the expand_json method """ @@ -226,7 +240,7 @@ def test_expand_json(): check_theme(theme) -def test_extend_image_filename(): +def test_extend_image_filename(mock_geometry): """ Test the extend_image_filename method """ @@ -246,7 +260,7 @@ def test_extend_image_filename(): assert 'MyBeautifulTheme' == theme.theme_name -def test_save_retrieve(): +def test_save_retrieve(mock_geometry): """ Load a dummy theme, save it and reload it """ @@ -260,16 +274,31 @@ def test_save_retrieve(): check_theme(lt) -@patch('openlp.core.display.screens.ScreenList.current') +def test_set_default_header_footer_overridden(mock_geometry): + """ + Check that when the theme header and footer locations are overridden, the defaults are not set + """ + # GIVEN: A theme with the overrides on + theme = Theme() + theme.set_default_header = MagicMock() + theme.set_default_footer = MagicMock() + theme.font_main_override = True + theme.font_footer_override = True + + # WHEN: set_default_header_footer is called + theme.set_default_header_footer() + + # THEN: Neither header or footer default fns should have been called + assert theme.set_default_header.call_count == 0 + assert theme.set_default_footer.call_count == 0 + + def test_set_default_footer(mock_geometry): """ Test the set_default_footer function sets the footer back to default (reletive to the screen) """ # GIVEN: A screen geometry object and a Theme footer with a strange area - mock_geometry.display_geometry = MagicMock() - mock_geometry.display_geometry.height.return_value = 600 - mock_geometry.display_geometry.width.return_value = 400 theme = Theme() theme.font_main_x = 20 theme.font_footer_x = 207 @@ -288,16 +317,12 @@ def test_set_default_footer(mock_geometry): assert theme.font_footer_height == 60, 'height should have been reset to (screen_size_height / 10)' -@patch('openlp.core.display.screens.ScreenList.current') def test_set_default_header(mock_geometry): """ Test the set_default_header function sets the header back to default (reletive to the screen) """ # GIVEN: A screen geometry object and a Theme header with a strange area - mock_geometry.display_geometry = MagicMock() - mock_geometry.display_geometry.height.return_value = 600 - mock_geometry.display_geometry.width.return_value = 400 theme = Theme() theme.font_footer_x = 200 theme.font_main_x = 687 @@ -316,14 +341,12 @@ def test_set_default_header(mock_geometry): assert theme.font_main_height == 540, 'height should have been reset to (screen_size_height * 9 / 10)' -@patch('openlp.core.display.screens.ScreenList.current') def test_set_default_header_footer(mock_geometry): """ Test the set_default_header_footer function sets the header and footer back to default (reletive to the screen) """ # GIVEN: A screen geometry object and a Theme header with a strange area - mock_geometry.display_geometry = MagicMock() theme = Theme() theme.font_footer_x = 200 theme.font_main_x = 687 diff --git a/tests/functional/openlp_core/ui/test_mainwindow.py b/tests/functional/openlp_core/ui/test_mainwindow.py index 2a8cb7b05..bcf4e2d3a 100644 --- a/tests/functional/openlp_core/ui/test_mainwindow.py +++ b/tests/functional/openlp_core/ui/test_mainwindow.py @@ -155,7 +155,7 @@ def test_mainwindow_configuration(main_window): 'authentication_token', 'settings_form', 'service_manager', 'theme_manager', 'projector_manager'] expected_functions_list = ['bootstrap_initialise', 'bootstrap_post_set_up', 'bootstrap_completion', - 'theme_update_global', 'config_screen_changed'] + 'config_screen_changed', 'theme_update_global'] assert list(Registry().service_list.keys()) == expected_service_list, \ 'The service list should have been {}'.format(Registry().service_list.keys()) assert list(Registry().functions_list.keys()) == expected_functions_list, \ diff --git a/tests/functional/openlp_core/ui/test_slidecontroller.py b/tests/functional/openlp_core/ui/test_slidecontroller.py index 0542e3f4a..afed98d08 100644 --- a/tests/functional/openlp_core/ui/test_slidecontroller.py +++ b/tests/functional/openlp_core/ui/test_slidecontroller.py @@ -21,7 +21,7 @@ """ Package to test the openlp.core.ui.slidecontroller package. """ -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, patch, sentinel from PyQt5 import QtCore, QtGui @@ -697,6 +697,79 @@ def test_on_slide_selected_index_service_item_not_command(mocked_execute, regist mocked_slide_selected.assert_called_once_with() +def test_set_background_image(registry): + """ + Test that the display and preview background are set + """ + # GIVEN: A slide controller + slide_controller = SlideController(None) + slide_controller.preview_display = MagicMock() + mock_display = MagicMock() + slide_controller.displays = [mock_display] + + # WHEN: set_background_image is called + slide_controller.set_background_image(sentinel.colour, sentinel.image) + + # THEN: The preview and main display are called with the new colour and image + slide_controller.preview_display.set_background_image.assert_called_once_with(sentinel.colour, sentinel.image) + mock_display.set_background_image.assert_called_once_with(sentinel.colour, sentinel.image) + + +def test_theme_updated(mock_settings): + """ + Test that the theme_updated function updates the service if hot reload is on + """ + # GIVEN: A slide controller and settings return true + slide_controller = SlideController(None) + slide_controller.service_item = sentinel.service_item + slide_controller._process_item = MagicMock() + slide_controller.preview_widget = MagicMock() + slide_controller.preview_widget.current_slide_number.return_value = 14 + mock_settings.value.return_value = True + + # WHEN: theme_updated is called + slide_controller.theme_updated() + + # THEN: process_item is called with the current service_item and slide number + slide_controller._process_item.assert_called_once_with(sentinel.service_item, 14) + + +def test_theme_updated_no_reload(mock_settings): + """ + Test that the theme_updated function does not update the service if hot reload is off + """ + # GIVEN: A slide controller and settings return false + slide_controller = SlideController(None) + slide_controller.service_item = sentinel.service_item + slide_controller._process_item = MagicMock() + slide_controller.preview_widget = MagicMock() + mock_settings.value.return_value = False + + # WHEN: theme_updated is called + slide_controller.theme_updated() + + # THEN: process_item is not called + assert slide_controller._process_item.call_count == 0 + + +def test_reload_theme(mock_settings): + """ + Test that the reload_theme function triggers the reload_theme function for the displays + """ + # GIVEN: A slide controller and mocked displays + slide_controller = SlideController(None) + slide_controller.preview_display = MagicMock() + mock_display = MagicMock() + slide_controller.displays = [mock_display] + + # WHEN: reload_theme is called + slide_controller.reload_theme() + + # THEN: reload_theme is called with the preview and main display + slide_controller.preview_display.reload_theme.assert_called_once_with() + mock_display.reload_theme.assert_called_once_with() + + @patch.object(Registry, 'execute') def test_process_item(mocked_execute, registry): """