← Back to team overview

openlp-core team mailing list archive

[Merge] lp:~ic90/openlp/animated-alerts into lp:openlp

 

Nico Opiyo has proposed merging lp:~ic90/openlp/animated-alerts into lp:openlp.

Commit message:
Added scrolling alerts

Requested reviews:
  Nico Opiyo (ic90)
  Raoul Snyman (raoul-snyman)
  Tomas Groth (tomasgroth)

For more details, see:
https://code.launchpad.net/~ic90/openlp/animated-alerts/+merge/369540

Refactored the tests and optimized alert display code plus fixed spacing
-- 
Your team OpenLP Core is subscribed to branch lp:openlp.
=== added file 'openlp/core/display/html/display.css'
--- openlp/core/display/html/display.css	1970-01-01 00:00:00 +0000
+++ openlp/core/display/html/display.css	2019-07-01 17:51:00 +0000
@@ -0,0 +1,80 @@
+@keyframes alert-scrolling-text {
+  0% { transform: translateX(100%); opacity: 1; }
+  99% { opacity: 1; }
+  100% { transform: translateX(-101%); opacity: 0;}
+}
+
+/* ALERT BACKGROUND STYLING */
+.bg-default {
+  position: absolute;
+  margin: 0;
+  padding: 0;
+  left: 0;
+  z-index: 11;
+  width: 100%;
+  height: 0;
+  min-height: 0;
+  overflow: hidden;
+  transform: translate(0,0);
+  transition: min-height 1s ease-out .5s;
+  white-space: nowrap;
+  display: flex;
+  flex-direction: row;  
+  align-items: center;
+  /* align-content: center; */
+}
+
+.bg-default span {
+  display: inline-block;
+  padding-left: 120%;
+}
+
+.show-bg {
+  /* height: auto; */
+  min-height: 25%;
+  transition: min-height 1s ease-in .5s;
+}
+
+.middle {  
+  align-items: center;
+}
+
+.alert-container {
+  position: absolute;
+  display: flex;
+  flex-direction: row;
+  height: 100vh;
+  width: 100vw;    
+}
+
+.top { 
+  align-items: flex-start;
+}
+
+.bottom {  
+  align-items: flex-end;
+}
+
+/* ALERT TEXT STYLING */
+#alert {  
+  z-index: 100;  
+  overflow: visible;  
+  color: #ffffff;
+  font-size: 40pt;    
+  padding: 0;
+  margin: 0;
+  opacity: 0; 
+  transition: opacity .5s linear;   
+}
+
+#alert.hide-text {
+  opacity: 0;
+}
+
+#alert.show-text {  
+  transform: none;
+  transition: none;
+  animation: none;
+  padding: auto 5px;
+  opacity: 1;   
+}

=== modified file 'openlp/core/display/html/display.html'
--- openlp/core/display/html/display.html	2019-03-17 10:36:12 +0000
+++ openlp/core/display/html/display.html	2019-07-01 17:51:00 +0000
@@ -2,7 +2,7 @@
 <html>
   <head>
     <title>Display Window</title>
-    <link href="reveal.css" rel="stylesheet">
+    <link href="reveal.css" rel="stylesheet">  
     <style type="text/css">
     body {
       background: transparent !important;
@@ -24,16 +24,21 @@
       visibility: visible;
       z-index: -1;
     }
-    </style>
+    </style>    
+    <link rel="stylesheet" type="text/css" href="display.css"></link>
     <script type="text/javascript" src="qrc:///qtwebchannel/qwebchannel.js"></script>
     <script type="text/javascript" src="reveal.js"></script>
     <script type="text/javascript" src="display.js"></script>
   </head>
+
   <body>
+    <div class="alert-container">
+      <div id="alert-background" class="bg-default"><span id="alert">Testing alerts</span></div>
+    </div>
     <div class="reveal">
-      <div id="global-background" class="slide-background present" data-loaded="true"></div>
+      <div id="global-background" class="slide-background present" data-loaded="true"></div>      
       <div class="slides"></div>
       <div class="footer"></div>
-    </div>
+    </div>    
   </body>
 </html>

=== modified file 'openlp/core/display/html/display.js'
--- openlp/core/display/html/display.js	2019-06-21 20:53:42 +0000
+++ openlp/core/display/html/display.js	2019-07-01 17:51:00 +0000
@@ -53,6 +53,50 @@
 };
 
 /**
+ * Transition state enumeration
+ */
+var TransitionState = {
+  EntranceTransition: "entranceTransition",
+  NoTransition: "noTransition",
+  ExitTransition: "exitTransition"
+};
+
+/**
+ * Animation state enumeration
+ */
+var AnimationState = {
+  NoAnimation: "noAnimation",
+  ScrollingText: "scrollingText",
+  NonScrollingText: "noScrollingText"
+};
+
+/**
+ * Alert location enumeration
+ */
+var AlertLocation = {
+  Top: 0,
+  Middle: 1,
+  Bottom: 2
+};
+
+/**
+ * Alert state enumeration
+ */
+var AlertState = {
+  Displaying: "displaying",  
+  NotDisplaying: "notDisplaying"
+}
+
+/**
+ * Alert delay enumeration 
+ */
+var AlertDelay = {
+  FiftyMilliseconds: 50,
+  OneSecond: 1000,
+  OnePointFiveSeconds: 1500
+}
+
+/**
  * Return an array of elements based on the selector query
  * @param {string} selector - The selector to find elements
  * @returns {array} An array of matching elements
@@ -234,7 +278,12 @@
  * The Display object is what we use from OpenLP
  */
 var Display = {
+  _alerts: [],
   _slides: {},
+  _alertSettings: {},
+  _alertState: AlertState.NotDisplaying,
+  _transitionState: TransitionState.NoTransition,
+  _animationState: AnimationState.NoAnimation,
   _revealConfig: {
     margin: 0.0,
     minScale: 1.0,
@@ -356,19 +405,197 @@
   /**
    * Display an alert
    * @param {string} text - The alert text
-   * @param {int} location - The location of the text (top, middle or bottom)
-  */
- alert: function (text, location) {
-  console.debug(" alert text: " + text, ", location: " + location);
-  /*
-   * The implementation should show an alert.
-   * It should be able to handle receiving a new alert before a previous one is "finished", basically queueing it.
-   */
-  return;
-},
-
-  /**
-   * Add a slides. If the slide exists but the HTML is different, update the slide.
+   * @param {string} JSON object - The settings for the alert object e.g '{"backgroundColor": "rgb(255, 85, 0)", 
+   * "location": 1, "fontFace": "Open Sans Condensed", "fontSize": 90, "fontColor": "rgb(255, 255, 255)", 
+   * "timeout": 10, "repeat": 2, "scroll": true}'
+  */
+  alert: function (text, alertSettings) {
+    var alertBackground = $('#alert-background')[0];
+    var alertText = $('#alert')[0];
+    if (text == "") {
+      return null;
+    }
+    else {
+      if (this._alertState === AlertState.Displaying) {
+        Display.addAlertToQueue(text, alert_settings);
+      }
+    }
+    var settings = JSON.parse(alertSettings);
+    this._alertSettings = settings;    
+    Display.setAlertText(text, settings.fontColor, settings.fontFace, settings.fontSize);
+    Display.setAlertLocation(settings.location);    
+    /* Check if the alert is a queued alert */
+    if (Display._alertState !== AlertState.DisplayingFromQueue) {
+      Display._alertState = AlertState.Displaying;
+    }
+    
+    alertBackground.addEventListener('transitionend', Display.alertTransitionEndEvent, false);            
+    alertText.addEventListener('animationend', Display.alertAnimationEndEvent, false);                          
+    
+    Display.showAlertBackground(settings.backgroundColor);                
+  },  
+  /**
+   * Add an alert to the alert queue 
+   * @param {string} text - The alert text to be displayed
+   * @param {string} setttings - JSON object containing the settings for the alert
+   */
+  addAlertToQueue: function (text, settings) {
+    var alertObject = {text: text, settings: settings};        
+    this._alerts.push(JSON.stringify(alertObject));
+    return null;   
+  },
+  /**
+   * Set Alert Text
+   * @param {string} text - The alert text to display
+   */
+  setAlertText: function (text, color, fontFace, fontSize) {
+    var alertText = $("#alert")[0];
+    alertText.textContent = text;
+    alertText.style.color = color;
+    alertText.style.fontFamily = fontFace;
+    alertText.style.fontSize = fontSize + "pt";
+  },      
+  /**
+   * The alertTransitionEndEvent called after a transition has ended
+   */
+  alertTransitionEndEvent: function (e) {
+    e.stopPropagation();
+    console.debug("Transition end event reached");    
+    if (Display._transitionState === TransitionState.EntranceTransition) {        
+      Display._transitionState = TransitionState.NoTransition;
+      Display.showAlertText(Display._alertSettings);      
+    }
+    else if (Display._transitionState === TransitionState.ExitTransition) {        
+      Display._transitionState = TransitionState.NoTransition;      
+      Display.removeAlertLocation(Display._alertSettings.location);
+      Display.clearAlertSettings();      
+      setTimeout(function () {        
+        Display.showNextAlert();
+      }, AlertDelay.OnePointFiveSeconds);
+              
+    }    
+  },
+  /**
+   * The alertAnimationEndEvent called after an animation has ended
+   */
+  alertAnimationEndEvent: function (e) {
+    e.stopPropagation();                     
+    Display.hideAlertText(); 
+  },
+  /**
+   * Start background entrance transition for display of alert
+   * @param {string} hex_color - The background color of the alert
+   */
+  showAlertBackground: function (bg_color) {    
+    var alertBackground = $("#alert-background")[0];              
+    alertBackground.classList.add("show-bg");      
+    alertBackground.style.backgroundColor = bg_color;            
+    this._transitionState = TransitionState.EntranceTransition;
+  },
+  /**
+   * Set the location of the alert
+   * @param {int} location - Integer number with the location of the alert on screen
+   */
+  setAlertLocation: function (location) {
+    var alertContainer = $(".alert-container")[0];
+
+    switch (location) {
+      case AlertLocation.Top:
+        alertContainer.classList.add("top");                       
+        break;
+      case AlertLocation.Middle:
+        alertContainer.classList.add("middle");                
+        break;
+      case AlertLocation.Bottom:
+      default:
+        alertContainer.classList.add("bottom");                       
+        break;
+    }    
+  },
+  /**
+   * Remove the location class set after displaying the alert
+   * @param {int} location - Integer number with the location of the alert on screen
+   */
+  removeAlertLocation: function (location) {
+    var alertContainer = $(".alert-container")[0];
+    console.debug("The value of location for removal is: " + location);
+
+    switch (location) {
+      case AlertLocation.Top:
+        alertContainer.classList.remove("top");                       
+        break;
+      case AlertLocation.Middle:
+        alertContainer.classList.remove("middle");                
+        break;
+      case AlertLocation.Bottom:
+      default:
+        alertContainer.classList.remove("bottom");                       
+        break;
+    }    
+  },
+  /**
+   * Hide the alert background after the alert has been shown
+   */
+  hideAlertBackground: function () {
+    var alertBackground = $("#alert-background")[0];
+    alertBackground.classList.remove("show-bg");        
+    this._transitionState = TransitionState.ExitTransition;
+    this._alertState = AlertState.NotDisplaying;        
+  },  
+  /**
+   * Sets the alert text styles correctly after the entrance transition has ended.
+   * @param {json} settings object - The settings to use for the animation
+   */
+  showAlertText: function (settings) {        
+    var alertText = $("#alert")[0];        
+            
+    if (settings.scroll) {      
+      var animationSettings = "alert-scrolling-text " + settings.timeout +
+                              "s linear 0.6s " + settings.repeat + " normal";       
+      alertText.style.animation = animationSettings;
+      Display._animationState = AnimationState.ScrollingText;
+    }
+    else {                
+      Display._animationState = AnimationState.NonScrollingText;       
+      alertText.classList.add("show-text");      
+      setTimeout (function () {                       
+        Display._animationState = AnimationState.NoAnimation;
+        alertText.classList.add("hide-text");
+        alertText.classList.remove("show-text");    
+        Display.hideAlertText();
+      }, settings.timeout * AlertDelay.OneSecond);
+    }                 
+  },
+  /**
+   *  Reset styling and hide the alert text after displaying the animation
+   */
+  hideAlertText: function () {
+    var alertText = $('#alert')[0];            
+    Display._animationState = AnimationState.NoAnimation;
+    alertText.style.animation = "";    
+    Display.hideAlertBackground();                                            
+  },
+  /** 
+  * Display the next alert in the queue
+  */
+  showNextAlert: function () {      
+    if (Display._alerts.length > 0) {
+      var alertObject = JSON.parse(this._alerts.shift());
+      this._alertState = AlertState.DisplayingFromQueue;
+      Display.alert(alertObject.text, alertObject.settings);    
+    } 
+    else {
+      return null;
+    }
+  },
+  /**
+   * Clears the alert settings after displaying an alert 
+   */
+  clearAlertSettings: function () {    
+    this._alertSettings = {};    
+  },
+  /**
+   * Add a slide. If the slide exists but the HTML is different, update the slide.
    * @param {string} verse - The verse number, e.g. "v1"
    * @param {string} text - The HTML for the verse, e.g. "line1<br>line2"
    * @param {string} footer_text - The HTML for the footer"

=== modified file 'openlp/core/display/window.py'
--- openlp/core/display/window.py	2019-06-21 22:09:36 +0000
+++ openlp/core/display/window.py	2019-07-01 17:51:00 +0000
@@ -397,8 +397,9 @@
         self.scale = scale
         self.run_javascript('Display.setScale({scale});'.format(scale=scale * 100))
 
-    def alert(self, text, location):
+    def alert(self, text, settings):
         """
         Set an alert
         """
-        self.run_javascript('Display.alert({text}, {location});'.format(text=text, location=location))
+        self.run_javascript('Display.alert("{text}", \'{settings}\');'.format(text=text, settings=settings))
+        # TODO: Add option to prevent scrolling

=== modified file 'openlp/plugins/alerts/alertsplugin.py'
--- openlp/plugins/alerts/alertsplugin.py	2019-04-13 13:00:22 +0000
+++ openlp/plugins/alerts/alertsplugin.py	2019-07-01 17:51:00 +0000
@@ -126,7 +126,9 @@
     'alerts/location': AlertLocation.Bottom,
     'alerts/background color': '#660000',
     'alerts/font color': '#ffffff',
-    'alerts/timeout': 5
+    'alerts/timeout': 10,
+    'alerts/repeat': 1,
+    'alerts/scroll': True
 }
 
 

=== modified file 'openlp/plugins/alerts/lib/alertsmanager.py'
--- openlp/plugins/alerts/lib/alertsmanager.py	2019-04-13 13:00:22 +0000
+++ openlp/plugins/alerts/lib/alertsmanager.py	2019-07-01 17:51:00 +0000
@@ -23,7 +23,9 @@
 The :mod:`~openlp.plugins.alerts.lib.alertsmanager` module contains the part of the plugin which manages storing and
 displaying of alerts.
 """
-from PyQt5 import QtCore
+import json
+
+from PyQt5 import QtCore, QtGui
 
 from openlp.core.common.i18n import translate
 from openlp.core.common.mixins import LogMixin, RegistryProperties
@@ -83,8 +85,26 @@
                                    not Settings().value('core/display on monitor')):
             return
         text = self.alert_list.pop(0)
-        alert_tab = self.parent().settings_tab
-        self.live_controller.displays[0].alert(text, alert_tab.location)
+
+        # Get the rgb color format of the font & background hex colors from settings
+        rgb_font_color = self.hex_to_rgb(QtGui.QColor(Settings().value('alerts/font color')))
+        rgb_background_color = self.hex_to_rgb(QtGui.QColor(Settings().value('alerts/background color')))
+
+        # Put alert settings together in dict that will be passed to Display in Javascript
+        alert_settings = {
+            'backgroundColor': rgb_background_color,
+            'location': Settings().value('alerts/location'),
+            'fontFace': Settings().value('alerts/font face'),
+            'fontSize': Settings().value('alerts/font size'),
+            'fontColor': rgb_font_color,
+            'timeout': Settings().value('alerts/timeout'),
+            'repeat': Settings().value('alerts/repeat'),
+            'scroll': Settings().value('alerts/scroll')
+        }
+        self.live_controller.displays[0].alert(text, json.dumps(alert_settings))
+        # Check to see if we have a timer running.
+        # if self.timer_id == 0:
+        #    self.timer_id = self.startTimer(int(alert_tab.timeout) * 1000)
 
     def timerEvent(self, event):
         """
@@ -98,3 +118,13 @@
         self.killTimer(self.timer_id)
         self.timer_id = 0
         self.generate_alert()
+
+    def hex_to_rgb(self, rgb_values):
+        """
+        Converts rgb color values from QColor to rgb string
+
+        :param rgb_values:
+        :return: rgb color string
+        :rtype: string
+        """
+        return "rgb(" + str(rgb_values.red()) + ", " + str(rgb_values.green()) + ", " + str(rgb_values.blue()) + ")"

=== modified file 'openlp/plugins/alerts/lib/alertstab.py'
--- openlp/plugins/alerts/lib/alertstab.py	2019-04-13 13:00:22 +0000
+++ openlp/plugins/alerts/lib/alertstab.py	2019-07-01 17:51:00 +0000
@@ -47,35 +47,56 @@
         self.font_layout.addRow(self.font_label, self.font_combo_box)
         self.font_color_label = QtWidgets.QLabel(self.font_group_box)
         self.font_color_label.setObjectName('font_color_label')
-        self.color_layout = QtWidgets.QHBoxLayout()
-        self.color_layout.setObjectName('color_layout')
         self.font_color_button = ColorButton(self.font_group_box)
         self.font_color_button.setObjectName('font_color_button')
-        self.color_layout.addWidget(self.font_color_button)
-        self.color_layout.addSpacing(20)
-        self.background_color_label = QtWidgets.QLabel(self.font_group_box)
-        self.background_color_label.setObjectName('background_color_label')
-        self.color_layout.addWidget(self.background_color_label)
-        self.background_color_button = ColorButton(self.font_group_box)
-        self.background_color_button.setObjectName('background_color_button')
-        self.color_layout.addWidget(self.background_color_button)
-        self.font_layout.addRow(self.font_color_label, self.color_layout)
+        self.font_layout.addRow(self.font_color_label, self.font_color_button)
         self.font_size_label = QtWidgets.QLabel(self.font_group_box)
         self.font_size_label.setObjectName('font_size_label')
         self.font_size_spin_box = QtWidgets.QSpinBox(self.font_group_box)
         self.font_size_spin_box.setObjectName('font_size_spin_box')
         self.font_layout.addRow(self.font_size_label, self.font_size_spin_box)
-        self.timeout_label = QtWidgets.QLabel(self.font_group_box)
+        self.left_layout.addWidget(self.font_group_box)
+        # Background Settings
+        self.background_group_box = QtWidgets.QGroupBox(self.left_column)
+        self.background_group_box.setObjectName('background_group_box')
+        self.background_layout = QtWidgets.QFormLayout(self.background_group_box)
+        self.background_layout.setObjectName('background_settings_layout')
+        self.background_color_label = QtWidgets.QLabel(self.background_group_box)
+        self.background_color_label.setObjectName('background_color_label')
+        self.background_color_button = ColorButton(self.background_group_box)
+        self.background_color_button.setObjectName('background_color_button')
+        self.background_layout.addRow(self.background_color_label,self.background_color_button)
+        self.left_layout.addWidget(self.background_group_box)
+        # Scroll Settings
+        self.scroll_group_box = QtWidgets.QGroupBox(self.left_column)
+        self.scroll_group_box.setObjectName('scroll_group_box')
+        self.scroll_group_layout = QtWidgets.QFormLayout(self.scroll_group_box)
+        self.scroll_group_layout.setObjectName('scroll_group_layout')
+        self.scroll_check_box = QtWidgets.QCheckBox(self.scroll_group_box)
+        self.scroll_check_box.setObjectName('scroll_check_box')
+        self.scroll_group_layout.addRow(self.scroll_check_box)
+        self.repeat_label = QtWidgets.QLabel(self.scroll_group_box)
+        self.repeat_label.setObjectName('repeat_label')
+        self.repeat_spin_box = QtWidgets.QSpinBox(self.scroll_group_box)
+        self.repeat_spin_box.setObjectName('repeat_spin_box')
+        self.scroll_group_layout.addRow(self.repeat_label, self.repeat_spin_box)
+        self.left_layout.addWidget(self.scroll_group_box)
+        # Other Settings
+        self.settings_group_box = QtWidgets.QGroupBox(self.left_column)
+        self.settings_group_box.setObjectName('settings_group_box')
+        self.settings_layout = QtWidgets.QFormLayout(self.settings_group_box)
+        self.settings_layout.setObjectName('settings_layout')
+        self.timeout_label = QtWidgets.QLabel(self.settings_group_box)
         self.timeout_label.setObjectName('timeout_label')
-        self.timeout_spin_box = QtWidgets.QSpinBox(self.font_group_box)
+        self.timeout_spin_box = QtWidgets.QSpinBox(self.settings_group_box)
         self.timeout_spin_box.setMaximum(180)
         self.timeout_spin_box.setObjectName('timeout_spin_box')
-        self.font_layout.addRow(self.timeout_label, self.timeout_spin_box)
+        self.settings_layout.addRow(self.timeout_label, self.timeout_spin_box)
         self.vertical_label, self.vertical_combo_box = create_valign_selection_widgets(self.font_group_box)
         self.vertical_label.setObjectName('vertical_label')
         self.vertical_combo_box.setObjectName('vertical_combo_box')
-        self.font_layout.addRow(self.vertical_label, self.vertical_combo_box)
-        self.left_layout.addWidget(self.font_group_box)
+        self.settings_layout.addRow(self.vertical_label, self.vertical_combo_box)
+        self.left_layout.addWidget(self.settings_group_box)
         self.left_layout.addStretch()
         self.preview_group_box = QtWidgets.QGroupBox(self.right_column)
         self.preview_group_box.setObjectName('preview_group_box')
@@ -92,16 +113,22 @@
         self.font_combo_box.activated.connect(self.on_font_combo_box_clicked)
         self.timeout_spin_box.valueChanged.connect(self.on_timeout_spin_box_changed)
         self.font_size_spin_box.valueChanged.connect(self.on_font_size_spin_box_changed)
+        self.repeat_spin_box.valueChanged.connect(self.on_repeat_spin_box_changed)
+        self.scroll_check_box.toggled.connect(self.scroll_check_box_toggled)
 
     def retranslate_ui(self):
-        self.font_group_box.setTitle(translate('AlertsPlugin.AlertsTab', 'Font'))
+        self.font_group_box.setTitle(translate('AlertsPlugin.AlertsTab', 'Font Settings'))
         self.font_label.setText(translate('AlertsPlugin.AlertsTab', 'Font name:'))
         self.font_color_label.setText(translate('AlertsPlugin.AlertsTab', 'Font color:'))
         self.background_color_label.setText(UiStrings().BackgroundColorColon)
         self.font_size_label.setText(translate('AlertsPlugin.AlertsTab', 'Font size:'))
         self.font_size_spin_box.setSuffix(' {unit}'.format(unit=UiStrings().FontSizePtUnit))
+        self.background_group_box.setTitle(translate('AlertsPlugin.AlertsTab', 'Background Settings'))
+        self.settings_group_box.setTitle(translate('AlertsPlugin.AlertsTab', 'Other Settings'))
         self.timeout_label.setText(translate('AlertsPlugin.AlertsTab', 'Alert timeout:'))
         self.timeout_spin_box.setSuffix(' {unit}'.format(unit=UiStrings().Seconds))
+        self.repeat_label.setText(translate('AlertsPlugin.AlertsTab', 'Repeat (no. of times):'))
+        self.scroll_check_box.setText(translate('AlertsPlugin.AlertsTab', 'Enable Scrolling'))
         self.preview_group_box.setTitle(UiStrings().Preview)
         self.font_preview.setText(UiStrings().OpenLP)
 
@@ -140,6 +167,24 @@
         self.font_size = self.font_size_spin_box.value()
         self.update_display()
 
+    def on_repeat_spin_box_changed(self):
+        """
+        The repeat spin box has changed
+        """
+        self.repeat = self.repeat_spin_box.value()
+        self.changed = True
+
+    def scroll_check_box_toggled(self):
+        """
+        The scrolling checkbox has been toggled
+        """
+        if self.scroll_check_box.isChecked():
+            self.repeat_spin_box.setEnabled(True)
+        else:
+            self.repeat_spin_box.setEnabled(False)
+        self.scroll = self.scroll_check_box.isChecked()
+        self.changed = True
+
     def load(self):
         """
         Load the settings into the UI.
@@ -152,12 +197,17 @@
         self.background_color = settings.value('background color')
         self.font_face = settings.value('font face')
         self.location = settings.value('location')
+        self.repeat = settings.value('repeat')
+        self.scroll = settings.value('scroll')
         settings.endGroup()
         self.font_size_spin_box.setValue(self.font_size)
         self.timeout_spin_box.setValue(self.timeout)
         self.font_color_button.color = self.font_color
         self.background_color_button.color = self.background_color
+        self.repeat_spin_box.setValue(self.repeat)
+        self.repeat_spin_box.setEnabled(self.scroll)
         self.vertical_combo_box.setCurrentIndex(self.location)
+        self.scroll_check_box.setChecked(self.scroll)
         font = QtGui.QFont()
         font.setFamily(self.font_face)
         self.font_combo_box.setCurrentFont(font)
@@ -181,6 +231,8 @@
         settings.setValue('timeout', self.timeout)
         self.location = self.vertical_combo_box.currentIndex()
         settings.setValue('location', self.location)
+        settings.setValue('repeat', self.repeat)
+        settings.setValue('scroll', self.scroll_check_box.isChecked())
         settings.endGroup()
         if self.changed:
             self.settings_form.register_post_process('update_display_css')

=== modified file 'package.json'
--- package.json	2019-04-13 13:00:22 +0000
+++ package.json	2019-07-01 17:51:00 +0000
@@ -15,7 +15,7 @@
     "phantomjs-prebuilt": "^2.1.16"
   },
   "scripts": {
-    "test": "karma start"
+    "test": "karma start --single-run"
   },
   "author": "OpenLP Developers",
   "license": "GPL-3.0-or-later",

=== modified file 'tests/js/test_display.js'
--- tests/js/test_display.js	2019-01-16 06:15:21 +0000
+++ tests/js/test_display.js	2019-07-01 17:51:00 +0000
@@ -18,6 +18,14 @@
   it("AudioState should exist", function () {
     expect(AudioState).toBeDefined();
   });
+
+  it("TransitionState should exist", function(){
+    expect(TransitionState).toBeDefined();
+  });
+
+  it("AnimationState should exist", function(){
+    expect(AnimationState).toBeDefined();
+  });
 });
 
 describe("The function", function () {
@@ -138,6 +146,343 @@
     Display.goToSlide("v1");
     expect(Reveal.slide).toHaveBeenCalledWith(0);
   });
+
+  it("should have an alert() method", function () {
+    expect(Display.alert).toBeDefined();
+  });
+
+});
+
+describe("Display.alert", function () {
+  var alertBackground, alertText, settings;
+
+  beforeEach(function () {
+    document.body.innerHTML = "";
+    alertContainer = document.createElement("div");
+    alertContainer.setAttribute("class", "alert-container");
+    document.body.appendChild(alertContainer);
+    alertBackground = document.createElement("div");
+    alertBackground.setAttribute("id", "alert-background");
+    alertContainer.appendChild(alertBackground);
+    alertText = document.createElement("span");
+    alertText.setAttribute("id","alert");
+    alertBackground.appendChild(alertText);
+    settings = '{ \
+      "location": 1, "fontFace": "Segoe UI, Tahoma, Geneva, Verdana, sans-serif", \
+      "fontSize": 40, "fontColor": "#ffffff", "backgroundColor": "#660000", \
+      "timeout": 5, "repeat": 1, "scroll": true \
+    }';
+  });
+
+  it("should return null if called without any text", function () {
+    expect(Display.alert("", settings)).toBeNull();
+  });
+
+  it("should set the correct alert text", function () {
+    spyOn(Display, "setAlertText");
+    spyOn(Display, "setAlertLocation");     
+    Display.alert("OPEN-LP-3.0 Alert Test", settings);
+        
+    expect(Display.setAlertText).toHaveBeenCalled();
+    expect(Display.setAlertLocation).toHaveBeenCalled();
+  });
+
+  it("should call the addAlertToQueue method if an alert is displaying", function () {
+    spyOn(Display, "addAlertToQueue");    
+    Display._alerts = [];
+    Display._alertState = AlertState.Displaying;
+    var text = "Testing alert queue";
+    
+    Display.alert(text, settings);
+    
+    expect(Display.addAlertToQueue).toHaveBeenCalledWith(text, settings);
+  });
+
+  it("should set the alert settings correctly", function() {    
+    Display.alert("Testing settings", settings);
+        
+    expect(Display._alertSettings).toEqual(JSON.parse(settings));
+  });
+});
+
+describe("Display.showAlertBackground", function () {
+
+  var alertBackground, bg_color;
+  beforeEach(function () {
+    document.body.innerHTML = "";        
+    bg_color = "rgb(102, 0, 0)";
+    alertBackground = document.createElement("div");
+    alertBackground.setAttribute("id", "alert-background");
+    alertBackground.setAttribute("class", "bg-default");    
+    document.body.appendChild(alertBackground);           
+  });
+
+  it("should set the correct transition state", function () {
+    Display.showAlertBackground(bg_color);
+    expect(Display._transitionState).toEqual(TransitionState.EntranceTransition);
+  });
+
+  it("should apply the styles correctly when showAlertBackground is called", function () {
+    Display.showAlertBackground(bg_color);    
+         
+    expect(alertBackground.style.backgroundColor).toEqual(bg_color);      
+    expect(alertBackground.className).toEqual("bg-default show-bg");                     
+  });  
+});
+
+describe("Display.hideAlertBackground", function () {
+  var alertBackground;
+  beforeEach( function() {
+    document.body.innerHTML = "";
+    alertBackground = document.createElement("div");
+    alertBackground.setAttribute("id", "alert-background"); 
+    alertBackground.setAttribute("class", "bg-default show-bg");     
+    document.body.appendChild(alertBackground);    
+  });
+  
+  it("reset the background to default once an alert has been displayed", function() {    
+    Display.hideAlertBackground();
+    
+    expect(Display._transitionState).toEqual(TransitionState.ExitTransition);
+    expect(Display._alertState).toEqual(AlertState.NotDisplaying);          
+    expect(alertBackground.className).toEqual("bg-default");    
+  });
+});
+
+describe("Display.setAlertText", function() {
+  var alertText;
+  beforeEach( function() {
+    document.body.innerHTML = "";
+    alertText = document.createElement("span");
+    alertText.setAttribute("id", "alert");      
+    document.body.appendChild(alertText);      
+  });
+  it("should set the alert text correctly", function () {    
+    Display.setAlertText("OpenLP Alert Text", "#ffffff", "Tahoma", 40);
+    
+    expect(alertText.textContent).toEqual("OpenLP Alert Text");
+    expect(alertText.style.color).toEqual("rgb(255, 255, 255)");
+    expect(alertText.style.fontFamily).toEqual("Tahoma");
+    expect(alertText.style.fontSize).toEqual("40pt");
+  });
+});
+
+describe("Display.setAlertLocation", function() {
+  beforeEach(function() {
+    document.body.innerHTML = "";    
+    alertContainer = document.createElement("div");
+    alertContainer.setAttribute("class", "alert-container");
+    document.body.appendChild(alertContainer);    
+  });
+  it("should set the correct class when location is top of the page", function () {    
+    Display.setAlertLocation(0);
+
+    expect(alertContainer.className).toEqual("alert-container top");
+  });
+
+  it("should set the correct class when location is middle of the page", function () {    
+    Display.setAlertLocation(1);
+    
+    expect(alertContainer.className).toEqual("alert-container middle");       
+  });
+
+  it("should set the correct class when location is bottom of the page", function () {
+    Display.setAlertLocation(2);
+
+    expect(alertContainer.className).toEqual("alert-container bottom");
+  });
+});
+
+describe("Display.removeAlertLocation", function () {
+  beforeEach(function() {
+    document.body.innerHTML = "";    
+    alertContainer = document.createElement("div");
+    alertContainer.setAttribute("class", "alert-container");
+    document.body.appendChild(alertContainer);
+  });
+  it("should remove the correct class when location is top of the page", function () {
+    alertContainer.classList.add("top");
+    Display.removeAlertLocation(0);
+
+    expect(alertContainer.className).toEqual("alert-container");
+  });
+
+  it("should remove the correct class when location is middle of the page", function () {
+    alertContainer.classList.add("middle");
+    Display.removeAlertLocation(1);
+    
+    expect(alertContainer.className).toEqual("alert-container");       
+  });
+
+  it("should remove the correct class when location is bottom of the page", function () {
+    alertContainer.classList.add("bottom");
+    Display.removeAlertLocation(2);
+
+    expect(alertContainer.className).toEqual("alert-container");
+  });
+});
+
+describe("Display.showAlertText", function () {
+  var alertText, settings;
+  beforeEach(function () {
+    document.body.innerHTML = "";    
+    alertText = document.createElement("span");
+    alertText.setAttribute("id", "alert");
+    document.body.appendChild(alertText);
+    settings = {
+      "location": 2, "fontFace": "Tahoma", "fontSize": 40, 
+      "fontColor": "rgb(255, 255, 255)", "backgroundColor": "rgb(102, 0, 0)",
+      "timeout": 0.01, "repeat": 1, "scroll": true
+    };
+    Display._transitionState = TransitionState.EntranceTransition;
+  });  
+
+  it("should set the correct animation when text is set to scroll)", function () {              
+    Display.showAlertText(settings);
+
+    expect(alertText.style.animation).toEqual("alert-scrolling-text " + settings.timeout + "s linear 0.6s 1 normal");
+    expect(Display._animationState).toEqual(AnimationState.ScrollingText);    
+  });
+
+  it("should set the correct styles when text is not scrolling", function (done) {
+    settings.scroll = false;       
+    Display._transitionState = TransitionState.EntranceTransition;   
+    spyOn(Display, "hideAlertText");    
+    Display.showAlertText(settings);
+   
+    // expect(alertText.style.animation).toEqual("");
+    expect(Display._animationState).toEqual(AnimationState.NonScrollingText);
+    expect(alertText.classList.contains('show-text')).toBe(true);
+    setTimeout (function () {      
+      expect(Display._animationState).toEqual(AnimationState.NoAnimation);
+      expect(Display.hideAlertText).toHaveBeenCalled();      
+      done();
+    }, settings.timeout * 1000);
+  });
+});
+
+describe("Display.hideAlertText", function() {
+  var alertBackground, alertText, keyframeStyle;
+  beforeEach(function () {
+    document.body.innerHTML = "";
+    alertBackground = document.createElement("div");
+    alertBackground.setAttribute("id", "alert-background"); 
+    alertBackground.setAttribute("class", "bg-default show-bg");     
+    document.body.appendChild(alertBackground);    
+    alertText = document.createElement("span");
+    alertText.setAttribute("id", "alert");
+    alertText.style.opacity = 1;
+    alertText.style.animation = "alert-scrolling-text 5s linear 0s 1 bg-default";
+    alertBackground.appendChild(alertText);        
+    Display._animationState = AnimationState.ScrollingText;
+  });
+
+  it("should reset the text styles and animation state after the text has scrolled", function() {    
+    spyOn(Display, "hideAlertBackground");    
+    Display.hideAlertText();
+
+    expect(alertText.style.animation).toEqual("");        
+    expect(Display._animationState).toEqual(AnimationState.NoAnimation);
+  });
+
+  it("should call the hideAlertBackground method", function() {          
+    spyOn(Display, "hideAlertBackground");
+    Display.hideAlertText();
+
+
+    expect(Display.hideAlertBackground).toHaveBeenCalled();    
+  });  
+});
+
+describe("Display.addAlertToQueue", function () {  
+  it("should add an alert to the queue if one is displaying already", function() {
+    Display._alerts = [];
+    Display._alertState = AlertState.Displaying;
+    settings = '{ \
+      "location": 1, "fontFace": "Segoe UI, Tahoma, Geneva, Verdana, sans-serif", \
+      "fontSize": 40, "fontColor": "#ffffff", "backgroundColor": "#660000", \
+      "timeout": 5, "repeat": 1, "scrolling_text": true \
+    }';
+    var alertObject = {text: "Testing alert queue", settings: settings};
+    var queuedAlert = JSON.stringify(alertObject);    
+    
+    Display.addAlertToQueue("Testing alert queue", settings);
+
+    expect(Display._alerts.length).toEqual(1);
+    expect(Display._alerts[0]).toEqual(queuedAlert);
+  });
+});
+
+describe("Display.showNextAlert", function () {
+  Display.showNextAlert();
+
+  it("should return null if there are no alerts in the queue", function () {
+    Display._alerts = [];
+    Display.showNextAlert();
+
+    expect(Display.showNextAlert()).toBeNull();
+  });
+
+  it("should call the alert function correctly if there is an alert in the queue", function () {    
+    var settings = {
+      "location": 2, "fontFace": "Tahoma", "fontSize": 40, 
+      "fontColor": "rgb(255, 255, 255)", "backgroundColor": "rgb(102, 0, 0)",
+      "timeout": 5, "repeat": 1, "scrolling_text": true
+    };
+    var alertObject = {text: "Queued Alert", settings: settings};
+    Display._alerts.push(JSON.stringify(alertObject));
+    spyOn(Display, "alert");    
+    Display.showNextAlert();
+    
+    expect(Display.alert).toHaveBeenCalled();
+    expect(Display.alert).toHaveBeenCalledWith("Queued Alert",alertObject.settings);
+  });
+});
+
+describe("Display.alertTransitionEndEvent", function() {
+  beforeEach( function() {
+    
+  });
+
+  it("should set the correct state and call showAlertText after the alert entrance transition", function() {    
+    var fake_settings = {test: "fake_settings"};
+    var e = jasmine.createSpyObj('e', ['stopPropagation']);  
+    Display._alertSettings = fake_settings;
+    spyOn(Display, "showAlertText");    
+    Display._transitionState = TransitionState.EntranceTransition;    
+    Display.alertTransitionEndEvent();
+
+    expect(Display._transitionState).toEqual(TransitionState.NoTransition);
+    expect(Display.showAlertText).toHaveBeenCalledWith(fake_settings);
+  });
+  
+  it("should set the correct state after the alert exit transition", function() {        
+    spyOn(Display, "showNextAlert");    
+    Display._transitionState = TransitionState.ExitTransition;    
+    Display.alertTransitionEndEvent();
+    
+    expect(Display._transitionState).toEqual(TransitionState.NoTransition);    
+  });
+});
+
+describe("Display.alertAnimationEndEvent", function () {
+  it("should call the hideAlertText method", function() {
+    spyOn(Display, "hideAlertText");
+
+    Display.alertAnimationEndEvent();
+
+    expect(Display.hideAlertText).toHaveBeenCalled();
+  });
+});
+
+describe("Display.clearAlertSettings", function () {  
+  it("should clear the alert settings once an alert has been displayed", function () {
+    var fake_settings = {test: "fake_settings"};  
+    Display._alertSettings = fake_settings;
+    Display.clearAlertSettings();
+
+    expect(Display._alertSettings).toEqual({});
+  });
 });
 
 describe("Display.addTextSlide", function () {
@@ -249,6 +594,7 @@
     };
 
     spyOn(Display, "reinit");
+    spyOn(Reveal, "slide");    
 
     Display.setTextSlides(slides);
     Display.setTheme(theme);


Follow ups