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/last directory import': None,
'themes/theme level': ThemeLevel.Global, 'themes/theme level': ThemeLevel.Global,
'themes/item transitions': False, 'themes/item transitions': False,
'themes/hot reload': False,
'user interface/live panel': True, 'user interface/live panel': True,
'user interface/live splitter geometry': QtCore.QByteArray(), 'user interface/live splitter geometry': QtCore.QByteArray(),
'user interface/lock panel': True, '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' json_path = AppLocation.get_directory(AppLocation.AppDir) / 'core' / 'lib' / 'json' / 'theme.json'
jsn = get_text_file_string(json_path) jsn = get_text_file_string(json_path)
self.load_theme(jsn) self.load_theme(jsn)
self.set_default_header_footer()
self.background_filename = None self.background_filename = None
self.background_source = None self.background_source = None
self.version = 2 self.version = 2
@ -334,13 +335,18 @@ class Theme(object):
def set_default_header_footer(self): def set_default_header_footer(self):
""" """
Set the header and footer size into the current primary screen. Set the default header and footer size to match the current primary screen.
10 px on each side is removed to allow for a border. Obeys theme override variables.
""" """
self.set_default_header() if not self.font_main_override:
self.set_default_footer() self.set_default_header()
if not self.font_footer_override:
self.set_default_footer()
def set_default_header(self): def set_default_header(self):
"""
Sets the default header position and size, ignores font_main_override
"""
current_screen_geometry = ScreenList().current.display_geometry current_screen_geometry = ScreenList().current.display_geometry
self.font_main_x = 10 self.font_main_x = 10
self.font_main_y = 0 self.font_main_y = 0
@ -348,6 +354,9 @@ class Theme(object):
self.font_main_height = current_screen_geometry.height() * 9 / 10 self.font_main_height = current_screen_geometry.height() * 9 / 10
def set_default_footer(self): def set_default_footer(self):
"""
Sets the default footer position and size, ignores font_footer_override
"""
current_screen_geometry = ScreenList().current.display_geometry current_screen_geometry = ScreenList().current.display_geometry
self.font_footer_x = 10 self.font_footer_x = 10
self.font_footer_y = current_screen_geometry.height() * 9 / 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.service_theme = self.theme_combo_box.currentText()
self.settings.setValue('servicemanager/service theme', self.service_theme) self.settings.setValue('servicemanager/service theme', self.service_theme)
self.regenerate_service_items(True) Registry().execute('theme_update_service')
def theme_change(self): 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) self.mediacontroller_live_stop.connect(self.media_controller.on_media_stop)
else: else:
getattr(self, 'slidecontroller_preview_clear').connect(self.on_clear) 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): def new_song_menu(self):
""" """
@ -868,6 +872,16 @@ class SlideController(QtWidgets.QWidget, LogMixin, RegistryProperties):
for display in self.displays: for display in self.displays:
display.set_background_image(bg_color, image_path) 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): def reload_theme(self):
""" """
Reload the theme on displays. Reload the theme on displays.

View File

@ -144,6 +144,7 @@ class ThemeManager(QtWidgets.QWidget, RegistryBase, Ui_ThemeManager, LogMixin, R
# Variables # Variables
self._theme_list = {} self._theme_list = {}
self.old_background_image_path = None self.old_background_image_path = None
Registry().register_function('config_screen_changed', self.screen_changed)
def get_global_theme(self): def get_global_theme(self):
return self.get_theme_data(self.global_theme) return self.get_theme_data(self.global_theme)
@ -184,6 +185,14 @@ class ThemeManager(QtWidgets.QWidget, RegistryBase, Ui_ThemeManager, LogMixin, R
self.load_themes() self.load_themes()
Registry().register_function('theme_update_global', self.change_global_from_tab) 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): def upgrade_themes(self):
""" """
Upgrade the xml files to json. Upgrade the xml files to json.
@ -292,7 +301,6 @@ class ThemeManager(QtWidgets.QWidget, RegistryBase, Ui_ThemeManager, LogMixin, R
:param field: :param field:
""" """
theme = Theme() theme = Theme()
theme.set_default_header_footer()
self.theme_form.theme = theme self.theme_form.theme = theme
self.theme_form.exec() self.theme_form.exec()
self.load_themes() self.load_themes()
@ -566,7 +574,9 @@ class ThemeManager(QtWidgets.QWidget, RegistryBase, Ui_ThemeManager, LogMixin, R
if not theme_data: if not theme_data:
self.log_debug('No theme data - using default theme') self.log_debug('No theme data - using default theme')
return 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): 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 Writes the theme to the disk and including the background image and thumbnail if necessary
:param Theme theme: The theme data object. :param Theme theme: The theme data object.
:param image: The theme thumbnail. Optionally.
:param background_override: Background to use rather than background_source. Optionally. :param background_override: Background to use rather than background_source. Optionally.
:rtype: None :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 = QtWidgets.QCheckBox(self.universal_group_box)
self.item_transitions_check_box.setObjectName('item_transitions_check_box') self.item_transitions_check_box.setObjectName('item_transitions_check_box')
self.universal_group_box_layout.addWidget(self.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.addWidget(self.universal_group_box)
self.left_layout.addStretch() self.left_layout.addStretch()
self.level_group_box = QtWidgets.QGroupBox(self.right_column) 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.global_group_box.setTitle(translate('OpenLP.ThemesTab', 'Global Theme'))
self.universal_group_box.setTitle(translate('OpenLP.ThemesTab', 'Universal Settings')) self.universal_group_box.setTitle(translate('OpenLP.ThemesTab', 'Universal Settings'))
self.item_transitions_check_box.setText(translate('OpenLP.ThemesTab', '&Transition between service items')) 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.level_group_box.setTitle(translate('OpenLP.ThemesTab', 'Theme Level'))
self.song_level_radio_button.setText(translate('OpenLP.ThemesTab', 'S&ong Level')) self.song_level_radio_button.setText(translate('OpenLP.ThemesTab', 'S&ong Level'))
self.song_level_label.setText( self.song_level_label.setText(
@ -138,6 +142,7 @@ class ThemesTab(SettingsTab):
self.theme_level = self.settings.value('themes/theme level') self.theme_level = self.settings.value('themes/theme level')
self.global_theme = self.settings.value('themes/global theme') self.global_theme = self.settings.value('themes/global theme')
self.item_transitions_check_box.setChecked(self.settings.value('themes/item transitions')) 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: if self.theme_level == ThemeLevel.Global:
self.global_level_radio_button.setChecked(True) self.global_level_radio_button.setChecked(True)
elif self.theme_level == ThemeLevel.Service: 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/theme level', self.theme_level)
self.settings.setValue('themes/global theme', self.global_theme) 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/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) self.renderer.set_theme_level(self.theme_level)
if self.tab_visited: if self.tab_visited:
self.settings_form.register_post_process('theme_update_global') 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 sys
import time import time
import pytest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@ -37,6 +38,18 @@ from openlp.core.lib.theme import Theme
from openlp.core.ui import HideMode 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.window.QtWidgets.QVBoxLayout')
@patch('openlp.core.display.webengine.WebEngineView') @patch('openlp.core.display.webengine.WebEngineView')
def test_x11_override_on(mocked_webengine, mocked_addWidget, mock_settings): 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.window.QtWidgets.QVBoxLayout')
@patch('openlp.core.display.webengine.WebEngineView') @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 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.window.QtWidgets.QVBoxLayout')
@patch('openlp.core.display.webengine.WebEngineView') @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 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.window.QtWidgets.QVBoxLayout')
@patch('openlp.core.display.webengine.WebEngineView') @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 Test the set_theme function
""" """

View File

@ -21,12 +21,26 @@
""" """
Package to test the openlp.core.lib.theme package. Package to test the openlp.core.lib.theme package.
""" """
import pytest
from PyQt5 import QtCore
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from openlp.core.lib.theme import BackgroundType, BackgroundGradientType, TransitionType, TransitionSpeed, Theme 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(): def test_background_type_to_string():
""" """
Test the to_string method of :class:`BackgroundType` 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 assert TransitionSpeed.from_string(transition_speed_slow) == TransitionSpeed.Slow
def test_new_theme(): def test_new_theme(mock_geometry):
""" """
Test the Theme constructor Test the Theme constructor
""" """
@ -195,7 +209,7 @@ def test_new_theme():
check_theme(default_theme) check_theme(default_theme)
def test_expand_json(): def test_expand_json(mock_geometry):
""" """
Test the expand_json method Test the expand_json method
""" """
@ -226,7 +240,7 @@ def test_expand_json():
check_theme(theme) check_theme(theme)
def test_extend_image_filename(): def test_extend_image_filename(mock_geometry):
""" """
Test the extend_image_filename method Test the extend_image_filename method
""" """
@ -246,7 +260,7 @@ def test_extend_image_filename():
assert 'MyBeautifulTheme' == theme.theme_name assert 'MyBeautifulTheme' == theme.theme_name
def test_save_retrieve(): def test_save_retrieve(mock_geometry):
""" """
Load a dummy theme, save it and reload it Load a dummy theme, save it and reload it
""" """
@ -260,16 +274,31 @@ def test_save_retrieve():
check_theme(lt) 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): def test_set_default_footer(mock_geometry):
""" """
Test the set_default_footer function sets the footer back to default Test the set_default_footer function sets the footer back to default
(reletive to the screen) (reletive to the screen)
""" """
# GIVEN: A screen geometry object and a Theme footer with a strange area # 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 = Theme()
theme.font_main_x = 20 theme.font_main_x = 20
theme.font_footer_x = 207 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)' 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): def test_set_default_header(mock_geometry):
""" """
Test the set_default_header function sets the header back to default Test the set_default_header function sets the header back to default
(reletive to the screen) (reletive to the screen)
""" """
# GIVEN: A screen geometry object and a Theme header with a strange area # 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 = Theme()
theme.font_footer_x = 200 theme.font_footer_x = 200
theme.font_main_x = 687 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)' 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): def test_set_default_header_footer(mock_geometry):
""" """
Test the set_default_header_footer function sets the header and footer back to default Test the set_default_header_footer function sets the header and footer back to default
(reletive to the screen) (reletive to the screen)
""" """
# GIVEN: A screen geometry object and a Theme header with a strange area # GIVEN: A screen geometry object and a Theme header with a strange area
mock_geometry.display_geometry = MagicMock()
theme = Theme() theme = Theme()
theme.font_footer_x = 200 theme.font_footer_x = 200
theme.font_main_x = 687 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', 'authentication_token', 'settings_form', 'service_manager', 'theme_manager',
'projector_manager'] 'projector_manager']
expected_functions_list = ['bootstrap_initialise', 'bootstrap_post_set_up', 'bootstrap_completion', 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, \ assert list(Registry().service_list.keys()) == expected_service_list, \
'The service list should have been {}'.format(Registry().service_list.keys()) 'The service list should have been {}'.format(Registry().service_list.keys())
assert list(Registry().functions_list.keys()) == expected_functions_list, \ assert list(Registry().functions_list.keys()) == expected_functions_list, \

View File

@ -21,7 +21,7 @@
""" """
Package to test the openlp.core.ui.slidecontroller package. 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 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() 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') @patch.object(Registry, 'execute')
def test_process_item(mocked_execute, registry): def test_process_item(mocked_execute, registry):
""" """