Merge branch 'dynamic_themes' into 'master'

Dynamic themes

See merge request openlp/openlp!200
This commit is contained in:
Tim Bentley 2020-07-01 16:12:01 +00:00
commit 28a7840b9f
10 changed files with 175 additions and 27 deletions

View File

@ -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,

View File

@ -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.
"""
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

View File

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

View File

@ -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.

View File

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

View File

@ -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')

View File

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

View File

@ -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

View File

@ -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, \

View File

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