diff --git a/.cfg-dist/categories.tsv b/.cfg-dist/categories.tsv
new file mode 100755
index 0000000..08bd84c
--- /dev/null
+++ b/.cfg-dist/categories.tsv
@@ -0,0 +1,4 @@
+intro Introduction
+lastshop Last Shop
+items Items
+closing Closing
\ No newline at end of file
diff --git a/.cfg-dist/loading.tsv b/.cfg-dist/loading.tsv
new file mode 100755
index 0000000..feb3dc6
--- /dev/null
+++ b/.cfg-dist/loading.tsv
@@ -0,0 +1,2 @@
+1 Just a minute
+2 I'm looking up fruit and vegetables
\ No newline at end of file
diff --git a/.cfg-dist/log.tsv b/.cfg-dist/log.tsv
new file mode 100755
index 0000000..637ccc6
--- /dev/null
+++ b/.cfg-dist/log.tsv
@@ -0,0 +1,2 @@
+1 Participant coughs
+2 Participant laughs
\ No newline at end of file
diff --git a/.cfg-dist/messages.tsv b/.cfg-dist/messages.tsv
new file mode 100755
index 0000000..9e76e0f
--- /dev/null
+++ b/.cfg-dist/messages.tsv
@@ -0,0 +1,26 @@
+h1 intro Hello Hello, my name is Gerry and thank you for coming in today.
+h2 intro What's your name What’s your name?
+h3 intro Great them Great! Nice to meet you, [name*]!
+h4 intro Introduce study For this study, I want to know about the sorts of fresh fruit and vegetables you buy, and how you describe them.
+h5 intro About the information they provide The information you tell me will be used to understand more about how people pick and choose their fruit and vegetables.
+h6 intro Question if it makes sense Does this make sense?
+ls1 lastshop Last time you bought So, first of all, tell me about the last time you bought some fresh fruit or vegetables. When was it?
+ls2 lastshop What did you buy? What did you buy?
+i1 items How did you buy? How many did you buy?
+i2 items Why did you buy? Why did you buy them?
+i3 items Loose or multipack? Do you prefer to pick loose [name*] or a multipack of them?
+i4 items Buy often? Do you buy them often?
+i5 items Always buy the same number? Do you always buy the same number of [name*]?
+i6 items Supermarket or greengrocer? Do buy them in the supermarket or a green grocer?
+i7 items Weekly shop? Is that while you do your weekly shop?
+i8 items How you chose some? Can you describe how you chose [name*]?
+i9 items Handle before picking up? Did you handle them before picking it up?
+i10 items Different varieties? Did you look at different varieties?
+i11 items What's good? Describe it. What’s a good [name]? Describe it for me.
+i12 items Size? Is bigger better, or smaller?
+i13 items What colour? What about the colour?
+i14 items Why do you prefer characteristic? Why do you prefer [characteristic]?
+i15 items Do you smell/feel? Do you smell them or feel their texture?
+i16 items Feel good? Tell me what makes a [name] feel good?
+i17 items Different smell? Is there a different in smell?
+c1 closing Goodbye Great! Goodbye [name$].
\ No newline at end of file
diff --git a/.cfg-dist/settings.cfg b/.cfg-dist/settings.cfg
new file mode 100755
index 0000000..96c1b76
--- /dev/null
+++ b/.cfg-dist/settings.cfg
@@ -0,0 +1,115 @@
+
+
+
+[MVUI]
+
+# Title of the Mobile VUI window
+window_title: Gerry, the Green Grocer
+
+# VUI UI
+background_colour: #000000
+
+# VUI UI text decoration
+typeface: Helvetica
+font_size: 32
+text_colour: #ffffff
+
+# Initial text on the Mobile VUI window
+initial_text: Welcome to Gerry, the Virtual Green Grocer
Please wait a moment...
+
+; Size of the orb itself
+orb_size: 120
+
+; Maximum size of the animations around the orb, including the orb
+orb_size_max: 240
+
+; Enable fluttering when listening (uses a mic input)
+orb_enable_flutter: True
+
+; Size of the orb's border
+orb_width: 10
+
+; Colour of the orb when the VUI is resting
+orb_nothing: #223247
+
+; Colour of the orb when the VUI is listening
+orb_listening: #513469
+
+; Colour of the orb when the VUI is computing
+orb_computing: #e1e1e1
+
+; Colour of the orb when the VUI is speaking
+orb_speaking: #AB3F7D
+
+; Colour of the orb flutter
+orb_flutter_colour: #49515C
+
+
+
+[ActiveMQ]
+
+# ActiveMQ/STOMP server
+host: localhost
+
+# ActiveMQ/STOMP port
+port: 61613
+
+# ActiveMQ/STOMP username
+username: admin
+
+# ActiveMQ/STOMP password
+password: password
+
+# ActiveMQ/STOMP queue where NottReal will listen for status updates
+nottreal_queue: /queue/nottreal
+
+# ActiveMQ/STOMP queue where the Wizard's messages will be sent
+destination_queue: /queue/voicesynth
+
+# ActiveMQ/STOMP message format
+message_text: message: %%s
+
+# Message sent to interrupt any output
+message_interrupt: interrupt
+
+# Default format for messages about on the state NottReal should be in
+message_state: state: %%s
+
+# String for the nothing state messages sent to NottReal (substituted into message_text)
+message_state_nothing: nothing
+
+# String for the speaking state messages sent to NottReal (substituted into message_text)
+message_state_speaking: speaking
+
+# String for the listening state messages sent to NottReal (substituted into message_text)
+message_state_listening: listening
+
+# String for the computing state messages sent to NottReal (substituted into message_text)
+message_state_computing: computing
+
+
+
+[VoiceCerevoice]
+
+# Command to speak something (e.g. ./run_aria_tts.sh on macoS/Linux, run_arias_tts.bat on Windows)
+command_speak: ./run_aria_tts.sh "%%s"
+
+# Interrupt command (e.g. pkill -f run_arias_tts.sh on macOS/Linux, taskkill /F /IM run_arias_tts.bat on Windows)
+command_interrupt: pkill -f run_arias_tts.sh
+
+
+
+[VoiceMacOS]
+
+# Options for the macOS voice
+command_options: -vserena
+
+
+
+[VoiceShellCmd]
+
+# Command to speak something
+command_speak: say "%%s"
+
+# Command to cancel current voice output
+command_interrupt: killall say
diff --git a/.gitignore b/.gitignore
new file mode 100755
index 0000000..377f776
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,111 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+.hypothesis/
+.pytest_cache/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# pyenv
+.python-version
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+
+# NottReal
+.DS_Store
+*/.DS_Store
+cerevoice/
+data/
+cfg/
diff --git a/LICENCE b/LICENCE
new file mode 100644
index 0000000..339fdc3
--- /dev/null
+++ b/LICENCE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 Martin Porcheron and Mixed Reality Laboratory
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100755
index 0000000..5010a13
--- /dev/null
+++ b/README.md
@@ -0,0 +1,86 @@
+
+ NottReal
+
+
+A Python application for running Wizard of Oz studies with a voice-based user interface.
+
+## Dependencies
+NottReal requires Python 3 and some dependencies, run `pip3` in the root directory:
+
+ pip install -r requirements.tx
+
+## Running
+Run the code by calling `python3 nottreal.py`, with the various options accessible with `-h` option. Summarily:
+
+* Logging is controlled by using the `-l` option, with the levels `WARNING`, `DEBUG`, `ERROR`, `INFO`, `CRITICAL`
+
+* The configuration directory can be set using the `-c` option. A sample configuration directory is in `.cfg-dist`. Create a copy of this directory and use the option to specify the location.
+
+ If you put your configuration in a directory called `cfg`, you do not need to set this option.
+
+* NottReal can log all 'spoken' output to TSB files in a specified directory, where a file is created each time the application is run. Point NottReal to this directory with the `-d` option.
+
+* Multiple voices are supported and can be set using the `-v` option followed by the chosen system. Built in choices are `ShellCmd`, `macOS`, `activeMQ`, `cerevoice`, and `outputToLog` (most have configuration in `settings.cfg`).
+
+* Multiple output windows are supported, although only `MVUIWindow` is implemented. This window looks a bit like a Mobile VUI). Set this to open automatically with the `-o` option, e.g.` -oMVUIWindow`.
+
+
+
+## App layout and configuration
+Copy the contents of the `.cfg_dist` directory to a new directory (e.g. `cfg`). The application doesn't load configuration files from `cfg_dist`---these are only accessed if no configuration is specified.
+
+In summary, the NottReal *Wizard Window* UI has the following features:
+ a) A *textbox* to type messages to send to participant
+ b) A list of queued *messages*
+ c) A list of previously sent *messages*
+ d) A list of categories of *prepared messages*
+ e) A list of *prepared messages*
+ f) A list of previously filled *slots*
+ g) A list of optional *log-only messages*
+ h) A list of *loading messages* to display on the Wizard window
+ g) A list of options, dependant upon the voice used
+
+NottReal can store all sent messages in a timestamped log. You can configure this by passing in a directory's path to the `-d` option.
+
+There is a fake mobile voice user interface view that can be enabled from the menu (or opened by default using the `-w` option). The configuration for this is in the `settings.cfg` file.
+
+In more detail, the application includes a *textbox* to type messages to send to the participant. If a previous message is being spoken when another message is sent, it will be queued up. Previously uttered/delivered messages are displayed at the bottom of the UI.
+
+NottReal also includes *prepared messages* that can be quickly sent to the participant through the simulated voice. These messages are categorised and presented as tabs in the UI. The categories can be configured in the file `categorises.tsv` in the configuration directory and consist of a unique category ID and the label in a tab-separated format:
+
+ unique_cat_1 Category 1
+ unique_cat_2 Category 1
+ unique_cat_3 Category 1
+ unique_cat_4 Category 1
+
+You must have at least once category, thus if you do not wish to use this feature, simply leave a placeholder category in `cfg/categorises.tsv`, e.g.:
+
+ category Prepared messages
+
+Prepared messages are words that will be sent to the voice simulator on being double clicked. They are defined in `messages.tsv` in the configuration directory as a unique message ID, a category ID, a title and the text:
+
+ unique_message_1 unique_cat_1 Title Prepared message to be sent to the participant
+
+Prepared messages can also contain *slots*, which are segments of text to be replaced at run-time by the Wizard. For example, part of a message may include words uttered by a participant. Slots are defined as a name within square brackets:
+
+ unique_message_2 unique_cat_1 Title 2 Prepared message to be sent to the participant with a [slot]
+
+When you double click a message with a slot, the UI will place the message in the *textbox* and automatically highlight it so that it can be edited quickly. Subsituted slot values are displayed in the UI also. If there are multiple slots, pressing either the `enter` or `tab` key will move to the next slot. Press `ctrl` + `enter` will send a message if there are no slots left.
+
+Slots can use a previously substituted value automatically using the asterisk at the end of its name:
+
+ unique_message_3 unique_cat_1 Title 3 Prepared message to be sent to the participant with a [slot*]
+
+On the first use of this message, the Wizard will have to type a value for the slot. On successive double-clicks of this message, NottReal will automatically substitue the value. There is an option to reset this tracking on a category change. Alternatively, if a particular should cancel tracking for that particular slot, it can use a dollar at the end of its name:
+
+ unique_message_4 unique_cat_1 Title 4 Prepared message to be sent to the participant with a [slot$]
+
+The window also includes a dropdown list of messages to be sent to the voice simulator. These automatically show the loading animation during and after being sent. They are specified in `loading.tsv`, consisting of a unique ID and the message:
+
+ unique_loading_message_1 Message text
+
+The window contains messages that can be added to the log data only (e.g. if you wanted to record certain events occuring without simulating a voice):
+
+ unique_log_message_1 Log message
+
+Finally, to interrupt the currently delivered output, press `Ctrl` + `c` (or `Cmd` + `c` on macOS). Optionally (and by default), this clears queued messages too.
diff --git a/nottreal.py b/nottreal.py
new file mode 100755
index 0000000..b0c5ab7
--- /dev/null
+++ b/nottreal.py
@@ -0,0 +1,19 @@
+#!/usr/bin/env python
+
+"""
+NottReal — An application for running Wizard of Oz studies with a
+simulated voice user interface.
+"""
+
+from src import nottreal
+
+__author__ = 'Martin Porcheron'
+__copyright__ = 'Copyright 2020, Martin Porcheron and Mixed Reality Lab'
+__credits__ = ['Martin Porcheron']
+__license__ = 'MIT'
+__version__ = '1.0.0'
+__maintainer__ = 'Martin Porcheron'
+__email__ = 'martin+nottreal@porcheron.uk'
+__status__ = 'Development'
+
+nottreal.main()
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..0eb270b
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,5 @@
+numpy>=1.16.5
+python-gettext>=3.0
+PyAudio>=0.2.11
+PySide2>=5.12
+sounddevice>=0.3.15
\ No newline at end of file
diff --git a/setup.py b/setup.py
new file mode 100755
index 0000000..27838bc
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python
+
+"""
+NottReal — An application for running Wizard of Oz studies with a
+simulated voice user interface.
+"""
+
+from setuptools import setup, find_packages
+
+setup(
+ name='NottReal',
+ version='1.0.0',
+ description=('An application for running Wizard of Oz studies with '
+ 'a simulated voice user interface.'),
+ url='http://github.com/mporcheron/nottreal/',
+ author='Martin Porcheron',
+ author_email='martin+nottreal@porcheron.uk',
+ classifiers=[
+ 'Development Status :: 4 - Beta',
+ 'Intended Audience :: Developers',
+ 'License :: OSI Approved :: MIT License',
+ 'Programming Language :: Python :: 3 :: Only'],
+ keywords='voice user interfaces vuis wizard of oz woz',
+ package_dir={'': 'src'},
+ packages=find_packages(where='src'),
+ python_requires='>=3.5, <4',
+ install_requires=['numpy','python-gettext', 'pyaudio', 'PySide2','sounddevice'],
+ package_data={
+ 'sample': ['cfg_dist/categories.tsv',
+ 'cfg_dist/log.tsv',
+ 'cfg_dist/loading.tsv',
+ 'cfg_dist/messages.tsv',
+ 'cfg_dist/settings.cfg']
+ },
+ entry_points={
+ 'console_scripts': [
+ 'run = nottreal:main',
+ ],
+ }
+)
+
diff --git a/src/nottreal/__init__.py b/src/nottreal/__init__.py
new file mode 100755
index 0000000..4644e52
--- /dev/null
+++ b/src/nottreal/__init__.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python
+
+from .utils.init import *
+from .nottreal import *
+from argparse import ArgumentParser
+import os, glob, gettext
+
+modules = glob.glob(os.path.join(os.path.dirname(__file__), "*.py"))
+__all__ = [os.path.basename(f)[:-3]
+ for f in modules if not f.endswith("__init__.py")]
+
+
+def main():
+ """
+ Entry point for the application. Checks the command line arguments,
+ validates the configuration, and starts the GUI application.
+ """
+
+ gettext.bindtextdomain('nottreal', 'locale')
+ gettext.install('nottreal')
+
+ parser = ArgumentParser(prog='NottReal')
+ parser.add_argument('-l', '--log',
+ choices={'DEBUG','INFO','WARNING','ERROR','CRITICAL'},
+ default='INFO',
+ help='Minimum level of log output.')
+ parser.add_argument('-c', '--config_dir',
+ default='cfg',
+ type=ArgparseUtils.dir_contains_config,
+ help='Directory containing the configuration files')
+ parser.add_argument('-d', '--output_dir',
+ default=None,
+ type=ArgparseUtils.dir_is_writeable,
+ help='Directory to dump logs from spoken text (disabled by default)')
+ parser.add_argument('-v', '--voice',
+ default='outputToLog',
+ help='Built-in voice synthesis library to use')
+ parser.add_argument('-o', '--output_win',
+ default='disabled',
+ help='Show an output window on opening')
+ parser.add_argument('-dev', '--dev',
+ action='store_true',
+ help='Enable developer mode/disable catching of errors')
+ args = parser.parse_args()
+
+ Logger.init(getattr(Logger, args.log))
+ Logger.info(__name__, "Hello, World")
+
+ nottreal.App(args)
+
+ Logger.info(__name__, "Goodbye, World")
+ sys.exit(0)
+
\ No newline at end of file
diff --git a/src/nottreal/controllers/__init__.py b/src/nottreal/controllers/__init__.py
new file mode 100755
index 0000000..e8ea234
--- /dev/null
+++ b/src/nottreal/controllers/__init__.py
@@ -0,0 +1,5 @@
+import glob, os
+
+modules = glob.glob(os.path.join(os.path.dirname(__file__), "*.py"))
+__all__ = [os.path.basename(f)[:-3]
+ for f in modules if not f.endswith("__init__.py")]
\ No newline at end of file
diff --git a/src/nottreal/controllers/c_abstract.py b/src/nottreal/controllers/c_abstract.py
new file mode 100755
index 0000000..cff7e14
--- /dev/null
+++ b/src/nottreal/controllers/c_abstract.py
@@ -0,0 +1,62 @@
+
+import abc
+
+class AbstractController:
+ def __init__(self, nottreal, args):
+ """
+ Abstract controller class. All controllers should inherit
+ this class
+
+ Arguments:
+ nottreal {App} -- Application instance
+ args {[str]} -- Application arguments
+ """
+ self.nottreal = nottreal
+ self.args = args
+
+ self.responder = self.nottreal.responder
+ self.router = self.nottreal.router
+
+ @abc.abstractmethod
+ def respond_to(self):
+ """
+ Label of the controller this class will respond to. Note
+ that multiple controllers can have the same label, but the
+ last controller to be instantiated wins
+
+ Alternatively can be a list of labels.
+
+ Decorators:
+ abc.abstractmethod
+ Returns:
+ str/[str] -- Label(s) for this controller
+ """
+ pass
+
+ def relinquish(self, instance):
+ """
+ Relinquish control over signals destined for this
+ controller to the controller?
+
+ Arguments:
+ instance {AbstractController} -- Controller that has
+ said it wants to be a responder for the same signals
+ as this controller.
+ Returns:
+ {bool} -- True if it's OK to relinquish control
+ """
+ return False
+
+ def ready(self):
+ """
+ Run any additional commands once all controllers are ready
+ (allows for running cross-controller hooks)
+ """
+ pass
+
+ @abc.abstractmethod
+ def quit(self):
+ """
+ Make any necessary arrangements to quit the app now
+ """
+ pass
diff --git a/src/nottreal/controllers/c_data.py b/src/nottreal/controllers/c_data.py
new file mode 100755
index 0000000..ae0f6ee
--- /dev/null
+++ b/src/nottreal/controllers/c_data.py
@@ -0,0 +1,124 @@
+
+from ..utils.log import Logger
+from .c_abstract import AbstractController
+
+from datetime import datetime
+
+import os
+
+class DataRecorderController(AbstractController):
+ """
+ Class to record messages sent to the user
+
+ Extends:
+ AbstractController
+
+ Variables:
+ TIMESTAMP_FORMAT {str} -- Timestamp for files and inside the log
+ FILE_PREFIX {str} -- Filename prefix
+ FILE_EXT {str} -- Filename suffix
+ """
+ TIMESTAMP_FORMAT = '%Y-%m-%d %H.%M.%S'
+ FILE_PREFIX = 'log-'
+ FILE_EXT = '.txt'
+
+ def __init__(self, nottreal, args):
+ """
+ Controller to record messages sent to the users
+
+ Arguments:
+ nottreal {App} -- Application instance
+ args {[str]} -- Application arguments
+ """
+ super().__init__(nottreal, args)
+
+ if args.output_dir:
+ self._dir = args.output_dir
+ timestamp = datetime.now().strftime(self.TIMESTAMP_FORMAT)
+ path = '%s%s%s' % (self.FILE_PREFIX, timestamp, self.FILE_EXT)
+ self._filepath = os.path.join(self._dir, path)
+
+ try:
+ self._file = open(self._filepath, mode='a')
+ if self._file:
+ self._enable = True
+ Logger.info(
+ __name__,
+ 'Messages will be recorded to "%s"' % self._filepath)
+ except IOError:
+ self._enable = False
+ Logger.critical(
+ __name__,
+ 'Failed to open "%s" to record messages' % self._filepath)
+ else:
+ self._enable = False
+ Logger.info(
+ __name__,
+ 'Disable recording of messages to the data log')
+
+ def quit(self):
+ """
+ Close and quit the data recorder if it still exists
+ """
+ if self._enable and self._file:
+ self._file.close()
+
+ def respond_to(self):
+ """
+ This class will handle 'data' commands only.
+
+ Returns:
+ str -- Label for this controller
+ """
+ return 'data'
+
+ def custom_event(self, id, text):
+ """
+ Record a custom event to the log
+
+ Arguments:
+ text {str} -- Text spoken
+ """
+ if self._enable:
+ Logger.debug(
+ __name__,
+ 'Log event for "%s" with message "%s"' % (id, text))
+
+ timestamp = datetime.now().strftime(self.TIMESTAMP_FORMAT)
+ print(
+ '%s\t_Event\t%s\t\t%s' % (timestamp, id, text),
+ file=self._file,
+ flush=True)
+
+ def raw_text(self, text):
+ """
+ Record some text being spoken to the data log
+
+ Arguments:
+ text {str} -- Text spoken
+ """
+ if self._enable:
+ timestamp = datetime.now().strftime(self.TIMESTAMP_FORMAT)
+ print(
+ '%s\t\t\t\t%s' % (timestamp, text),
+ file=self._file,
+ flush=True)
+
+ def prepared_text(self, text, cat, id, slots):
+ """
+ Record some text being spoken to the data log that was from a
+ prepared message
+
+ Arguments:
+ text {str} -- Text spoken
+ cat {int} -- Category ID of the prepared message
+ id {int} -- ID of the prepared message
+ slots {dict(str,str)} -- Slots changed by the user
+ """
+ if self._enable:
+ timestamp = datetime.now().strftime(self.TIMESTAMP_FORMAT)
+ print(
+ '%s\t%s\t%s\t%s\t%s' % (timestamp, cat, id, slots, text),
+ file=self._file,
+ flush=True)
+
\ No newline at end of file
diff --git a/src/nottreal/controllers/c_output.py b/src/nottreal/controllers/c_output.py
new file mode 100755
index 0000000..df6f5c2
--- /dev/null
+++ b/src/nottreal/controllers/c_output.py
@@ -0,0 +1,114 @@
+
+from ..utils.log import Logger
+from ..models.m_mvc import VUIState
+from .c_abstract import AbstractController
+
+import abc, string
+
+class OutputController(AbstractController):
+ """
+ Create the controller for the output views.
+
+ Extends:
+ AbstractController
+
+ """
+ def __init__(self, nottreal, args):
+ """
+ Controller to manage the MVUI window that can be displayed to
+ the "user" of the system (i.e. a view that makes this look
+ like a real Mobile VUI assistant).
+
+ Arguments:
+ nottreal {App} -- Application instance
+ args {[str]} -- Application arguments
+ """
+ super().__init__(nottreal, args)
+
+ def ready(self):
+ if self.args.output_win and self.args.output_win != 'disabled':
+ self.toggle_show(self.args.output_win)
+
+ def respond_to(self):
+ """
+ This class will handle "output" commands
+
+ Returns:
+ str -- Label for this controller
+ """
+ return 'output'
+
+ def toggle_show(self, output):
+ """
+ Toggle the display of an output window
+
+ Arguments:
+ output {str} -- Name of the output to show
+ """
+ try:
+ self.nottreal.view.output[output.lower()].toggle_visibility()
+ except KeyError:
+ Logger.error(__name__, 'No output view "%s"' % output)
+
+ def toggle_maximise(self, output):
+ """
+ Toggle the fullscreen display the window if it's visible
+
+ Arguments:
+ output {str} -- Name of the output to toggle
+ """
+ try:
+ self.nottreal.view.output[output.lower()].toggle_fullscreen()
+ except KeyError:
+ Logger.error(__name__, 'No output view "%s"' % output)
+
+ def now_speaking(self, text = None, orb = 1):
+ """
+ Show text in the window (if it's visible) while it's been spoken
+
+ Appending is not implemented!
+
+ Keyword Arguments:
+ text {str} -- Text to show (default: None}
+ orb {int} - Orb ID, 0 = rest, 1 = speak, 2 = listen, 3 = busy (default: 1)
+ """
+ for output in self.nottreal.view.output.values():
+ if output.is_visible():
+ output.set_state(orb)
+ output.set_message(text)
+
+ def now_resting(self):
+ """
+ Show that the system is no longer speaking in the MVUI window, nor
+ is it doing anything else (if it's visible).
+ """
+ for output in self.nottreal.view.output.values():
+ if output.is_visible():
+ output.set_state(VUIState.NOTHING)
+
+ def now_listening(self):
+ """
+ Show that the user is currently being listened to in the MVUI
+ window (if it's visible).
+ """
+ for output in self.nottreal.view.output.values():
+ if output.is_visible():
+ output.set_state(VUIState.LISTENING)
+
+ def now_computing(self):
+ """
+ Highlight that a respond is currently being computed in the MUI
+ window (if it's visible).
+ """
+ for output in self.nottreal.view.output.values():
+ if output.is_visible():
+ output.set_state(VUIState.COMPUTING)
+
+
+ def quit(self):
+ """
+ Close and quit the MVUI window, if it still exists.
+ """
+ if self._mvui_win:
+ self._mvui_win.close()
+
diff --git a/src/nottreal/controllers/c_voice.py b/src/nottreal/controllers/c_voice.py
new file mode 100755
index 0000000..e46409a
--- /dev/null
+++ b/src/nottreal/controllers/c_voice.py
@@ -0,0 +1,704 @@
+
+from ..utils.log import Logger
+from .c_abstract import AbstractController
+from ..models.m_mvc import Message, VUIState
+
+from collections import deque
+from threading import Event
+from subprocess import Popen, call
+
+import abc, string, signal, threading, time, sys
+
+class VoiceController(AbstractController):
+ def __init__(self, nottreal, args):
+ """
+ Controller to generate the voice of the Wizard.
+
+ Arguments:
+ nottreal {App} -- Application instance
+ args {[str]} -- Application arguments
+ """
+ super().__init__(nottreal, args)
+
+ self._voice = args.voice
+ self._voiceInstance = None
+
+ def ready(self):
+ name = 'Voice%s%s' % (self._voice[0].title(), self._voice[1:])
+
+ try:
+ self._voiceInstance = self.nottreal.controllers[name]
+ except KeyError:
+ Logger.critical(
+ __name__,
+ 'Could not find voice controller "%s"' % name)
+ return
+
+ Logger.info(__name__, 'Setting voice to "%s"' % name)
+
+ self.responder('voice', self._voiceInstance)
+ self.router('voice', 'init', args=self.args)
+
+ def quit(self):
+ """
+ This class doesn't have to do anything on quit
+ """
+ pass
+
+ def respond_to(self):
+ """
+ This class will handle "voice" and "voice_root" commands. This
+ controller will be replaced as handler of "voice" commands by the
+ chosen subsystem.
+
+ Returns:
+ [{str}] -- Label for this controller
+ """
+ return ['voice', 'voice_root']
+
+ def relinquish(self, instance):
+ """
+ Relinquish control over voice signals to the right subsystem.
+
+ Arguments:
+ instance {AbstractController} -- Controller that has said it wants
+ to be a responder for the same signals as this controller.
+ Returns:
+ {bool} -- True if it's the voice subsystem we're expecting
+ """
+ return instance == self._voiceInstance
+
+ def speak(self, text):
+ """
+ Respond to the "speak" button being clicked.
+
+ Arguments:
+ text {string} -- Text that should be spoken
+ Return:
+ {bool} -- True if the text was queued to be spoken
+ """
+ Logger.error(__name__, 'No voice instantiated!')
+ return False
+
+ def stop_speaking(self, clear_all=None):
+ """
+ Immediately cancel talking
+
+ Arguments:
+ clear_all {bool} -- True if any unspoken text should not be spoken
+ also, UI configured element if None (Default {None}}
+ Return:
+ {bool} -- False
+ """
+ Logger.error(__name__, 'No voice instantiated!')
+ return False
+
+class AbstractVoiceController(AbstractController):
+ """
+ Base class that implements a simple abstract voice controller.
+
+ Extends:
+ AbstractVoiceController
+ """
+ def __init__(self, nottreal, args):
+ """
+ Base voice class that does nothing.
+
+ Arguments:
+ nottreal {} -- Application instance
+ args {[str]} -- Application arguments
+ """
+ super().__init__(nottreal, args)
+
+ self.append_text = False
+ self.auto_listening = True
+
+ @abc.abstractmethod
+ def init(self, args):
+ """
+ Initialise this voice subsystem.
+
+ Decorators:
+ abc.abstractmethod
+
+ Arguments:
+ args {[str]} -- Arguments passed through for the voice subsystem.
+ """
+ self.router(
+ 'wizard',
+ 'register_option',
+ label='Listening state after speech',
+ method=self._set_auto_listening,
+ default=self.auto_listening)
+
+ @abc.abstractmethod
+ def quit(self):
+ """
+ Shutdown the voice subsystem.
+ """
+ pass
+
+ @abc.abstractmethod
+ def speak(self,
+ text,
+ cat = None,
+ id = None,
+ slots = None,
+ loading = False):
+ """
+ Produce a particular utterance.
+
+ Decorators:
+ abc.abstractmethod
+
+ Arguments:
+ text {str} -- Text to say
+
+ Keyword Arguments:
+ cat {str} -- Category ID if a prepared message
+ id {str} -- Prepared message ID if a prepared message
+ slots {dict(str,str)} -- Slots changed by the user
+ loading {bool} -- Is a loading message
+ """
+ self._produce_voice(text, text, cat, id, slots)
+
+ def append_text(self):
+ """
+ Determine whether we should append the next messages instead of
+ replacing them.
+
+ Returns:
+ {bool} -- {True} to append text to the wizard
+ """
+ return self.append_text
+
+ def _set_append_text(self, new_value):
+ """
+ Set whether we should append the next messages instead of
+ replacing them.
+
+ Arguments:
+ new_value {bool} -- New checked status
+ """
+ if new_value:
+ Logger.debug(__name__, 'Will append text to existing dialogue')
+ else:
+ Logger.debug(__name__, 'Will not append text to existing dialogue')
+
+ self.append_text = new_value
+
+ def _set_auto_listening(self, new_value):
+ """
+ After speaking, should the UI default to the NOTHING state (False) or
+ the LISTENING state (True).
+
+ Arguments:
+ new_value {bool} -- New checked status
+ """
+ if new_value:
+ Logger.debug(__name__, 'Will default to LISTENING state')
+ else:
+ Logger.debug(__name__, 'Will default to NOTHING state')
+
+ self.auto_listening = new_value
+
+ @abc.abstractmethod
+ def _produce_voice(self,
+ text,
+ prepared_text,
+ cat = None,
+ id = None,
+ slots = None):
+ """
+ Call the voice subsystem to produce the voice. This will be
+ called from the separate thread.
+
+ You should block on this thread until the voice is spoken.
+
+ Calls the method `send_to_recorder` to send the data to
+ the recorder. If overriding this, you should to the same
+ too!
+
+ Decorators:
+ abc.abstractmethod
+
+ Arguments:
+ text {str} -- Raw text from the top of the queue
+ prepared_text {str} -- Text from the top of the queue
+ that has been prepared by #prepare_text()
+
+ Keyword Arguments:
+ cat {str} -- Category ID if a prepared message
+ id {str} -- Prepared message ID if a prepared message
+ slots {dict(str,str)} -- Slots changed by the user
+ """
+ self.send_to_recorder(text, cat, id, slots)
+
+ def send_to_recorder(self, text, cat = None, id = None, slots = None):
+ """
+ Send data to the data recorder.
+
+ Arguments:
+ text {str} -- Text from the top of the queue
+
+ Keyword Arguments:
+ cat {str} -- Category ID if a prepared message
+ id {str} -- Prepared message ID if a prepared message
+ slots {dict(str,str)} -- Slots changed by the user
+ """
+ if cat and id:
+ self.router(
+ 'data',
+ 'prepared_text',
+ text=text,
+ cat=cat,
+ id=id,
+ slots=slots)
+ else:
+ self.router('data', 'raw_text', text=text)
+
+ def stop_speaking(self, clear_all=None):
+ """
+ Immediately cancel talking
+
+ Keyword Arguments:
+ clear_all {bool} -- True if any unspoken text should
+ not be spoken also, UI configured element if
+ {None} (Default {None}})
+ Return:
+ {bool} -- False
+ """
+ Logger.error(
+ __name__,
+ 'Cannot cancel output on non-thread voice controller')
+ return False
+
+class ThreadedBaseVoice(AbstractVoiceController):
+ """
+ Base class that implements threading for calling a voice
+ subsystem from a separate thread.
+
+ Extends:
+ AbstractVoiceController
+ """
+
+ def __init__(self, nottreal, args, blocking = True):
+ """
+ Create the thread that sends commands to the voice subsystem
+
+ Arguments:
+ nottreal {App} -- Application instance
+ args {[str]} -- Application arguments
+
+ Keyword Arguments:
+ blocking {bool} -- Thread blocked during talking
+ """
+ super().__init__(nottreal, args)
+
+ self._blocking = blocking
+ self._is_speaking = False
+
+ def init(self, args):
+ """
+ Create the voice thread to handle the voice subsystem
+
+ Arguments:
+ args {[str]} -- Arguments for the voice subsystem
+ initiation
+ """
+ super().init(args)
+
+ self._sleep_between_queue_checks = .3
+ self._interrupt = Event()
+ self._stop_voice_loop = False
+ self._text_queue = deque()
+
+ self.append_override = Message.NO_OVERRIDE
+ self._dont_append_cat_change = True
+ self._clear_queue_on_interrupt = True
+
+ self.nottreal.router(
+ 'wizard',
+ 'register_option',
+ label='Clear queue on interrupt',
+ method=self._set_clear_queue_on_interrupt,
+ default=self._clear_queue_on_interrupt)
+
+ self._voice_thread = threading.Thread(
+ target=self._speak,
+ args=())
+ Logger.debug(__name__, 'Voice thread created')
+
+ self._voice_thread.daemon = True
+ self._voice_thread.start()
+
+ def _set_dont_append_cat_change(self, value):
+ """
+ After a category change, should the append option be set
+ to {False} for the first utterance,.
+
+ Arguments:
+ value {bool} -- New checked status
+ """
+ Logger.debug(__name__, 'Don\'t append on first message: %r' % value)
+ self._dont_append_cat_change = value
+ self.append_override = Message.NO_OVERRIDE
+
+ def _set_clear_queue_on_interrupt(self, value):
+ """
+ Clear the queue when the speech output is interrupted
+
+ Arguments:
+ value {bool} -- New checked status
+ """
+ Logger.debug(__name__, 'Clear queue on interrupt: %r' % value)
+ self._clear_queue_on_interrupt = value
+
+ def category_changed(self, new_cat_id):
+ """
+ Report to the voice subsystem that the user has changed
+ the category
+
+ Arguments:
+ new_cat_id {str} -- New category ID
+ """
+ if self._dont_append_cat_change:
+ self.append_override = Message.FORCE_DONT_APPEND
+ else:
+ self.append_override = Message.NO_OVERRIDE
+
+ def speak(self,
+ text,
+ cat = None,
+ id = None,
+ slots = None,
+ loading = False):
+ """
+ Add the message to the queue to be spoken.
+
+ Arguments:
+ text {str} -- Text to output to the macOS say command
+ Keyword Arguments:
+ cat {str} -- Category ID if a prepared message
+ id {str} -- Prepared message ID if a prepared message
+ slots {dict(str,str)} -- Slots changed by the user
+ loading {bool} -- Is a loading message (default: False)
+ Return:
+ {bool} -- True if the text was queued to be spoken
+ """
+ self.router('wizard', 'enqueue_text', text=text)
+ message = Message(
+ text.strip(),
+ override=self.append_override,
+ cat=cat, id=id, slots=slots, loading=loading)
+ self._text_queue.append(message)
+ self.append_override = Message.NO_OVERRIDE
+ return True
+
+ def quit(self):
+ """
+ Interrupt the voice thread so that it comes to a graceful halt
+ """
+ self._stop_voice_loop = True
+
+ def _prepare_text(self, text):
+ """
+ Prepare text by stripping quotes and deciding whether it
+ should be shown in the wizard window
+
+ Return:
+ {(str, str)} -- Prepared text for the voice subsystem
+ and text to show ({None} if should not be written
+ to screen)
+ """
+ text = text.replace('"', '')
+ return (text, text)
+
+ def _speak(self):
+ """
+ Continuously process the queue of text to synthesise. Run
+ this in a separate thread.
+ """
+ Logger.debug(__name__, 'Voice thread started')
+
+ while self._stop_voice_loop is False:
+ while not self._text_queue or (self._blocking and self._is_speaking):
+ time.sleep(self._sleep_between_queue_checks)
+
+ try:
+ message = self._text_queue.popleft()
+ append_override = message.override
+ text = message.text
+ loading = message.loading
+ prepared_text, text_to_show = self._prepare_text(text)
+
+ # if append_override is not 0:
+ # append = True if append_override == Message.FORCE_APPEND else False
+ # else:
+ # append = super().append_text()
+
+ if loading:
+ self._on_start_speaking(
+ text=text,
+ text_to_show=text_to_show,
+ state=VUIState.COMPUTING)
+ else:
+ self._on_start_speaking(
+ text=text,
+ text_to_show=text_to_show,
+ state=VUIState.SPEAKING)
+
+ if self._blocking:
+ self._is_speaking = True
+
+ self._produce_voice(
+ text,
+ prepared_text,
+ message.cat,
+ message.id,
+ message.slots)
+
+ if self._blocking:
+ self._on_stop_speaking()
+
+ except Exception as e:
+ Logger.critical(
+ __name__,
+ 'Error generating voice: %s' % repr(e))
+ raise e
+
+ Logger.debug(__name__, 'Voice thread finished')
+
+ def is_interrupted(self):
+ return not self._interrupt.is_set()
+
+ def wait(self, timeout):
+ return self._interrupt.wait(timeout)
+
+ def stop_speaking(self, clear_all=None):
+ """
+ Immediately cancel talking
+
+ Arguments:
+ clear_all {bool} -- True if any unspoken text should
+ not be spoken also, UI configured element
+ if {None} (Default {None}}
+ Return:
+ {bool} -- True
+ """
+ if clear_all is None:
+ clear_all = self._clear_queue_on_interrupt
+
+ if clear_all:
+ self.router('wizard', 'clear_queue')
+ self._text_queue.clear()
+ Logger.debug(__name__, 'Queued text cleared')
+ else:
+ Logger.debug(__name__, 'Not clearing the queued')
+
+ self._interrupt_voice()
+
+ return True
+
+ def _on_start_speaking(
+ self,
+ text=None,
+ text_to_show=None,
+ state=VUIState.SPEAKING):
+ """
+ Update NottReal to denote we've started speaking
+
+ Keyword arguments:
+ text {str} -- Text currently been spoken
+ text_to_show {str} -- Text currently been spoken
+ (user friendly)
+ state {int} -- State of the Wizard
+ """
+ self._is_speaking = True
+ self.router('wizard', 'now_speaking', text=text)
+ self.router('output', 'now_speaking', text=text_to_show, orb=state)
+
+ def _on_stop_speaking(self, state=None):
+ """
+ Update NottReal to denote we've finished speaking
+
+ Keyword arguments:
+ state {int} -- State of the Wizard
+ """
+ self._is_speaking = False
+
+ if ((state == None and self.auto_listening) or
+ (state == VUIState.LISTENING)):
+ self.router('output', 'now_listening')
+ elif state == VUIState.COMPUTING:
+ self.router('output', 'now_computing')
+ else:
+ self.router('output', 'now_resting')
+
+ def _interrupt_voice(self):
+ """
+ Immediately cancel waiting (if we are waiting)
+ """
+ Logger.debug(__name__, 'Interrupt voice command output')
+ self._interrupt.set()
+ self._interrupt.clear()
+
+class NonBlockingThreadedBaseVoice(ThreadedBaseVoice):
+ """
+ A threaded voice controller where the voice subsystem is
+ non-blocking (i.e. it may run on separate a thread/process)
+ """
+ def __init__(self, nottreal, args):
+ """
+ Create the thread that sends commands to the voice subsystem.
+
+ Arguments:
+ nottreal {App} -- Application instance
+ args {[str]} -- Application arguments
+ """
+ super().__init__(nottreal, args, blocking = False)
+
+
+class VoiceOutputToLog(ThreadedBaseVoice):
+ """
+ Output the speech to the log only.
+
+ Extends:
+ AbstractVoiceSystem
+ """
+ def __init__(self, nottreal, args):
+ """
+ Create the thread that sends commands to the log
+
+ Arguments:
+ nottreal {App} -- Application instance
+ args {[str]} -- Application arguments
+ """
+ super().__init__(nottreal, args)
+
+ self._no_waiting = False
+
+ def init(self, args):
+ super().init(args)
+ self.nottreal.router('wizard',
+ 'register_option',
+ label='No waiting',
+ method=self._set_no_waiting)
+
+ def _set_no_waiting(self, value):
+ """
+ Change the artificial waiting for the delay in the
+ generation of text to simulate speech.
+
+ Arguments:
+ value {bool} -- New checked status
+ """
+ Logger.debug(__name__, 'Instant printing: %r' % value)
+ self._no_waiting = value
+
+ def _produce_voice(self,
+ text,
+ prepared_text,
+ cat = None,
+ id = None,
+ slots = None):
+ """
+ Receive the text to produce, output it to the log,
+ and block if desired.
+
+ Arguments:
+ text {str} -- Text from the manager window
+ prepared_text {str} -- Text from the manager
+ window (not difference to text)
+
+ Keyword Arguments:
+ cat {str} -- Category ID if a prepared message
+ id {str} -- Prepared message ID if a prepared message
+ slots {dict(str,str)} -- Slots changed by the user
+ """
+ self.send_to_recorder(text, cat, id, slots)
+ Logger.info(__name__, 'Now saying "%s"' % text)
+
+ if not self._no_waiting:
+ timeout = len(text)/10
+ super().wait(timeout)
+
+class VoiceShellCmd(ThreadedBaseVoice):
+ """
+ Call a command via the shell to generate the voice.
+
+ Extends:
+ AbstractVoiceSystem
+ """
+ def __init__(self, nottreal, args):
+ """
+ Create the thread that sends commands to the external shell
+
+ Arguments:
+ nottreal {App} -- Application instance
+ args {[str]} -- Application arguments
+ """
+ super().__init__(nottreal, args)
+
+ def init(self, args):
+ """
+ Load the configuration.
+ """
+ super().init(args)
+
+ self._cfg = self.nottreal.config.cfg()
+
+ self._command_speak = self._cfg.get(
+ 'VoiceShellCmd',
+ 'command_speak')
+ self._command_interrupt = self._cfg.get(
+ 'VoiceShellCmd',
+ 'command_interrupt')
+
+ def _prepare_text(self, text):
+ """
+ Construct the command for the shell execution and
+ prepare the text for display
+
+ Arguments:
+ text {str} -- Text from the Wizard manager window
+
+ Return:
+ {(str, str)} -- Command and the prepared text
+ ({None} if should not be written to screen)
+ """
+ cmd_text = text.replace('"', '')
+
+ return (self._command_speak % cmd_text, text)
+
+ def _produce_voice(self,
+ text,
+ prepared_text,
+ cat = None,
+ id = None,
+ slots = None):
+ """
+ Receive the text (which should be a command), and then
+ call it.
+
+ Arguments:
+ text {str} -- Text to record as being produced
+ prepared_text {str} -- Command to call through a shell
+
+ Keyword Arguments:
+ cat {str} -- Category ID if a prepared message
+ id {str} -- Prepared message ID if a prepared message
+ slots {dict(str,str)} -- Slots changed by the user
+ """
+ self.send_to_recorder(text, cat, id, slots)
+ Logger.debug(__name__, 'Calling %s' % prepared_text)
+ call(prepared_text, shell=True)
+
+ def _interrupt_voice(self):
+ """
+ Immediately cancel waiting (if we are waiting)
+ """
+ if len(self._command_interrupt) > 0:
+ return call(self._command_interrupt, shell=True)
+ else:
+ Logger.error('voice', 'No interrupt command supplied')
diff --git a/src/nottreal/controllers/c_voice_activemq.py b/src/nottreal/controllers/c_voice_activemq.py
new file mode 100755
index 0000000..b3a83c4
--- /dev/null
+++ b/src/nottreal/controllers/c_voice_activemq.py
@@ -0,0 +1,206 @@
+
+from ..utils.log import Logger
+from .c_voice import NonBlockingThreadedBaseVoice
+
+from subprocess import Popen, call
+from collections import deque
+
+import os, time, threading, stomp
+
+class VoiceActiveMQ(NonBlockingThreadedBaseVoice):
+ """
+ Send the voice to an ActiveMQ/STOMP channel and create a channel
+ for receiving messages back. Configuration is in the main
+ settings.cfg file.
+
+ This uses stomp.py, which you can install with pip:
+ pip3 install stomp.py
+
+ Simply put, this controller will send messages to be spoken now
+ to an ActiveMQ/STOMP channel, and assume they are spoken
+ immediately and are currently being spoken until there is a
+ message sent back.
+
+ We assume that when we send a message, it is spoken immediately.
+ That said, we listen back from whatever is at the other end for
+ speaking and nothing states, and change/update our state when
+ `speaking` is happening. This allows for a remote system to
+ block new messages.
+
+ Extends:
+ NonBlockingThreadedBaseVoice
+
+ Variables:
+ RECEIVE_QUEUE, SEND_QUEUE {int} -- ActiveMQ queue identifiers
+
+ Extends:
+ AbstractVoiceSystem
+ """
+ RECEIVE_QUEUE, SEND_QUEUE = range(0,2)
+
+ def __init__(self, nottreal, args):
+ """
+ Create the thread that sends commands to an ActiveMQ/STOMP
+ queue
+
+ Arguments:
+ nottreal {App} -- Application instance
+ args {[str]} -- Application arguments
+ """
+ super().__init__(nottreal, args)
+
+ def init(self, args):
+ """
+ Load the configuration and connect to the ActiveMQ/STOMP
+ server, and register the NottReal receiver
+ """
+ super().init(args)
+
+ self._cfg = self.nottreal.config.cfg()
+
+ self._queued_messages = deque()
+
+ self._host = self._cfg.get('ActiveMQ', 'host')
+ self._port = self._cfg.getint('ActiveMQ', 'port')
+ self._username = self._cfg.get('ActiveMQ', 'username')
+ self._password = self._cfg.get('ActiveMQ', 'password')
+ self._receive_queue = self._cfg.get('ActiveMQ', 'nottreal_queue')
+ self._send_queue = self._cfg.get('ActiveMQ', 'destination_queue')
+
+ self._message = self._cfg.get('ActiveMQ', 'message_text')
+ self._message_interrupt = self._cfg.get(
+ 'ActiveMQ',
+ 'message_interrupt')
+
+ Logger.debug(
+ __name__,
+ 'Connecting to ActiveMQ server %s:%d' % (self._host, self._port))
+
+ try:
+ self._conn = stomp.Connection([(self._host, self._port)])
+ self._conn.set_listener('', VoiceActiveMQ.Listener(self))
+ self._conn.connect(self._username, self._password, wait=True)
+
+ Logger.debug(__name__, 'Subscribing to %s' % self._receive_queue)
+ self._conn.subscribe(
+ destination=self._receive_queue,
+ id=self.RECEIVE_QUEUE,
+ ack='auto')
+ except stomp.exception.ConnectFailedException as e:
+ self._conn = False
+ Logger.critical(
+ __name__,
+ 'Failed to connect to ActiveMQ/STOMP server: %s' % str(e))
+
+ def quit(self):
+ """
+ Disconnect from the ActiveMQ/STOMP server
+ """
+ if self._conn:
+ self._conn.disconnect()
+ super().quit()
+
+
+ class Listener(stomp.ConnectionListener):
+ """
+ Listen to messages from the ActiveMQ/STOMP server
+
+ Arguments:
+ app {App} -- Application instance
+ """
+ def __init__(self, parent):
+ __name__ = self.__class__.__name__
+
+ self.parent = parent
+ self.nottreal = parent.nottreal
+ self._cfg = self.nottreal.config.cfg()
+
+ message_state_format = self._cfg.get('ActiveMQ', 'message_state')
+ self._message_state_nothing = message_state_format % self._cfg.get('ActiveMQ', 'message_state_nothing')
+ self._message_state_speaking = message_state_format % self._cfg.get('ActiveMQ', 'message_state_speaking')
+ self._message_state_listening = message_state_format % self._cfg.get('ActiveMQ', 'message_state_listening')
+ self._message_state_computing = message_state_format % self._cfg.get('ActiveMQ', 'message_state_computing')
+
+ def on_error(self, headers, message):
+ Logger.error(
+ __name__,
+ 'Error passed via ActiveMQ/STOMP: %s' % message)
+
+ def on_message(self, headers, message):
+ if message == self._message_state_nothing:
+ Logger.debug(__name__, 'Apparently nothing is happening....')
+ self.parent._on_stop_speaking(state=VUIState.NOTHING)
+
+ elif message == self._message_state_listening:
+ Logger.debug(__name__, 'Apparently we\'re listening...')
+ self.parent._on_stop_speaking(state=VUIState.LISTENING)
+
+ elif message == self._message_state_computing:
+ Logger.debug(__name__, 'Apparently computation is happening...')
+ self.parent._on_stop_speaking(state=VUIState.COMPUTING)
+
+ elif message == self._message_state_speaking:
+ try:
+ self.parent._on_start_speaking(
+ self.parent._queued_messages.pop())
+ Logger.debug(__name__, 'Apparently we\'re speaking...')
+ except IndexError:
+ Logger.error(
+ __name__,
+ 'Apparently we\'re speaking, but we don\' know what');
+
+ else:
+ Logger.warning(__name__, 'Unknown message: %s' % message)
+
+
+ def _prepare_text(self, text):
+ """
+ Construct the command for the shell execution and prepare
+ the text for display
+
+ Arguments:
+ text {str} -- Text from the Wizard manager window
+
+ Return:
+ {(str, str)} -- Command and the prepared text ({None} if
+ should not be written to screen)
+ """
+ if not self._conn:
+ Logger.critical(__name__, 'Not connected to ActiveMQ/STOMP server')
+ return ('', '')
+
+ return (self._message % text, text)
+
+ def _produce_voice(self, text, prepared_text, cat = None, id = None, slots = None):
+ """
+ Receive the text (which should be a command), and then call it
+
+ Arguments:
+ text {str} -- Text to record as being produced
+ prepared_text {str} -- Command to call through a shell
+
+ Keyword Arguments:
+ cat {str} -- Category ID if a prepared message
+ id {str} -- Prepared message ID if a prepared message
+ slots {dict(str,str)} -- Slots changed by the user
+ """
+ if not self._conn:
+ Logger.critical(__name__, 'Not connected to ActiveMQ/STOMP server')
+ return
+
+ Logger.debug(__name__, 'Sending message: %s' % prepared_text)
+ self.send_to_recorder(text, cat, id, slots)
+ self._queued_messages.append(text)
+ self._conn.send(body=prepared_text, destination=self._send_queue)
+
+ def _interrupt_voice(self):
+ """
+ Immediately cancel waiting (if we are waiting)
+ """
+ if not self._conn:
+ Logger.critical(__name__, 'Not connected to ActiveMQ/STOMP server')
+ return
+
+ self._conn.send(
+ body=self._message_interrupt,
+ destination=self._send_queue)
diff --git a/src/nottreal/controllers/c_voice_cerevoice.py b/src/nottreal/controllers/c_voice_cerevoice.py
new file mode 100755
index 0000000..14b5b32
--- /dev/null
+++ b/src/nottreal/controllers/c_voice_cerevoice.py
@@ -0,0 +1,113 @@
+
+from ..utils.log import Logger
+from .c_voice import VoiceShellCmd
+
+from subprocess import call
+
+import time, threading, platform
+
+class VoiceCerevoice(VoiceShellCmd):
+ """
+ Use the cerevoice library (not included).
+
+ Extends:
+ VoiceShellCmd
+ """
+ def __init__(self, nottreal, args):
+ """
+ Create the thread that sends commands through to Cerevoice.
+
+ Arguments:
+ nottreal {App} -- Application instance
+ args {[str]} -- Application arguments
+ """
+ super().__init__(nottreal, args)
+
+ def init(self, args):
+
+ """
+ Set the macOS commands.
+ """
+ super().init(args)
+
+ self._cfg = self.nottreal.config.cfg()
+
+ self._command_speak = self._cfg.get(
+ 'VoiceCerevoice',
+ 'command_speak')
+ self._command_interrupt = self._cfg.get(
+ 'VoiceCerevoice',
+ 'command_interrupt')
+
+ self._calm_voice = False
+ self._cerevoice_spurts = True
+
+ self.router('wizard',
+ 'register_option',
+ label='Calm voice',
+ method=self._set_calm,
+ default=self._calm_voice)
+ self.router('wizard',
+ 'register_option',
+ label='Use Cerevoice spurts?',
+ method=self._set_cerevoice_spurts,
+ default=self._cerevoice_spurts)
+
+ def _set_cerevoice_spurts(self, value):
+ """
+ Change whether the certain shortcuts should be substituted with the
+ Cerevoice commands?
+
+ Arguments:
+ value {bool} -- New checked status
+ """
+ if value:
+ Logger.debug(__name__, 'Cerevoice spurts enabled')
+ else:
+ Logger.debug(__name__, 'Cerevoice spurts disabled')
+
+ self._cerevoice_spurts = value
+
+ def _set_calm(self, value):
+ """
+ Change whether the words are spoken with a calm voice.
+
+ Arguments:
+ value {bool} -- New checked status
+ """
+ if value:
+ Logger.debug(__name__, 'Calm voice enabled')
+ else:
+ Logger.debug(__name__, 'Calm voice disabled')
+
+ self._calm_voice = value
+
+ def _prepare_text(self, text):
+ """
+ Construct the command for the shell execution and prepare the text for
+ display.
+
+ Arguments:
+ text {str} -- Text from the Wizard manager window
+
+ Return:
+ {(str, str)} -- Command and the prepared text ({None} if should not
+ be written to screen)
+ """
+ text_for_cmd = text.replace(' and', ', and')
+
+ if text[-1] != '.' and text[-1] != '!' and text[-1] != '?':
+ text = text + '.'
+
+ if self._cerevoice_spurts:
+ spurts = {'oh': "oh", 'hm?': "hm?", 'mm': "mm", 'um': "um", 'um?': "um?", 'erm': "erm", 'er': "er", 'hm hm': "hm hm", 'haha': "haha", 'ah?': "ah?", 'ah!': "ah!", 'yeah?': "yeah?", 'yeah': "yeah", 'yeah!': "yeah!", 'oh!': "oh", 'hmm': "hmm"}
+ try:
+ text_for_cmd = spurts[text_for_cmd]
+ text = None
+ except KeyError:
+ pass
+
+ if self._calm_voice:
+ text_for_cmd = "%s" % text_for_cmd
+
+ return (self._command_speak % text_for_cmd, text)
diff --git a/src/nottreal/controllers/c_voice_macos.py b/src/nottreal/controllers/c_voice_macos.py
new file mode 100755
index 0000000..eb80d98
--- /dev/null
+++ b/src/nottreal/controllers/c_voice_macos.py
@@ -0,0 +1,37 @@
+
+from ..utils.log import Logger
+from .c_voice import VoiceShellCmd
+
+from subprocess import Popen, call
+
+import os, time, threading
+
+class VoiceMacOS(VoiceShellCmd):
+ """
+ Use the built-in say command in macOS.
+
+ Extends:
+ AbstractVoiceSystem
+ """
+ def __init__(self, nottreal, args):
+ """
+ Create the thread that sends commands to the macOS `say` command.
+
+ Arguments:
+ nottreal {App} -- Application instance
+ args {[str]} -- Application arguments
+ """
+ super().__init__(nottreal, args)
+
+ def init(self, args):
+
+ """
+ Set the macOS commands.
+ """
+ super().init(args)
+
+ self._cfg = self.nottreal.config.cfg()
+
+ self._command_speak = \
+ 'say %s "%%s"' % self._cfg.get('VoiceMacOS', 'command_options')
+ self._command_interrupt = 'killall say'
diff --git a/src/nottreal/controllers/c_wizard.py b/src/nottreal/controllers/c_wizard.py
new file mode 100644
index 0000000..f4862c6
--- /dev/null
+++ b/src/nottreal/controllers/c_wizard.py
@@ -0,0 +1,170 @@
+
+from ..utils.log import Logger
+from ..models.m_mvc import WizardOption
+from .c_abstract import AbstractController
+
+class WizardController(AbstractController):
+ """Primary controller for the Wizard window"""
+ def __init__(self, nottreal, args):
+ """
+ Controller to manage the Wizard's manager window.
+
+ Arguments:
+ nottreal {App} -- Application instance
+ args {[str]} -- Application arguments
+ """
+ super().__init__(nottreal, args)
+
+ def ready(self):
+ """
+ Called when the framework is established and the controller can
+ start controlling.
+ """
+ self._clear_slots_on_tab_change = False
+ self.register_option(
+ label=_('Clear slot tracking on tab change'),
+ method=self._set_clear_slots_on_tab_change,
+ default=self._clear_slots_on_tab_change)
+
+ Logger.info(__name__, "Opening the Wizard window…")
+ self.nottreal.view.wizard_window.show()
+
+ def respond_to(self):
+ """
+ This class will handle "wizard" commands only.
+
+ Returns:
+ str -- Label for this controller
+ """
+ return 'wizard'
+
+ def register_option(self,
+ label,
+ method,
+ type = WizardOption.CHECKBOX,
+ default = False):
+ """
+ Create an option for the user to specify
+
+ Arguments:
+ label {str} -- Label of the option
+ method {method} -- Method to call with the value when
+ its changed
+
+ Keyword Arguments:
+ type {int} -- The type of option
+ (default: {WizardOption.CHECKBOX})
+ default {bool} -- Default value (default: {False})
+ """
+ Logger.debug(__name__,
+ 'Option "%s" registered with default "%s"' % (label, default))
+ option = WizardOption(label, method, type, default)
+ self.nottreal.view.wizard_window.options.add(option)
+
+ def speak_text(self,
+ text,
+ cat = None,
+ id = None,
+ slots = {},
+ loading = False):
+ """
+ Pass the text onward to the voice controller. This should
+ be called from the Wizard window via the router.
+
+ Arguments:
+ text {str} -- Text to speak
+
+ Keyword Arguments:
+ cat {str} -- Category ID if a prepared message
+ id {str} -- Prepared message ID if a prepared message
+ slots {dict(str,str)} -- Slots changed by the user
+ loading {bool} -- Is a loading message
+ """
+ for name, value in slots.items():
+ self.nottreal.view.wizard_window.slot_history.add(name, value)
+
+ self.router(
+ 'voice',
+ 'speak',
+ text=text,
+ cat=cat,
+ id=id,
+ slots=slots,
+ loading=loading)
+
+ def tab_changed(self, new_tab):
+ """
+ Called from the Wizard window when the tab view changes
+
+ Arguments:
+ new_tab {str} -- New tab/category ID
+ """
+ self.router('voice', 'category_changed', new_cat_id=new_tab)
+
+ if self._clear_slots_on_tab_change:
+ Logger.debug(__name__, 'Clear parameter tracking')
+ self.nottreal.view.wizard_window.command.clear_saved_slots()
+
+ def log_message(self, id, text):
+ """
+ Log a message to the data file
+
+ Arguments:
+ id {str} -- ID of the message to log
+ text {str} -- Test of the message to log
+ """
+ try:
+ self.router('data', 'custom_event', id=id, text=text)
+ except Exception as e:
+ Logger.error(__name__, 'Error filing log message: %s' % str(e))
+
+
+ def now_speaking(self, text):
+ """
+ Mark some text as now being spoken (and so should be removed
+ from the queue).
+
+ Arguments:
+ text {str} -- Text that is queued to be spoken and is
+ no longer queued.
+ """
+ self.nottreal.view.wizard_window.msg_queue.remove(text)
+ self.nottreal.view.wizard_window.msg_history.add(text)
+
+ def stop_speaking(self):
+ """
+ Immediately cancel talking and optionally clear the queue based
+ on runtimne option.
+ """
+ self.router('voice', 'stop_speaking')
+
+ def enqueue_text(self, text):
+ """
+ Mark some text as queued to be spoken.
+
+ Arguments:
+ text {str} -- Text that is queued to be spoken
+ """
+ self.nottreal.view.wizard_window.msg_queue.add(text)
+
+ def clear_queue(self):
+ """
+ Clear the message queue
+ """
+ self.nottreal.view.wizard_window.msg_queue.clear()
+
+ def quit(self):
+ """
+ Close and quit the Wizard window if it still exists.
+ """
+ self.nottreal.view.wizard_window.close()
+
+ def _set_clear_slots_on_tab_change(self, value):
+ """
+ Change whether to clear parameter tracking on tab change
+
+ Arguments:
+ value {bool} -- New checked status
+ """
+ Logger.debug(__name__, 'Slot tracking on tab change: %r' % value)
+ self._clear_slots_on_tab_change = value
\ No newline at end of file
diff --git a/src/nottreal/models/__init__.py b/src/nottreal/models/__init__.py
new file mode 100755
index 0000000..e8ea234
--- /dev/null
+++ b/src/nottreal/models/__init__.py
@@ -0,0 +1,5 @@
+import glob, os
+
+modules = glob.glob(os.path.join(os.path.dirname(__file__), "*.py"))
+__all__ = [os.path.basename(f)[:-3]
+ for f in modules if not f.endswith("__init__.py")]
\ No newline at end of file
diff --git a/src/nottreal/models/m_abstract.py b/src/nottreal/models/m_abstract.py
new file mode 100755
index 0000000..dfe7354
--- /dev/null
+++ b/src/nottreal/models/m_abstract.py
@@ -0,0 +1,46 @@
+
+import abc
+
+class AbstractModel:
+ def __init__(self, args):
+ """
+ Abstract model class. All models should inherit this class.
+
+ Arguments:
+ args {[str]} -- Application arguments
+ """
+ pass
+
+ @abc.abstractmethod
+ def cats(self, cat_id = None):
+ """
+ Return one or all categories.
+
+ Arguments:
+ cat_id {str} -- Return a particular category
+
+ Returns:
+ [{str}] -- Categor(y/ies)
+
+ Raises:
+ KeyError -- If no matching category is found
+ NotImplementedError -- If the child class hasn't implemented this method
+ """
+ raise NotImplementedError('cats method not implemented')
+
+ def stmnts(self, cat_id = None, stmnt_id = None):
+ """
+ Return one or more message by their ID or category
+
+ Arguments:
+ cat_id {str} -- Find messages within a category
+ stmnt_id {str} -- Find a message by its ID
+
+ Returns:
+ str/[str] -- Message(s)
+
+ Raises:
+ KeyError -- If no matching messages are found
+ NotImplementedError -- If the child class hasn't implemented this method
+ """
+ raise NotImplementedError('stmnts method not implemented')
\ No newline at end of file
diff --git a/src/nottreal/models/m_cfg.py b/src/nottreal/models/m_cfg.py
new file mode 100755
index 0000000..803be5a
--- /dev/null
+++ b/src/nottreal/models/m_cfg.py
@@ -0,0 +1,30 @@
+
+from .m_abstract import AbstractModel
+from ..utils.log import Logger
+
+from collections import OrderedDict
+
+import sys, os, configparser
+
+class ConfigModel(AbstractModel):
+ def __init__(self, args):
+ """Load data from configuration files.
+
+ Arguments:
+ args {[arg]} -- Application arguments
+ """
+ self.config_dir = args.config_dir
+ super().__init__(args)
+
+ Logger.debug(__name__, 'Loading data from the configuration file')
+
+ self.config = configparser.ConfigParser()
+ self.config.read(args.config_dir + '/settings.cfg')
+
+ Logger.info(__name__, 'Configuration loaded')
+
+ def get(self, section, option):
+ return self.config.get(section, option)
+
+ def cfg(self):
+ return self.config
diff --git a/src/nottreal/models/m_mvc.py b/src/nottreal/models/m_mvc.py
new file mode 100644
index 0000000..f8bb084
--- /dev/null
+++ b/src/nottreal/models/m_mvc.py
@@ -0,0 +1,100 @@
+
+class WizardOption:
+ """
+ An option for the wizard to use at runtime
+
+ Variables:
+ CHECKBOX {int} -- Identifier for an option that's a checkbox
+ """
+ CHECKBOX = 0
+
+ def __init__(self,
+ label,
+ method,
+ type = 0,
+ default = False,
+ added = False):
+ """
+ Create a runtime Wizard option
+
+ Arguments:
+ label {str} -- Label of the option
+ label {method} -- Method to call with the value when its changed
+
+ Keyword Arguments:
+ type {int} -- The type of option (default: {self.CHECKBOX})
+ default {bool} -- Default value (default: {False})
+ added {bool} -- Has been added to the UI (default: {False})
+ """
+ self.label = label
+ self.method = method
+ self.type = type
+ self.default = default
+ self.value = default
+ self.added = added
+ self.ui = None
+
+ def change(self, value):
+ """
+ Change the value and call the method that wants it.
+
+ Arguments:
+ value {mixed} -- New value (depends on type)
+ """
+ self.value = value
+ self.method(value)
+
+class Message:
+ """
+ A queued message to send to the user.
+
+ Variables:
+ NO_OVERRIDE {int} -- Type to let the user fully control appending
+ FORCE_APPEND {int} -- Type to force append a message
+ FORCE_DONT_APPEND {int} -- Type to force no appending of messages
+ """
+ NO_OVERRIDE, FORCE_APPEND, FORCE_DONT_APPEND = range(0,3)
+
+ def __init__(self,
+ text,
+ override = NO_OVERRIDE,
+ cat=None,
+ id=None,
+ slots=None,
+ loading=False):
+ """
+ Create a message queue item.
+
+ Arguments:
+ text {[type]} -- [description]
+
+ Keyword Arguments:
+ override {int} -- Override the append option for this
+ message
+ (default: {Message.NO_OVERRIDE})
+ cat {str/int} -- Category ID if a prepared message
+ (default: {None})
+ id {str/int} -- Prepared message ID if a prepared
+ message (default: {None})
+ slots {dict(str,str)} -- Slots changed by the user
+ loading {bool} -- Is a Loading message (default: {False})
+ """
+ self.text = text
+ self.override = override
+ self.cat = cat
+ self.id = id
+ self.slots = slots
+ self.loading = loading
+
+class VUIState:
+ """
+ State of the Wizarded VUI.
+
+ Extends:
+ AbstractController
+
+ Variables:
+ NOTHING, SPEAKING, LISTENING, COMPUTING {int} -- States of the Wizard
+
+ """
+ NOTHING, SPEAKING, LISTENING, COMPUTING = range(0,4)
\ No newline at end of file
diff --git a/src/nottreal/models/m_tsv.py b/src/nottreal/models/m_tsv.py
new file mode 100755
index 0000000..736d8c8
--- /dev/null
+++ b/src/nottreal/models/m_tsv.py
@@ -0,0 +1,109 @@
+
+from .m_abstract import AbstractModel
+from ..utils.log import Logger
+
+from collections import OrderedDict
+
+import sys, csv
+
+class TSVModel(AbstractModel):
+ def __init__(self, args):
+ """
+ Load data from tab-separated values.
+
+ Arguments:
+ args {[arg]} -- Application arguments
+ """
+ super().__init__(args)
+ dir = args.config_dir
+
+ Logger.debug(__name__, 'Loading data from the TSV files')
+
+ Logger.debug(__name__, 'Load categories data')
+ self.cats = self._parseTsv(dir, 'categories.tsv', ['id', 'label'])
+
+ Logger.debug(__name__, 'Load messages data')
+ self.msgs = self._parseTsv(dir, 'messages.tsv', ['id', 'cat_id', 'label', 'text'])
+
+ Logger.debug(__name__, 'Load loading messages data')
+ default = OrderedDict()
+ default[0] = {'id': 0, 'message': 'Just a minute'}
+ default[1] = {'id': 0, 'message': 'I\'m working on it'}
+ self.loading_msgs = self._parseTsv(dir, 'loading.tsv', ['id', 'message'], default)
+
+ Logger.debug(__name__, 'Load custom log messages data')
+ self.log_msgs = self._parseTsv(dir, 'log.tsv', ['id', 'message'], OrderedDict())
+
+ for idx, message in self.msgs.items():
+ if 'msgs' not in self.cats[message['cat_id']]:
+ self.cats[message['cat_id']]['msgs'] = []
+
+ if message['cat_id'] not in self.cats[message['cat_id']]['msgs']:
+ self.cats[message['cat_id']]['msgs'].append(message)
+
+ for cat_id, cat in self.cats.items():
+ if 'msgs' not in cat:
+ cat['msgs'] = OrderedDict()
+
+ Logger.info(__name__, 'Model successfully loaded from files')
+
+
+ def msgs(self, cat_id = None, msg_id = None):
+ """
+ Return one or more messages by their ID or category
+
+ Arguments:
+ cat_id {str} -- Find messages within a category
+ msg_id {str} -- Find a message by its ID
+
+ Returns:
+ str/[str] -- Message(s)
+
+ Raises:
+ KeyError -- If no matching messages are found
+ """
+ if cat_id is not None:
+ try:
+ index = self.cats[cat_id]['msgs']
+ except KeyError:
+ raise KeyError('No matching category for id "%s"' % cat_id)
+ cat_id_str = '"%s"' % cat_id
+ else:
+ cat_id_str = 'all messages'
+ index = self.msgs
+
+ if msg_id is not None:
+ try:
+ return self.msgs[msg_id]
+ except KeyError:
+ raise KeyError('No matching message for id "%s" in %s' % (msg_id, cat_id_str))
+
+ return self.msgs
+
+ def _parseTsv(self, dir, file_name, field_names, default=None):
+ """Parse a tab-separated values file into an OrderedDict. Must include
+ an id column (specify location with the field_names argument).
+
+ Arguments:
+ dir {str} -- Directory to load file from
+ file_name {str} -- File name to parse in the configured directory
+ field_names {[str]} -- List of field name keys
+
+ Keyword Arguments:
+ default {OrderedDict} -- Default values (or None)
+
+ Returns:
+ OrderedDict -- Dict of values from the model
+ """
+ data = OrderedDict()
+ try:
+ with open(dir + '/' + file_name) as tsv_file:
+ reader = csv.DictReader(tsv_file, delimiter='\t', fieldnames=field_names)
+ for row in reader:
+ data[row['id']] = row
+ except FileNotFoundError as e:
+ if default is not None:
+ return default
+ else:
+ raise e
+ return data
diff --git a/src/nottreal/nottreal.py b/src/nottreal/nottreal.py
new file mode 100644
index 0000000..0fb938e
--- /dev/null
+++ b/src/nottreal/nottreal.py
@@ -0,0 +1,160 @@
+
+from .utils.init import ClassUtils
+from .utils.dir import *
+from .utils.log import Logger
+from .controllers import c_abstract
+from .models import *
+from .views import *
+
+from PySide2.QtWidgets import (QApplication, QLabel, QPushButton,
+ QVBoxLayout, QWidget)
+from PySide2.QtCore import Slot, Qt
+
+import sys, inspect, importlib, glob, os, importlib, pkgutil, threading
+
+class App:
+ def __init__(self, args):
+ """Create the controller for the application
+
+ Arguments:
+ args {[str]} -- Application arguments
+ """
+ Logger.debug(__name__, 'Welcome to the GUI application')
+
+ self.appname = 'NottReal'
+
+ self.args = args
+ self._controllers = {}
+ self._responders = {'app': self}
+ self._thread = threading.current_thread()
+
+ # initialise the models
+ self.data = m_tsv.TSVModel(args)
+ self.config = m_cfg.ConfigModel(args)
+
+ # initialise the controllers
+ module_path = DirUtils.pwd() + '/src/nottreal/controllers'
+ classes = ClassUtils.load_all_subclasses(
+ module_path,
+ c_abstract.AbstractController,
+ 'controllers.')
+
+ self.controllers = {}
+ for name, cls in classes.items():
+ try:
+ self.controllers[name] = cls(self, args)
+ Logger.debug(__name__, 'Loaded controller "%s"' % name)
+ except TypeError:
+ Logger.error(__name__, (
+ '"%s" has invalid constructor arguments' % (name)))
+
+ respond_tos = self.controllers[name].respond_to()
+ if type(respond_tos) is list:
+ for repond_to in respond_tos:
+ self.responder(repond_to, self.controllers[name])
+ elif type(respond_tos) is str:
+ self.responder(respond_tos, self.controllers[name])
+
+ # initialise the views
+ self.view = v_gui.Gui(self, args, self.data, self.config)
+ self.view.init_ui()
+
+
+ try:
+ self.router('voice_root', 'ready')
+ except KeyError:
+ Logger.critical(__name__, 'Root voice controller not found')
+ return
+
+ try:
+ self.router('wizard', 'ready')
+ except KeyError:
+ Logger.critical(__name__, 'Wizard window controller not found')
+ return
+
+ try:
+ self.router('output', 'ready')
+ except KeyError:
+ Logger.critical(__name__, 'Output window controller not found')
+ return
+
+ self.view.runLoop()
+
+ Logger.debug(__name__, 'Exiting the GUI application')
+
+ def quit(self):
+ """Gracefully shutdown the application"""
+ self.view.quit()
+
+ def responder(self, name, responder = None):
+ """
+ Retrieve a particular message responder, or set one
+
+ Arguments:
+ name {str} -- Name of the responder
+ responder [AbstractController] --
+ Instance of the controller that responds (default: {None})
+
+ Returns:
+ AbstractController -- Controller instance
+ """
+ if responder is not None:
+ responder_class = responder.__class__.__name__
+ if name not in self._responders:
+ self._responders[name] = responder
+ Logger.debug(__name__, 'Controller "%s" is handling "%s" signals' % (responder_class, name))
+ elif self._responders[name].relinquish(responder):
+ curr_class = self._responders[name].__class__.__name__
+ self._responders[name] = responder
+ Logger.debug(__name__, 'Controller "%s" is handling "%s" signals (taking over from "%s")' % (responder_class, name, curr_class))
+ else:
+ curr_class = self._responders[name].__class__.__name__
+ Logger.warning(__name__, 'Controller "%s" requested to respond to "%s" signals, but rejected by current holder, "%s"' % (responder_class, name, curr_class))
+
+ try:
+ return self._responders[name]
+ except KeyError:
+ raise KeyError('No responder named "%s"' % name)
+
+ def router(self, responder, action, **kwargs):
+ """
+ Route a message between elements the framework to a responder
+
+ Arguments:
+ recipient {str} -- Recipient responder
+ action {[str]} -- Message to pass
+ **kwargs {[mixed]} -- Additional arguments to pass through
+ """
+ responderInstances = {}
+ method = None
+
+ try:
+ if responder is '_':
+ responderInstances = {responder:self._responders[responder] for responder in self._responders.keys() if responder is not 'app'}
+ else:
+ responderInstances[responder] = self._responders[responder]
+ except KeyError as e:
+ Logger.critical(
+ __name__,
+ 'No responder for "%s": "%s"' % (responder, repr(e)))
+ raise e
+
+ for responder, responderInstance in responderInstances.items():
+ if self.args.dev:
+ method = getattr(responderInstance, action)
+ method(**kwargs)
+ else:
+ try:
+ method = getattr(responderInstance, action)
+ if method is None:
+ Logger.error(__name__, 'No actor for the "%s" signal in the controller "%s"' % (action, responder))
+ else:
+ Logger.debug(__name__, 'Pass "%s" signal to the controller "%s"' % (action, responder))
+ try:
+ method(**kwargs)
+ except TypeError:
+ Logger.error(__name__, 'Actor for "%s" signal in the controller "%s" is not type-compatible' % (action, responder))
+ except SystemExit:
+ pass
+ except Exception as e:
+ Logger.error(__name__, 'Error calling the "%s" action on "%s": "%s"' % (action, responder, repr(e)))
diff --git a/src/nottreal/utils/__init__.py b/src/nottreal/utils/__init__.py
new file mode 100644
index 0000000..e8ea234
--- /dev/null
+++ b/src/nottreal/utils/__init__.py
@@ -0,0 +1,5 @@
+import glob, os
+
+modules = glob.glob(os.path.join(os.path.dirname(__file__), "*.py"))
+__all__ = [os.path.basename(f)[:-3]
+ for f in modules if not f.endswith("__init__.py")]
\ No newline at end of file
diff --git a/src/nottreal/utils/dir.py b/src/nottreal/utils/dir.py
new file mode 100644
index 0000000..1a8437c
--- /dev/null
+++ b/src/nottreal/utils/dir.py
@@ -0,0 +1,15 @@
+
+import abc, sys, os
+
+class DirUtils:
+
+ def pwd():
+ """Get the present working directory
+
+ Returns:
+ {str}
+ """
+ if getattr(sys, 'frozen', False):
+ return sys._MEIPASS
+ else:
+ return os.getcwd()
diff --git a/src/nottreal/utils/init.py b/src/nottreal/utils/init.py
new file mode 100644
index 0000000..5877362
--- /dev/null
+++ b/src/nottreal/utils/init.py
@@ -0,0 +1,113 @@
+
+from .log import Logger
+from .dir import DirUtils
+from argparse import ArgumentTypeError
+
+import importlib, os, pkgutil, sys
+
+class ArgparseUtils:
+
+ def dir_contains_config(dir):
+ """
+ Does a directory contain the required configuration files and
+ are they readable?
+
+ If no supplied directory is given (or the default is given), and it
+ is invalid, the distribution configuration (in `.cfg-dist`) is used.
+
+ Arguments:
+ dir {str} -- Directory relative to this directory
+
+ Raises:
+ ArgumentTypeError -- if the user supplies the distribution config
+ directory as their choice, or if their supplied choice of
+ directory does not exist, is not readable, or is missing
+ the required files
+
+ Returns:
+ {str} -- Path to the configuration directory
+ """
+ if dir == '.cfg-dist':
+ raise ArgumentTypeError(('You cannot use the distribution '
+ 'configuration directory'))
+
+ dist_dir = '.cfg-dist'
+ pwd = DirUtils.pwd() + '/'
+ requested_dir = pwd + dir
+
+ if not os.path.isdir(requested_dir):
+ if dir == 'cfg' and os.path.isdir(pwd + '.cfg-dist'):
+ print('%s not found' % requested_dir,
+ '∴ falling back to distribution configuration', sep=' ')
+ dir = dist_dir
+ requested_dir = pwd + dist_dir
+ else:
+ raise ArgumentTypeError((
+ '%s is not a readable directory' % (dir)))
+ elif os.access(dir, os.R_OK):
+ files = ('settings.cfg', 'categories.tsv', 'messages.tsv')
+ for file in files:
+ if not os.access(requested_dir + '/' + file, os.R_OK):
+ raise ArgumentTypeError((
+ '%s/%s is not a readable file' % (dir, file)))
+
+ return dir
+
+ def dir_is_writeable(dir):
+ """
+ Is a directory writeable?
+
+ Arguments:
+ dir {str} -- Directory relative to this directory
+
+ Returns:
+ {str} -- `dir` if directory is writeable
+
+ Raises:
+ ArgumentTypeError -- If `dir` is not valid or writeable
+ """
+ pwd = DirUtils.pwd() + '/'
+
+ if dir and not os.path.isdir(pwd + dir):
+ raise ArgumentTypeError(
+ ('%s is not a valid directory' % dir))
+ elif dir and not os.access(dir, os.W_OK):
+ raise ArgumentTypeError(
+ ('%s is not a writeable directory' % dir))
+
+ return dir
+
+class ClassUtils:
+ def load_all_subclasses(module_path, subclass, prefix = ''):
+ """
+ Search a directory/module for files and import classes
+ that subclass (can be multi-layer) a class.
+
+ Arguments:
+ module_path {str} -- Directory to search
+ subclass {class} -- Class everthing must inherit from
+ prefix {str} -- Prefix from module above (Default: ``)
+ """
+ for (_, name, ispkg) in pkgutil.iter_modules([module_path]):
+ Logger.debug(__name__, 'Loading "%s.py"' % name)
+ importlib.import_module('..' + prefix + name, __package__)
+
+ return ClassUtils.get_all_subclasses(subclass)
+
+ def get_all_subclasses(rootclass):
+ """
+ Recursively get all subclasses
+
+ Arguments:
+ rootclass {class} -- Class to look for subclasses of
+
+ Returns:
+ [class]
+ """
+ subclasses = {}
+
+ for subclass in rootclass.__subclasses__():
+ subclasses[subclass.__name__] = subclass
+ subclasses.update(ClassUtils.get_all_subclasses(subclass))
+
+ return subclasses
\ No newline at end of file
diff --git a/src/nottreal/utils/log.py b/src/nottreal/utils/log.py
new file mode 100755
index 0000000..e6554f6
--- /dev/null
+++ b/src/nottreal/utils/log.py
@@ -0,0 +1,173 @@
+
+import time, logging, threading
+
+class Logger(object):
+ """
+ Python logging wrapper
+ from https://github.com/MixedRealityLab/conditional-voice-recorder/
+ """
+ _loggers = {}
+
+ CRITICAL=logging.CRITICAL
+ ERROR=logging.ERROR
+ WARNING=logging.WARNING
+ INFO=logging.INFO
+ DEBUG=logging.DEBUG
+ NOTSET=logging.NOTSET
+
+ chosen_level=logging.INFO
+
+ COLOURS = {
+ 'critical': '\033[1;41m', # red bg bold
+ 'error': '\033[1;31m', # red bold
+ 'warning': '\033[0;43m', # yellow bg
+ 'info': '\033[0m', # no colour
+ 'debug': '\033[0;2m' # grey
+ }
+
+ @staticmethod
+ def debug(tag, message=None):
+ """Post a debug-level message
+
+ Arguments:
+ tag {str} -- Tag for the log message
+ message {str} -- Message to post or if one is not provided, the tag
+ is used as the message instead
+ """
+ Logger._post('debug', tag, message)
+
+ @staticmethod
+ def info(tag, message=None):
+ """Post an info-level message
+
+ Arguments:
+ tag {str} -- Tag for the log message
+ message {str} -- Message to post or if one is not provided, the tag
+ is used as the message instead
+ """
+ Logger._post('info', tag, message)
+
+ @staticmethod
+ def warning(tag, message=None):
+ """Post a warning-level message
+
+ Arguments:
+ tag {str} -- Tag for the log message
+ message {str} -- Message to post or if one is not provided, the tag
+ is used as the message instead
+ """
+ Logger._post('warning', tag, message)
+
+ @staticmethod
+ def error(tag, message=None):
+ """Post an error-level message
+
+ Arguments:
+ tag {str} -- Tag for the log message
+ message {str} -- Message to post or if one is not provided, the tag
+ is used as the message instead
+ """
+ Logger._post('error', tag, message)
+
+ @staticmethod
+ def critical(tag, message=None):
+ """Post a critical-level message
+
+ Arguments:
+ tag {str} -- Tag for the log message
+ message {str} -- Message to post or if one is not provided, the tag
+ is used as the message instead
+ """
+ Logger._post('critical', tag, message)
+
+ @staticmethod
+ def log(tag, message=None):
+ """
+ Post a log-level message.
+
+ :param String tag: tag for the log message.
+ :param String message: message to post, if one is not provided, the tag
+ is used as the message instead.
+ :return: None
+ """
+ Logger._post('log', tag, message)
+
+ @staticmethod
+ def exception(tag, message=None):
+ """
+ Post an exception-level message.
+
+ :param String tag: tag for the log message.
+ :param String message: message to post, if one is not provided, the tag
+ is used as the message instead.
+ :return: None
+ """
+ Logger._post('exception', tag, message)
+
+ @staticmethod
+ def init(level):
+ """
+ Initiate the logging system.
+
+ :param int level: level to set for the logger (only applies on first
+ call, can't be changed once logger is created).
+ :return: Logger
+ """
+ Logger.chosen_level = level
+ # logging.basicConfig(
+ # format='%(asctime)s\t%(levelname)-8s\t%(name)-16s\t%(message)s',
+ # level=level)
+ logging.basicConfig(
+ format='%(asctime)s\t%(levelname)-4s\t%(name)-25s\t%(message)s',
+ level=level)
+
+ @staticmethod
+ def _post(level, tag, message=None):
+ """
+ Post a message to a logger of a given tag at the given level.
+
+ :param String tag: tag for the log message.
+ :param String level: level of the log message, as a lowercase String,
+ :param String message: message to post, the message is posted as-is, but
+ in the right colour.
+ :return: None
+ """
+ if message == None:
+ message = tag
+ tag = "nottreal"
+
+ message = "%s%s\033[0m" % (Logger.COLOURS[level], message)
+
+ logger = Logger._get_logger(level, tag)
+ method = getattr(logger, level)
+ method(Logger._message(message))
+
+ @staticmethod
+ def _get_logger(level, tag):
+ """
+ Retrieve a Logger for a given tag.
+
+ :param int level: level to set for the logger (only applies on first
+ call, can't be changed once logger is created).
+ :param String tag: tag for the log message.
+ :return: Logger
+ """
+ try:
+ return Logger._loggers[tag]
+ except KeyError:
+ trimmed_tag = tag.replace('src.nottreal', '')
+ Logger._loggers[tag] = logging.getLogger(trimmed_tag)
+ Logger._loggers[tag].setLevel(Logger.chosen_level)
+ return Logger._loggers[tag]
+
+ @staticmethod
+ def _message(message):
+ """
+ Augment a message by including Thread information.
+
+ :param String message: message to post, adds time and Thread (info)rmation
+ to the String.
+ :return: String
+ """
+ str_thread = "Thread-%d" % threading.current_thread().ident
+ return "%s\t%s" % (str_thread, message)
diff --git a/src/nottreal/views/__init__.py b/src/nottreal/views/__init__.py
new file mode 100644
index 0000000..e8ea234
--- /dev/null
+++ b/src/nottreal/views/__init__.py
@@ -0,0 +1,5 @@
+import glob, os
+
+modules = glob.glob(os.path.join(os.path.dirname(__file__), "*.py"))
+__all__ = [os.path.basename(f)[:-3]
+ for f in modules if not f.endswith("__init__.py")]
\ No newline at end of file
diff --git a/src/nottreal/views/v_gui.py b/src/nottreal/views/v_gui.py
new file mode 100644
index 0000000..a3aa675
--- /dev/null
+++ b/src/nottreal/views/v_gui.py
@@ -0,0 +1,94 @@
+
+from ..utils.init import ClassUtils
+from ..utils.dir import *
+from ..utils.log import Logger
+from .v_wizard import WizardWindow
+from .v_output_abstract import AbstractOutputView
+
+from PySide2.QtWidgets import (QApplication, QStyleFactory)
+
+import importlib, pkgutil, sys
+
+class Gui:
+ """The primary GUI application class"""
+ def __init__(self, nottreal, args, data, config):
+ """
+ Initialise the GUI application libraries
+
+ Arguments:
+ nottreal {App} -- Main NottReal class
+ args {[str]} -- CLI arguments
+ data {TSVModel} -- Data from static data files
+ config {ConfigModel} -- Data from static configuration files
+ """
+ self._qtapp = QApplication(sys.argv)
+ QApplication.setStyle(QStyleFactory.create('Fusion'))
+
+ self.nottreal = nottreal
+ self.args = args
+ self.data = data
+ self.config = config
+
+ def init_ui(self):
+ module_path = DirUtils.pwd() + '/src/nottreal/views'
+ classes = ClassUtils.load_all_subclasses(
+ module_path,
+ AbstractOutputView,
+ 'views.')
+
+ self.output = {}
+ for name, cls in classes.items():
+ try:
+ instance = cls(
+ self.nottreal,
+ self.args,
+ self.data,
+ self.config)
+
+ if instance.activated():
+ self.output[name.lower()] = instance
+ instance.init_ui()
+ Logger.info(__name__, 'Loaded output view "%s"' % name)
+ else:
+ Logger.info(
+ __name__,
+ 'Output view "%s" is disabled' % name)
+ except TypeError:
+ Logger.error(__name__, (
+ '"%s" has invalid constructor arguments' % (name)))
+
+ self.wizard_window = WizardWindow(
+ self.nottreal,
+ self.args,
+ self.data,
+ self.config)
+
+ def output_views(self, args, data, config):
+ """
+ Get the MVUI window (or initialise it if it doesn't exist)
+
+ Argument:
+ args {[str]} -- CLI arguments
+ data {TSVModel} -- Data from static data files
+ config {ConfigModel} -- Data from static configuration files
+
+ Returns:
+ {WizardWindow}
+ """
+ try:
+ return self._mvui_window
+ except AttributeError:
+ self._mvui_window = MVUIWindow(
+ self._nottreal,
+ args,
+ data,
+ config)
+ return self._mvui_window
+
+ def runLoop(self):
+ """Show the GUI application by starting the UI loop"""
+ self._qtapp.exec_()
+
+ def quit(self):
+ """Quit the GUI application"""
+ self._qtapp.quit()
\ No newline at end of file
diff --git a/src/nottreal/views/v_output_abstract.py b/src/nottreal/views/v_output_abstract.py
new file mode 100644
index 0000000..0849e0c
--- /dev/null
+++ b/src/nottreal/views/v_output_abstract.py
@@ -0,0 +1,126 @@
+
+from ..utils.log import Logger
+
+from PySide2.QtWidgets import (QWidget)
+
+import abc
+
+class AbstractOutputView(QWidget):
+ """
+ A VUI output window, must be implemented as a {QWidget}
+
+ Extends:
+ QWidget
+ """
+ def __init__(self, nottreal, args, data, config):
+ """
+ A window that displays output
+
+ Don't do anything in this method, the instance is destroyed if
+ the output view is not activated
+
+ Arguments:
+ nottreal {App} -- Main NottReal class
+ args {[str]} -- CLI arguments
+ data {TSVModel} -- Data from static data files
+ config {ConfigModel} -- Data from static configuration
+ files
+ """
+ self.nottreal = nottreal
+ self.args = args
+ self.data = data
+ self.config = config
+
+ super(AbstractOutputView, self).__init__()
+
+ @abc.abstractmethod
+ def init_ui(self):
+ """
+ Initialise the UI
+
+ Decorators:
+ abc.abstractmethod
+
+ Returns:
+ {bool}
+ """
+ pass
+
+ @abc.abstractmethod
+ def activated(self):
+ """
+ Return {True} if this output window should receive messages.
+
+ Decorators:
+ abc.abstractmethod
+
+ Returns:
+ {bool}
+ """
+ return False
+
+ @abc.abstractmethod
+ def get_label(self):
+ """
+ Return the name of the output view.
+
+ Decorators:
+ abc.abstractmethod
+
+ Returns:
+ {str}
+ """
+ pass
+
+ def is_visible(self):
+ """
+ Is the window visible?
+
+ Returns:
+ {bool}
+ """
+ return self.isVisible()
+
+ def toggle_visibility(self):
+ """
+ Toggle visibility of the window
+ """
+ if self.isVisible():
+ self.hide()
+ self.close()
+ Logger.info(__name__, '%s is closed' % self.get_label())
+ else:
+ self.show()
+ Logger.info(__name__, '%s is visible' % self.get_label())
+
+ def toggle_fullscreen(self):
+ """
+ Toggle fullscreen/windowed mode
+ """
+ if self.isFullScreen():
+ self.showNormal()
+ Logger.info(__name__, '%s is windows' % self.get_label())
+ else:
+ self.showFullScreen()
+ Logger.info(__name__, '%s is fullscreen' % self.get_label())
+
+ @abc.abstractmethod
+ def set_message(self, text):
+ """
+ A new message to show immediately
+
+ Arguments:
+ text {str} -- Text of the message
+ """
+ pass
+
+ @abc.abstractmethod
+ def set_state(self, state):
+ """
+ Update the displayed state of the VUI
+
+ Arguments:
+ state {models.VUIState} -- New state of the VUI
+ """
+ pass
+
\ No newline at end of file
diff --git a/src/nottreal/views/v_output_mvui.py b/src/nottreal/views/v_output_mvui.py
new file mode 100644
index 0000000..85bbc3b
--- /dev/null
+++ b/src/nottreal/views/v_output_mvui.py
@@ -0,0 +1,520 @@
+
+from ..utils.log import Logger
+from ..models.m_mvc import VUIState
+from .v_output_abstract import AbstractOutputView
+
+from PySide2.QtWidgets import (QGridLayout, QGraphicsOpacityEffect, QLabel, QScrollArea, QSizePolicy, QWidget)
+from PySide2.QtGui import (QBrush, QColor, QFont, QFontMetrics, QIcon, QPainter, QPainterPath, QPalette, QPen, QRadialGradient, QTextDocument, QTextFormat, QTextOption)
+from PySide2.QtCore import (Qt, QEasingCurve, QEventLoop, QPoint, QPointF, QPropertyAnimation, QRect, QRectF, QSizeF, QTimer, QVariantAnimation, Slot)
+
+import math, sounddevice, threading, numpy
+
+class MVUIWindow(AbstractOutputView):
+ """
+ A Mobile VUI-like output.
+
+ Extends:
+ QMainWindow
+ """
+ def __init__(self, nottreal, args, data, config):
+ """
+ A simple mobile-like VUI
+
+ Arguments:
+ nottreal {App} -- Main NottReal class
+ args {[str]} -- CLI arguments
+ data {TSVModel} -- Data from static data files
+ config {ConfigModel} -- Data from static configuration files
+ """
+ super(MVUIWindow, self).__init__(nottreal, args, data, config)
+
+ def init_ui(self):
+ """Initialise the UI"""
+ Logger.debug(__name__, 'Initialising the MVUI window')
+
+ self._background_colour = self.config.get('MVUI', 'background_colour');
+
+ self.setWindowTitle(self.config.get('MVUI', 'window_title'))
+ self.setStyleSheet('background-color: %s' % self._background_colour)
+
+ self.setGeometry(800, 10, 700, 800)
+
+ # create the layout
+ layout = QGridLayout()
+ layout.setVerticalSpacing(100)
+ layout.setHorizontalSpacing(100)
+ self.setLayout(layout)
+
+ layout.setRowStretch(0, .5)
+
+ # create the message widget
+ default_msg = self.nottreal.config.get('MVUI', 'initial_text')
+ self.message = MessageWidget(self, default_msg)
+ layout.addWidget(self.message, 1, 1)
+ layout.setRowStretch(1, 10)
+
+ layout.setRowStretch(2, .5)
+
+ # create the state widget (i.e. the orb)
+ self.state = Orb(self, VUIState.COMPUTING)
+ layout.addWidget(self.state, 3, 1)
+ layout.setRowStretch(3, 0)
+ layout.setRowMinimumHeight(3, self.state.size_max)
+
+ layout.setRowStretch(4, .5)
+
+ layout.setColumnStretch(0, 1)
+ layout.setColumnStretch(1, 10)
+ layout.setColumnStretch(2, 1)
+
+
+ def activated(self):
+ return True
+
+ def get_label(self):
+ return 'Mobile VUI'
+
+ def set_message(self, text):
+ """
+ A new message to show immediately
+
+ Arguments:
+ text {str} -- Text of the message
+ """
+ self.message.set(text)
+
+ def set_state(self, state):
+ """
+ Update the displayed state of the VUI
+
+ Arguments:
+ state {models.VUIState} -- New state of the VUI
+ """
+ self.state.set(state)
+
+class MessageWidget(QScrollArea):
+ """
+ Text displayed in the UI
+
+ Extends:
+ {QScrollArea}
+
+ Variables:
+ DOUBLE_CLICK_TIMER {int} -- Two clicks in this many ms is
+ a double click
+ ID, LABEL, TEXT {int} -- Column IDs for the prepared messages
+ list
+ """
+ DOUBLE_CLICK_TIMER = 450
+ ID, LABEL, TEXT = range(3)
+
+ def __init__(self, parent, default):
+ """
+ Create the label that'll show the messages to the user
+
+ Arguments
+ parent {QWidget} -- Parent widget
+ default {str} -- Default/inital text
+ """
+ self.parent = parent
+ self._cfg = parent.nottreal.config
+
+ super(MessageWidget, self).__init__(parent)
+
+ self.setWidgetResizable(True)
+ self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Expanding)
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ self.setAlignment(Qt.AlignTop|Qt.AlignCenter)
+
+ # create the label
+ typeface = self._cfg.cfg().get('MVUI', 'typeface')
+ font_size = self._cfg.cfg().getint('MVUI', 'font_size')
+ text_colour = self._cfg.cfg().get('MVUI', 'text_colour')
+
+ self._label = QLabel('%s
' % default)
+ self._label.setTextFormat(Qt.RichText)
+ self._label.setWordWrap(True)
+ self._label.setFont(QFont(typeface, font_size, QFont.Bold))
+ self._label.setStyleSheet('color: ' + text_colour)
+ self._label.setAlignment(Qt.AlignTop|Qt.AlignCenter)
+ self._label.setSizePolicy(
+ QSizePolicy.MinimumExpanding,
+ QSizePolicy.Maximum)
+ #setMaximumSize(self._scroll_widget.width() - 30, 1000)
+ self._label.setWindowOpacity(0)
+
+ self.setWidget(self._label)
+
+ self.effect = QGraphicsOpacityEffect()
+ self._label.setGraphicsEffect(self.effect)
+ self._animation = QPropertyAnimation(self.effect, b'opacity')
+ self._fade_in()
+
+ @Slot()
+ def _change_colour(self, color):
+ palette = self.palette()
+ palette.setColor(QPalette.WindowText, color)
+ self.setPalette(palette)
+
+ def _fade_in(self):
+ """
+ Fade the text in
+
+ From https://stackoverflow.com/questions/48191399/pyqt-fading-a-qlabel
+ """
+ self._animation.stop()
+ self._animation.setStartValue(0)
+ self._animation.setEndValue(1)
+ self._animation.setDuration(350)
+ self._animation.setEasingCurve(QEasingCurve.InBack)
+ self._animation.start()
+
+ def _fade_out(self):
+ """
+ Fade the text out
+
+ From https://stackoverflow.com/questions/48191399/pyqt-fading-a-qlabel
+ """
+ self._animation.setStartValue(1)
+ self._animation.setEndValue(0)
+ self._animation.setDuration(350)
+ self._animation.setEasingCurve(QEasingCurve.OutBack)
+ self._animation.start()
+
+ def set(self, text):
+ """
+ Change the message displayed
+
+ Arguments:
+ text {str} -- New message to show
+ """
+ html = '%s
' % text
+
+ self._fade_out()
+ loop = QEventLoop()
+ self._animation.finished.connect(loop.quit)
+ loop.exec_()
+
+ self._label.setText(html)
+
+ self._fade_in()
+ loop = QEventLoop()
+ self._animation.finished.connect(loop.quit)
+ loop.exec_()
+
+class Orb(QWidget):
+ """
+ Orb that shows the state of the VUI.
+
+ Extends:
+ {QWidget}
+
+ Variables:
+ FADE_OUT {int} -- Type used to determine opacity changing directions
+ FADE_IN {int} -- Type used to determine opacity changing directions
+ CALC_VOL_EVERY_MS {float} -- Frequency to recalculate the volume
+ SPEAKING_MIN_OPACITY {int} -- Minimum opacity for speaking glow
+ SPEAKING_OPACITY_CHANGE {int} -- Change in opacity per frame (/255)
+ COMPUTING_SLICE_CHANGE {int} -- Movement of slice per frame (/360)
+ STATE_FADE_OPACITY {int} -- Change in opacity per frame (/1)
+ REPAINT_EVERY_MS {float} -- How often to repaint (milliseconds)
+ """
+ NO_FADE, FADE_OUT, FADE_IN = range(0,3)
+
+ CALC_VOL_EVERY_MS = int(1/2*1000)
+
+ SPEAKING_MIN_OPACITY = 150
+ SPEAKING_OPACITY_CHANGE = 5
+ COMPUTING_SLICE_CHANGE = 8
+ STATE_FADE_OPACITY = .12
+
+ REPAINT_EVERY_MS = 1/12*1000
+
+ def __init__(self, parent, default):
+ """
+ Create the orb container
+
+ Arguments
+ parent {QWidget} -- Parent widget
+ default {int} -- Default/initial state
+ """
+ self.parent = parent
+ super(Orb, self).__init__(parent)
+
+ self._state = default
+
+ self.FADE_STEPSIZE = math.ceil(255 / self.STATE_FADE_OPACITY)
+ self._previous_state = None
+ self._previous_state_opacity = 255
+
+ cfg = parent.nottreal.config.cfg()
+
+ # initial sizes
+ self._border_width = cfg.getint('MVUI', 'orb_width')
+ double_border = 2*self._border_width
+ self._size = cfg.getint('MVUI', 'orb_size')
+ self._sizef = self._get_sizef(self._size, double_border)
+ self._rectf = QRectF(QPointF(0, 0), self._sizef)
+
+ self.size_max = cfg.getint('MVUI', 'orb_size_max')
+ self._sizef_max = self._get_sizef(self.size_max)
+
+ self._y_offset = (self._sizef_max.height() - self._size) / 2
+
+ # create orb base and glow circles
+ self._border = {}
+ self._border_glow = {}
+ self._border[VUIState.NOTHING] = cfg.get('MVUI', 'orb_nothing')
+ self._border[VUIState.LISTENING] = cfg.get('MVUI', 'orb_listening')
+ self._border[VUIState.COMPUTING] = cfg.get('MVUI', 'orb_computing')
+ self._border[VUIState.SPEAKING] = cfg.get('MVUI', 'orb_speaking')
+
+ # speaking glow
+ self._speaking_fade_opacity = 255
+ self._speaking_fade_direction = self.FADE_OUT
+
+ # computing slice
+ self._computing_slice_angle = 90
+
+ # enable fluttering?
+ self._enable_flutter = cfg.getboolean('MVUI', 'orb_enable_flutter')
+ if not self._enable_flutter:
+ Logger.info(__name__, 'Volume flutter is disabled');
+ self._flutter = 0.4
+
+ # start drawing
+ self._timer = QTimer(self)
+ self._timer.timeout.connect(self.update)
+ self._timer.start(self.REPAINT_EVERY_MS)
+
+ def _get_sizef(self, size, border = 0):
+ """
+ Calculate the QSizeF, reducing dimensions by the border size.
+
+ Arguments:
+ size {int} -- Size of the overall orb including border
+
+ Keyword Arguments:
+ border {int} -- Border size (default: {0})
+
+ Returns:
+ QSizeF -- Size of the orb (excluding border)
+ """
+ offset_size = size - border
+ return QSizeF(offset_size, offset_size)
+
+ def _set_volume_level_loop(self):
+ Logger.info(__name__, 'Listening to the mic for volume flutter');
+
+ self._flutter_variation = .2
+ self._flutter_variation_dir = self.FADE_IN
+
+ stream = sounddevice.InputStream(
+ callback=self._set_volume_level_callback)
+ with stream:
+ while self._hot_mic:
+ sounddevice.sleep(1000)
+
+ Logger.info(__name__, 'Stopped listening to the mic');
+
+ def _set_volume_level_callback(self, indata, frames, time, status):
+ volume_norm = numpy.linalg.norm(indata)
+ self._flutter = max(min(math.sin(volume_norm + self._flutter_variation * 1.4), .8), self._flutter_variation)
+
+ if self._flutter_variation_dir is self.FADE_OUT:
+ self._flutter_variation -= .002
+ else:
+ self._flutter_variation += .002
+
+ if self._flutter_variation > .4:
+ self._flutter_variation_dir = self.FADE_OUT
+ elif self._flutter_variation < .3:
+ self._flutter_variation_dir = self.FADE_IN
+
+ def paintEvent(self, e):
+ """
+ Repaint the orb
+
+ Arguments:
+ e {QPaintEvent} -- Event that covers the painting to be done
+ """
+ width = e.rect().width()
+ x_offset = (width - self._size) / 2
+
+ # fade out the previous state
+ if self._previous_state_opacity < 0:
+ if self._previous_state == VUIState.SPEAKING:
+ self.paint_speaking_orb(
+ colour = self._border[self._previous_state],
+ opacity = -self._previous_state_opacity,
+ x_offset = x_offset)
+ elif self._previous_state == VUIState.LISTENING:
+ self.paint_listening_orb(
+ colour = self._border[self._previous_state],
+ opacity = -self._previous_state_opacity,
+ x_offset = x_offset,
+ width = width)
+ elif self._previous_state == VUIState.COMPUTING:
+ self.paint_computing_orb(
+ colour = self._border[self._previous_state],
+ opacity = -self._previous_state_opacity,
+ x_offset = x_offset)
+ else:
+ self.paint_base_orb(
+ colour = self._border[self._previous_state],
+ opacity = -self._previous_state_opacity,
+ x_offset = x_offset)
+
+ self._previous_state_opacity += self.STATE_FADE_OPACITY
+
+ # fade in the new state
+ if (self._previous_state_opacity > -1 and
+ self._previous_state_opacity < 256):
+ if self._state == VUIState.SPEAKING:
+ self.paint_speaking_orb(
+ colour = self._border[self._state],
+ opacity = self._previous_state_opacity,
+ x_offset = x_offset)
+ elif self._state == VUIState.LISTENING:
+ self.paint_listening_orb(
+ colour = self._border[self._state],
+ opacity = self._previous_state_opacity,
+ x_offset = x_offset,
+ width = width)
+ elif self._state == VUIState.COMPUTING:
+ self.paint_computing_orb(
+ colour = self._border[self._state],
+ opacity = self._previous_state_opacity,
+ x_offset = x_offset)
+ else:
+ self.paint_base_orb(
+ colour = self._border[self._state],
+ opacity = self._previous_state_opacity,
+ x_offset = x_offset)
+
+ self._previous_state_opacity = \
+ min(1, self._previous_state_opacity + self.STATE_FADE_OPACITY)
+
+ def paint_base_orb(self, colour, opacity, x_offset):
+ qp_orb = QPainter()
+ qp_orb.begin(self)
+ qp_orb.setRenderHint(QPainter.Antialiasing)
+
+ qp_orb.setPen(
+ QPen(QColor(colour),
+ self._border_width,
+ Qt.SolidLine,
+ Qt.FlatCap,
+ Qt.MiterJoin))
+
+ qp_orb.setBrush(QColor(self.parent._background_colour))
+ qp_orb.setOpacity(opacity)
+ qp_orb.drawEllipse(self._rectf.translated(x_offset, self._y_offset))
+
+ def paint_speaking_orb(self, colour, opacity, x_offset):
+ if opacity == 1:
+ if self._speaking_fade_opacity <= self.SPEAKING_MIN_OPACITY:
+ self._speaking_fade_direction = self.FADE_IN
+ elif self._speaking_fade_opacity > 254:
+ self._speaking_fade_direction = self.FADE_OUT
+
+ self.paint_base_orb(
+ colour,
+ self._speaking_fade_opacity/255,
+ x_offset)
+
+ if self._speaking_fade_direction == self.FADE_IN:
+ self._speaking_fade_opacity += self.SPEAKING_OPACITY_CHANGE
+ else:
+ self._speaking_fade_opacity -= self.SPEAKING_OPACITY_CHANGE
+
+ else:
+ self._speaking_fade_opacity = 255
+ self._speaking_fade_direction = self.FADE_OUT
+
+ self.paint_base_orb(
+ colour,
+ opacity,
+ x_offset)
+
+ def paint_listening_orb(self, colour, opacity, x_offset, width):
+ opacity = max(self._flutter, opacity)
+ qp_orb = QPainter()
+ qp_orb.begin(self)
+ qp_orb.setRenderHint(QPainter.Antialiasing)
+
+ gradient = QRadialGradient(QPoint(width, width), width/2);
+ gradient.setColorAt(0, QColor(0, 0, 0, 1));
+ gradient.setColorAt(1, colour);
+
+ qp_orb.setBrush(
+ QBrush(gradient))
+ qp_orb.setPen(
+ QPen(QColor(colour),
+ self._border_width,
+ Qt.SolidLine,
+ Qt.FlatCap,
+ Qt.MiterJoin))
+
+ qp_orb.setOpacity(self._flutter)
+ qp_orb.drawEllipse(self._rectf.translated(x_offset, self._y_offset))
+
+
+ def paint_computing_orb(self, colour, opacity, x_offset):
+ self.paint_base_orb(colour, opacity, x_offset)
+
+ path_slice = QPainterPath()
+ ax = self._size + self._border_width
+ ay = 0
+
+ bx = self._size/2
+ by = self._size + self._border_width
+
+ path_slice.moveTo(0, 0)
+ path_slice.lineTo(ax, ay)
+ path_slice.lineTo(bx, by)
+ path_slice.lineTo(0, 0)
+ self._computing_slice = path_slice
+
+ qp_slice = QPainter()
+ qp_slice.begin(self)
+ qp_slice.setRenderHint(QPainter.Antialiasing)
+
+ centrex = x_offset - self._border_width + (self._size/2)
+ centrey = self._y_offset - self._border_width + (self._size/2)
+
+ qp_slice.save()
+ qp_slice.translate(centrex, centrey)
+ qp_slice.rotate(self._computing_slice_angle)
+
+ qp_slice.setOpacity(opacity)
+
+ qp_slice.fillPath(
+ self._computing_slice,
+ QColor(self.parent._background_colour))
+ qp_slice.restore()
+
+ self._computing_slice_angle = \
+ (self._computing_slice_angle + self.COMPUTING_SLICE_CHANGE) % 360
+
+ def set(self, state):
+ if (self._state != VUIState.LISTENING and
+ state == VUIState.LISTENING
+ and self._enable_flutter):
+ self._hot_mic = True
+ vol_thread = threading.Thread(target=self._set_volume_level_loop)
+ vol_thread.daemon = True
+ vol_thread.start()
+
+ if (self._state == VUIState.LISTENING and
+ state != VUIState.LISTENING
+ and self._enable_flutter):
+ self._hot_mic = False
+
+ if self._previous_state_opacity > 0:
+ self._previous_state = self._state
+ self._previous_state_opacity = -self._previous_state_opacity
+
+ self._state = state
+
\ No newline at end of file
diff --git a/src/nottreal/views/v_wizard.py b/src/nottreal/views/v_wizard.py
new file mode 100644
index 0000000..dfcb9ec
--- /dev/null
+++ b/src/nottreal/views/v_wizard.py
@@ -0,0 +1,1127 @@
+
+from ..utils.log import Logger
+from ..models.m_mvc import WizardOption
+
+from collections import OrderedDict, deque
+from PySide2.QtWidgets import (QAbstractItemView, QAction, QApplication, QCheckBox, QComboBox,
+ QDialogButtonBox, QGridLayout, QGroupBox,
+ QHBoxLayout, QLabel, QMainWindow,
+ QPlainTextEdit, QPushButton, QStyleFactory,
+ QVBoxLayout, QTabWidget, QTreeView, QWidget)
+from PySide2.QtGui import (QIcon, QTextCursor, QStandardItemModel, QPalette)
+from PySide2.QtCore import (Qt, QItemSelection, QItemSelectionModel, QTimer,
+ Slot)
+
+import sys, re
+
+class WizardWindow(QMainWindow):
+ """
+ The main window of the application (i.e. the Wizard's control panel)
+
+ Extends:
+ QMainWindow
+ """
+ def __init__(self, nottreal, args, data, config):
+ """
+ The Window for controlling the VUI
+
+ Arguments:
+ nottreal {App} -- Main NottReal class
+ args {[str]} -- CLI arguments
+ data {TSVModel} -- Data from static data files
+ config {ConfigModel} -- Data from static configuration files
+ """
+ self.nottreal = nottreal
+ self.args = args
+ self.data = data
+ self.config = config
+
+ # shortcuts
+ self.router = nottreal.router
+
+ Logger.debug(__name__, 'Initialising the Wizard window')
+
+ super(WizardWindow, self).__init__()
+ self.setWindowTitle(nottreal.appname)
+
+ # Window layout
+ layout = QGridLayout()
+ layout.setVerticalSpacing(0)
+
+ window_main = QWidget()
+ window_main.setLayout(layout)
+ self.setCentralWidget(window_main)
+
+ self._create_menu()
+
+ # add prepared messages
+ self.prepared_msgs = PreparedMessagesWidget(self, data.cats)
+ layout.addWidget(self.prepared_msgs, 0, 0)
+ layout.setRowStretch(0, 3)
+
+ # add slot history and message queue
+ row2widget = QGroupBox()
+ row2layout = QHBoxLayout()
+
+ row2widget.setContentsMargins(0, 5, 0, 0)
+
+ self.slot_history = SlotHistoryWidget(row2widget)
+ row2layout.addWidget(self.slot_history)
+
+ self.msg_queue = MessageQueueWidget(row2widget)
+ row2layout.addWidget(self.msg_queue)
+ row2widget.setLayout(row2layout)
+
+ layout.addWidget(row2widget, 1, 0)
+ layout.setRowStretch(1, 2)
+
+ # add the command area
+ self.command = CommandWidget(
+ self,
+ data.log_msgs,
+ data.loading_msgs)
+ layout.addWidget(self.command, 2, 0)
+ layout.setRowStretch(2, 1)
+
+ # add the runtime options area
+ self.options = OptionsWidget(self, {})
+ layout.addWidget(self.options, 3, 0)
+ layout.setRowStretch(3, 0)
+
+ # add the message history
+ self.msg_history = MessageHistoryWidget(self)
+ layout.addWidget(self.msg_history, 4, 0)
+ layout.setRowStretch(4, 1)
+
+ self.setGeometry(0, 0, 800, 600)
+
+ Logger.info(__name__, 'Wizard window ready')
+
+ def _create_menu(self):
+ """
+ Create the menu
+ """
+ main_menu = self.menuBar()
+ file_menu = main_menu.addMenu(_('File'))
+ wizard_menu = main_menu.addMenu(_('Wizard'))
+ output_menu = main_menu.addMenu(_('Output'))
+
+ exit_button = QAction(_('Quit'), self)
+ exit_button.setMenuRole(QAction.QuitRole)
+ exit_button.setShortcut('Ctrl+Q')
+ exit_button.setStatusTip(_('Quit %s' % self.nottreal.appname))
+ exit_button.triggered.connect(self.close)
+ file_menu.addAction(exit_button)
+
+ next_tab_button = QAction(_('Next tab'), self)
+ next_tab_button.setData('next_tab')
+ next_tab_button.setShortcut('Meta+Tab')
+ next_tab_button.setStatusTip(_('Move to the next tab/category'))
+ next_tab_button.triggered.connect(self._on_menu_item_selected)
+ wizard_menu.addAction(next_tab_button)
+
+ prev_tab_button = QAction(_('Previous tab'), self)
+ prev_tab_button.setData('prev_tab')
+ prev_tab_button.setShortcut('Meta+Shift+Tab')
+ prev_tab_button.setStatusTip(_('Move to the previous tab/category'))
+ prev_tab_button.triggered.connect(self._on_menu_item_selected)
+ wizard_menu.addAction(prev_tab_button)
+
+ wizard_menu.addSeparator()
+
+ interrupt_voice_button = QAction(_('Interrupt current voice output'), self)
+ interrupt_voice_button.setData('interrupt_output')
+ interrupt_voice_button.setShortcuts(['Meta+C','Ctrl+C'])
+ interrupt_voice_button.setStatusTip(_('Interrupt the current output and optionally clear the queue'))
+ interrupt_voice_button.triggered.connect(self._on_menu_item_selected)
+ wizard_menu.addAction(interrupt_voice_button)
+
+ first = True
+ for output in self.nottreal.view.output.items():
+ suffix = output[0]
+ name = output[1].get_label()
+
+ show_output_button = QAction(_('Show/hide %s window' % name), self)
+ show_output_button.setData('show_output_button_%s' % suffix)
+ show_output_button.setStatusTip(
+ _('Toggle the visibility of the %s window' % name))
+ show_output_button.triggered.connect(self._on_menu_item_selected)
+ if first:
+ show_output_button.setShortcut('Ctrl+W')
+ output_menu.addAction(show_output_button)
+
+ max_output_button = QAction(_('Maximise %s window') % name, self)
+ max_output_button.setData('max_output_button_%s' % suffix)
+ max_output_button.setStatusTip(
+ _('Toggle the maximisation of the %s window' % name))
+ max_output_button.triggered.connect(self._on_menu_item_selected)
+ if first:
+ max_output_button.setShortcut('Ctrl+Shift+F')
+ first = False
+ output_menu.addAction(max_output_button)
+
+ output_menu.addSeparator()
+
+ resting_orb_button = QAction(_('Trigger resting orb'), self)
+ resting_orb_button.setData('resting_orb_button')
+ resting_orb_button.setShortcut('Ctrl+R')
+ resting_orb_button.setStatusTip(_('Show the user that the Wizard is resting'))
+ resting_orb_button.triggered.connect(self._on_menu_item_selected)
+ output_menu.addAction(resting_orb_button)
+
+ computing_orb_button = QAction(_('Trigger busy orb'), self)
+ computing_orb_button.setData('computing_orb_button')
+ computing_orb_button.setShortcut('Ctrl+B')
+ computing_orb_button.setStatusTip(_('Show the user that the Wizard is computing'))
+ computing_orb_button.triggered.connect(self._on_menu_item_selected)
+ output_menu.addAction(computing_orb_button)
+
+ listening_orb_button = QAction(_('Trigger listening orb'), self)
+ listening_orb_button.setData('listening_orb_button')
+ listening_orb_button.setShortcut('Ctrl+L')
+ listening_orb_button.setStatusTip(_('Show the user that the Wizard is listening'))
+ listening_orb_button.triggered.connect(self._on_menu_item_selected)
+ output_menu.addAction(listening_orb_button)
+
+ output_menu.addSeparator()
+
+ @Slot()
+ def _on_menu_item_selected(self):
+ data = self.sender().data()
+ if data == 'resting_orb_button':
+ self.router('output', 'now_resting')
+ return
+ elif data == 'computing_orb_button':
+ self.router('output', 'now_computing')
+ return
+ elif data == 'listening_orb_button':
+ self.router('output', 'now_listening')
+ return
+ elif data == 'next_tab':
+ self.prepared_msgs.adjust_selected_tab(1)
+ return
+ elif data == 'prev_tab':
+ self.prepared_msgs.adjust_selected_tab(-1)
+ return
+ elif data == 'interrupt_output':
+ self.router('wizard', 'stop_speaking')
+ return
+ else:
+ for output in self.nottreal.view.output.items():
+ suffix = output[0]
+
+ if data == ('show_output_button_%s' % suffix):
+ self.router('output', 'toggle_show', output=suffix)
+ return
+ elif data == ('max_output_button_%s' % suffix):
+ self.router('output', 'toggle_maximise', output=suffix)
+ return
+
+ Logger.critical(__name__, 'Unknown menu item selected')
+
+ def closeEvent(self, event):
+ self.router('app', 'quit');
+ event.accept()
+
+class PreparedMessagesWidget(QTabWidget):
+ """
+ Tabbed view of prepared messages
+
+ Extends:
+ {QTabWidget}
+
+ Variables:
+ DOUBLE_CLICK_TIMER {int} -- Two clicks in this many ms is
+ a double click
+ ID, LABEL, TEXT {int} -- Column IDs for the prepared messages
+ list
+ """
+ DOUBLE_CLICK_TIMER = 450
+ ID, LABEL, TEXT = range(3)
+
+ def __init__(self, parent, cats):
+ """
+ Create the tabs and lists of prepared messages.
+
+ Arguments
+ parent {QWidget} -- Parent widget
+ cats {dict(str,str)} -- Categories and their messages
+ """
+ super(PreparedMessagesWidget, self).__init__(parent)
+
+ self.parent = parent
+
+ self.selected_msg = None
+
+ self._cats = cats
+ self._msgs_models = OrderedDict()
+ self._msgs_widgets = OrderedDict()
+
+ first = True
+ for cat_id, cat in cats.items():
+ treeview = QTreeView()
+ treeview.setRootIsDecorated(False)
+ treeview.setAlternatingRowColors(True)
+
+ model = QStandardItemModel(0, 3, self)
+ model.setHeaderData(self.ID, Qt.Horizontal, _('ID'))
+ model.setHeaderData(self.LABEL, Qt.Horizontal, _('Label'))
+ model.setHeaderData(self.TEXT, Qt.Horizontal, _('Text'))
+
+ treeview.clicked.connect(self._on_msg_doubleclick_check)
+ treeview.keyReleaseEvent = self._on_msg_key_release
+
+ self._doubleclick_timer = QTimer()
+ self._doubleclick_timer.setSingleShot(True)
+ self._doubleclick_timer.timeout.connect(self._on_msg_click)
+
+ treeview.setModel(model)
+ treeview.setSelectionMode(QAbstractItemView.SingleSelection)
+ treeview.setEditTriggers(QAbstractItemView.NoEditTriggers)
+
+ for idx, msg in enumerate(cat['msgs']):
+ model.insertRow(idx)
+ model.setData(model.index(idx, self.ID), msg['id'])
+ model.setData(model.index(idx, self.LABEL), msg['label'])
+ model.setData(model.index(idx, self.TEXT), msg['text'])
+
+ treeview.resizeColumnToContents(0)
+ treeview.resizeColumnToContents(1)
+
+ widget = QWidget()
+ layout = QVBoxLayout()
+ layout.addWidget(treeview)
+ widget.setLayout(layout)
+
+ self._msgs_models[cat_id] = model
+ self._msgs_widgets[cat_id] = treeview
+ self.addTab(widget, cat['label'])
+
+ self.currentChanged.connect(self._on_tab_change)
+ self.resize(300,200)
+
+ def selected_tab_label(self):
+ """
+ Get the label of the currently selected tab
+
+ Returns:
+ {str}
+ """
+ return self.tabText(self.currentIndex())
+
+ def adjust_selected_tab(self, adjustment):
+ """
+ Change the currently selected tab by the value of {adjustment}
+
+ Arguments:
+ adjustment {int} -- A positive or negative integer
+ """
+ num_tabs = len(self._msgs_widgets)
+ curr_tab = self.currentIndex()
+ new_tab = (curr_tab+adjustment) % num_tabs
+
+ self.setCurrentIndex(new_tab)
+
+ def _speak_msg(self, msg_id):
+ """
+ Speak a message with the text from a prepared message.
+
+ Arguments:
+ msg_id {str} -- Message ID
+ """
+ self.selected_msg = msg_id
+ msgs = list(self._cats.values())[self.currentIndex()]['msgs']
+ msg = next(msg for msg in msgs if msg['id'] == msg_id)
+
+ self.parent.command.speak_text(msg['text'])
+
+ def _fill_msg(self, msg_id):
+ """
+ Fill the text box with the text from a prepared message.
+
+ Arguments:
+ msg_id {str} -- Message ID
+ """
+ self.selected_msg = msg_id
+ msgs = list(self._cats.values())[self.currentIndex()]['msgs']
+ msg = next(msg for msg in msgs if msg['id'] == msg_id)
+
+ self.parent.command.set_text(msg['text'])
+
+ def _set_msgs(self, model, msgs):
+ """Set the messages for a particular model
+
+ Arguments:
+ model {QStandardItemModel} -- Model of prepared messages
+ msgs {dict} -- Messages with `id`, `label`, & `text` values
+ """
+ for idx, msg in enumerate(msgs):
+ model.insertRow(idx)
+ model.setData(model.index(idx, self.ID), msg['id'])
+ model.setData(model.index(idx, self.LABEL), msg['label'])
+ model.setData(model.index(idx, self.TEXT), msg['text'])
+
+ def _get_selected_msg(self, treeview):
+ """
+ Get the ID of the currently selected message in a treeview
+
+ Arguments:
+ treeview {QTreeVIew} -- List widget that shows all
+ prepared messages
+
+ Returns:
+ {str} -- Message ID
+ """
+ model = treeview.model()
+
+ selected_indicies = treeview.selectionModel().selectedIndexes()
+ selected_index = selected_indicies[0]
+
+ index = model.index(
+ selected_index.row(),
+ self.ID,
+ selected_index.parent())
+
+ return model.itemData(index)[0]
+
+ @Slot()
+ def _on_tab_change(self):
+ """Identify the tab to notify other areas of the application"""
+ tab_index = self.currentIndex()
+ cat_id = list(self._cats.keys())[tab_index]
+ treeview = self._msgs_widgets[cat_id]
+ model = treeview.model()
+
+ treeview.setFocus()
+ if model.rowCount() > 0:
+ selection_model = treeview.selectionModel()
+ selection_model.clear()
+ selection_model.setCurrentIndex(
+ model.index(0, 0),
+ QItemSelectionModel.Select|QItemSelectionModel.Rows)
+
+ self.parent.router('wizard', 'tab_changed', new_tab=cat_id)
+
+ @Slot()
+ def _on_msg_click(self):
+ """
+ Slot called when a message is clicked in the list view
+
+ Double clicks are proxied through a timer to prevent the click
+ slot and the double click slot firing
+
+ Decorators:
+ {Slot}
+ """
+ try:
+ if isinstance(self.sender(), QTimer):
+ msg_id = self._get_selected_msg(self._timerProxyFor)
+ else:
+ msg_id = self._get_selected_msg(self.sender())
+
+ self._fill_msg(msg_id)
+ except:
+ Log.error(__name__, 'Message unclicked?')
+
+ @Slot()
+ def _on_msg_doubleclick(self):
+ """
+ Slot called when a message is double clicked in the list view
+
+ Decorators:
+ {Slot}
+ """
+ try:
+ msg_id = self._get_selected_msg(self.sender())
+ self._speak_msg(msg_id)
+ except IndexError:
+ Log.warning(__name__, 'Message unclicked?')
+
+ @Slot()
+ def _on_msg_doubleclick_check(self):
+ """
+ Start/check previous timer to see if this is a double click or
+ a single click. If the last click was within the doubleclick
+ threshold then call the double click function.
+
+ Decorators:
+ {Slot}
+ """
+ if self._doubleclick_timer.isActive():
+ self._doubleclick_timer.stop()
+ self._on_msg_doubleclick()
+ else:
+ self._doubleclick_timer.start(self.DOUBLE_CLICK_TIMER)
+ self._timerProxyFor = self.sender()
+
+ @Slot()
+ def _on_msg_key_release(self, event):
+ """
+ Handle key releases on the message list, to catch the return
+ key being pressed. Enter is the same as clicking, Ctrl+Eenter
+ (cmd on a Mac) is the same as double clicking.
+
+ Decorators:
+ {Slot}
+
+ Arguments:
+ event {KeyEvent} -- Event triggered by the key release
+ """
+ if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter:
+ tab_index = self._msg_tabs.currentIndex()
+ cat_id = list(self._cats.keys())[tab_index]
+ treeview = self._msg_widgets[cat_id]
+ msg_id = self.selected_msg_id(treeview)
+ if (event.modifiers() == Qt.ControlModifier):
+ self._speak_msg(msg_id)
+ else:
+ self._fill_msg(msg_id)
+ else:
+ event.accept()
+
+class SlotHistoryWidget(QTreeView):
+ """
+ A list of previously filled slots in a treeview widget
+
+ Extends:
+ {QTreeView}
+
+ Variables:
+ SLOT_NAME, SLOT_VALUE {int} -- Column IDs for the slot history
+ """
+ SLOT_NAME, SLOT_VALUE = range(2)
+
+ def __init__(self, parent):
+ """Create the list for queued messages
+
+ Arguments
+ parent {QWidget} -- Parent widget
+ """
+ super(SlotHistoryWidget, self).__init__(parent)
+
+ self.parent = parent
+
+ self.setRootIsDecorated(False)
+ self.setAlternatingRowColors(True)
+
+ self.model = QStandardItemModel(0, 2, self)
+ self.model.setHeaderData(
+ self.SLOT_NAME,
+ Qt.Horizontal,
+ _('Slot'))
+ self.model.setHeaderData(
+ self.SLOT_VALUE,
+ Qt.Horizontal,
+ _('Previously entered value'))
+
+ self.setModel(self.model)
+ self.setSelectionMode(QAbstractItemView.NoSelection)
+ self.setEditTriggers(QAbstractItemView.NoEditTriggers)
+
+ def add(self, name, value):
+ """
+ Add an item to the list of used slots
+
+ Arguments:
+ name {str} -- Slot name
+ value {str} -- User entered value
+ """
+ self.model.insertRow(0)
+ self.model.setData(self.model.index(0, self.SLOT_NAME), name)
+ self.model.setData(self.model.index(0, self.SLOT_VALUE), value)
+
+class MessageQueueWidget(QTreeView):
+ """
+ A list of queued messages in a treeview widget
+
+ Extends:
+ QTreeView
+
+ Variables:
+ UPCOMING_MESSAGE {int} -- Column ID for the queue
+ """
+ QUEUED_MESSAGE = 0
+
+ def __init__(self, parent):
+ """Create the list for queued messages
+
+ Arguments
+ parent {QWidget} -- Parent widget
+ """
+ super(MessageQueueWidget, self).__init__(parent)
+
+ self.parent = parent
+
+ self._queued_messages = deque()
+
+ self.setRootIsDecorated(False)
+ self.setAlternatingRowColors(True)
+
+ self.model = QStandardItemModel(0, 1, self)
+ self.model.setHeaderData(
+ self.QUEUED_MESSAGE,
+ Qt.Horizontal,
+ _('Queued message'))
+
+ self.setModel(self.model)
+ self.setSelectionMode(QAbstractItemView.NoSelection)
+ self.setEditTriggers(QAbstractItemView.NoEditTriggers)
+
+ def add(self, text):
+ """
+ Add an item to the top of the model of the message queue
+
+ Arguments:
+ text {str} -- Text to add to the queue
+ """
+ self._queued_messages.append(text)
+ self.model.insertRow(0)
+ self.model.setData(self.model.index(0, self.QUEUED_MESSAGE), text)
+
+ def clear(self):
+ """
+ Clear the message queue
+ """
+ self.model.removeRows(0, len(self._queued_messages))
+ self._queued_messages.clear()
+
+ def remove(self, text):
+ """
+ Remove an item from the queued messages.
+
+ Arguments:
+ text {str} -- Text to delete from the queue
+ """
+ try:
+ idx = self._queued_messages.index(text)
+ idxr = len(self._queued_messages) - idx - 1
+ self.model.removeRow(idxr)
+ del self._queued_messages[idx]
+ except ValueError:
+ pass
+
+class CommandWidget(QGroupBox):
+ """
+ Primary command area that includes the text box
+
+ Extends:
+ {QGroupBox}
+
+ Variables:
+ RE_TEXT_SLOT {str} -- Regex matching slots in text
+ RE_TEXT_SLOT_REPL {str} -- Symbol that denotes this
+ slot value can auto changed to the previously used value
+ (if it exists)
+ RE_TEXT_SLOT_REPL {str} -- Symbol that denotes this
+ slot's value tracking should be stopped
+ """
+ RE_TEXT_SLOT = '\[([\w /\*\$|]*)\]'
+ RE_TEXT_SLOT_REPL = '*'
+ RE_TEXT_SLOT_ENDREPL = '$'
+
+ def __init__(self, parent, log_msgs, loading_msgs):
+ """
+ Create the area to type text and submit it to the voice
+ subsystem
+
+ Arguments
+ parent {QWidget} -- Parent widget
+ log_msgs {dict} -- Configured log messages
+ loading_msgs {dict} -- Configured loading messages
+ """
+ super(CommandWidget, self).__init__(parent)
+
+ self.parent = parent
+
+ self._reset_slot_tracking()
+ self.clear_saved_slots()
+
+ self.setContentsMargins(0, 5, 0, 0)
+
+ layout = QVBoxLayout()
+ layout.setSpacing(0)
+ self.setLayout(layout)
+
+ # text area
+ self._text_speak = QPlainTextEdit()
+ self._on_text_key_press_input = self._text_speak.keyPressEvent
+ self._text_speak.keyPressEvent = self._on_text_key_press
+ layout.addWidget(self._text_speak)
+
+ # options for log mesages
+ self._combo_log_messages = QComboBox()
+ self._combo_log_messages.currentIndexChanged.connect(
+ self._on_log_message)
+ self._combo_log_messages.addItem(_('Log an event'))
+ for key, value in log_msgs.items():
+ self._combo_log_messages.addItem(value['message'],
+ value['id'])
+
+ # options for loading messages
+ self._combo_loading_messages = QComboBox()
+ self._combo_loading_messages.currentIndexChanged.connect(
+ self._on_loading_message)
+ self._combo_loading_messages.addItem(
+ _('Send a loading message...'))
+ for key, value in loading_msgs.items():
+ self._combo_loading_messages.addItem(value['message'])
+
+ # clear and speak buttons
+ self._button_clear = QPushButton(_('Clear'))
+ self._button_clear.clicked.connect(self._on_clear)
+
+ self._button_speak = QPushButton(_('Speak'))
+ self._button_speak.clicked.connect(self._on_speak)
+ self._button_speak.setDefault(True)
+ self._button_speak.setAutoDefault(True)
+
+ # assemble it all
+ buttonBar = QGroupBox()
+ buttonBar.setContentsMargins(0, 5, 0, 0)
+ buttonBarLayout = QHBoxLayout()
+ buttonBar.setLayout(buttonBarLayout)
+ buttonBarLayout.addWidget(self._combo_log_messages)
+ buttonBarLayout.addWidget(self._combo_loading_messages)
+
+ buttonBox = QDialogButtonBox()
+ buttonBox.addButton(self._button_clear, QDialogButtonBox.HelpRole)
+ buttonBox.addButton(self._button_speak, QDialogButtonBox.HelpRole)
+ buttonBarLayout.addWidget(buttonBox)
+
+ layout.addWidget(buttonBar)
+
+ def set_text(self, text):
+ """
+ Set the message text
+
+ Arguments:
+ text {str} -- Text to set
+ """
+ self._text_speak.setPlainText(text)
+ self._text_speak.setFocus()
+ self._select_msg_slot(from_start = True)
+
+ def speak_text(self, text, loading=False):
+ """
+ Send text to the voice subsystem. if it contains slots, then
+ the first slot is selected and the text will not be sent. When
+ sent the textbox is cleared.
+
+ Arguments:
+ text {str} -- Text to speak
+ loading {bool} -- Is a loading message (default: False)
+ """
+ text = text.strip()
+ if len(text) > 0:
+ requires_editing = False
+ min_match_idx = 0
+
+ matches = re.finditer(self.RE_TEXT_SLOT, text)
+ for match in matches:
+ start = match.start(0)
+ end = match.end(0)
+
+ autoreplace = match.group(0)[1:-1].endswith(self.RE_TEXT_SLOT_REPL)
+ autoreplace_end = match.group(0)[1:-1].endswith(self.RE_TEXT_SLOT_ENDREPL)
+ name = match.group(0)[1:-1].replace(self.RE_TEXT_SLOT_REPL, '')
+ name = name.replace(self.RE_TEXT_SLOT_ENDREPL, '')
+
+ if (autoreplace or autoreplace_end) and name in self._saved_slots:
+ value = self._saved_slots[name]
+ Logger.debug(
+ __name__,
+ 'Replacing slot "%s" with value "%s"' % (name, value))
+ text = text.replace(match.group(0), value)
+
+ if autoreplace_end:
+ del self._saved_slots[name]
+ else:
+ requires_editing = True
+
+ if requires_editing:
+ Logger.debug(
+ __name__,
+ 'Message has slots that aren\'t filled ("%s")' % name)
+ self.set_text(text)
+ else:
+ self._record_last_msg_slot()
+ self.parent.router(
+ 'wizard',
+ 'speak_text',
+ text=text,
+ cat=self.parent.prepared_msgs.selected_tab_label(),
+ id=self.parent.prepared_msgs.selected_msg,
+ slots=self._current_slots,
+ loading=loading)
+ self._reset_slot_tracking()
+ self._text_speak.setPlainText('')
+
+ def clear_saved_slots(self):
+ """
+ Clear saved slots
+ """
+ self._saved_slots = {}
+
+ def _reset_slot_tracking(self):
+ """
+ Reset the tracking of text slots.
+ """
+ self._current_slots = {}
+ self._cache_slot_autoreplace = False
+ self._cache_slot_autoreplace_end = False
+ self._cache_slot_name = None
+ self._cache_slot_before = None
+ self._cache_slot_after = None
+
+ def _record_last_msg_slot(self):
+ """
+ Called once all the message slots have been selected, and at
+ the selection of every message slot.
+
+ Saves the previously entered message slot.
+ """
+ text = self._text_speak.toPlainText()
+ if self._cache_slot_name is not None and text.startswith(self._cache_slot_before):
+ slot_value = text[len(self._cache_slot_before):]
+ slot_value = slot_value[:-len(self._cache_slot_after)]
+ try:
+ if slot_value[0] != '[':
+ self._current_slots[self._cache_slot_name] = slot_value
+ if self._cache_slot_autoreplace_end:
+ try:
+ del self._saved_slots[self._cache_slot_name]
+ except KeyError:
+ pass
+ self._cache_slot_autoreplace = False
+ elif self._cache_slot_autoreplace:
+ self._saved_slots[self._cache_slot_name] = slot_value
+ except IndexError:
+ pass
+
+ def _select_msg_slot(
+ self,
+ from_start = False,
+ reverse = False,
+ loop = True):
+ """
+ Select the next sloteter in the prepared message. Slots are
+ encoded as text between square brackets, [like] so.
+
+ If there are no more slots, then the cursor/selection remain
+ unchanged.
+
+ Arguments:
+ from_start {bool} -- Seek from start of input (True) or
+ the current position ({False}). If {reverse} is
+ true, will start from the end (Default: {False})
+ reverse {bool} -- Select the previous item
+ loop {bool} -- If at the end, loop back to the start
+
+ Returns:
+ {bool} -- True if sloteter selected, False if none exists
+ """
+ text = self._text_speak.toPlainText()
+ current_pos = self._text_speak.textCursor().position()
+
+ self._record_last_msg_slot()
+
+ if reverse:
+ if from_start:
+ current_pos = len(text)
+ Logger.debug(
+ __name__,
+ 'Seek to previous sloteter in the message from the end')
+ else:
+ current_pos -= 1
+ Logger.debug(
+ __name__,
+ ('Seek to previous sloteter in the message from pos %d'
+ % current_pos))
+
+ for match in re.finditer(self.RE_TEXT_SLOT, text[:current_pos]):
+ pass
+
+ # reverse doesn't need this offset
+ current_pos = 0
+ else:
+ if from_start:
+ current_pos = 0
+ #Logger.debug(__name__, 'Seek to next sloteter in the message from the start')
+ #else:
+ #Logger.debug(__name__, 'Seek to next sloteter in the message from pos %d' % current_pos)
+
+ match = re.search(self.RE_TEXT_SLOT, text[current_pos:])
+
+ try:
+ if match:
+ start = match.start(0)
+ end = match.end(0)
+
+ self._cache_slot_autoreplace = match.group(0)[1:-1].endswith(self.RE_TEXT_SLOT_REPL)
+ self._cache_slot_autoreplace_end = match.group(0)[1:-1].endswith(self.RE_TEXT_SLOT_ENDREPL)
+
+ self._cache_slot_name = match.group(0)[1:-1].replace(self.RE_TEXT_SLOT_REPL, '')
+ self._cache_slot_before = text[0:start+current_pos]
+
+ if self._cache_slot_autoreplace_end:
+ self._cache_slot_name = match.group(0)[1:-1].replace(self.RE_TEXT_SLOT_ENDREPL, '')
+ self._cache_slot_autoreplace = True
+
+ after_pos = start + current_pos + len(self._cache_slot_name) + 2
+ if (self._cache_slot_autoreplace or
+ self._cache_slot_autoreplace_end):
+ after_pos += 1
+
+ self._cache_slot_after = text[after_pos:]
+
+ self._text_speak.moveCursor(QTextCursor.Start)
+ for charn in range(0,start+current_pos):
+ self._text_speak.moveCursor(QTextCursor.NextCharacter)
+ for charn in range(0,end-start):
+ self._text_speak.moveCursor(
+ QTextCursor.NextCharacter,
+ mode=QTextCursor.KeepAnchor)
+ return True
+ elif loop:
+ # only hit here if moving forward and at the end
+ self._select_msg_slot(from_start=True, loop=False)
+ return True
+ else:
+ return False
+ except UnboundLocalError:
+ # we'll get here if moving backward and at the start
+ if loop:
+ self._select_msg_slot(
+ from_start=True,
+ reverse=True,
+ loop=False)
+ else:
+ return False
+
+ @Slot()
+ def _on_text_key_press(self, event):
+ """
+ Respond to key presses on the text box. This function replaces
+ the default key press method (which is moved to
+ {_on_text_key_press_input()}). This will swallow returns and
+ tabs presses.
+
+ Tab/enter moves between slots (i.e. next in [square] brackets
+ in the prepared message), with the shift modifier returning
+ the direction of travel.
+
+ Ctrl+enter (cmd on a Mac) presses the 'Speak' button.
+
+ Decorators:
+ Slot
+
+ Arguments:
+ event {QKeyEvent} -- Event triggered by the key press
+ """
+ all_highlighted = self._text_speak.toPlainText() == self._text_speak.textCursor().selectedText()
+
+ if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter:
+ if (event.modifiers() == Qt.ControlModifier):
+ self._on_speak()
+ elif (event.modifiers() == Qt.ShiftModifier):
+ self._select_msg_slot(reverse=True)
+ elif self._select_msg_slot():
+ self._on_speak()
+ elif (event.key() == Qt.Key_C and
+ ((event.modifiers() == Qt.ControlModifier) or
+ (event.modifiers() == Qt.MetaModifier))):
+ self.router('wizard', 'stop_speaking')
+ elif event.key() == Qt.Key_Tab:
+ self._select_msg_slot()
+ elif event.key() == Qt.Key_Backtab:
+ self._select_msg_slot(reverse=True)
+ elif all_highlighted:
+ self.parent.prepared_msgs.selected_msg = None
+ self._on_text_key_press_input(event)
+ else:
+ self._on_text_key_press_input(event)
+
+ @Slot(int)
+ def _on_log_message(self, num):
+ """
+ Log an event
+
+ Arguments:
+ num {int} -- Selected item
+
+ Decorators:
+ Slot
+ """
+ if num > 0:
+ id = self._combo_log_messages.currentData()
+ text = self._combo_log_messages.currentText()
+ self.parent.router('wizard', 'log_message', id=id, text=text)
+ self._combo_log_messages.setCurrentIndex(0)
+
+ @Slot(int)
+ def _on_loading_message(self, num):
+ """
+ Loading message selected, send it as if it was spoken
+
+ Arguments:
+ num {int} -- Selected item
+
+ Decorators:
+ Slot
+ """
+ if num > 0:
+ text = self._combo_loading_messages.currentText()
+ self.speak_text(text, loading=True)
+ self._combo_loading_messages.setCurrentIndex(0)
+
+ @Slot()
+ def _on_clear(self):
+ """
+ Clear button pressed -- just remove the text from the text box
+
+ Decorators:
+ Slot
+ """
+ self._reset_slot_tracking()
+ self.parent.prepared_msgs.selected_msg = None
+ self._text_speak.setPlainText('')
+
+ @Slot()
+ def _on_speak(self):
+ """
+ Speak button pressed, so speak the text if there are no
+ slots in it
+
+ Decorators:
+ Slot
+ """
+ text = self._text_speak.toPlainText()
+ self.speak_text(text)
+
+class OptionsWidget(QGroupBox):
+ """
+ Runtime options
+
+ Extends:
+ {QGroupBox}
+
+ Variables:
+ OPTIONS_COLUMNS {int} -- Number of columns of options
+ """
+ OPTIONS_COLUMNS = 2
+
+ def __init__(self, parent, options = {}):
+ """
+ Create the area for runtime Wizard options
+
+ Arguments
+ parent {QWidget} -- Parent widget
+ options {dict} -- Runtime options
+ """
+ super(OptionsWidget, self).__init__(parent)
+
+ self.parent = parent
+
+ self.setContentsMargins(10, 5, 0, 0)
+
+ self.layout = QGridLayout()
+ self.setLayout(self.layout)
+
+ self._options = {}
+ for label, option in options.items():
+ self.add(option)
+
+ def add(self, option):
+ """
+ Add wizard option to the manager window
+
+ Arguments:
+ option {models.nottreal.WizardOption} -- A wizard option
+ """
+ if option.label not in self._options:
+ checkbox = QCheckBox(option.label, self)
+ checkbox.setCheckState(Qt.Checked if option.value else Qt.Unchecked)
+ checkbox.stateChanged.connect(self._option_changed)
+
+ row = len(self._options) // self.OPTIONS_COLUMNS
+ col = len(self._options) % self.OPTIONS_COLUMNS
+
+ self.layout.addWidget(checkbox, row, col)
+
+ option.ui = checkbox
+ option.added = True
+ self._options[option.label] = option
+
+ @Slot()
+ def _option_changed(self):
+ """
+ Slot when an option is changed
+
+ Arguments:
+ option {models.nottreal.WizardOption} -- A wizard option
+ """
+ label = self.sender().text()
+ try:
+ checkbox = self._options[label].ui
+ state = True if checkbox.checkState() == Qt.Checked else False
+ self._options[label].method(state)
+ except KeyError:
+ Logger.error(
+ __name__,
+ 'Could not find registered option with label "%s"' % label)
+ pass
+
+class MessageHistoryWidget(QGroupBox):
+ """
+ A list of previously sent messages in a treeview widget inside a groupbox
+
+ Extends:
+ QGroupBox
+
+ Variables:
+ SPOKEN_MESSAGE {int} -- Column ID for the sent message
+ """
+ SPOKEN_MESSAGE = 0
+
+ def __init__(self, parent):
+ """
+ Create the list for sent messages
+
+ Arguments
+ parent {QWidget} -- Parent widget
+ """
+ super(MessageHistoryWidget, self).__init__(parent)
+
+ self.parent = parent
+
+ self.setContentsMargins(0, 5, 0, 0)
+
+ self.layout = QGridLayout()
+ self.setLayout(self.layout)
+
+ self._widget = QTreeView(self)
+ self._widget.setRootIsDecorated(False)
+ self._widget.setAlternatingRowColors(True)
+
+ self.model = QStandardItemModel(0, 1, self)
+ self.model.setHeaderData(
+ self.SPOKEN_MESSAGE,
+ Qt.Horizontal,
+ _('Previously spoken message'))
+
+ self._widget.setModel(self.model)
+ self._widget.setSelectionMode(QAbstractItemView.NoSelection)
+ self._widget.setEditTriggers(QAbstractItemView.NoEditTriggers)
+
+ self.layout.addWidget(self._widget)
+
+ def add(self, text):
+ """
+ Add an item to the top of the model of history of spoken messages
+
+ Arguments:
+ text {str} -- Text to add to the queue
+ """
+ self.model.insertRow(0)
+ self.model.setData(self.model.index(0, self.SPOKEN_MESSAGE), text)
+
\ No newline at end of file