This commit is contained in:
Jan Grewe 2021-03-12 18:46:40 +01:00
commit 9a81ddfbcf
28 changed files with 567 additions and 484 deletions

51
.github/workflows/python-app_linux.yml vendored Normal file
View File

@ -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

View File

@ -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

View File

@ -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

5
.gitignore vendored
View File

@ -1,2 +1,5 @@
*.pyc
*__pycache__
*__pycache__
resources.py
build
dist

View File

@ -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")

View File

@ -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()

View File

@ -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()

View File

@ -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):

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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)

148
blipblop/ui/settings.py Normal file
View File

@ -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()

View File

@ -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()

View File

@ -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()

48
blipblop_darwin.spec Normal file
View File

@ -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'
)

51
blipblop_linux.spec Normal file
View File

@ -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')

49
blipblop_win.spec Normal file
View File

@ -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'
)

2
docs/auditory_task.md Normal file
View File

@ -0,0 +1,2 @@
# Auditory reaction times

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@ -1,4 +1,27 @@
# BlipBlop
Tiny tool for measuring reaction times upon auditory or visual stimuli.
![blip blop](images/blipblop_logo.png)
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
![main screen](images/blipblop_main.png)
### 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.

9
docs/license.md Normal file
View File

@ -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.

1
docs/results.md Normal file
View File

@ -0,0 +1 @@
# Results view

0
docs/tasks.md Normal file
View File

2
docs/visual_task.md Normal file
View File

@ -0,0 +1,2 @@
# Visual reaction times

View File

@ -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>