diff --git a/.github/workflows/python-app_linux.yml b/.github/workflows/python-app_linux.yml new file mode 100644 index 0000000..42371c7 --- /dev/null +++ b/.github/workflows/python-app_linux.yml @@ -0,0 +1,51 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: BlipBlop app linux + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pyqt5 pyinstaller flake8 + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: create package + run: | + pyrcc5 resources.qrc -o resources.py + pyinstaller blipblop_linux.spec + + - name: Upload a Build Artifact + uses: actions/upload-artifact@v2.2.2 + with: + # Artifact name + name: BlibBlop-linux + # A file, directory or wildcard pattern that describes what to upload + path: | + dist + retention-days: 1 + + diff --git a/.github/workflows/python-app_macos.yml b/.github/workflows/python-app_macos.yml index 3401d98..a4f2761 100644 --- a/.github/workflows/python-app_macos.yml +++ b/.github/workflows/python-app_macos.yml @@ -1,7 +1,7 @@ # This workflow will install Python dependencies, run tests and lint with a single version of Python # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions -name: Python application +name: BlipBlop app macOS on: push: @@ -36,13 +36,13 @@ jobs: - name: create package run: | pyrcc5 resources.qrc -o resources.py - pyinstaller --onefile --windowed --name="BlipBlop" --osx-bundle-identifier="de.uni-tuebingen.neuroetho.blipblop" -i icons/blipblop_logo.icns --add-data="icons/blipblop_logo.icns:." --add-data="resources.py:." blipblop.py + pyinstaller --osx-bundle-identifier="de.uni-tuebingen.neuroetho.blipblop" blipblop_darwin.spec - name: Upload a Build Artifact uses: actions/upload-artifact@v2.2.2 with: # Artifact name - name: blipblop-binary + name: BlipBlop-macOS # A file, directory or wildcard pattern that describes what to upload path: | dist diff --git a/.github/workflows/python-app_win.yml b/.github/workflows/python-app_win.yml index 8f64119..14ec40f 100644 --- a/.github/workflows/python-app_win.yml +++ b/.github/workflows/python-app_win.yml @@ -1,7 +1,7 @@ # This workflow will install Python dependencies, run tests and lint with a single version of Python # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions -name: Python application +name: BlipBlop app windows on: push: @@ -24,7 +24,6 @@ jobs: run: | python -m pip install --upgrade pip pip install pyqt5 pyinstaller flake8 - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 run: | @@ -36,7 +35,7 @@ jobs: - name: create package run: | pyrcc5 resources.qrc -o resources.py - pyinstaller --onefile --windowed --name="BlipBlop" -i icons/blipblop_logo.ico --add-data="icons/blipblop_logo.icns;." --add-data="resources.py;." blipblop.py + pyinstaller blipblop_win.spec - name: Upload a Build Artifact uses: actions/upload-artifact@v2.2.2 diff --git a/.gitignore b/.gitignore index 4ef3e36..df8847a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ *.pyc -*__pycache__ \ No newline at end of file +*__pycache__ +resources.py +build +dist \ No newline at end of file diff --git a/blipblop/constants.py b/blipblop/constants.py index 10b6dfd..46f9a80 100644 --- a/blipblop/constants.py +++ b/blipblop/constants.py @@ -4,36 +4,47 @@ from PyQt5.QtGui import QIcon from PyQt5.QtMultimedia import QMediaContent from PyQt5.QtCore import QUrl -import resources +import resources # needs to be imported somewhere in the project to be picked up by qt -organization = "neuroetho.uni-tuebingen.de" -application = "blipblop" -version = 0.1 +organization_name = "de.uni-tuebingen.neuroetho" +application_name = "BlipBlop" +application_version = 0.1 PACKAGE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) ICONS_FOLDER = os.path.join(PACKAGE_ROOT, "icons") DOCS_ROOT_FILE = os.path.join(PACKAGE_ROOT, "docs", "index.md") SNDS_FOLDER = os.path.join(PACKAGE_ROOT, "sounds") +# Find and list sounds +SNDS_PATHS = glob.glob(os.path.join(SNDS_FOLDER, "*.wav")) +SNDS_PATHS = sorted(SNDS_PATHS) +SNDS_DICT = {} + +for snd in SNDS_PATHS: + SNDS_DICT[snd.split(os.sep)[-1].split(".")[0]] = snd + +""" This snippet is kept because it shows how to iterate the qt resources.py file +it = QDirIterator(":", QDirIterator.Subdirectories); +while it.hasNext(): + name = it.next() + if "sounds/" in name: + SNDS_DICT[name.split("/")[-1]] = "qrc" + name +""" + +# Find and list icons and images ICONS_PATHS = glob.glob(os.path.join(ICONS_FOLDER, "*.png")) ICONS_PATHS.extend(glob.glob(os.path.join(ICONS_FOLDER, "*.icns"))) ICONS_PATHS = sorted(ICONS_PATHS) ICON_DICT = {} -SNDS_PATHS = glob.glob(os.path.join(SNDS_FOLDER, "*.wav")) -SNDS_PATHS = sorted(SNDS_PATHS) -SNDS_DICT = {} - for icon in ICONS_PATHS: ICON_DICT[icon.split(os.sep)[-1].split(".")[0]] = icon -for snd in SNDS_PATHS: - SNDS_DICT[snd.split(os.sep)[-1].split(".")[0]] = snd - def get_sound(name): if name in SNDS_DICT.keys(): - return QMediaContent(QUrl.fromLocalFile(os.path.abspath(SNDS_DICT[name]))) + url = QUrl.fromLocalFile(SNDS_DICT[name]) + return QMediaContent(url) else: print("Sound %s not found!" % name) return None @@ -43,5 +54,4 @@ def get_icon(name): if name in ICON_DICT.keys(): return QIcon(ICON_DICT[name]) else: - return QIcon("nix_logo.png") - + return QIcon("blipblop_logo.png") diff --git a/blipblop/main.py b/blipblop/main.py index c4fe103..6929a56 100644 --- a/blipblop/main.py +++ b/blipblop/main.py @@ -1,5 +1,5 @@ -#!/usr/bin/python3 import sys +from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QApplication from PyQt5.QtCore import QSettings from blipblop.ui.mainwindow import BlipBlop @@ -8,22 +8,22 @@ import blipblop.constants as cnst try: # Include in try/except block if you're also targeting Mac/Linux from PyQt5.QtWinExtras import QtWin - myappid = 'neuroetho.uni-tuebingen.de.blipblop.0.1' + myappid = "%s.%s" %(cnst.organization_name, cnst.application_version) QtWin.setCurrentProcessExplicitAppUserModelID(myappid) except ImportError: pass def main(): app = QApplication(sys.argv) - app.setApplicationName("blipblop") - app.setApplicationVersion("0.1") - app.setOrganizationDomain("neuroetho.uni-tuebingen.de") - app.setWindowIcon(cnst.get_icon("blipblop_logo.png")) + app.setApplicationName(cnst.application_name) + app.setApplicationVersion(str(cnst.application_version)) + app.setOrganizationDomain(cnst.organization_name) + app.setWindowIcon(QIcon(":/icons/app_icon_png")) settings = QSettings() - width = settings.value("app/width", 1024) - height = settings.value("app/height", 768) - x = settings.value("app/pos_x", 100) - y = settings.value("app/pos_y", 100) + width = int(settings.value("app/width", 1024)) + height = int(settings.value("app/height", 768)) + x = int(settings.value("app/pos_x", 100)) + y = int(settings.value("app/pos_y", 100)) window = BlipBlop() window.setMinimumWidth(800) window.setMinimumHeight(600) @@ -38,7 +38,3 @@ def main(): settings.setValue("app/pos_x", pos.x()) settings.setValue("app/pos_y", pos.y()) sys.exit(code) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/blipblop/ui/audioblop.py b/blipblop/ui/audioblop.py index 7a5536a..518ad2d 100644 --- a/blipblop/ui/audioblop.py +++ b/blipblop/ui/audioblop.py @@ -1,105 +1,13 @@ -from PyQt5.QtWidgets import QAction, QComboBox, QFormLayout, QGridLayout, QLabel, QPushButton, QSizePolicy, QSlider, QSpinBox, QSplitter, QTextEdit, QVBoxLayout, QWidget +from PyQt5.QtWidgets import QAction, QGridLayout, QLabel, QPushButton, QSizePolicy, QSplitter, QVBoxLayout, QWidget from PyQt5.QtCore import QPoint, QRandomGenerator, QTimer, Qt, pyqtSignal from PyQt5.QtGui import QColor, QFont, QIcon, QKeySequence, QPainter, QPen, QPixmap from PyQt5.QtMultimedia import QMediaPlayer -import os import blipblop.constants as cnst from blipblop.ui.countdownlabel import CountdownLabel +from blipblop.ui.settings import AuditoryTaskSettings import datetime as dt -class SettingsPanel(QWidget): - def __init__(self, parent=None): - super().__init__(parent=parent) - - self._trial_spinner = QSpinBox() - self._trial_spinner.setMinimum(5) - self._trial_spinner.setMaximum(25) - self._trial_spinner.setValue(5) - self._trial_spinner.setToolTip("Number of consecutive trials (5 - 25)") - - self._min_delay_spinner = QSpinBox() - self._min_delay_spinner.setMinimum(1) - self._min_delay_spinner.setMaximum(10) - self._min_delay_spinner.setValue(1) - self._min_delay_spinner.setToolTip("Minimum delay between start of trial and stimulus display [s]") - - self._max_delay_spinner = QSpinBox() - self._max_delay_spinner.setMinimum(1) - self._max_delay_spinner.setMaximum(10) - self._max_delay_spinner.setValue(5) - self._max_delay_spinner.setToolTip("Maximum delay between start of trial and stimulus display [s]") - - self._countdown_spinner = QSpinBox() - self._countdown_spinner.setMinimum(1) - self._countdown_spinner.setMaximum(10) - self._countdown_spinner.setValue(3) - self._countdown_spinner.setToolTip("Pause between trials [s]") - - self._saliency_slider = QSlider(Qt.Horizontal) - self._saliency_slider.setMinimum(0) - self._saliency_slider.setMaximum(100) - self._saliency_slider.setSliderPosition(100) - self._saliency_slider.setTickInterval(25) - self._saliency_slider.setTickPosition(QSlider.TicksBelow) - self._saliency_slider.setToolTip("Saliency of the stimulus, i.e. its loudness") - - self._sound_combo = QComboBox() - for k in cnst.SNDS_DICT.keys(): - self._sound_combo.addItem(k) - - self._instructions = QTextEdit() - self._instructions.setMarkdown("* fixate central cross\n * press start (enter) when ready\n * press space bar as soon as the stimulus occurs") - self._instructions.setMinimumHeight(200) - self._instructions.setReadOnly(True) - - form_layout = QFormLayout() - form_layout.addRow("Settings", None) - form_layout.addRow("number of trials", self._trial_spinner) - form_layout.addRow("minimum delay [s]", self._min_delay_spinner) - form_layout.addRow("maximum delay [s]", self._max_delay_spinner) - form_layout.addRow("pause [s]", self._countdown_spinner) - form_layout.addRow("stimulus saliency", self._saliency_slider) - form_layout.addRow("stimulus sound", self._sound_combo) - form_layout.addRow("instructions", self._instructions) - self.setLayout(form_layout) - - @property - def trials(self): - return self._trial_spinner.value() - - @property - def saliency(self): - return self._saliency_slider.sliderPosition() - - @property - def size(self): - return self._size_slider.sliderPosition() - - @property - def min_delay(self): - return self._min_delay_spinner.value() - - @property - def max_delay(self): - return self._max_delay_spinner.value() - - @property - def countdown(self): - return self._countdown_spinner.value() - - @property - def sound(self): - return self._sound_combo.currentText() - - def set_enabled(self, enabled): - self._trial_spinner.setEnabled(enabled) - self._saliency_slider.setEnabled(enabled) - self._countdown_spinner.setEnabled(enabled) - self._min_delay_spinner.setEnabled(enabled) - self._max_delay_spinner.setEnabled(enabled) - self._sound_combo.setEnabled(False) - class AudioBlop(QWidget): task_done = pyqtSignal() @@ -107,7 +15,7 @@ class AudioBlop(QWidget): def __init__(self, parent=None) -> None: super().__init__(parent=parent) - + widget = QWidget() grid = QGridLayout() grid.setColumnStretch(0, 1) @@ -116,37 +24,37 @@ class AudioBlop(QWidget): grid.setRowStretch(3, 1) widget.setLayout(grid) - l = QLabel("Auditory reaction test") - l.setPixmap(QPixmap(":/icons/auditory_task")) - grid.addWidget(l, 0, 0, Qt.AlignLeft) - - l2 =QLabel("Measurement of auditory reaction times\npress enter to start") + icon_label = QLabel("Auditory reaction test") + icon_label.setPixmap(QPixmap(":/icons/auditory_task")) + grid.addWidget(icon_label, 0, 0, Qt.AlignLeft) + + heading_label = QLabel("Measurement of auditory reaction times\npress enter to start") font = QFont() font.setBold(True) font.setPointSize(20) - l2.setFont(font) - l2.setStyleSheet("color: #2D4B9A") - grid.addWidget(l2, 1, 0, 1, 2, Qt.AlignLeft) - + heading_label.setFont(font) + heading_label.setStyleSheet("color: #2D4B9A") + grid.addWidget(heading_label, 1, 0, 1, 2, Qt.AlignLeft) + settings_btn = QPushButton(QIcon(":/icons/settings"), "") settings_btn.setToolTip("edit task settings") settings_btn.setShortcut(QKeySequence("alt+s")) settings_btn.clicked.connect(self.on_toggle_settings) grid.addWidget(settings_btn, 0, 3, Qt.AlignRight) - + self._status_label = QLabel("Ready to start, press enter ...") - grid.addWidget(self._status_label, 3, 0, Qt.AlignLeft) + grid.addWidget(self._status_label, 4, 0, Qt.AlignLeft) self._countdown_label = CountdownLabel(text="Next trial in:") - grid.addWidget(self._countdown_label, 3, 1, Qt.AlignCenter) + grid.addWidget(self._countdown_label, 4, 1, Qt.AlignCenter) self._countdown_label.countdown_done.connect(self.run_trial) - + self._draw_area = QLabel() self._draw_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) grid.addWidget(self._draw_area, 2, 1) - - self._settings = SettingsPanel() - + + self._settings = AuditoryTaskSettings() + self._splitter = QSplitter() self._splitter.addWidget(widget) self._splitter.addWidget(self._settings) @@ -155,7 +63,7 @@ class AudioBlop(QWidget): vbox = QVBoxLayout() vbox.addWidget(self._splitter) self.setLayout(vbox) - + self.reset_canvas() self.create_actions() @@ -168,12 +76,12 @@ class AudioBlop(QWidget): self._player = QMediaPlayer() self._random_generator = QRandomGenerator() self.setFocus() - + def create_actions(self): self._start_action = QAction("start trial") self._start_action.setShortcuts([QKeySequence("enter"), QKeySequence("return")]) self._start_action.triggered.connect(self.on_trial_start) - + self._reaction = QAction("reaction") self._reaction.setShortcut(QKeySequence("space")) self._reaction.triggered.connect(self.on_reaction) @@ -181,7 +89,7 @@ class AudioBlop(QWidget): self._abort = QAction("abort") self._abort.setShortcut(QKeySequence("escape")) self._abort.triggered.connect(self.on_abort) - + self.addAction(self._start_action) self.addAction(self._reaction) self.addAction(self._abort) @@ -189,7 +97,7 @@ class AudioBlop(QWidget): def on_reaction(self): if not self._session_running or not self._trial_running: return - + self._response_time = dt.datetime.now() if self._trial_counter < self._settings.trials: self._status_label.setText("Trial %i of %i" % (self._trial_counter, self._settings.trials)) @@ -206,14 +114,13 @@ class AudioBlop(QWidget): return self._countdown_label.start(self._settings.countdown) - def reset_canvas(self): bkg_color = QColor() bkg_color.setAlphaF(0.0) canvas = QPixmap(400, 400) self._canvas_center = QPoint(200, 200) canvas.fill(bkg_color) - self.draw_fixation(canvas) + self.draw_fixation(canvas) self._draw_area.setPixmap(canvas) self._draw_area.update() @@ -222,7 +129,7 @@ class AudioBlop(QWidget): right = QPoint(225, 200) top = QPoint(200, 175) bottom = QPoint(200, 225) - painter = QPainter(pixmap) + painter = QPainter(pixmap) painter.setPen(QPen(Qt.red, 2, Qt.SolidLine)) painter.drawLine(left, right) painter.drawLine(top, bottom) @@ -234,12 +141,13 @@ class AudioBlop(QWidget): def blip(self): self._player.play() self._start_time = dt.datetime.now() - + def on_trial_start(self): if self._trial_running: return if not self._session_running: self._settings.set_enabled(False) + self._settings.store_settings() self._session_running = True self._countdown_label.start(time=self._settings.countdown) @@ -251,9 +159,10 @@ class AudioBlop(QWidget): self._trial_counter += 1 self._status_label.setText("Trial %i of %i running" % (self._trial_counter, self._settings.trials)) content = cnst.get_sound(self._settings.sound) + self._player.setMedia(content) self._player.setVolume(self._settings.saliency) - + min_interval = int(self._settings.min_delay * 10) max_interval = int(self._settings.max_delay * 10) interval = self._random_generator.bounded(min_interval, max_interval) * 100 @@ -263,15 +172,15 @@ class AudioBlop(QWidget): self._timer.setInterval(int(interval)) self._timer.timeout.connect(self.blip) self._timer.start() - + def on_abort(self): self.reset() self.task_aborted.emit() - + @property def results(self): return self._reaction_times - + def reset(self): self._trial_counter = 0 self._session_running = 0 @@ -281,7 +190,7 @@ class AudioBlop(QWidget): self._status_label.setText("Ready to start...") self._settings.set_enabled(True) self._countdown_label.stop() - + def on_toggle_settings(self): if self._splitter.sizes()[1] > 0: self._splitter.widget(1).hide() diff --git a/blipblop/ui/centralwidget.py b/blipblop/ui/centralwidget.py index 0780b5f..360e60a 100644 --- a/blipblop/ui/centralwidget.py +++ b/blipblop/ui/centralwidget.py @@ -52,6 +52,7 @@ class CentralWidget(QWidget): def reset(self): self._task_results = [] + self._results_screen.reset() self._stack.setCurrentIndex(0) def on_new_visual_task(self): diff --git a/blipblop/ui/filescreen.py b/blipblop/ui/filescreen.py deleted file mode 100644 index 8ea6c98..0000000 --- a/blipblop/ui/filescreen.py +++ /dev/null @@ -1,142 +0,0 @@ -from PyQt5.QtWidgets import QComboBox, QFrame, QGroupBox, QHBoxLayout, QLabel, QSplitter, QTextEdit, QVBoxLayout, QWidget -from PyQt5.QtCore import QItemSelectionModel, Qt - -from nixview.util.file_handler import FileHandler -from nixview.util.descriptors import ItemDescriptor -import nixview.communicator as comm -import nixview.constants as cnst -from nixview.data_models.tree_model import NixTreeView, TreeModel, TreeType - - -class FileScreen(QWidget): - def __init__(self, parent=None) -> None: - super().__init__(parent=parent) - self._file_handler = FileHandler() - - vbox = QVBoxLayout() - self.setLayout(vbox) - - main_splitter = QSplitter(Qt.Vertical) - self.layout().addWidget(main_splitter) - - self._info = EntityInfo(self) - main_splitter.addWidget(self._info) - - self._data_tree = NixTreeView(self) - - self._tree_type_combo = QComboBox() - self._tree_type_combo.adjustSize() - self._tree_type_combo.addItems([TreeType.Data.value, TreeType.Full.value, TreeType.Metadata.value]) - self._tree_type_combo.currentTextChanged.connect(self.update) - - hbox = QHBoxLayout() - hbox.addWidget(QLabel("Tree type:")) - hbox.addWidget(self._tree_type_combo) - hbox.addStretch() - data_group = QGroupBox("Data") - data_vbox = QVBoxLayout() - data_vbox.setContentsMargins(1, 10, 1, 1) - - data_vbox.addLayout(hbox) - data_vbox.addWidget(self._data_tree) - data_group.setLayout(data_vbox) - - main_splitter.addWidget(data_group) - main_splitter.setSizes([200, 600]) - vbox.addWidget(main_splitter) - - def dataTreeSelection(self, current_index, last_index): - if not current_index.isValid(): - return - item = current_index.internalPointer() - comm.communicator.item_selected.emit(item) - self._info.setEntityInfo(item.node_descriptor) - - def update(self): - tt = TreeType.Data - if self._tree_type_combo.currentText() == TreeType.Data.value: - tt = TreeType.Data - elif self._tree_type_combo.currentText() == TreeType.Full.value: - tt = TreeType.Full - elif self._tree_type_combo.currentText() == TreeType.Metadata.value: - tt = TreeType.Metadata - self._info.setEntityInfo(None) - data_model = TreeModel(self._file_handler, tt) - self._data_tree.setModel(data_model) - selection_model = QItemSelectionModel(data_model) - self._data_tree.setSelectionModel(selection_model) - selection_model.currentChanged.connect(self.dataTreeSelection) - for i in range(data_model.columnCount(None)): - self._data_tree.resizeColumnToContents(i) - self._info.setFileInfo(self._file_handler.file_descriptor) - - def reset(self): - pass - - -class EntityInfo(QWidget): - - def __init__(self, parent): - super().__init__(parent=parent) - self._file_handler = FileHandler() - self.setLayout(QHBoxLayout()) - - self._metadata_tree = NixTreeView() - - mdata_grp = QGroupBox("Metadata") - mdata_grp.setLayout(QVBoxLayout()) - mdata_grp.layout().setContentsMargins(1, 10, 1, 1) - mdata_grp.layout().addWidget(self._metadata_tree) - - file_info_grp = QGroupBox("File info") - file_info_grp.setLayout(QVBoxLayout()) - file_info_grp.layout().setContentsMargins(1, 10, 1, 1) - self._file_info = QTextEdit("File information") - self._file_info.setEnabled(True) - self._file_info.setTextInteractionFlags(Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse) - self._file_info.setFrameShape(QFrame.NoFrame) - self._file_info.setLineWrapMode(QTextEdit.WidgetWidth) - file_info_grp.layout().addWidget(self._file_info) - - entity_info_grp = QGroupBox("Entity info") - entity_info_grp.setLayout(QVBoxLayout()) - entity_info_grp.layout().setContentsMargins(1, 10, 1, 1) - self._entity_info = QTextEdit("Entity information") - self._file_info.setEnabled(True) - self._file_info.setTextInteractionFlags(Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse) - self._file_info.setFrameShape(QFrame.NoFrame) - self._file_info.setLineWrapMode(QTextEdit.WidgetWidth) - entity_info_grp.layout().addWidget(self._entity_info) - - self._splitter = QSplitter(Qt.Horizontal) - self._splitter.addWidget(file_info_grp) - self._splitter.addWidget(entity_info_grp) - self._splitter.addWidget(mdata_grp) - self._splitter.setSizes([200, 400, 0]) - self._splitter.setStretchFactor(0, 0) - self._splitter.setStretchFactor(1, 1) - self._splitter.setStretchFactor(2, 1) - - self.layout().addWidget(self._splitter) - - - def setFileInfo(self, file_info): - if file_info is not None: - self._file_info.setText(file_info.toHtml()) - - def setEntityInfo(self, entity_info): - if entity_info is None or not isinstance(entity_info, ItemDescriptor): - self._splitter.setSizes([200, 400, 0]) - self._entity_info.setText("") - self._metadata_tree.setModel(None) - return - - if entity_info.metadata_id is not None: - self._splitter.setSizes([200, 400, 400]) - else: - self._splitter.setSizes([200, 400, 0]) - self._entity_info.setText(entity_info.to_html()) - metadata_model = TreeModel(self._file_handler, TreeType.Metadata, root_section_id=entity_info.metadata_id) - self._metadata_tree.setModel(metadata_model) - for i in range(metadata_model.columnCount(None)): - self._metadata_tree.resizeColumnToContents(i) diff --git a/blipblop/ui/help.py b/blipblop/ui/help.py index 00f68d0..15f3854 100644 --- a/blipblop/ui/help.py +++ b/blipblop/ui/help.py @@ -1,30 +1,40 @@ from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QFrame, QHBoxLayout, QPushButton, QSizePolicy, QTextBrowser, QVBoxLayout, QWidget -from PyQt5.QtCore import QUrl +from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QFrame, QHBoxLayout +from PyQt5.QtWidgets import QPushButton, QSizePolicy, QTextBrowser, QVBoxLayout, QWidget +from PyQt5.QtCore import QSettings, QUrl, Qt import blipblop.constants as cnst + class HelpDialog(QDialog): - - def __init__(self, parent = None) -> None: + + def __init__(self, parent=None) -> None: super().__init__(parent=parent) - - self.setModal(True) - self.setMinimumSize(500, 750) + self._settings = QSettings() + width = int(self._settings.value("help/width", 640)) + height = int(self._settings.value("help/height", 480)) + x = int(self._settings.value("help/pos_x", 100)) + y = int(self._settings.value("help/pos_y", 100)) + self.setModal(True) + self.setMinimumSize(640, 480) + self.resize(width, height) + self.move(x, y) + self.finished.connect(self.on_finished) self.help = HelpBrowser() self.help._edit.historyChanged.connect(self._on_history_changed) - - self.back_btn = QPushButton(QIcon(":/icons/back_btn"), "back") + + self.back_btn = QPushButton(QIcon(":/icons/docs_back"), "back") self.back_btn.setEnabled(False) self.back_btn.clicked.connect(self.help._edit.backward) - self.home_btn = QPushButton(QIcon(":/icons/home_btn"),"home") + self.home_btn = QPushButton(QIcon(":/icons/docs_home"), "home") self.home_btn.clicked.connect(self.help._edit.home) - self.fwd_btn = QPushButton(QIcon(":/icons/fwd_btn"),"forward") + self.fwd_btn = QPushButton(QIcon(":/icons/docs_forward"), "forward") + self.fwd_btn.setLayoutDirection(Qt.RightToLeft) self.fwd_btn.setEnabled(False) self.fwd_btn.clicked.connect(self.help._edit.forward) - + empty = QWidget() empty.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) @@ -32,8 +42,8 @@ class HelpDialog(QDialog): hbox.addWidget(self.back_btn) hbox.addWidget(self.home_btn) hbox.addWidget(self.fwd_btn) - hbox.addWidget(empty) - + hbox.addWidget(empty) + bbox = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) bbox.accepted.connect(self.accept) layout = QVBoxLayout() @@ -42,17 +52,23 @@ class HelpDialog(QDialog): layout.addWidget(self.help) layout.addWidget(bbox) self.setLayout(layout) - + def _on_history_changed(self): self.back_btn.setEnabled(self.help._edit.isBackwardAvailable()) self.fwd_btn.setEnabled(self.help._edit.isForwardAvailable()) + def on_finished(self): + self._settings.setValue("help/width", self.width()) + self._settings.setValue("help/height", self.height()) + self._settings.setValue("help/pos_x", self.x()) + self._settings.setValue("help/pos_y", self.y()) + class HelpBrowser(QWidget): + def __init__(self, parent=None) -> None: super().__init__(parent=parent) self.setLayout(QVBoxLayout()) - # FIXME https://stackoverflow.com/a/43217828 about loading from esource files doc_url = QUrl.fromLocalFile(cnst.DOCS_ROOT_FILE) self._edit = QTextBrowser() self._edit.setOpenLinks(True) diff --git a/blipblop/ui/mainwindow.py b/blipblop/ui/mainwindow.py index 38d995b..726b66f 100644 --- a/blipblop/ui/mainwindow.py +++ b/blipblop/ui/mainwindow.py @@ -24,17 +24,17 @@ class BlipBlop(QMainWindow): self.show() def create_actions(self): - self._quit_action = QAction(QIcon(":/icons/quit"), "Quit", self) + self._quit_action = QAction(QIcon(":/icons/quit"), "quit application", self) self._quit_action.setStatusTip("Quit BlipBlop") self._quit_action.setShortcut(QKeySequence("Ctrl+q")) self._quit_action.triggered.connect(self.on_quit) - self._new_action = QAction(QIcon(":/icons/new_session"), "New session", self) + self._new_action = QAction(QIcon(":/icons/new_session"), "new session", self) self._new_action.setStatusTip("Start a new session discarding previous results") self._new_action.setShortcut(QKeySequence("Ctrl+n")) self._new_action.triggered.connect(self.on_new) - self._results_action = QAction(QIcon(":/icons/results_table"), "Show results", self) + self._results_action = QAction(QIcon(":/icons/results_table"), "show results", self) self._results_action.setStatusTip("Show results as table") self._results_action.setShortcut(QKeySequence("Ctrl+r")) self._results_action.setEnabled(True) @@ -51,13 +51,13 @@ class BlipBlop(QMainWindow): self._help_action.setEnabled(True) self._help_action.triggered.connect(self.on_help) - self._visual_task_action = QAction(QIcon(":/icons/visual_task"), "visual") + self._visual_task_action = QAction(QIcon(":/icons/visual_task"), "visual task") self._visual_task_action.setStatusTip("Start measuring visual reaction times") self._visual_task_action.setShortcut(QKeySequence("Ctrl+1")) self._visual_task_action.setEnabled(True) self._visual_task_action.triggered.connect(self.on_visual) - self._auditory_task_action = QAction(QIcon(":/icons/auditory_task"), "auditory") + self._auditory_task_action = QAction(QIcon(":/icons/auditory_task"), "auditory task") self._auditory_task_action.setStatusTip("Start measuring auditory reaction times") self._auditory_task_action.setShortcut(QKeySequence("Ctrl+2")) self._auditory_task_action.setEnabled(True) @@ -77,6 +77,7 @@ class BlipBlop(QMainWindow): self._toolbar.addAction(self._visual_task_action) self._toolbar.addAction(self._auditory_task_action) self._toolbar.addAction(self._results_action) + self._toolbar.addSeparator() self._toolbar.addAction(self._help_action) empty = QWidget() diff --git a/blipblop/ui/resultsscreen.py b/blipblop/ui/resultsscreen.py index ac062a0..a439552 100644 --- a/blipblop/ui/resultsscreen.py +++ b/blipblop/ui/resultsscreen.py @@ -1,20 +1,19 @@ import io import csv from PyQt5 import QtWidgets -from PyQt5.QtGui import QFont, QKeySequence +from PyQt5.QtGui import QFont, QIcon, QKeySequence from PyQt5.QtWidgets import QAction, QLabel, QStackedLayout, QTableWidget, QTableWidgetItem, QWidget from PyQt5.QtCore import Qt, pyqtSignal -import blipblop.constants as cnst from blipblop.util.results import MeasurementResults class ResultsScreen(QWidget): back_signal = pyqtSignal() - + def __init__(self, parent=None) -> None: super().__init__(parent=parent) - + self.table = QTableWidget() self._stack = QStackedLayout(self) label = QLabel("There are no results to show\n(press ESC to go back)") @@ -27,34 +26,34 @@ class ResultsScreen(QWidget): self._stack.addWidget(label) # 0 self._stack.addWidget(self.table) # 1 self.setLayout(self._stack) - + self._back_action = QAction("back") self._back_action.setShortcut(QKeySequence("escape")) self._back_action.triggered.connect(self.on_back) - + self._copy_action = QAction("copy") self._copy_action.setShortcut(QKeySequence("ctrl+c")) self._copy_action.triggered.connect(self.copy_selection) self.addAction(self._back_action) self.addAction(self._copy_action) - + def set_results(self, measurement_results): if len(measurement_results) == 0: return - + for mr in measurement_results: if not isinstance(mr, MeasurementResults): print("Some result entries are no MeasurementResults!") - return - + return + row_count = max([len(r.results) for r in measurement_results]) col_count = len(measurement_results) self.table.setRowCount(row_count) self.table.setColumnCount(col_count) for col, mr in enumerate(measurement_results): - headerItem = QTableWidgetItem(cnst.get_icon("visual_task") if "visual" in mr.name.lower() else cnst.get_icon("auditory_task"), "") + headerItem = QTableWidgetItem(QIcon(":/icons/visual_task") if "visual" in mr.name.lower() else QIcon(":/icons/auditory_task"), "") headerItem.setToolTip("%s started at\n %s " % (mr.name, mr.starttime)) self.table.setHorizontalHeaderItem(col, headerItem) for row, r in enumerate(mr.results): @@ -89,4 +88,3 @@ class ResultsScreen(QWidget): def reset(self): self.table.clear() self._stack.setCurrentIndex(0) - diff --git a/blipblop/ui/settings.py b/blipblop/ui/settings.py new file mode 100644 index 0000000..7535694 --- /dev/null +++ b/blipblop/ui/settings.py @@ -0,0 +1,148 @@ +from PyQt5.QtWidgets import QComboBox, QFormLayout, QSlider, QSpinBox, QTextEdit, QWidget +from PyQt5.QtCore import Qt, QSettings +import blipblop.constants as cnst + + +class TaskSettings(QWidget): + + def __init__(self, parent=None): + super().__init__(parent=parent) + self.settings = QSettings() + + self._trial_spinner = QSpinBox() + self._trial_spinner.setMinimum(5) + self._trial_spinner.setMaximum(25) + self._trial_spinner.setValue(int(self.settings.value("task/trials", 5))) + self._trial_spinner.setToolTip("Number of consecutive trials (5 - 25)") + + self._min_delay_spinner = QSpinBox() + self._min_delay_spinner.setMinimum(1) + self._min_delay_spinner.setMaximum(10) + self._min_delay_spinner.setValue(int(self.settings.value("task/min_delay", 1))) + self._min_delay_spinner.setToolTip("Minimum delay between start of trial and stimulus display [s]") + + self._max_delay_spinner = QSpinBox() + self._max_delay_spinner.setMinimum(1) + self._max_delay_spinner.setMaximum(10) + self._max_delay_spinner.setValue(int(self.settings.value("task/max_delay", 5))) + self._max_delay_spinner.setToolTip("Maximum delay between start of trial and stimulus display [s]") + + self._countdown_spinner = QSpinBox() + self._countdown_spinner.setMinimum(1) + self._countdown_spinner.setMaximum(10) + self._countdown_spinner.setValue(int(self.settings.value("task/pause", 3))) + self._countdown_spinner.setToolTip("Pause between trials [s]") + + self._saliency_slider = QSlider(Qt.Horizontal) + self._saliency_slider.setMinimum(0) + self._saliency_slider.setMaximum(100) + self._saliency_slider.setSliderPosition(int(self.settings.value("task/saliency", 100))) + self._saliency_slider.setTickInterval(25) + self._saliency_slider.setTickPosition(QSlider.TicksBelow) + + self._instructions = QTextEdit() + self._instructions.setMarkdown("* fixate central cross\n * press start (enter) when ready\n * press space bar as soon as the stimulus occurs") + self._instructions.setMinimumHeight(200) + self._instructions.setReadOnly(True) + + self.form_layout = QFormLayout() + self.form_layout.addRow("Settings", None) + self.form_layout.addRow("number of trials", self._trial_spinner) + self.form_layout.addRow("minimum delay [s]", self._min_delay_spinner) + self.form_layout.addRow("maximum delay [s]", self._max_delay_spinner) + self.form_layout.addRow("pause [s]", self._countdown_spinner) + self.form_layout.addRow("stimulus saliency", self._saliency_slider) + self.form_layout.addRow("instructions", self._instructions) + self.setLayout(self.form_layout) + + @property + def trials(self): + return self._trial_spinner.value() + + @property + def saliency(self): + return self._saliency_slider.sliderPosition() + + @property + def size(self): + return self._size_slider.sliderPosition() + + @property + def min_delay(self): + return self._min_delay_spinner.value() + + @property + def max_delay(self): + return self._max_delay_spinner.value() + + @property + def countdown(self): + return self._countdown_spinner.value() + + def set_enabled(self, enabled): + self._trial_spinner.setEnabled(enabled) + self._saliency_slider.setEnabled(enabled) + self._countdown_spinner.setEnabled(enabled) + self._min_delay_spinner.setEnabled(enabled) + self._max_delay_spinner.setEnabled(enabled) + + def store_settings(self): + self.settings.setValue("task/trials", self.trials) + self.settings.setValue("task/salience", self.saliency) + self.settings.setValue("task/min_delay", self.min_delay) + self.settings.setValue("task/max_delay", self.max_delay) + self.settings.setValue("task/pause", self.countdown) + + +class AuditoryTaskSettings(TaskSettings): + + def __init__(self, parent=None): + super().__init__(parent=parent) + self._saliency_slider.setToolTip("Saliency of the stimulus, i.e. its loudness") + + self._sound_combo = QComboBox() + for k in cnst.SNDS_DICT.keys(): + self._sound_combo.addItem(k) + self._sound_combo.setCurrentIndex(int(self.settings.value("auditory_task/sound_index", 0))) + self.form_layout.insertRow(self.form_layout.rowCount() - 1, "stimulus sound", self._sound_combo) + + def set_enabled(self, enabled): + super().set_enabled(enabled) + self._sound_combo.setEnabled(enabled) + + def store_settings(self): + super().store_settings() + self.settings.setValue("auditory_task/sound_index", self._sound_combo.currentIndex()) + + @property + def sound(self): + return self._sound_combo.currentText() + + +class VisualTaskSettings(TaskSettings): + + def __init__(self, parent=None): + super().__init__(parent=parent) + self._saliency_slider.setToolTip("Saliency of the stimulus, i.e. its opacity") + + self._size_slider = QSlider(Qt.Horizontal) + self._size_slider.setMinimum(0) + self._size_slider.setMaximum(200) + self._size_slider.setSliderPosition(int(self.settings.value("visual_task/stimulus_size", 100))) + self._size_slider.setTickInterval(25) + self._size_slider.setTickPosition(QSlider.TicksBelow) + self._size_slider.setToolTip("Diameter of the stimulus in pixel") + + self.form_layout.insertRow(self.form_layout.rowCount() - 1, "stimulus size", self._size_slider) + + def set_enabled(self, enabled): + super().set_enabled(enabled) + self._size_slider.setEnabled(enabled) + + def store_settings(self): + super().store_settings() + self.settings.setValue("visual_task/stimulus_size", self.size) + + @property + def size(self): + return self._size_slider.sliderPosition() diff --git a/blipblop/ui/startscreen.py b/blipblop/ui/startscreen.py index 107ea02..f5eb498 100644 --- a/blipblop/ui/startscreen.py +++ b/blipblop/ui/startscreen.py @@ -1,13 +1,11 @@ -import os from PyQt5.QtWidgets import QWidget, QGridLayout, QLabel from PyQt5.QtGui import QFont, QPixmap from PyQt5.QtCore import Qt, pyqtSignal -import blipblop.constants as cnst - class MyLabel(QLabel): clicked = pyqtSignal() + def mouseReleaseEvent(self, QMouseEvent): if QMouseEvent.button() == Qt.LeftButton: self.clicked.emit() @@ -23,11 +21,11 @@ class StartScreen(QWidget): layout = QGridLayout() layout.setColumnStretch(0, 1) layout.setColumnStretch(6, 1) - + layout.setRowStretch(0, 1) layout.setRowStretch(4, 1) self.setLayout(layout) - + label = QLabel("Measure your reaction times!\nselect a task") font = QFont() font.setBold(True) @@ -36,24 +34,26 @@ class StartScreen(QWidget): label.setFont(font) label.setAlignment(Qt.AlignCenter) layout.addWidget(label, 1, 2, 1, 3, Qt.AlignCenter) - + visual_task_label = MyLabel() + visual_task_label.setStatusTip("Start a new visual measurement (Ctrl+1)") visual_task_label.setToolTip("Click to start a new visual task (Ctrl+1)") visual_task_label.setPixmap(QPixmap(":/icons/visual_task_large")) visual_task_label.setMaximumWidth(256) visual_task_label.clicked.connect(self.new_visual_task) - + auditory_task_label = MyLabel() - auditory_task_label.setToolTip("Click to start a new auditory task (Ctrl+2)") + auditory_task_label.setStatusTip("Start a new auditory measurement (Ctrl+2)") + auditory_task_label.setToolTip("click to start a new auditory task (Ctrl+2)") auditory_task_label.setPixmap(QPixmap(":/icons/auditory_task_large")) auditory_task_label.setMaximumWidth(256) auditory_task_label.clicked.connect(self.new_auditory_task) - - layout.addWidget(visual_task_label, 2, 1, 2, 2, Qt.AlignCenter ) - layout.addWidget(auditory_task_label, 2, 4, 2, 2, Qt.AlignCenter ) + + layout.addWidget(visual_task_label, 2, 1, 2, 2, Qt.AlignCenter) + layout.addWidget(auditory_task_label, 2, 4, 2, 2, Qt.AlignCenter) def new_auditory_task(self): self.auditory_task_signal.emit() - + def new_visual_task(self): self.visual_task_signal.emit() diff --git a/blipblop/ui/visualblip.py b/blipblop/ui/visualblip.py index 0608dc7..eac2c72 100644 --- a/blipblop/ui/visualblip.py +++ b/blipblop/ui/visualblip.py @@ -1,104 +1,11 @@ -from PyQt5.QtWidgets import QAction, QFormLayout, QGridLayout, QLabel, QLineEdit, QPushButton, QSizePolicy, QSlider, QSpinBox, QSplitter, QTextEdit, QVBoxLayout, QWidget -from PyQt5.QtCore import QPoint, QRandomGenerator, QTimer, Qt, pyqtSignal, QSettings -from PyQt5.QtGui import QColor, QFont, QKeySequence, QPainter, QBrush, QPen, QPixmap +from PyQt5.QtWidgets import QAction, QGridLayout, QLabel, QPushButton, QSizePolicy, QSplitter, QVBoxLayout, QWidget +from PyQt5.QtCore import QPoint, QRandomGenerator, QTimer, Qt, pyqtSignal +from PyQt5.QtGui import QColor, QFont, QIcon, QKeySequence, QPainter, QBrush, QPen, QPixmap -import os -import blipblop.constants as cnst from blipblop.ui.countdownlabel import CountdownLabel +from blipblop.ui.settings import VisualTaskSettings import datetime as dt -class SettingsPanel(QWidget): - def __init__(self, parent=None): - super().__init__(parent=parent) - - self._trial_spinner = QSpinBox() - self._trial_spinner.setMinimum(5) - self._trial_spinner.setMaximum(25) - self._trial_spinner.setValue(5) - self._trial_spinner.setToolTip("Number of consecutive trials (5 - 25)") - - self._min_delay_spinner = QSpinBox() - self._min_delay_spinner.setMinimum(1) - self._min_delay_spinner.setMaximum(10) - self._min_delay_spinner.setValue(1) - self._min_delay_spinner.setToolTip("Minimum delay between start of trial and stimulus display [s]") - - self._max_delay_spinner = QSpinBox() - self._max_delay_spinner.setMinimum(1) - self._max_delay_spinner.setMaximum(10) - self._max_delay_spinner.setValue(5) - self._max_delay_spinner.setToolTip("Maximum delay between start of trial and stimulus display [s]") - - self._saliency_slider = QSlider(Qt.Horizontal) - self._saliency_slider.setMinimum(0) - self._saliency_slider.setMaximum(100) - self._saliency_slider.setSliderPosition(100) - self._saliency_slider.setTickInterval(25) - self._saliency_slider.setTickPosition(QSlider.TicksBelow) - self._saliency_slider.setToolTip("Saliency of the stimulus, i.e. its opacity") - - self._size_slider = QSlider(Qt.Horizontal) - self._size_slider.setMinimum(0) - self._size_slider.setMaximum(200) - self._size_slider.setSliderPosition(100) - self._size_slider.setTickInterval(25) - self._size_slider.setTickPosition(QSlider.TicksBelow) - self._size_slider.setToolTip("Diameter of the stimulus in pixel") - - self._countdown_spinner = QSpinBox() - self._countdown_spinner.setMinimum(2) - self._countdown_spinner.setMaximum(30) - self._countdown_spinner.setValue(5) - self._countdown_spinner.setToolTip("Pause/countdown for next trial") - - self._instructions = QTextEdit() - self._instructions.setMarkdown("* fixate central cross\n * press start (enter) when ready\n * press space bar as soon as the stimulus occurs") - self._instructions.setMinimumHeight(200) - self._instructions.setReadOnly(True) - - form_layout = QFormLayout() - form_layout.addRow("Settings", None) - form_layout.addRow("number of trials", self._trial_spinner) - form_layout.addRow("pause until next trial [s]", self._countdown_spinner) - form_layout.addRow("minimum delay [s]", self._min_delay_spinner) - form_layout.addRow("maximum delay [s]", self._max_delay_spinner) - form_layout.addRow("stimulus saliency", self._saliency_slider) - form_layout.addRow("stimulus size", self._size_slider) - form_layout.addRow("instructions", self._instructions) - self.setLayout(form_layout) - - @property - def trials(self): - return self._trial_spinner.value() - - @property - def saliency(self): - return self._saliency_slider.sliderPosition() - - @property - def size(self): - return self._size_slider.sliderPosition() - - @property - def min_delay(self): - return self._min_delay_spinner.value() - - @property - def max_delay(self): - return self._max_delay_spinner.value() - - @property - def countdown(self): - return self._countdown_spinner.value() - - def set_enabled(self, enabled): - self._trial_spinner.setEnabled(enabled) - self._saliency_slider.setEnabled(enabled) - self._size_slider.setEnabled(enabled) - self._countdown_spinner.setEnabled(enabled) - self._min_delay_spinner.setEnabled(enabled) - self._max_delay_spinner.setEnabled(enabled) - class VisualBlip(QWidget): task_done = pyqtSignal() @@ -106,7 +13,7 @@ class VisualBlip(QWidget): def __init__(self, parent=None) -> None: super().__init__(parent=parent) - + widget = QWidget() grid = QGridLayout() grid.setColumnStretch(0, 1) @@ -115,37 +22,37 @@ class VisualBlip(QWidget): grid.setRowStretch(3, 1) widget.setLayout(grid) - l = QLabel("Visual reaction test") - l.setPixmap(QPixmap(os.path.join(cnst.ICONS_FOLDER, "visual_task.png"))) - grid.addWidget(l, 0, 0, Qt.AlignLeft) + icon_label = QLabel("Visual reaction test") + icon_label.setPixmap(QPixmap(":/icons/visual_task")) + grid.addWidget(icon_label, 0, 0, Qt.AlignLeft) - l2 = QLabel("Measurement of visual reaction times\npress enter to start") + heading_label = QLabel("Measurement of visual reaction times\npress enter to start") font = QFont() font.setBold(True) font.setPointSize(20) - l2.setFont(font) - l2.setStyleSheet("color: #2D4B9A") - grid.addWidget(l2, 1, 0, 1, 2, Qt.AlignLeft) - - settings_btn = QPushButton(cnst.get_icon("settings"), "") + heading_label.setFont(font) + heading_label.setStyleSheet("color: #2D4B9A") + grid.addWidget(heading_label, 1, 0, 1, 2, Qt.AlignLeft) + + settings_btn = QPushButton(QIcon(":/icons/settings"), "") settings_btn.setToolTip("edit task settings") settings_btn.setShortcut(QKeySequence("alt+s")) settings_btn.clicked.connect(self.on_toggle_settings) grid.addWidget(settings_btn, 0, 3, Qt.AlignRight) - + self._status_label = QLabel("Ready to start, press enter ...") - grid.addWidget(self._status_label, 3, 0, Qt.AlignBaseline) - + grid.addWidget(self._status_label, 4, 0, Qt.AlignBaseline) + self._countdown_label = CountdownLabel(text="Next trial in:") - grid.addWidget(self._countdown_label, 3, 1, Qt.AlignCenter) + grid.addWidget(self._countdown_label, 4, 1, Qt.AlignCenter) self._countdown_label.countdown_done.connect(self.run_trial) - + self._draw_area = QLabel() self._draw_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) grid.addWidget(self._draw_area, 2, 1) - - self._settings = SettingsPanel() - + + self._settings = VisualTaskSettings() + self._splitter = QSplitter() self._splitter.addWidget(widget) self._splitter.addWidget(self._settings) @@ -154,7 +61,7 @@ class VisualBlip(QWidget): vbox = QVBoxLayout() vbox.addWidget(self._splitter) self.setLayout(vbox) - + self.reset_canvas() self.create_actions() @@ -167,12 +74,12 @@ class VisualBlip(QWidget): self._random_generator = QRandomGenerator() self.setFocus() - + def create_actions(self): self._start_action = QAction("start trial") self._start_action.setShortcuts([QKeySequence("enter"), QKeySequence("return")]) self._start_action.triggered.connect(self.on_trial_start) - + self._reaction = QAction("reaction") self._reaction.setShortcut(QKeySequence("space")) self._reaction.triggered.connect(self.on_reaction) @@ -180,7 +87,7 @@ class VisualBlip(QWidget): self._abort = QAction("abort") self._abort.setShortcut(QKeySequence("escape")) self._abort.triggered.connect(self.on_abort) - + self.addAction(self._start_action) self.addAction(self._reaction) self.addAction(self._abort) @@ -188,7 +95,7 @@ class VisualBlip(QWidget): def on_reaction(self): if not self._session_running or not self._trial_running: return - + self._response_time = dt.datetime.now() if self._trial_counter < self._settings.trials: self._status_label.setText("Trial %i of %i" % (self._trial_counter, self._settings.trials)) @@ -212,7 +119,7 @@ class VisualBlip(QWidget): canvas = QPixmap(400, 400) self._canvas_center = QPoint(200, 200) canvas.fill(bkg_color) - self.draw_fixation(canvas) + self.draw_fixation(canvas) self._draw_area.setPixmap(canvas) self._draw_area.update() @@ -221,7 +128,7 @@ class VisualBlip(QWidget): right = QPoint(225, 200) top = QPoint(200, 175) bottom = QPoint(200, 225) - painter = QPainter(pixmap) + painter = QPainter(pixmap) painter.setPen(QPen(Qt.red, 2, Qt.SolidLine)) painter.drawLine(left, right) painter.drawLine(top, bottom) @@ -232,24 +139,25 @@ class VisualBlip(QWidget): def blip(self): stim_size = self._settings.size - painter = QPainter(self._draw_area.pixmap()) + painter = QPainter(self._draw_area.pixmap()) painter.setPen(QPen(Qt.red, 1, Qt.SolidLine)) color = QColor(Qt.red) color.setAlphaF(self._settings.saliency/100) - painter.setBrush(QBrush(color, Qt.SolidPattern)) + painter.setBrush(QBrush(color, Qt.SolidPattern)) painter.drawEllipse(self._canvas_center, stim_size, stim_size) painter.end() self._start_time = dt.datetime.now() self._draw_area.update() - + def on_trial_start(self): if self._trial_running: return if not self._session_running: self._settings.set_enabled(False) - self._session_running = True + self._settings.store_settings() + self._session_running = True self._countdown_label.start(time=self._settings.countdown) - + def run_trial(self): self._trial_running = True if self._trial_counter >= self._settings.trials: @@ -267,15 +175,15 @@ class VisualBlip(QWidget): self._timer.setInterval(int(interval)) self._timer.timeout.connect(self.blip) self._timer.start() - + def on_abort(self): self.reset() self.task_aborted.emit() - + @property def results(self): return self._reaction_times - + def reset(self): self.reset_canvas() self._trial_counter = 0 @@ -286,7 +194,7 @@ class VisualBlip(QWidget): self._status_label.setText("Ready to start...") self._countdown_label.stop() self._settings.set_enabled(True) - + def on_toggle_settings(self): if self._splitter.sizes()[1] > 0: self._splitter.widget(1).hide() diff --git a/blipblop_darwin.spec b/blipblop_darwin.spec new file mode 100644 index 0000000..76e5105 --- /dev/null +++ b/blipblop_darwin.spec @@ -0,0 +1,48 @@ +# -*- mode: python ; coding: utf-8 -*- + +block_cipher = None + + +a = Analysis(['blipblop_main.py'], + pathex=['.'], + binaries=[], + datas=[('docs/index.md', "docs"), + ('docs/visual_task.md', "docs"), + ('docs/auditory_task.md', "docs"), + ('docs/license.md', "docs"), + ('docs/tasks.md', "docs"), + ('docs/images/blipblop_main.png', "docs/images"), + ('docs/images/blipblop_logo.png', "docs/images"), + ('sounds/bell.wav', "sounds"), + ('sounds/complete.wav', "sounds"), + ('sounds/message.wav', "sounds"), + ('icons/blipblop_logo.icns', "."), + ], + hiddenimports=[], + hookspath=[], + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False) + +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) + +exe = EXE(pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='BlipBlop', + debug=True, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + icon='icons/blipblop_logo.icns' + ) diff --git a/blipblop_linux.spec b/blipblop_linux.spec new file mode 100644 index 0000000..7bad729 --- /dev/null +++ b/blipblop_linux.spec @@ -0,0 +1,51 @@ +# -*- mode: python ; coding: utf-8 -*- + +block_cipher = None + + +a = Analysis(['blipblop_main.py'], + pathex=['.'], + binaries=[], + datas=[('docs/index.md', "docs"), + ('docs/visual_task.md', "docs"), + ('docs/auditory_task.md', "docs"), + ('docs/license.md', "docs"), + ('docs/results.md', "docs"), + ('docs/tasks.md', "docs"), + ('docs/images/blipblop_main.png', "docs/images"), + ('docs/images/blipblop_logo.png', "docs/images"), + ('sounds/bell.wav', "sounds"), + ('sounds/complete.wav', "sounds"), + ('sounds/message.wav', "sounds"), + ], + hiddenimports=[], + hookspath=[], + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False) + +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) + +exe = EXE(pyz, + a.scripts, + [], + exclude_binaries=True, + name='BlipBlop', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False ) + +coll = COLLECT(exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='BlipBlop') diff --git a/blipblop.py b/blipblop_main.py similarity index 100% rename from blipblop.py rename to blipblop_main.py diff --git a/blipblop_win.spec b/blipblop_win.spec new file mode 100644 index 0000000..cbe9f7d --- /dev/null +++ b/blipblop_win.spec @@ -0,0 +1,49 @@ +# -*- mode: python ; coding: utf-8 -*- + +block_cipher = None + + +a = Analysis(['blipblop_main.py'], + pathex=['.'], + binaries=[], + datas=[('docs/index.md', "docs"), + ('docs/visual_task.md', "docs"), + ('docs/auditory_task.md', "docs"), + ('docs/license.md', "docs"), + ('docs/tasks.md', "docs"), + ('docs/images/blipblop_main.png', "docs/images"), + ('docs/images/blipblop_logo.png', "docs/images"), + ('sounds/bell.wav', "sounds"), + ('sounds/complete.wav', "sounds"), + ('sounds/message.wav', "sounds"), + ('icons/blipblop_logo.ico', '.') + ], + hiddenimports=[], + hookspath=[], + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False) +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) + +exe = EXE(pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + exclude_binaries=True, + name='BlipBlop', + debug=True, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + upx_exclude=[], + runtime_tmpdir=None, + icon='icons/blipblop_logo.ico' + ) + diff --git a/docs/auditory_task.md b/docs/auditory_task.md new file mode 100644 index 0000000..f9c53e2 --- /dev/null +++ b/docs/auditory_task.md @@ -0,0 +1,2 @@ +# Auditory reaction times + diff --git a/docs/images/blipblop_logo.png b/docs/images/blipblop_logo.png new file mode 100644 index 0000000..9d38447 Binary files /dev/null and b/docs/images/blipblop_logo.png differ diff --git a/docs/images/blipblop_main.png b/docs/images/blipblop_main.png new file mode 100644 index 0000000..c442c1f Binary files /dev/null and b/docs/images/blipblop_main.png differ diff --git a/docs/index.md b/docs/index.md index 8bb5014..f53d5e8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,4 +1,27 @@ + # BlipBlop -Tiny tool for measuring reaction times upon auditory or visual stimuli. + + +BlipBlop is a tiny tool for measuring reaction times upon auditory or visual stimulation. This tool is open-source. The source code can be found on [GitHub](https://github.com/jgrewe/blipblop). It is published under the MIT open source [license](license.md). +Auditory stimuli have been taken from the freedesktop system sounds [freedesktop](https://freedesktop.org) + + +## Main Window + + + + +### Visual task + +Measure reaction times to a visual stimulus that pops in the center of the screen. ([more](visual_task.md)) + +### Auditory task + +Measure reaction times to a auditory stimulus. ([more](auditory_task.md)) + +### Results View + +A simple tablular view that shows the measured reaction times. The data is given in seconds. Select the data and press (**ctrl+c** or **cmd+c**) to copy the selection to clipboard and paste it into your favorite spreadsheet tool for further analysis. + diff --git a/docs/license.md b/docs/license.md new file mode 100644 index 0000000..4c57d76 --- /dev/null +++ b/docs/license.md @@ -0,0 +1,9 @@ +# License + +Copyright 2021, Jan Grewe + +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. diff --git a/docs/results.md b/docs/results.md new file mode 100644 index 0000000..e43f7df --- /dev/null +++ b/docs/results.md @@ -0,0 +1 @@ +# Results view \ No newline at end of file diff --git a/docs/tasks.md b/docs/tasks.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/visual_task.md b/docs/visual_task.md new file mode 100644 index 0000000..5c7035c --- /dev/null +++ b/docs/visual_task.md @@ -0,0 +1,2 @@ +# Visual reaction times + diff --git a/resources.qrc b/resources.qrc index 9f198f3..feb0ca0 100644 --- a/resources.qrc +++ b/resources.qrc @@ -2,6 +2,7 @@ <RCC version="1.0"> <qresource prefix="icons"> <file alias="app_icon">icons/blipblop_logo.icns</file> + <file alias="app_icon_png">icons/blipblop_logo.png</file> <file alias="blipblop_logo">icons/blipblop_logo.png</file> <file alias="visual_task">icons/visual_task.png</file> <file alias="visual_task_large">icons/visual_task_large.png</file> @@ -11,17 +12,16 @@ <file alias="docs_back">icons/back_btn.png</file> <file alias="docs_forward">icons/fwd_btn.png</file> <file alias="help">icons/help.png</file> + <file alias="quit">icons/blipblop_quit.png</file> <file alias="new_session">icons/new_session.png</file> <file alias="new_session_larg">icons/new_session_large.png</file> <file alias="results_table">icons/blipblop_table.png</file> <file alias="settings">icons/settings.png</file> </qresource> - <qresource prefix="sounds"> - <file alias="bell">sounds/bell.wav</file> - <file alias="message">sounds/message.wav</file> - <file alias="complete">sounds/complete.wav</file> - </qresource> <qresource prefix="docs"> <file alias="index">docs/index.md</file> + <file alias="visual_task">docs/visual_task.md</file> + <file alias="auditory_task">docs/auditory_task.md</file> + <file alias="license">docs/license.md</file> </qresource> </RCC> \ No newline at end of file