# -*- coding: utf-8 -*-
"""
The :mod:`neurotic.gui.standalone` module implements the main window of the
app.
.. autoclass:: MainWindow
"""
import os
import gc
import platform
import requests
import subprocess
import pkg_resources
from packaging import version
import quantities as pq
import neo
from ephyviewer import QT, QT_MODE
from .. import __version__, _elephant_tools, default_log_level, log_file
from ..datasets import MetadataSelector, load_dataset
from ..datasets.metadata import _selector_labels
from ..gui.config import EphyviewerConfigurator
import logging
logger = logging.getLogger(__name__)
def open_path_with_default_program(path):
"""
Open a directory or file with its default program.
"""
if platform.system() == "Windows":
os.startfile(path)
elif platform.system() == "Darwin":
subprocess.Popen(["open", path])
else:
subprocess.Popen(["xdg-open", path])
def open_url(url):
"""
Open a URL in a browser.
"""
QT.QDesktopServices.openUrl(QT.QUrl(url))
def get_versioned_docs_url():
"""
Get the URL for the docs that best fits the package version.
"""
url = 'https://neurotic.readthedocs.io/en/'
if version.parse(__version__).is_devrelease:
url += 'latest'
else:
url += version.parse(__version__).base_version
return url
[docs]class MainWindow(QT.QMainWindow):
"""
The main window of the app.
"""
request_download = QT.pyqtSignal()
request_load_dataset = QT.pyqtSignal()
def __init__(self, file=None, initial_selection=None, lazy=True, theme='light', ui_scale='small', support_increased_line_width=False, show_datetime=False):
"""
Initialize a new MainWindow.
"""
QT.QMainWindow.__init__(self)
self.setWindowIcon(QT.QIcon(':/neurotic-logo-150.png'))
self.setWindowTitle('neurotic')
self.resize(600, 300)
self.statusBar()
# lazy loading using Neo RawIO
self.lazy = lazy
# available themes are 'light', 'dark', 'original', and 'printer-friendly'
self.theme = theme
# available sizes are 'tiny', 'small', 'medium', 'large', and 'huge'
self.ui_scale = ui_scale
self.default_font_size = QT.QFont().pointSize()
# support_increased_line_width=True eliminates the extremely poor
# performance associated with TraceViewer's line_width > 1.0, but it
# also degrades overall performance somewhat and uses a mode of
# pyqtgraph that is reportedly unstable
self.support_increased_line_width = support_increased_line_width
# show_datetime=True will display the real-world date and time next to
# the in-file time, but this may be inaccurate for several reasons,
# e.g. if data acquisition was paused and continued after some delay or
# if an AxoGraph chart was not started immediately after creation
self.show_datetime = show_datetime
# windows are appended to this list so that they persist after the
# function that spawned them returns
self.windows = []
# metadata selector
self.metadata_selector = _MetadataSelectorQt(self)
# loading label
loading_label = QT.QLabel('Launching, please wait...')
loading_label.setFrameStyle(QT.QFrame.Panel | QT.QFrame.Sunken)
loading_label.setAlignment(QT.Qt.AlignCenter)
font = loading_label.font()
font.setPointSize(self.default_font_size+4)
loading_label.setFont(font)
# initially stack the metadata selector above the loading label
self.stacked_layout = QT.QStackedLayout()
self.stacked_layout.addWidget(self.metadata_selector) # index 0
self.stacked_layout.addWidget(loading_label) # index 1
central_widget = QT.QWidget()
central_widget.setLayout(self.stacked_layout)
self.setCentralWidget(central_widget)
# create a worker thread for downloading data
self.download_thread = QT.QThread()
self.download_worker = _DownloadWorker(self)
self.download_worker.moveToThread(self.download_thread)
self.request_download.connect(self.download_worker.download)
self.download_worker.download_finished.connect(self.on_download_finished)
# create a worker thread for loading datasets
self.load_dataset_thread = QT.QThread()
self.load_dataset_worker = _LoadDatasetWorker(self)
self.load_dataset_worker.moveToThread(self.load_dataset_thread)
self.request_load_dataset.connect(self.load_dataset_worker.load_dataset)
self.load_dataset_worker.load_dataset_finished.connect(self.on_load_dataset_finished)
self.load_dataset_worker.show_status_msg.connect(self.statusBar().showMessage)
self.blk = None
# construct the menus
self.create_menus()
# open metadata file
if file is not None:
# try the user-specified file
self.metadata_selector.file = file
self.metadata_selector.load()
if self.metadata_selector.all_metadata is None:
# use an example metadata file if the user-specified file failed to
# load or one was not provided
self.metadata_selector.file = pkg_resources.resource_filename('neurotic', 'example/metadata.yml')
self.metadata_selector.load()
# select a dataset if the user provided one
if initial_selection is not None:
try:
self.metadata_selector.setCurrentRow(list(self.metadata_selector.all_metadata).index(initial_selection))
except (TypeError, ValueError) as e:
logger.error(f'Bad dataset key, will ignore: {e}')
self.statusBar().showMessage('ERROR: Bad dataset key, will '
'ignore', msecs=5000)
def create_menus(self):
"""
Construct the menus of the app.
"""
menu_bar = self.menuBar()
menu_bar.setNativeMenuBar(False) # disable for macOS, see GH-239
file_menu = menu_bar.addMenu(self.tr('&File'))
do_open_metadata = file_menu.addAction('&Open metadata')
do_open_metadata.setShortcut('Ctrl+O')
do_open_metadata.setStatusTip('Open a YAML file containing dataset '
'configurations')
do_open_metadata.triggered.connect(self.open_metadata)
do_edit_metadata = file_menu.addAction('&Edit metadata')
do_edit_metadata.setStatusTip('Tip: Remember to reload metadata after '
'saving changes')
do_edit_metadata.setShortcut('Ctrl+E')
do_edit_metadata.triggered.connect(self.edit_metadata)
do_reload_metadata = file_menu.addAction('&Reload metadata')
do_reload_metadata.setStatusTip('Reload the metadata file that is '
'currently open (use after making '
'changes)')
do_reload_metadata.setShortcut('Ctrl+R')
do_reload_metadata.triggered.connect(self.metadata_selector.load)
file_menu.addSeparator()
self.do_download_data = file_menu.addAction('&Download data')
self.do_download_data.setShortcut('Ctrl+D')
self.do_download_data.triggered.connect(self.download_files)
do_open_directory = file_menu.addAction('Open data &folder')
do_open_directory.setShortcut('Ctrl+F')
do_open_directory.triggered.connect(self.open_directory)
self.do_launch = file_menu.addAction('&Launch')
self.do_launch.setShortcut('Return')
self.do_launch.triggered.connect(self.start_launch)
options_menu = menu_bar.addMenu(self.tr('&Options'))
do_toggle_lazy = options_menu.addAction('&Fast loading')
do_toggle_lazy.setStatusTip('Reduces load time and memory usage, disables expensive features like spike detection')
do_toggle_lazy.setCheckable(True)
do_toggle_lazy.setChecked(self.lazy)
do_toggle_lazy.triggered.connect(self.toggle_lazy)
do_toggle_show_datetime = options_menu.addAction('&Display date and time')
do_toggle_show_datetime.setStatusTip('May be inaccurate for some data files unless manually set with rec_datetime')
do_toggle_show_datetime.setCheckable(True)
do_toggle_show_datetime.setChecked(self.show_datetime)
do_toggle_show_datetime.triggered.connect(self.toggle_show_datetime)
appearance_menu = menu_bar.addMenu(self.tr('&Appearance'))
ui_scale_group = QT.QActionGroup(appearance_menu)
ui_scale_actions = {}
for size in ['tiny', 'small', 'medium', 'large', 'huge']:
ui_scale_actions[size] = appearance_menu.addAction(f'&{size.capitalize()} scale')
ui_scale_actions[size].setCheckable(True)
ui_scale_actions[size].triggered.connect(lambda checked, size=size: self.set_ui_scale(size))
ui_scale_group.addAction(ui_scale_actions[size])
if self.ui_scale in ui_scale_actions:
ui_scale_actions[self.ui_scale].setChecked(True)
self.set_ui_scale(self.ui_scale)
else:
raise ValueError('ui scale "{}" is unrecognized'.format(self.ui_scale))
appearance_menu.addSeparator()
theme_group = QT.QActionGroup(appearance_menu)
theme_actions = {}
for theme in ['light', 'dark', 'original', 'printer-friendly']:
theme_actions[theme] = appearance_menu.addAction(f'&{theme.capitalize()} theme')
theme_actions[theme].setCheckable(True)
theme_actions[theme].triggered.connect(lambda checked, theme=theme: self.set_theme(theme))
theme_group.addAction(theme_actions[theme])
if self.theme in theme_actions:
theme_actions[self.theme].setChecked(True)
else:
raise ValueError('theme "{}" is unrecognized'.format(self.theme))
appearance_menu.addSeparator()
do_toggle_support_increased_line_width = appearance_menu.addAction('&Thick traces')
do_toggle_support_increased_line_width.setStatusTip('Thickens signal traces at the cost of reduced performance')
do_toggle_support_increased_line_width.setCheckable(True)
do_toggle_support_increased_line_width.setChecked(self.support_increased_line_width)
do_toggle_support_increased_line_width.triggered.connect(self.toggle_support_increased_line_width)
help_menu = menu_bar.addMenu(self.tr('&Help'))
self.do_toggle_debug_logging = help_menu.addAction('Show and log &debug messages')
self.do_toggle_debug_logging.setCheckable(True)
self.do_toggle_debug_logging.setChecked(logger.parent.level == logging.DEBUG)
self.do_toggle_debug_logging.triggered.connect(self.toggle_debug_logging)
do_view_log_file = help_menu.addAction('View &log file')
do_view_log_file.triggered.connect(self.view_log_file)
do_open_issues = help_menu.addAction('Report issues')
do_open_issues.triggered.connect(lambda: open_url('https://github.com/jpgill86/neurotic/issues'))
help_menu.addSeparator()
do_show_check_for_updates = help_menu.addAction('Check for updates')
do_show_check_for_updates.triggered.connect(self.show_check_for_updates)
do_open_update_docs = help_menu.addAction('How to update')
do_open_update_docs.triggered.connect(lambda: open_url('https://neurotic.readthedocs.io/en/latest/update.html'))
do_open_release_notes = help_menu.addAction('Release notes')
do_open_release_notes.triggered.connect(lambda: open_url('https://neurotic.readthedocs.io/en/latest/releasenotes.html'))
help_menu.addSeparator()
do_open_docs = help_menu.addAction('Documentation')
do_open_docs.triggered.connect(lambda: open_url(get_versioned_docs_url()))
do_open_ui_guide = help_menu.addAction('User interface guide')
do_open_ui_guide.triggered.connect(lambda: open_url('https://ephyviewer.readthedocs.io/en/latest/interface.html'))
do_show_about = help_menu.addAction('&About neurotic')
do_show_about.triggered.connect(self.show_about)
def open_metadata(self):
"""
Open and load a metadata file.
"""
file, _ = QT.QFileDialog.getOpenFileName(
parent=self,
caption='Open metadata',
directory=None,
filter='YAML files (*.yml *.yaml)')
if file:
self.metadata_selector.file = file
self.metadata_selector.load()
def edit_metadata(self):
"""
Open the metadata file in an editor.
"""
try:
open_path_with_default_program(self.metadata_selector.file)
title = 'Reload metadata?'
text = 'The metadata file must be reloaded if you made changes. ' \
'Do you want to reload now?'
button = QT.QMessageBox.question(self, title, text,
defaultButton=QT.QMessageBox.Yes)
if button == QT.QMessageBox.Yes:
self.metadata_selector.load()
except FileNotFoundError as e:
logger.error(f'The metadata file was not found: {e}')
self.statusBar().showMessage('ERROR: The metadata file could not '
'be found', msecs=5000)
return
def download_files(self):
"""
Download all files for the selected dataset in a separate thread.
"""
self.download_thread.start()
self.request_download.emit()
self.do_download_data.setText('&Download in progress!')
self.do_download_data.setEnabled(False)
self.statusBar().showMessage('Starting downloads (see console window)',
msecs=5000)
def on_download_finished(self):
"""
Cleanup download thread and reload the metadata list content to update
file indicators.
"""
self.download_thread.quit()
self.metadata_selector.load()
self.do_download_data.setText('&Download data')
self.do_download_data.setEnabled(True)
self.statusBar().showMessage('Downloads complete', msecs=5000)
def open_directory(self):
"""
Open the directory of the selected dataset in Win Explorer / Mac
Finder.
"""
try:
open_path_with_default_program(self.metadata_selector['data_dir'])
except FileNotFoundError as e:
logger.error('The directory for the selected dataset was not '
'found locally, perhaps because it does not exist '
f'yet: {e}')
self.statusBar().showMessage('ERROR: Folder not found locally '
'(need to download?)', msecs=5000)
def start_launch(self):
"""
Load data for the selected dataset in a separate thread.
"""
self.do_launch.setText('&Launch in progress!')
self.do_launch.setEnabled(False)
self.metadata_selector.setEnabled(False)
self.stacked_layout.setCurrentIndex(1) # show loading label
self.load_dataset_thread.start()
self.request_load_dataset.emit()
def on_load_dataset_finished(self):
"""
Launch the ephyviewer window after loading the dataset.
"""
self.load_dataset_thread.quit()
try:
if self.blk is None:
pass
elif not isinstance(self.blk, neo.Block):
raise ValueError('blk must be a Neo Block but instead is '
f'{self.blk}')
else:
metadata = self.metadata_selector.selected_metadata
ephyviewer_config = EphyviewerConfigurator(metadata, self.blk, self.lazy)
ephyviewer_config.show_all()
win = ephyviewer_config.create_ephyviewer_window(theme=self.theme, ui_scale=self.ui_scale, support_increased_line_width=self.support_increased_line_width, show_datetime=self.show_datetime)
self.windows.append(win)
win.destroyed.connect(lambda qobject, i=len(self.windows)-1: self.free_resources(i))
win.show()
except FileNotFoundError as e:
logger.error('Some files were not found locally and may need to '
f'be downloaded: {e}')
self.statusBar().showMessage('ERROR: Launch failed because some '
'files are missing (need to '
'download?)', msecs=5000)
except Exception:
logger.exception('Encountered a fatal error. Traceback will be '
'written to log file.')
self.statusBar().showMessage('ERROR: Launch failed (see console '
'for details)', msecs=5000)
finally:
self.do_launch.setText('&Launch')
self.do_launch.setEnabled(True)
self.metadata_selector.setEnabled(True)
self.stacked_layout.setCurrentIndex(0) # show metadata selector
def toggle_debug_logging(self, checked):
"""
Toggle log filtering level between its original level and debug mode
"""
if checked:
logger.parent.setLevel(logging.DEBUG)
logger.debug('Debug messages enabled')
# lower the threshold for PyAV messages printed to the console from
# critical to warning
logging.getLogger('libav').setLevel(logging.WARNING)
else:
logger.debug('Disabling debug messages')
logger.parent.setLevel(default_log_level)
# raise the threshold for PyAV messages printed to the console from
# warning to critical
logging.getLogger('libav').setLevel(logging.CRITICAL)
def view_log_file(self):
"""
Open the log file in an editor.
"""
try:
open_path_with_default_program(log_file)
except FileNotFoundError as e:
logger.error(f'The log file was not found: {e}')
self.statusBar().showMessage('ERROR: The log file could not be '
'found', msecs=5000)
return
def show_check_for_updates(self):
"""
Check for new releases and display results in a message box.
"""
urls = {}
urls['query-latest-release'] = 'https://api.github.com/repos/jpgill86/neurotic/releases/latest'
urls['updating'] = 'https://neurotic.readthedocs.io/en/latest/update.html'
urls['releases'] = 'https://github.com/jpgill86/neurotic/releases'
try:
# query GitHub for the latest release
r = requests.get(urls['query-latest-release'])
latest = r.json()['tag_name']
if version.parse(latest) > version.parse(__version__):
text = f"""
<h2>A new version is available</h2>
<p><table>
<tr><td>Installed version:</td> <td>{__version__}</td></tr>
<tr><td>Latest version:</td> <td>{latest}</td></tr>
</table></p>
<p><a href='{urls['updating']}'>How do I update <b>neurotic</b>?</a></p>
"""
else:
text = f"""
<h2>neurotic is up to date</h2>
<p><table>
<tr><td>Installed version:</td> <td>{__version__}</td></tr>
<tr><td>Latest version:</td> <td>{latest}</td></tr>
</table></p>
"""
except:
# something went wrong with the query
text = f"""
<h2>Could not detect latest version</h2>
<p><table>
<tr><td>Installed version:</td> <td>{__version__}</td></tr>
<tr><td>Latest version:</td> <td>unknown</td></tr>
</table></p>
<p><a href='{urls['releases']}'>Check for latest version manually</a></p>
<p><a href='{urls['updating']}'>How do I update <b>neurotic</b>?</a></p>
"""
title = 'Check for updates'
QT.QMessageBox.about(self, title, text)
def show_about(self):
"""
Display the "About neurotic" message box.
"""
import platform
import ephyviewer
import numpy
import pyqtgraph
try:
pyqt_version = QT.PYQT_VERSION_STR
except AttributeError:
pyqt_version = 'unknown'
title = 'About neurotic'
urls = {}
urls['GitHub'] = 'https://github.com/jpgill86/neurotic'
urls['GitHub user'] = 'https://github.com/jpgill86'
urls['PyPI'] = 'https://pypi.org/project/neurotic'
text = f"""
<h2>neurotic {__version__}</h2>
<p><i>Curate, visualize, annotate, and share <br/>
your behavioral ephys data using Python</i></p>
<p>Author: Jeffrey Gill (<a href='{urls['GitHub user']}'>@jpgill86</a>)</p>
<p>Websites: <a href='{urls['GitHub']}'>GitHub</a>
| <a href='{urls['PyPI']}'>PyPI</a></p>
<p>Installed dependencies:</p>
<table width='80%' align='center'>
<tr><td>ephyviewer</td> <td>{ephyviewer.__version__}</td></tr>
<tr><td>neo</td> <td>{neo.__version__}</td></tr>
<tr><td>numpy</td> <td>{numpy.__version__}</td></tr>
<tr><td>{QT_MODE.lower()}</td> <td>{pyqt_version}</td></tr>
<tr><td>pyqtgraph</td> <td>{pyqtgraph.__version__}</td></tr>
<tr><td>python</td> <td>{platform.python_version()}</td></tr>
</table>
<p>Install path: <code>{os.path.dirname(os.path.dirname(__file__))}</code></p>
"""
QT.QMessageBox.about(self, title, text)
def toggle_lazy(self, checked):
self.lazy = checked
def toggle_show_datetime(self, checked):
self.show_datetime = checked
def set_ui_scale(self, size):
self.ui_scale = size
font_size = {
'tiny': self.default_font_size-4,
'small': self.default_font_size-2,
'medium': self.default_font_size,
'large': self.default_font_size+2,
'huge': self.default_font_size+4,
}
font = self.font()
font.setPointSize(font_size[size])
self.setFont(font)
def set_theme(self, theme):
self.theme = theme
def toggle_support_increased_line_width(self, checked):
self.support_increased_line_width = checked
def free_resources(self, i):
"""
Run garbage collection to unlock files and free memory for the closed
window with index ``i``.
Data files opened by Neo in lazy mode remain locked for as long as the
RawIO objects pointing to them exist in memory. Normally such objects
would be automatically garbage collected when they go out of scope,
i.e., when the window that created them is closed. However, due to an
issue in Neo, circular references to these objects are always created,
so they persist even after the window is closed. This function performs
a manual garbage collection after a window has been closed to clean up
any lingering Neo objects that keep files locked. For more info about
the issue, see https://github.com/NeuralEnsemble/python-neo/issues/684.
"""
# first remove the last remaining references to the closed window and
# Neo Block
self.windows[i] = None
self.blk = None
# run garbage collection
gc.collect()
class _MetadataSelectorQt(MetadataSelector, QT.QListWidget):
"""
A QListWidget that displays the state of a MetadataSelector.
"""
def __init__(self, mainwindow):
"""
Initialize a new _MetadataSelectorQt.
"""
MetadataSelector.__init__(self)
QT.QListWidget.__init__(self, parent=mainwindow)
self.mainwindow = mainwindow
self.setSelectionMode(QT.QListWidget.SingleSelection)
font = self.font()
font.setFamily('Courier')
self.setFont(font)
self.currentRowChanged.connect(self._on_select)
self.itemDoubleClicked.connect(self.mainwindow.start_launch)
def _on_select(self, currentRow):
"""
Update the MetadataSelector's selection after changing the
QListWidget's selection.
"""
if currentRow >= 0:
self._selection = list(self.all_metadata)[currentRow]
else:
self._selection = None
def load(self):
"""
Load or reload the metadata file and populate the QListWidget.
"""
# remember the current selection
old_selection = self._selection
try:
MetadataSelector.load(self)
except Exception as e:
logger.error(f'Bad metadata file\n{e}')
self.mainwindow.statusBar().showMessage('ERROR: Bad metadata file '
'(see console for '
'details)', msecs=5000)
if self.all_metadata is not None:
# clear and repopulate the list,
# which triggers the selection to change
self.clear()
for label in _selector_labels(self.all_metadata):
QT.QListWidgetItem(label, self)
if old_selection in self.all_metadata:
# reselect the original selection if it still exists
self.setCurrentRow(list(self.all_metadata).index(old_selection))
else:
# otherwise select the first item
self.setCurrentRow(0)
class _DownloadWorker(QT.QObject):
"""
A thread worker for downloading data files.
"""
download_finished = QT.pyqtSignal()
def __init__(self, mainwindow):
"""
Initialize a new _DownloadWorker.
"""
QT.QObject.__init__(self)
self.mainwindow = mainwindow
def download(self):
"""
Download all files and emit a signal when complete.
"""
self.mainwindow.metadata_selector.download_all_data_files()
self.download_finished.emit()
class _LoadDatasetWorker(QT.QObject):
"""
A thread worker for loading data sets.
"""
load_dataset_finished = QT.pyqtSignal()
show_status_msg = QT.pyqtSignal(str, int)
def __init__(self, mainwindow):
"""
Initialize a new _LoadDatasetWorker.
"""
QT.QObject.__init__(self)
self.mainwindow = mainwindow
def load_dataset(self):
"""
Load the selected dataset.
"""
metadata = self.mainwindow.metadata_selector.selected_metadata
lazy = self.mainwindow.lazy
try:
self.mainwindow.blk = load_dataset(metadata, lazy=lazy)
except FileNotFoundError as e:
logger.error('Some files were not found locally and may need to '
f'be downloaded: {e}')
self.show_status_msg.emit('ERROR: Launch failed because some '
'files are missing (need to download?)',
5000)
self.mainwindow.blk = None
except Exception:
logger.exception('Encountered a fatal error. Traceback will be '
'written to log file.')
self.show_status_msg.emit('ERROR: Launch failed (see console for '
'details)', 5000)
self.mainwindow.blk = None
finally:
self.load_dataset_finished.emit()