← Back to team overview

openlp-core team mailing list archive

[Merge] lp:~phill-ridout/openlp/ftw-json-theme-list into lp:openlp

 

Phill has proposed merging lp:~phill-ridout/openlp/ftw-json-theme-list into lp:openlp.

Commit message:
move ftw to new json config format. spruce up theme list page

Requested reviews:
  OpenLP Core (openlp-core)

For more details, see:
https://code.launchpad.net/~phill-ridout/openlp/ftw-json-theme-list/+merge/363275

lp:~phill-ridout/openlp/ftw-json-theme-list (revision 2852)
https://ci.openlp.io/job/Branch-01-Pull/2681/                          [SUCCESS]
https://ci.openlp.io/job/Branch-02a-Linux-Tests/2575/                  [SUCCESS]
https://ci.openlp.io/job/Branch-02b-macOS-Tests/345/                   [SUCCESS]
https://ci.openlp.io/job/Branch-03a-Build-Source/186/                  [SUCCESS]
https://ci.openlp.io/job/Branch-03b-Build-macOS/165/                   [SUCCESS]
https://ci.openlp.io/job/Branch-04a-Code-Lint/1648/                    [SUCCESS]
https://ci.openlp.io/job/Branch-04b-Test-Coverage/1461/                [SUCCESS]
https://ci.openlp.io/job/Branch-05-AppVeyor-Tests/349/                 [FAILURE]
-- 
Your team OpenLP Core is requested to review the proposed merge of lp:~phill-ridout/openlp/ftw-json-theme-list into lp:openlp.
=== modified file 'openlp/core/common/httputils.py'
--- openlp/core/common/httputils.py	2019-02-14 15:09:09 +0000
+++ openlp/core/common/httputils.py	2019-02-15 23:05:12 +0000
@@ -27,12 +27,16 @@
 import sys
 import time
 from random import randint
+from tempfile import gettempdir
 
 import requests
+from PyQt5 import QtCore
 
 from openlp.core.common import trace_error_handler
+from openlp.core.common.path import Path
 from openlp.core.common.registry import Registry
 from openlp.core.common.settings import ProxyMode, Settings
+from openlp.core.threading import ThreadWorker
 
 
 log = logging.getLogger(__name__ + '.__init__')
@@ -227,4 +231,46 @@
     return True
 
 
-__all__ = ['get_web_page']
+class DownloadWorker(ThreadWorker):
+    """
+    This worker allows a file to be downloaded in a thread
+    """
+    download_failed = QtCore.pyqtSignal()
+    download_succeeded = QtCore.pyqtSignal(Path)
+
+    def __init__(self, base_url, file_name):
+        """
+        Set up the worker object
+        """
+        self._base_url = base_url
+        self._file_name = file_name
+        self._download_cancelled = False
+        super().__init__()
+
+    def start(self):
+        """
+        Download the url to the temporary directory
+        """
+        if self._download_cancelled:
+            self.quit.emit()
+            return
+        try:
+            dest_path = Path(gettempdir()) / 'openlp' / self._file_name
+            url = f'{self._base_url}{self._file_name}'
+            is_success = download_file(self, url, dest_path)
+            if is_success and not self._download_cancelled:
+                self.download_succeeded.emit(dest_path)
+            else:
+                self.download_failed.emit()
+        except:                                                                 # noqa
+            log.exception('Unable to download %s', url)
+            self.download_failed.emit()
+        finally:
+            self.quit.emit()
+
+    @QtCore.pyqtSlot()
+    def cancel_download(self):
+        """
+        A slot to allow the download to be cancelled from outside of the thread
+        """
+        self._download_cancelled = True

=== modified file 'openlp/core/ui/firsttimeform.py'
--- openlp/core/ui/firsttimeform.py	2019-02-14 15:09:09 +0000
+++ openlp/core/ui/firsttimeform.py	2019-02-15 23:05:12 +0000
@@ -22,19 +22,19 @@
 """
 This module contains the first time wizard.
 """
+import json
 import logging
 import time
 import urllib.error
 import urllib.parse
 import urllib.request
-from configparser import ConfigParser, MissingSectionHeaderError, NoOptionError, NoSectionError
 from tempfile import gettempdir
 
 from PyQt5 import QtCore, QtWidgets
 
 from openlp.core.common import clean_button_text, trace_error_handler
 from openlp.core.common.applocation import AppLocation
-from openlp.core.common.httputils import download_file, get_url_file_size, get_web_page
+from openlp.core.common.httputils import DownloadWorker, download_file, get_url_file_size, get_web_page
 from openlp.core.common.i18n import translate
 from openlp.core.common.mixins import RegistryProperties
 from openlp.core.common.path import Path, create_paths
@@ -43,57 +43,50 @@
 from openlp.core.lib import build_icon
 from openlp.core.lib.plugin import PluginStatus
 from openlp.core.lib.ui import critical_error_message_box
-from openlp.core.threading import ThreadWorker, get_thread_worker, is_thread_finished, run_thread
+from openlp.core.threading import get_thread_worker, is_thread_finished, run_thread
 from openlp.core.ui.firsttimewizard import FirstTimePage, UiFirstTimeWizard
+from openlp.core.ui.icons import UiIcons
 
 
 log = logging.getLogger(__name__)
 
 
-class ThemeScreenshotWorker(ThreadWorker):
-    """
-    This thread downloads a theme's screenshot
-    """
-    screenshot_downloaded = QtCore.pyqtSignal(str, str, str)
-
-    def __init__(self, themes_url, title, filename, sha256, screenshot):
-        """
-        Set up the worker object
-        """
-        self.was_cancelled = False
-        self.themes_url = themes_url
-        self.title = title
-        self.filename = filename
-        self.sha256 = sha256
-        self.screenshot = screenshot
-        super().__init__()
-
-    def start(self):
-        """
-        Run the worker
-        """
-        if self.was_cancelled:
-            return
-        try:
-            download_path = Path(gettempdir()) / 'openlp' / self.screenshot
-            is_success = download_file(self, '{host}{name}'.format(host=self.themes_url, name=self.screenshot),
-                                       download_path)
-            if is_success and not self.was_cancelled:
-                # Signal that the screenshot has been downloaded
-                self.screenshot_downloaded.emit(self.title, self.filename, self.sha256)
-        except:                                                                 # noqa
-            log.exception('Unable to download screenshot')
-        finally:
-            self.quit.emit()
-
-    @QtCore.pyqtSlot(bool)
-    def set_download_canceled(self, toggle):
-        """
-        Externally set if the download was canceled
-
-        :param toggle: Set if the download was canceled or not
-        """
-        self.was_download_cancelled = toggle
+class ThemeListWidgetItem(QtWidgets.QListWidgetItem):
+    """
+    Subclass a QListWidgetItem to allow dynamic loading of thumbnails from an online resource
+    """
+    def __init__(self, themes_url, sample_theme_data, ftw, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        title = sample_theme_data['title']
+        thumbnail = sample_theme_data['thumbnail']
+        self.file_name = sample_theme_data['file_name']
+        self.sha256 = sample_theme_data['sha256']
+        self.setIcon(UiIcons().picture)  # Set a place holder icon whilst the thumbnails download
+        self.setText(title)
+        self.setToolTip(title)
+        worker = DownloadWorker(themes_url, thumbnail)
+        worker.download_failed.connect(self._on_download_failed)
+        worker.download_succeeded.connect(self._on_thumbnail_downloaded)
+        thread_name = f'thumbnail_download_{thumbnail}'
+        run_thread(worker, thread_name)
+        ftw.thumbnail_download_threads.append(thread_name)
+
+    def _on_download_failed(self):
+        """
+        Set an icon to indicate that the thumbnail download has failed.
+
+        :rtype: None
+        """
+        self.setIcon(UiIcons().exception)
+
+    def _on_thumbnail_downloaded(self, thumbnail_path):
+        """
+        Load the thumbnail as the icon when it has downloaded.
+
+        :param Path thumbnail_path: Path to the file to use as a thumbnail
+        :rtype: None
+        """
+        self.setIcon(build_icon(thumbnail_path))
 
 
 class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties):
@@ -110,6 +103,9 @@
         self.web_access = True
         self.web = ''
         self.setup_ui(self)
+        self.themes_list_widget.itemSelectionChanged.connect(self.on_themes_list_widget_selection_changed)
+        self.themes_deselect_all_button.clicked.connect(self.themes_list_widget.clearSelection)
+        self.themes_select_all_button.clicked.connect(self.themes_list_widget.selectAll)
 
     def get_next_page_id(self):
         """
@@ -144,18 +140,7 @@
             return -1
         elif self.currentId() == FirstTimePage.NoInternet:
             return FirstTimePage.Progress
-        elif self.currentId() == FirstTimePage.Themes:
-            self.application.set_busy_cursor()
-            while not all([is_thread_finished(thread_name) for thread_name in self.theme_screenshot_threads]):
-                time.sleep(0.1)
-                self.application.process_events()
-            # Build the screenshot icons, as this can not be done in the thread.
-            self._build_theme_screenshots()
-            self.application.set_normal_cursor()
-            self.theme_screenshot_threads = []
-            return self.get_next_page_id()
-        else:
-            return self.get_next_page_id()
+        return self.get_next_page_id()
 
     def exec(self):
         """
@@ -172,104 +157,83 @@
         """
         self.screens = screens
         self.was_cancelled = False
-        self.theme_screenshot_threads = []
+        self.thumbnail_download_threads = []
         self.has_run_wizard = False
 
-        self.themes_list_widget.itemChanged.connect(self.on_theme_selected)
-
     def _download_index(self):
         """
         Download the configuration file and kick off the theme screenshot download threads
         """
         # check to see if we have web access
         self.web_access = False
-        self.config = ConfigParser()
+        self.config = ''
+        web_config = None
         user_agent = 'OpenLP/' + Registry().get('application').applicationVersion()
         self.application.process_events()
         try:
-            web_config = get_web_page('{host}{name}'.format(host=self.web, name='download.cfg'),
+            web_config = get_web_page('{host}{name}'.format(host=self.web, name='download_3.0.json'),
                                       headers={'User-Agent': user_agent})
+            # web_config = Path(
+            #     'C:\\Users\\sroom\\Documents\\Phill Ridout\\play_ground\\openlp\\ftw-json\\download_3.0.json'
+            # ).read_text(encoding='utf-8')  # TODO: Remove!!!!!
         except ConnectionError:
             QtWidgets.QMessageBox.critical(self, translate('OpenLP.FirstTimeWizard', 'Network Error'),
                                            translate('OpenLP.FirstTimeWizard', 'There was a network error attempting '
                                                      'to connect to retrieve initial configuration information'),
                                            QtWidgets.QMessageBox.Ok)
-            web_config = False
-        if web_config:
-            try:
-                self.config.read_string(web_config)
-                self.web = self.config.get('general', 'base url')
-                self.songs_url = self.web + self.config.get('songs', 'directory') + '/'
-                self.bibles_url = self.web + self.config.get('bibles', 'directory') + '/'
-                self.themes_url = self.web + self.config.get('themes', 'directory') + '/'
-                self.web_access = True
-            except (NoSectionError, NoOptionError, MissingSectionHeaderError):
-                log.debug('A problem occurred while parsing the downloaded config file')
-                trace_error_handler(log)
+        if web_config and self._parse_config(web_config):
+            self.web_access = True
         self.application.process_events()
         self.downloading = translate('OpenLP.FirstTimeWizard', 'Downloading {name}...')
-        if self.has_run_wizard:
-            self.songs_check_box.setChecked(self.plugin_manager.get_plugin_by_name('songs').is_active())
-            self.bible_check_box.setChecked(self.plugin_manager.get_plugin_by_name('bibles').is_active())
-            self.presentation_check_box.setChecked(self.plugin_manager.get_plugin_by_name('presentations').is_active())
-            self.image_check_box.setChecked(self.plugin_manager.get_plugin_by_name('images').is_active())
-            self.media_check_box.setChecked(self.plugin_manager.get_plugin_by_name('media').is_active())
-            self.custom_check_box.setChecked(self.plugin_manager.get_plugin_by_name('custom').is_active())
-            self.song_usage_check_box.setChecked(self.plugin_manager.get_plugin_by_name('songusage').is_active())
-            self.alert_check_box.setChecked(self.plugin_manager.get_plugin_by_name('alerts').is_active())
         self.application.set_normal_cursor()
-        # Sort out internet access for downloads
-        if self.web_access:
-            songs = self.config.get('songs', 'languages')
-            songs = songs.split(',')
-            for song in songs:
+
+    def _parse_config(self, web_config):
+        try:
+            config = json.loads(web_config)
+            meta = config['_meta']
+            self.web = meta['base_url']
+
+            self.songs_url = self.web + meta['songs_dir'] + '/'
+            self.bibles_url = self.web + meta['bibles_dir'] + '/'
+            self.themes_url = self.web + meta['themes_dir'] + '/'
+
+            for song in config['songs'].values():
                 self.application.process_events()
-                title = self.config.get('songs_{song}'.format(song=song), 'title')
-                filename = self.config.get('songs_{song}'.format(song=song), 'filename')
-                sha256 = self.config.get('songs_{song}'.format(song=song), 'sha256', fallback='')
-                item = QtWidgets.QListWidgetItem(title, self.songs_list_widget)
-                item.setData(QtCore.Qt.UserRole, (filename, sha256))
+                item = QtWidgets.QListWidgetItem(song['title'], self.songs_list_widget)
+                item.setData(QtCore.Qt.UserRole, (song['file_name'], song['sha256']))
                 item.setCheckState(QtCore.Qt.Unchecked)
                 item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
-            bible_languages = self.config.get('bibles', 'languages')
-            bible_languages = bible_languages.split(',')
-            for lang in bible_languages:
+
+            for lang in config['bibles'].values():
                 self.application.process_events()
-                language = self.config.get('bibles_{lang}'.format(lang=lang), 'title')
-                lang_item = QtWidgets.QTreeWidgetItem(self.bibles_tree_widget, [language])
-                bibles = self.config.get('bibles_{lang}'.format(lang=lang), 'translations')
-                bibles = bibles.split(',')
-                for bible in bibles:
+                lang_item = QtWidgets.QTreeWidgetItem(self.bibles_tree_widget, [lang['title']])
+                for translation in lang['translations'].values():
                     self.application.process_events()
-                    title = self.config.get('bible_{bible}'.format(bible=bible), 'title')
-                    filename = self.config.get('bible_{bible}'.format(bible=bible), 'filename')
-                    sha256 = self.config.get('bible_{bible}'.format(bible=bible), 'sha256', fallback='')
-                    item = QtWidgets.QTreeWidgetItem(lang_item, [title])
-                    item.setData(0, QtCore.Qt.UserRole, (filename, sha256))
+                    item = QtWidgets.QTreeWidgetItem(lang_item, [translation['title']])
+                    item.setData(0, QtCore.Qt.UserRole, (translation['file_name'], translation['sha256']))
                     item.setCheckState(0, QtCore.Qt.Unchecked)
                     item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
             self.bibles_tree_widget.expandAll()
             self.application.process_events()
-            # Download the theme screenshots
-            themes = self.config.get('themes', 'files').split(',')
-            for theme in themes:
-                title = self.config.get('theme_{theme}'.format(theme=theme), 'title')
-                filename = self.config.get('theme_{theme}'.format(theme=theme), 'filename')
-                sha256 = self.config.get('theme_{theme}'.format(theme=theme), 'sha256', fallback='')
-                screenshot = self.config.get('theme_{theme}'.format(theme=theme), 'screenshot')
-                worker = ThemeScreenshotWorker(self.themes_url, title, filename, sha256, screenshot)
-                worker.screenshot_downloaded.connect(self.on_screenshot_downloaded)
-                thread_name = 'theme_screenshot_{title}'.format(title=title)
-                run_thread(worker, thread_name)
-                self.theme_screenshot_threads.append(thread_name)
+
+            for theme in config['themes'].values():
+                ThemeListWidgetItem(self.themes_url, theme, self, self.themes_list_widget)
             self.application.process_events()
+        except Exception:
+            log.exception('Unable to parse sample config file %s', web_config)
+            critical_error_message_box(
+                translate('OpenLP.FirstTimeWizard', 'Invalid index file'),
+                translate('OpenLP.FirstTimeWizard', 'OpenLP was unable to read the resource index file. '
+                                                    'Please try again later.'))
+            return False
+        return True
 
     def set_defaults(self):
         """
         Set up display at start of theme edit.
         """
         self.restart()
-        self.web = 'http://openlp.org/files/frw/'
+        self.web = 'https://get.openlp.org/ftw/'
         self.cancel_button.clicked.connect(self.on_cancel_button_clicked)
         self.no_internet_finish_button.clicked.connect(self.on_no_internet_finish_button_clicked)
         self.no_internet_cancel_button.clicked.connect(self.on_no_internet_cancel_button_clicked)
@@ -282,9 +246,18 @@
         create_paths(Path(gettempdir(), 'openlp'))
         self.theme_combo_box.clear()
         if self.has_run_wizard:
+            self.songs_check_box.setChecked(self.plugin_manager.get_plugin_by_name('songs').is_active())
+            self.bible_check_box.setChecked(self.plugin_manager.get_plugin_by_name('bibles').is_active())
+            self.presentation_check_box.setChecked(
+                self.plugin_manager.get_plugin_by_name('presentations').is_active())
+            self.image_check_box.setChecked(self.plugin_manager.get_plugin_by_name('images').is_active())
+            self.media_check_box.setChecked(self.plugin_manager.get_plugin_by_name('media').is_active())
+            self.custom_check_box.setChecked(self.plugin_manager.get_plugin_by_name('custom').is_active())
+            self.song_usage_check_box.setChecked(self.plugin_manager.get_plugin_by_name('songusage').is_active())
+            self.alert_check_box.setChecked(self.plugin_manager.get_plugin_by_name('alerts').is_active())
             # Add any existing themes to list.
-            for theme in self.theme_manager.get_themes():
-                self.theme_combo_box.addItem(theme)
+            self.theme_combo_box.insertSeparator(0)
+            self.theme_combo_box.addItems(sorted(self.theme_manager.get_themes()))
             default_theme = Settings().value('themes/global theme')
             # Pre-select the current default theme.
             index = self.theme_combo_box.findText(default_theme)
@@ -335,49 +308,34 @@
         Process the triggering of the cancel button.
         """
         self.was_cancelled = True
-        if self.theme_screenshot_threads:
-            for thread_name in self.theme_screenshot_threads:
+        if self.thumbnail_download_threads:  # TODO: Use main thread list
+            for thread_name in self.thumbnail_download_threads:
                 worker = get_thread_worker(thread_name)
                 if worker:
-                    worker.set_download_canceled(True)
+                    worker.cancel_download()
         # Was the thread created.
-        if self.theme_screenshot_threads:
-            while any([not is_thread_finished(thread_name) for thread_name in self.theme_screenshot_threads]):
+        if self.thumbnail_download_threads:
+            while any([not is_thread_finished(thread_name) for thread_name in self.thumbnail_download_threads]):
                 time.sleep(0.1)
         self.application.set_normal_cursor()
 
-    def on_screenshot_downloaded(self, title, filename, sha256):
-        """
-        Add an item to the list when a theme has been downloaded
-
-        :param title: The title of the theme
-        :param filename: The filename of the theme
-        """
-        self.themes_list_widget.blockSignals(True)
-        item = QtWidgets.QListWidgetItem(title, self.themes_list_widget)
-        item.setData(QtCore.Qt.UserRole, (filename, sha256))
-        item.setCheckState(QtCore.Qt.Unchecked)
-        item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
-        self.themes_list_widget.blockSignals(False)
-
-    def on_theme_selected(self, item):
-        """
-        Add or remove a de/selected sample theme from the theme_combo_box
-
-        :param QtWidgets.QListWidgetItem item: The item that has been de/selected
+    def on_themes_list_widget_selection_changed(self):
+        """
+        Update the `theme_combo_box` with the selected items
+
         :rtype: None
         """
-        theme_name = item.text()
-        if self.theme_manager and theme_name in self.theme_manager.get_themes():
-            return True
-        if item.checkState() == QtCore.Qt.Checked:
-            self.theme_combo_box.addItem(theme_name)
-            return True
-        else:
-            index = self.theme_combo_box.findText(theme_name)
-            if index != -1:
-                self.theme_combo_box.removeItem(index)
-            return True
+        existing_themes = []
+        if self.theme_manager:
+            existing_themes = self.theme_manager.get_themes()
+        for list_index in range(self.themes_list_widget.count()):
+            item = self.themes_list_widget.item(list_index)
+            if item.text() not in existing_themes:
+                cbox_index = self.theme_combo_box.findText(item.text())
+                if item.isSelected() and cbox_index == -1:
+                    self.theme_combo_box.insertItem(0, item.text())
+                elif not item.isSelected() and cbox_index != -1:
+                    self.theme_combo_box.removeItem(cbox_index)
 
     def on_no_internet_finish_button_clicked(self):
         """
@@ -396,18 +354,6 @@
         self.was_cancelled = True
         self.close()
 
-    def _build_theme_screenshots(self):
-        """
-        This method builds the theme screenshots' icons for all items in the ``self.themes_list_widget``.
-        """
-        themes = self.config.get('themes', 'files')
-        themes = themes.split(',')
-        for index, theme in enumerate(themes):
-            screenshot = self.config.get('theme_{theme}'.format(theme=theme), 'screenshot')
-            item = self.themes_list_widget.item(index)
-            if item:
-                item.setIcon(build_icon(Path(gettempdir(), 'openlp', screenshot)))
-
     def update_progress(self, count, block_size):
         """
         Calculate and display the download progress. This method is called by download_file().
@@ -456,13 +402,9 @@
                     self.max_progress += size
                 iterator += 1
             # Loop through the themes list and increase for each selected item
-            for i in range(self.themes_list_widget.count()):
-                self.application.process_events()
-                item = self.themes_list_widget.item(i)
-                if item.checkState() == QtCore.Qt.Checked:
-                    filename, sha256 = item.data(QtCore.Qt.UserRole)
-                    size = get_url_file_size('{path}{name}'.format(path=self.themes_url, name=filename))
-                    self.max_progress += size
+            for item in self.themes_list_widget.selectedItems():
+                size = get_url_file_size(f'{self.themes_url}{item.file_name}')
+                self.max_progress += size
         except urllib.error.URLError:
             trace_error_handler(log)
             critical_error_message_box(translate('OpenLP.FirstTimeWizard', 'Download Error'),
@@ -579,15 +521,12 @@
                     missed_files.append('Bible: {name}'.format(name=bible))
             bibles_iterator += 1
         # Download themes
-        for i in range(self.themes_list_widget.count()):
-            item = self.themes_list_widget.item(i)
-            if item.checkState() == QtCore.Qt.Checked:
-                theme, sha256 = item.data(QtCore.Qt.UserRole)
-                self._increment_progress_bar(self.downloading.format(name=theme), 0)
-                self.previous_size = 0
-                if not download_file(self, '{path}{name}'.format(path=self.themes_url, name=theme),
-                                     themes_destination_path / theme, sha256):
-                    missed_files.append('Theme: {name}'.format(name=theme))
+        for item in self.themes_list_widget.selectedItems():
+            self._increment_progress_bar(self.downloading.format(name=item.file_name), 0)
+            self.previous_size = 0
+            if not download_file(
+                    self, f'{self.themes_url}{item.file_name}', themes_destination_path / item.file_name, item.sha256):
+                missed_files.append(f'Theme: {item.file_name}')
         if missed_files:
             file_list = ''
             for entry in missed_files:

=== modified file 'openlp/core/ui/firsttimewizard.py'
--- openlp/core/ui/firsttimewizard.py	2019-02-14 15:09:09 +0000
+++ openlp/core/ui/firsttimewizard.py	2019-02-15 23:05:12 +0000
@@ -49,6 +49,39 @@
     Progress = 8
 
 
+class ThemeListWidget(QtWidgets.QListWidget):
+    """
+    Subclass a QListWidget so we can make it look better when it resizes.
+    """
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
+        self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+        self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn)
+        self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
+        self.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection)
+        self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
+        self.setIconSize(QtCore.QSize(133, 100))
+        self.setMovement(QtWidgets.QListView.Static)
+        self.setFlow(QtWidgets.QListView.LeftToRight)
+        self.setProperty("isWrapping", True)
+        self.setResizeMode(QtWidgets.QListView.Adjust)
+        self.setViewMode(QtWidgets.QListView.IconMode)
+        self.setUniformItemSizes(True)
+
+    def resizeEvent(self, event):
+        """
+        Resize the grid so the list looks better when its resized/
+
+        :param QtGui.QResizeEvent event: Not used
+        :return: None
+        """
+        nominal_width = 141  # Icon width of 133 + 4 each side
+        max_items_per_row = self.viewport().width() // nominal_width or 1  # or 1 to avoid divide by 0 errors
+        col_size = (self.viewport().width() - 1) / max_items_per_row
+        self.setGridSize(QtCore.QSize(col_size, 140))
+
+
 class UiFirstTimeWizard(object):
     """
     The UI widgets for the first time wizard.
@@ -175,27 +208,26 @@
         self.themes_page = QtWidgets.QWizardPage()
         self.themes_page.setObjectName('themes_page')
         self.themes_layout = QtWidgets.QVBoxLayout(self.themes_page)
-        self.themes_layout.setContentsMargins(20, 50, 20, 60)
         self.themes_layout.setObjectName('themes_layout')
-        self.themes_list_widget = QtWidgets.QListWidget(self.themes_page)
-        self.themes_list_widget.setViewMode(QtWidgets.QListView.IconMode)
-        self.themes_list_widget.setMovement(QtWidgets.QListView.Static)
-        self.themes_list_widget.setFlow(QtWidgets.QListView.LeftToRight)
-        self.themes_list_widget.setSpacing(4)
-        self.themes_list_widget.setUniformItemSizes(True)
-        self.themes_list_widget.setIconSize(QtCore.QSize(133, 100))
-        self.themes_list_widget.setWrapping(False)
-        self.themes_list_widget.setObjectName('themes_list_widget')
+        self.themes_list_widget = ThemeListWidget(self.themes_page)
         self.themes_layout.addWidget(self.themes_list_widget)
+        self.theme_options_layout = QtWidgets.QHBoxLayout()
         self.default_theme_layout = QtWidgets.QHBoxLayout()
         self.theme_label = QtWidgets.QLabel(self.themes_page)
         self.default_theme_layout.addWidget(self.theme_label)
         self.theme_combo_box = QtWidgets.QComboBox(self.themes_page)
         self.theme_combo_box.setEditable(False)
-        self.theme_combo_box.setInsertPolicy(QtWidgets.QComboBox.NoInsert)
-        self.theme_combo_box.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents)
-        self.default_theme_layout.addWidget(self.theme_combo_box)
-        self.themes_layout.addLayout(self.default_theme_layout)
+        self.default_theme_layout.addWidget(self.theme_combo_box, stretch=1)
+        self.theme_options_layout.addLayout(self.default_theme_layout, stretch=1)
+        self.select_buttons_layout = QtWidgets.QHBoxLayout()
+        self.themes_select_all_button = QtWidgets.QToolButton(self.themes_page)
+        self.themes_select_all_button.setIcon(UiIcons().select_all)
+        self.select_buttons_layout.addWidget(self.themes_select_all_button, stretch=1, alignment=QtCore.Qt.AlignRight)
+        self.themes_deselect_all_button = QtWidgets.QToolButton(self.themes_page)
+        self.themes_deselect_all_button.setIcon(UiIcons().select_none)
+        self.select_buttons_layout.addWidget(self.themes_deselect_all_button)
+        self.theme_options_layout.addLayout(self.select_buttons_layout, stretch=1)
+        self.themes_layout.addLayout(self.theme_options_layout)
         first_time_wizard.setPage(FirstTimePage.Themes, self.themes_page)
         # Progress page
         self.progress_page = QtWidgets.QWizardPage()
@@ -271,9 +303,12 @@
         self.songs_page.setSubTitle(translate('OpenLP.FirstTimeWizard', 'Select and download public domain songs.'))
         self.bibles_page.setTitle(translate('OpenLP.FirstTimeWizard', 'Sample Bibles'))
         self.bibles_page.setSubTitle(translate('OpenLP.FirstTimeWizard', 'Select and download free Bibles.'))
+        # Themes Page
         self.themes_page.setTitle(translate('OpenLP.FirstTimeWizard', 'Sample Themes'))
         self.themes_page.setSubTitle(translate('OpenLP.FirstTimeWizard', 'Select and download sample themes.'))
-        self.theme_label.setText(translate('OpenLP.FirstTimeWizard', 'Select default theme:'))
+        self.theme_label.setText(translate('OpenLP.FirstTimeWizard', 'Default theme:'))
+        self.themes_select_all_button.setToolTip(translate('OpenLP.FirstTimeWizard', 'Select all'))
+        self.themes_deselect_all_button.setToolTip(translate('OpenLP.FirstTimeWizard', 'Deselect all'))
         self.progress_page.setTitle(translate('OpenLP.FirstTimeWizard', 'Downloading and Configuring'))
         self.progress_page.setSubTitle(translate('OpenLP.FirstTimeWizard', 'Please wait while resources are downloaded '
                                                                            'and OpenLP is configured.'))

=== modified file 'openlp/core/ui/icons.py'
--- openlp/core/ui/icons.py	2019-02-14 15:09:09 +0000
+++ openlp/core/ui/icons.py	2019-02-15 23:05:12 +0000
@@ -138,6 +138,8 @@
             'search_plus': {'icon': 'fa.search-plus'},
             'search_ref': {'icon': 'fa.institution'},
             'search_text': {'icon': 'op.search-text'},
+            'select_all': {'icon': 'fa.check-square-o'},
+            'select_none': {'icon': 'fa.square-o'},
             'settings': {'icon': 'fa.cogs'},
             'shortcuts': {'icon': 'fa.wrench'},
             'song_usage': {'icon': 'fa.line-chart'},

=== modified file 'tests/functional/openlp_core/ui/test_firsttimeform.py'
--- tests/functional/openlp_core/ui/test_firsttimeform.py	2019-02-14 15:09:09 +0000
+++ tests/functional/openlp_core/ui/test_firsttimeform.py	2019-02-15 23:05:12 +0000
@@ -25,40 +25,70 @@
 import os
 import tempfile
 from unittest import TestCase
-from unittest.mock import MagicMock, call, patch
+from unittest.mock import MagicMock, call, patch, DEFAULT
 
 from openlp.core.common.path import Path
 from openlp.core.common.registry import Registry
-from openlp.core.ui.firsttimeform import FirstTimeForm
+from openlp.core.ui.firsttimeform import FirstTimeForm, ThemeListWidgetItem
 from tests.helpers.testmixin import TestMixin
 
 
-FAKE_CONFIG = """
-[general]
-base url = http://example.com/frw/
-[songs]
-directory = songs
-[bibles]
-directory = bibles
-[themes]
-directory = themes
-"""
-
-FAKE_BROKEN_CONFIG = """
-[general]
-base url = http://example.com/frw/
-[songs]
-directory = songs
-[bibles]
-directory = bibles
-"""
-
-FAKE_INVALID_CONFIG = """
-<html>
-<head><title>This is not a config file</title></head>
-<body>Some text</body>
-</html>
-"""
+INVALID_CONFIG = """
+{
+  "_comments": "The most recent version should be added to https://openlp.org/files/frw/download_3.0.json";,
+  "_meta": {
+}
+"""
+
+
+class TestThemeListWidgetItem(TestCase):
+    """
+    Test the :class:`ThemeListWidgetItem` class
+    """
+    def setUp(self):
+        self.sample_theme_data = {'file_name': 'BlueBurst.otz', 'sha256': 'sha_256_hash',
+                                  'thumbnail': 'BlueBurst.png', 'title': 'Blue Burst'}
+
+        download_worker_patcher = patch('openlp.core.ui.firsttimeform.DownloadWorker')
+        self.addCleanup(download_worker_patcher.stop)
+        self.mocked_download_worker = download_worker_patcher.start()
+        run_thread_patcher = patch('openlp.core.ui.firsttimeform.run_thread')
+        self.addCleanup(run_thread_patcher.stop)
+        self.mocked_run_thread = run_thread_patcher.start()
+
+    def test_init_sample_data(self):
+        """
+        Test that the theme data is loaded correctly in to a ThemeListWidgetItem object when instantiated
+        """
+        # GIVEN: A sample theme dictanary object
+        # WHEN: Creating an instance of `ThemeListWidgetItem`
+        instance = ThemeListWidgetItem('url', self.sample_theme_data, MagicMock())
+
+        # THEN: The data should have been set correctly
+        assert instance.file_name == 'BlueBurst.otz'
+        assert instance.sha256 == 'sha_256_hash'
+        assert instance.text() == 'Blue Burst'
+        assert instance.toolTip() == 'Blue Burst'
+        self.mocked_download_worker.assert_called_once_with('url', 'BlueBurst.png')
+
+    def test_init_download_worker(self):
+        """
+        Test that the `DownloadWorker` worker is set up correctly and that the thread is started.
+        """
+        # GIVEN: A sample theme dictanary object
+        mocked_ftw = MagicMock(spec=FirstTimeForm)
+        mocked_ftw.thumbnail_download_threads = []
+
+        # WHEN: Creating an instance of `ThemeListWidgetItem`
+        instance = ThemeListWidgetItem('url', self.sample_theme_data, mocked_ftw)
+
+        # THEN: The `DownloadWorker` should have been set up with the appropriate data
+        self.mocked_download_worker.assert_called_once_with('url', 'BlueBurst.png')
+        self.mocked_download_worker.download_failed.connect.called_once_with(instance._on_download_failed())
+        self.mocked_download_worker.download_succeeded.connect.called_once_with(instance._on_thumbnail_downloaded)
+        self.mocked_run_thread.assert_called_once_with(
+            self.mocked_download_worker(), 'thumbnail_download_BlueBurst.png')
+        assert mocked_ftw.thumbnail_download_threads == ['thumbnail_download_BlueBurst.png']
 
 
 class TestFirstTimeForm(TestCase, TestMixin):
@@ -92,7 +122,7 @@
         assert expected_screens == frw.screens, 'The screens should be correct'
         assert frw.web_access is True, 'The default value of self.web_access should be True'
         assert frw.was_cancelled is False, 'The default value of self.was_cancelled should be False'
-        assert [] == frw.theme_screenshot_threads, 'The list of threads should be empty'
+        assert [] == frw.thumbnail_download_threads, 'The list of threads should be empty'
         assert frw.has_run_wizard is False, 'has_run_wizard should be False'
 
     def test_set_defaults(self):
@@ -109,6 +139,7 @@
                 patch.object(frw, 'no_internet_finish_button') as mocked_no_internet_finish_btn, \
                 patch.object(frw, 'currentIdChanged') as mocked_currentIdChanged, \
                 patch.object(frw, 'theme_combo_box') as mocked_theme_combo_box, \
+                patch.object(frw, 'songs_check_box') as mocked_songs_check_box, \
                 patch.object(Registry, 'register_function') as mocked_register_function, \
                 patch('openlp.core.ui.firsttimeform.Settings', return_value=mocked_settings), \
                 patch('openlp.core.ui.firsttimeform.gettempdir', return_value='temp') as mocked_gettempdir, \
@@ -122,7 +153,7 @@
 
             # THEN: The default values should have been set
             mocked_restart.assert_called_once()
-            assert 'http://openlp.org/files/frw/' == frw.web, 'The default URL should be set'
+            assert 'https://get.openlp.org/ftw/' == frw.web, 'The default URL should be set'
             mocked_cancel_button.clicked.connect.assert_called_once_with(frw.on_cancel_button_clicked)
             mocked_no_internet_finish_btn.clicked.connect.assert_called_once_with(
                 frw.on_no_internet_finish_button_clicked)
@@ -134,6 +165,7 @@
             mocked_create_paths.assert_called_once_with(Path('temp', 'openlp'))
             mocked_theme_combo_box.clear.assert_called_once()
             mocked_theme_manager.assert_not_called()
+            mocked_songs_check_box.assert_not_called()
 
     def test_set_defaults_rerun(self):
         """
@@ -150,12 +182,17 @@
                 patch.object(frw, 'no_internet_finish_button') as mocked_no_internet_finish_btn, \
                 patch.object(frw, 'currentIdChanged') as mocked_currentIdChanged, \
                 patch.object(frw, 'theme_combo_box', **{'findText.return_value': 3}) as mocked_theme_combo_box, \
+                patch.multiple(frw, songs_check_box=DEFAULT, bible_check_box=DEFAULT, presentation_check_box=DEFAULT,
+                               image_check_box=DEFAULT, media_check_box=DEFAULT, custom_check_box=DEFAULT,
+                               song_usage_check_box=DEFAULT, alert_check_box=DEFAULT), \
                 patch.object(Registry, 'register_function') as mocked_register_function, \
                 patch('openlp.core.ui.firsttimeform.Settings', return_value=mocked_settings), \
                 patch('openlp.core.ui.firsttimeform.gettempdir', return_value='temp') as mocked_gettempdir, \
                 patch('openlp.core.ui.firsttimeform.create_paths') as mocked_create_paths, \
                 patch.object(frw.application, 'set_normal_cursor'):
-            mocked_theme_manager = MagicMock(**{'get_themes.return_value': ['a', 'b', 'c']})
+            mocked_plugin_manager = MagicMock()
+            mocked_theme_manager = MagicMock(**{'get_themes.return_value': ['b', 'a', 'c']})
+            Registry().register('plugin_manager', mocked_plugin_manager)
             Registry().register('theme_manager', mocked_theme_manager)
 
             # WHEN: The set_defaults() method is run
@@ -163,7 +200,7 @@
 
             # THEN: The default values should have been set
             mocked_restart.assert_called_once()
-            assert 'http://openlp.org/files/frw/' == frw.web, 'The default URL should be set'
+            assert 'https://get.openlp.org/ftw/' == frw.web, 'The default URL should be set'
             mocked_cancel_button.clicked.connect.assert_called_once_with(frw.on_cancel_button_clicked)
             mocked_no_internet_finish_btn.clicked.connect.assert_called_once_with(
                 frw.on_no_internet_finish_button_clicked)
@@ -173,9 +210,13 @@
             mocked_settings.value.assert_has_calls([call('core/has run wizard'), call('themes/global theme')])
             mocked_gettempdir.assert_called_once()
             mocked_create_paths.assert_called_once_with(Path('temp', 'openlp'))
-            mocked_theme_manager.assert_not_called()
+            mocked_theme_manager.get_themes.assert_called_once()
             mocked_theme_combo_box.clear.assert_called_once()
-            mocked_theme_combo_box.addItem.assert_has_calls([call('a'), call('b'), call('c')])
+            mocked_plugin_manager.get_plugin_by_name.assert_has_calls(
+                [call('songs'), call('bibles'), call('presentations'), call('images'), call('media'), call('custom'),
+                 call('songusage'), call('alerts')], any_order=True)
+            mocked_plugin_manager.get_plugin_by_name.assert_has_calls([call().is_active()] * 8, any_order=True)
+            mocked_theme_combo_box.addItems.assert_called_once_with(['a', 'b', 'c'])
             mocked_theme_combo_box.findText.assert_called_once_with('Default Theme')
             mocked_theme_combo_box.setCurrentIndex(3)
 
@@ -192,7 +233,7 @@
         mocked_is_thread_finished.side_effect = [False, True]
         frw = FirstTimeForm(None)
         frw.initialize(MagicMock())
-        frw.theme_screenshot_threads = ['test_thread']
+        frw.thumbnail_download_threads = ['test_thread']
         with patch.object(frw.application, 'set_normal_cursor') as mocked_set_normal_cursor:
 
             # WHEN: on_cancel_button_clicked() is called
@@ -201,43 +242,26 @@
             # THEN: The right things should be called in the right order
             assert frw.was_cancelled is True, 'The was_cancelled property should have been set to True'
             mocked_get_thread_worker.assert_called_once_with('test_thread')
-            mocked_worker.set_download_canceled.assert_called_with(True)
+            mocked_worker.cancel_download.assert_called_once()
             mocked_is_thread_finished.assert_called_with('test_thread')
             assert mocked_is_thread_finished.call_count == 2, 'isRunning() should have been called twice'
             mocked_time.sleep.assert_called_once_with(0.1)
             mocked_set_normal_cursor.assert_called_once_with()
 
-    def test_broken_config(self):
-        """
-        Test if we can handle an config file with missing data
-        """
-        # GIVEN: A mocked get_web_page, a First Time Wizard, an expected screen object, and a mocked broken config file
-        with patch('openlp.core.ui.firsttimeform.get_web_page') as mocked_get_web_page:
-            first_time_form = FirstTimeForm(None)
-            first_time_form.initialize(MagicMock())
-            mocked_get_web_page.return_value = FAKE_BROKEN_CONFIG
-
-            # WHEN: The First Time Wizard is downloads the config file
-            first_time_form._download_index()
-
-            # THEN: The First Time Form should not have web access
-            assert first_time_form.web_access is False, 'There should not be web access with a broken config file'
-
-    def test_invalid_config(self):
-        """
-        Test if we can handle an config file in invalid format
-        """
-        # GIVEN: A mocked get_web_page, a First Time Wizard, an expected screen object, and a mocked invalid config file
-        with patch('openlp.core.ui.firsttimeform.get_web_page') as mocked_get_web_page:
-            first_time_form = FirstTimeForm(None)
-            first_time_form.initialize(MagicMock())
-            mocked_get_web_page.return_value = FAKE_INVALID_CONFIG
-
-            # WHEN: The First Time Wizard is downloads the config file
-            first_time_form._download_index()
-
-            # THEN: The First Time Form should not have web access
-            assert first_time_form.web_access is False, 'There should not be web access with an invalid config file'
+    @patch('openlp.core.ui.firsttimeform.critical_error_message_box')
+    def test__parse_config_invalid_config(self, mocked_critical_error_message_box):
+        """
+        Test `FirstTimeForm._parse_config` when called with invalid data
+        """
+        # GIVEN: An instance of `FirstTimeForm`
+        first_time_form = FirstTimeForm(None)
+
+        # WHEN: Calling _parse_config with a string containing invalid data
+        result = first_time_form._parse_config(INVALID_CONFIG)
+
+        # THEN: _parse_data should return False and the user should have should have been informed.
+        assert result is False
+        mocked_critical_error_message_box.assert_called_once()
 
     @patch('openlp.core.ui.firsttimeform.get_web_page')
     @patch('openlp.core.ui.firsttimeform.QtWidgets.QMessageBox')

=== added file 'tests/interfaces/openlp_core/ui/test_firsttimeform.py'
--- tests/interfaces/openlp_core/ui/test_firsttimeform.py	1970-01-01 00:00:00 +0000
+++ tests/interfaces/openlp_core/ui/test_firsttimeform.py	2019-02-15 23:05:12 +0000
@@ -0,0 +1,89 @@
+# -*- coding: utf-8 -*-
+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection                                      #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2019 OpenLP Developers                                   #
+# --------------------------------------------------------------------------- #
+# 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; version 2 of the License.                              #
+#                                                                             #
+# 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, write to the Free Software Foundation, Inc., 59  #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA                          #
+###############################################################################
+"""
+Package to test the openlp.core.ui.firsttimeform package.
+"""
+from unittest import TestCase
+from unittest.mock import MagicMock, call, patch
+
+from openlp.core.common.path import Path
+from openlp.core.common.registry import Registry
+from openlp.core.ui.firsttimeform import ThemeListWidgetItem
+from openlp.core.ui.icons import UiIcons
+from tests.helpers.testmixin import TestMixin
+
+
+class TestThemeListWidgetItem(TestCase, TestMixin):
+    def setUp(self):
+        self.sample_theme_data = {'file_name': 'BlueBurst.otz', 'sha256': 'sha_256_hash',
+                                  'thumbnail': 'BlueBurst.png', 'title': 'Blue Burst'}
+
+        Registry.create()
+        self.registry = Registry()
+        mocked_app = MagicMock()
+        mocked_app.worker_threads = {}
+        Registry().register('application', mocked_app)
+        self.setup_application()
+
+        move_to_thread_patcher = patch('openlp.core.ui.firsttimeform.DownloadWorker.moveToThread')
+        self.addCleanup(move_to_thread_patcher.stop)
+        move_to_thread_patcher.start()
+        set_icon_patcher = patch('openlp.core.ui.firsttimeform.ThemeListWidgetItem.setIcon')
+        self.addCleanup(set_icon_patcher.stop)
+        self.mocked_set_icon = set_icon_patcher.start()
+        q_thread_patcher = patch('openlp.core.ui.firsttimeform.QtCore.QThread')
+        self.addCleanup(q_thread_patcher.stop)
+        q_thread_patcher.start()
+
+    def test_failed_download(self):
+        """
+        Test that icon get set to indicate a failure when `DownloadWorker` emits the download_failed signal
+        """
+        # GIVEN: An instance of `DownloadWorker`
+        instance = ThemeListWidgetItem('url', self.sample_theme_data, MagicMock())  # noqa Overcome GC issue
+        worker_threads = Registry().get('application').worker_threads
+        worker = worker_threads['thumbnail_download_BlueBurst.png']['worker']
+
+        # WHEN: `DownloadWorker` emits the `download_failed` signal
+        worker.download_failed.emit()
+
+        # THEN: Then the initial loading icon should have been replaced by the exception icon
+        self.mocked_set_icon.assert_has_calls([call(UiIcons().picture), call(UiIcons().exception)])
+
+    @patch('openlp.core.ui.firsttimeform.build_icon')
+    def test_successful_download(self, mocked_build_icon):
+        """
+        Test that the downloaded thumbnail is set as the icon when `DownloadWorker` emits the `download_succeeded`
+        signal
+        """
+        # GIVEN: An instance of `DownloadWorker`
+        instance = ThemeListWidgetItem('url', self.sample_theme_data, MagicMock())  # noqa Overcome GC issue
+        worker_threads = Registry().get('application').worker_threads
+        worker = worker_threads['thumbnail_download_BlueBurst.png']['worker']
+        test_path = Path('downlaoded', 'file')
+
+        # WHEN: `DownloadWorker` emits the `download_succeeded` signal
+        worker.download_succeeded.emit(test_path)
+
+        # THEN: An icon should have been built from the downloaded file and used to replace the loading icon
+        mocked_build_icon.assert_called_once_with(test_path)
+        self.mocked_set_icon.assert_has_calls([call(UiIcons().picture), call(mocked_build_icon())])


Follow ups