From 2d3991ae48cccc33f49ea4edb311c0ea820595c8 Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Mon, 8 Mar 2021 14:58:54 +0100 Subject: [PATCH 01/23] [use resources] --- blipblop/main.py | 11 ++++++----- blipblop/ui/visualblip.py | 10 +++++----- resources.qrc | 1 + 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/blipblop/main.py b/blipblop/main.py index c4fe103..532fa1a 100644 --- a/blipblop/main.py +++ b/blipblop/main.py @@ -1,5 +1,6 @@ #!/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 @@ -18,12 +19,12 @@ def main(): app.setApplicationName("blipblop") app.setApplicationVersion("0.1") app.setOrganizationDomain("neuroetho.uni-tuebingen.de") - app.setWindowIcon(cnst.get_icon("blipblop_logo.png")) + 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) diff --git a/blipblop/ui/visualblip.py b/blipblop/ui/visualblip.py index 0608dc7..7652e8d 100644 --- a/blipblop/ui/visualblip.py +++ b/blipblop/ui/visualblip.py @@ -1,6 +1,6 @@ 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.QtGui import QColor, QFont, QIcon, QKeySequence, QPainter, QBrush, QPen, QPixmap import os import blipblop.constants as cnst @@ -116,7 +116,7 @@ class VisualBlip(QWidget): widget.setLayout(grid) l = QLabel("Visual reaction test") - l.setPixmap(QPixmap(os.path.join(cnst.ICONS_FOLDER, "visual_task.png"))) + l.setPixmap(QPixmap(":/icons/visual_task")) grid.addWidget(l, 0, 0, Qt.AlignLeft) l2 = QLabel("Measurement of visual reaction times\npress enter to start") @@ -127,17 +127,17 @@ class VisualBlip(QWidget): l2.setStyleSheet("color: #2D4B9A") grid.addWidget(l2, 1, 0, 1, 2, Qt.AlignLeft) - settings_btn = QPushButton(cnst.get_icon("settings"), "") + 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() diff --git a/resources.qrc b/resources.qrc index 9f198f3..7cb7524 100644 --- a/resources.qrc +++ b/resources.qrc @@ -2,6 +2,7 @@ icons/blipblop_logo.icns + icons/blipblop_logo.png icons/blipblop_logo.png icons/visual_task.png icons/visual_task_large.png From f30c2eb36eccf6970c1f1da3250117d8499ad937 Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Mon, 8 Mar 2021 14:59:32 +0100 Subject: [PATCH 02/23] [main] use constants for app info --- .gitignore | 3 ++- blipblop/constants.py | 27 +++++++++++++-------------- blipblop/main.py | 6 +++--- blipblop/ui/audioblop.py | 8 ++++---- blipblop/ui/help.py | 6 +++--- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 4ef3e36..e4897ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *.pyc -*__pycache__ \ No newline at end of file +*__pycache__ +resources.py diff --git a/blipblop/constants.py b/blipblop/constants.py index 10b6dfd..f3115f6 100644 --- a/blipblop/constants.py +++ b/blipblop/constants.py @@ -2,13 +2,20 @@ import os import glob from PyQt5.QtGui import QIcon from PyQt5.QtMultimedia import QMediaContent -from PyQt5.QtCore import QUrl +from PyQt5.QtCore import QDirIterator, QUrl import resources -organization = "neuroetho.uni-tuebingen.de" -application = "blipblop" -version = 0.1 +SNDS_DICT = {} +it = QDirIterator(":", QDirIterator.Subdirectories); +while it.hasNext(): + name = it.next() + if "sounds/" in name: + SNDS_DICT[name.split("/")[-1]] = "qrc" + name + +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") @@ -20,20 +27,12 @@ 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]))) + return QMediaContent(QUrl(SNDS_DICT[name])) else: print("Sound %s not found!" % name) return None @@ -43,5 +42,5 @@ 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 532fa1a..eda2b1a 100644 --- a/blipblop/main.py +++ b/blipblop/main.py @@ -16,9 +16,9 @@ except ImportError: def main(): app = QApplication(sys.argv) - app.setApplicationName("blipblop") - app.setApplicationVersion("0.1") - app.setOrganizationDomain("neuroetho.uni-tuebingen.de") + app.setApplicationName(cnst.application_name) + app.setApplicationVersion(cnst.application_version) + app.setOrganizationDomain(cnst.organization_name) app.setWindowIcon(QIcon(":/icons/app_icon_png")) settings = QSettings() width = int(settings.value("app/width", 1024)) diff --git a/blipblop/ui/audioblop.py b/blipblop/ui/audioblop.py index 7a5536a..e584e87 100644 --- a/blipblop/ui/audioblop.py +++ b/blipblop/ui/audioblop.py @@ -1,5 +1,5 @@ from PyQt5.QtWidgets import QAction, QComboBox, QFormLayout, QGridLayout, QLabel, QPushButton, QSizePolicy, QSlider, QSpinBox, QSplitter, QTextEdit, QVBoxLayout, QWidget -from PyQt5.QtCore import QPoint, QRandomGenerator, QTimer, Qt, pyqtSignal +from PyQt5.QtCore import QPoint, QRandomGenerator, QTimer, Qt, endl, pyqtSignal from PyQt5.QtGui import QColor, QFont, QIcon, QKeySequence, QPainter, QPen, QPixmap from PyQt5.QtMultimedia import QMediaPlayer @@ -98,7 +98,7 @@ class SettingsPanel(QWidget): self._countdown_spinner.setEnabled(enabled) self._min_delay_spinner.setEnabled(enabled) self._max_delay_spinner.setEnabled(enabled) - self._sound_combo.setEnabled(False) + self._sound_combo.setEnabled(enabled) class AudioBlop(QWidget): @@ -135,10 +135,10 @@ class AudioBlop(QWidget): 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() diff --git a/blipblop/ui/help.py b/blipblop/ui/help.py index 00f68d0..2f482fa 100644 --- a/blipblop/ui/help.py +++ b/blipblop/ui/help.py @@ -16,12 +16,12 @@ class HelpDialog(QDialog): 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_fwd"),"forward") self.fwd_btn.setEnabled(False) self.fwd_btn.clicked.connect(self.help._edit.forward) From 4695d60cd15b49c7d65008de9624a5bcb16c4db4 Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Mon, 8 Mar 2021 15:09:29 +0100 Subject: [PATCH 03/23] fixes and additional icon to qrc --- blipblop/main.py | 5 ++--- blipblop/ui/mainwindow.py | 11 ++++++----- blipblop/ui/startscreen.py | 6 +++--- resources.qrc | 1 + 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/blipblop/main.py b/blipblop/main.py index eda2b1a..9c4e37c 100644 --- a/blipblop/main.py +++ b/blipblop/main.py @@ -9,7 +9,7 @@ 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 @@ -17,7 +17,7 @@ except ImportError: def main(): app = QApplication(sys.argv) app.setApplicationName(cnst.application_name) - app.setApplicationVersion(cnst.application_version) + app.setApplicationVersion(str(cnst.application_version)) app.setOrganizationDomain(cnst.organization_name) app.setWindowIcon(QIcon(":/icons/app_icon_png")) settings = QSettings() @@ -40,6 +40,5 @@ def main(): 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/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/startscreen.py b/blipblop/ui/startscreen.py index 107ea02..0dd3510 100644 --- a/blipblop/ui/startscreen.py +++ b/blipblop/ui/startscreen.py @@ -3,8 +3,6 @@ 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() @@ -38,13 +36,15 @@ class StartScreen(QWidget): 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) diff --git a/resources.qrc b/resources.qrc index 7cb7524..b2c92b3 100644 --- a/resources.qrc +++ b/resources.qrc @@ -12,6 +12,7 @@ icons/back_btn.png icons/fwd_btn.png icons/help.png + icons/blipblop_quit.png icons/new_session.png icons/new_session_large.png icons/blipblop_table.png From 2b5982eabbe7b6cb3e5cf11b29ce4da1c67e632e Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Mon, 8 Mar 2021 23:30:51 +0100 Subject: [PATCH 04/23] [settings] extract tasksettings class --- blipblop/ui/audioblop.py | 156 ++++++++---------------------------- blipblop/ui/settings.py | 132 +++++++++++++++++++++++++++++++ blipblop/ui/visualblip.py | 161 ++++++++------------------------------ 3 files changed, 198 insertions(+), 251 deletions(-) create mode 100644 blipblop/ui/settings.py diff --git a/blipblop/ui/audioblop.py b/blipblop/ui/audioblop.py index e584e87..47bcac9 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.QtCore import QPoint, QRandomGenerator, QTimer, Qt, endl, pyqtSignal +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(enabled) - 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, 4, 0, Qt.AlignLeft) self._countdown_label = CountdownLabel(text="Next trial in:") 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,7 +141,7 @@ 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 @@ -251,9 +158,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 +171,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 +189,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/settings.py b/blipblop/ui/settings.py new file mode 100644 index 0000000..65759e2 --- /dev/null +++ b/blipblop/ui/settings.py @@ -0,0 +1,132 @@ +from PyQt5.QtWidgets import QComboBox, QFormLayout, QSlider, QSpinBox, QTextEdit, QWidget +from PyQt5.QtCore import Qt +import blipblop.constants as cnst + + +class TaskSettings(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._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) + + +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.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) + + @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(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) + + @property + def size(self): + return self._size_slider.sliderPosition() diff --git a/blipblop/ui/visualblip.py b/blipblop/ui/visualblip.py index 7652e8d..e94b07d 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.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(":/icons/visual_task")) - 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) - + 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, 4, 0, Qt.AlignBaseline) - + self._countdown_label = CountdownLabel(text="Next trial in:") 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,24 @@ 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._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 +174,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 +193,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() From a19df544d7a02b1029673e55ce73a4305c46bc51 Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Mon, 8 Mar 2021 23:52:41 +0100 Subject: [PATCH 05/23] [settings] store settings --- blipblop/ui/audioblop.py | 1 + blipblop/ui/settings.py | 44 ++++++++++++++++++++++++++------------- blipblop/ui/visualblip.py | 1 + 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/blipblop/ui/audioblop.py b/blipblop/ui/audioblop.py index 47bcac9..518ad2d 100644 --- a/blipblop/ui/audioblop.py +++ b/blipblop/ui/audioblop.py @@ -147,6 +147,7 @@ class AudioBlop(QWidget): 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) diff --git a/blipblop/ui/settings.py b/blipblop/ui/settings.py index 65759e2..a417147 100644 --- a/blipblop/ui/settings.py +++ b/blipblop/ui/settings.py @@ -1,5 +1,5 @@ from PyQt5.QtWidgets import QComboBox, QFormLayout, QSlider, QSpinBox, QTextEdit, QWidget -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QSettings import blipblop.constants as cnst @@ -7,35 +7,36 @@ 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(5) + 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(1) + 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 = QSpinBox() self._max_delay_spinner.setMinimum(1) self._max_delay_spinner.setMaximum(10) - self._max_delay_spinner.setValue(5) + 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 = QSpinBox() self._countdown_spinner.setMinimum(1) self._countdown_spinner.setMaximum(10) - self._countdown_spinner.setValue(3) + 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(100) + self._saliency_slider.setSliderPosition(int(self.settings.value("task/saliency", 100))) self._saliency_slider.setTickInterval(25) self._saliency_slider.setTickPosition(QSlider.TicksBelow) @@ -76,7 +77,7 @@ class TaskSettings(QWidget): @property def countdown(self): - return self._countdown_spinner.value() + return self._countdown_spinner.value() def set_enabled(self, enabled): self._trial_spinner.setEnabled(enabled) @@ -85,6 +86,13 @@ class TaskSettings(QWidget): 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): @@ -95,13 +103,17 @@ class AuditoryTaskSettings(TaskSettings): self._sound_combo = QComboBox() for k in cnst.SNDS_DICT.keys(): self._sound_combo.addItem(k) - - self.form_layout.insertRow(self.form_layout.rowCount() -1, "stimulus sound", self._sound_combo) + 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() @@ -116,17 +128,21 @@ class VisualTaskSettings(TaskSettings): self._size_slider = QSlider(Qt.Horizontal) self._size_slider.setMinimum(0) self._size_slider.setMaximum(200) - self._size_slider.setSliderPosition(100) + 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) + 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/visualblip.py b/blipblop/ui/visualblip.py index e94b07d..eac2c72 100644 --- a/blipblop/ui/visualblip.py +++ b/blipblop/ui/visualblip.py @@ -154,6 +154,7 @@ class VisualBlip(QWidget): 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) From 6524c6afde9715b8146d9e1d7e781f241d17b31c Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Mon, 8 Mar 2021 23:53:05 +0100 Subject: [PATCH 06/23] flake8 --- blipblop/constants.py | 6 +- blipblop/ui/filescreen.py | 142 ----------------------------------- blipblop/ui/resultsscreen.py | 17 ++--- blipblop/ui/startscreen.py | 18 ++--- 4 files changed, 20 insertions(+), 163 deletions(-) delete mode 100644 blipblop/ui/filescreen.py diff --git a/blipblop/constants.py b/blipblop/constants.py index f3115f6..c4822bd 100644 --- a/blipblop/constants.py +++ b/blipblop/constants.py @@ -12,7 +12,7 @@ while it.hasNext(): name = it.next() if "sounds/" in name: SNDS_DICT[name.split("/")[-1]] = "qrc" + name - +print(SNDS_DICT) organization_name = "de.uni-tuebingen.neuroetho" application_name = "BlipBlop" application_version = 0.1 @@ -32,7 +32,8 @@ for icon in ICONS_PATHS: def get_sound(name): if name in SNDS_DICT.keys(): - return QMediaContent(QUrl(SNDS_DICT[name])) + url = QUrl(SNDS_DICT[name]) + return QMediaContent(url) else: print("Sound %s not found!" % name) return None @@ -43,4 +44,3 @@ def get_icon(name): return QIcon(ICON_DICT[name]) else: return QIcon("blipblop_logo.png") - 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/resultsscreen.py b/blipblop/ui/resultsscreen.py index ac062a0..ad768a3 100644 --- a/blipblop/ui/resultsscreen.py +++ b/blipblop/ui/resultsscreen.py @@ -11,10 +11,10 @@ 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,27 +27,27 @@ 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) @@ -89,4 +89,3 @@ class ResultsScreen(QWidget): def reset(self): self.table.clear() self._stack.setCurrentIndex(0) - diff --git a/blipblop/ui/startscreen.py b/blipblop/ui/startscreen.py index 0dd3510..f5eb498 100644 --- a/blipblop/ui/startscreen.py +++ b/blipblop/ui/startscreen.py @@ -1,4 +1,3 @@ -import os from PyQt5.QtWidgets import QWidget, QGridLayout, QLabel from PyQt5.QtGui import QFont, QPixmap from PyQt5.QtCore import Qt, pyqtSignal @@ -6,6 +5,7 @@ from PyQt5.QtCore import Qt, pyqtSignal class MyLabel(QLabel): clicked = pyqtSignal() + def mouseReleaseEvent(self, QMouseEvent): if QMouseEvent.button() == Qt.LeftButton: self.clicked.emit() @@ -21,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) @@ -34,26 +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.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() From 63ecf934e6ec81780586a985d8109007ef03ba3f Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Fri, 12 Mar 2021 10:46:46 +0100 Subject: [PATCH 07/23] [gitignore] add build and dist folders --- .gitignore | 2 ++ blipblop/main.py | 6 ++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index e4897ae..df8847a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ *.pyc *__pycache__ resources.py +build +dist \ No newline at end of file diff --git a/blipblop/main.py b/blipblop/main.py index 9c4e37c..183aa1c 100644 --- a/blipblop/main.py +++ b/blipblop/main.py @@ -1,4 +1,3 @@ -#!/usr/bin/python3 import sys from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QApplication @@ -15,6 +14,7 @@ except ImportError: pass def main(): + print("executing main.main") app = QApplication(sys.argv) app.setApplicationName(cnst.application_name) app.setApplicationVersion(str(cnst.application_version)) @@ -33,12 +33,10 @@ def main(): window.show() code = app.exec_() + print("Application exit!") pos = window.pos() settings.setValue("app/width", window.width()) settings.setValue("app/height", window.height()) 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 From 1fc9d3bdfc173623c2fa74fe7ec0e584395fd027 Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Fri, 12 Mar 2021 10:49:27 +0100 Subject: [PATCH 08/23] [docs] add skeletons for task descriptions --- blipblop/ui/help.py | 2 +- docs/auditory_task.md | 2 ++ docs/index.md | 18 +++++++++++++++++- docs/license.md | 1 + docs/visual_task.md | 2 ++ resources.qrc | 3 +++ 6 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 docs/auditory_task.md create mode 100644 docs/license.md create mode 100644 docs/visual_task.md diff --git a/blipblop/ui/help.py b/blipblop/ui/help.py index 2f482fa..362799d 100644 --- a/blipblop/ui/help.py +++ b/blipblop/ui/help.py @@ -52,7 +52,7 @@ 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 + # FIXME https://stackoverflow.com/a/43217828 about loading from resource files doc_url = QUrl.fromLocalFile(cnst.DOCS_ROOT_FILE) self._edit = QTextBrowser() self._edit.setOpenLinks(True) 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/index.md b/docs/index.md index 8bb5014..7e92787 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,4 +1,20 @@ # BlipBlop -Tiny tool for measuring reaction times upon auditory or visual stimuli. +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) + +The main window has three views: + +## Visual task + +Measure reaction times to a visual stimulus that pops in the center of the screen. ([more](qrc:/docs/visual_task)) + +## Auditory task + +Measure reaction times to a auditory stimulus. ([more](qrc:/docs/auditory_task)) + +## 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..96ead31 --- /dev/null +++ b/docs/license.md @@ -0,0 +1 @@ +#License \ No newline at end of file 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 b2c92b3..24ddf5d 100644 --- a/resources.qrc +++ b/resources.qrc @@ -25,5 +25,8 @@ docs/index.md + docs/visual_task.md + docs/auditory_task.md + docs/license.md \ No newline at end of file From 41390bc368c2297c4b1e8c0797a78ac43213dcb9 Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Fri, 12 Mar 2021 11:52:15 +0100 Subject: [PATCH 09/23] [main] rename entry point ... to NOT have the same name as the package. Apparently this confuses pyinstaller --- blipblop.py => blipblop_main.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename blipblop.py => blipblop_main.py (100%) diff --git a/blipblop.py b/blipblop_main.py similarity index 100% rename from blipblop.py rename to blipblop_main.py From 2cfffd7eb5f727d31310b63e84dc652e8a724edd Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Fri, 12 Mar 2021 11:52:50 +0100 Subject: [PATCH 10/23] [pyinstaller] add spec files for darwin and linux --- blipblop_darwin.spec | 45 ++++++++++++++++++++++++++++++++++++++++++++ blipblop_linux.spec | 44 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 blipblop_darwin.spec create mode 100644 blipblop_linux.spec diff --git a/blipblop_darwin.spec b/blipblop_darwin.spec new file mode 100644 index 0000000..fa5ca9d --- /dev/null +++ b/blipblop_darwin.spec @@ -0,0 +1,45 @@ +# -*- 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"), + ], + 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, + icon='icons/blipblop_logo.icns' + ) + +coll = COLLECT(exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='BlipBlop') diff --git a/blipblop_linux.spec b/blipblop_linux.spec new file mode 100644 index 0000000..22f86b4 --- /dev/null +++ b/blipblop_linux.spec @@ -0,0 +1,44 @@ +# -*- 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"), + ], + 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') From 22671bf59448f5a9626fb43b9be592f38f1efaa1 Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Fri, 12 Mar 2021 11:54:52 +0100 Subject: [PATCH 11/23] [actions] add wrokflow for linux --- .github/workflows/python-app_linux.yml | 51 ++++++++++++++++++++++++++ .github/workflows/python-app_macos.yml | 4 +- 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/python-app_linux.yml diff --git a/.github/workflows/python-app_linux.yml b/.github/workflows/python-app_linux.yml new file mode 100644 index 0000000..95fe4dd --- /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: Python application + +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.py + + - 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..e94e5ea 100644 --- a/.github/workflows/python-app_macos.yml +++ b/.github/workflows/python-app_macos.yml @@ -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 --onefile --windowed --osx-bundle-identifier="de.uni-tuebingen.neuroetho.blipblop" blipblop_dawin.py - 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 From 3041e0822e3915551f22b15d00434a3098e13ea1 Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Fri, 12 Mar 2021 12:12:05 +0100 Subject: [PATCH 12/23] [constants] revert finding sounds, do not use qresources --- blipblop/constants.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/blipblop/constants.py b/blipblop/constants.py index c4822bd..46f9a80 100644 --- a/blipblop/constants.py +++ b/blipblop/constants.py @@ -2,17 +2,10 @@ import os import glob from PyQt5.QtGui import QIcon from PyQt5.QtMultimedia import QMediaContent -from PyQt5.QtCore import QDirIterator, QUrl +from PyQt5.QtCore import QUrl -import resources +import resources # needs to be imported somewhere in the project to be picked up by qt -SNDS_DICT = {} -it = QDirIterator(":", QDirIterator.Subdirectories); -while it.hasNext(): - name = it.next() - if "sounds/" in name: - SNDS_DICT[name.split("/")[-1]] = "qrc" + name -print(SNDS_DICT) organization_name = "de.uni-tuebingen.neuroetho" application_name = "BlipBlop" application_version = 0.1 @@ -22,6 +15,23 @@ 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) @@ -30,9 +40,10 @@ ICON_DICT = {} for icon in ICONS_PATHS: ICON_DICT[icon.split(os.sep)[-1].split(".")[0]] = icon + def get_sound(name): if name in SNDS_DICT.keys(): - url = QUrl(SNDS_DICT[name]) + url = QUrl.fromLocalFile(SNDS_DICT[name]) return QMediaContent(url) else: print("Sound %s not found!" % name) From fc474bd29da5a3380b80234c8f462ef1020aa0e0 Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Fri, 12 Mar 2021 12:14:41 +0100 Subject: [PATCH 13/23] [sounds] add to spec files, remove from resources --- blipblop_darwin.spec | 3 +++ blipblop_linux.spec | 3 +++ resources.qrc | 5 ----- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/blipblop_darwin.spec b/blipblop_darwin.spec index fa5ca9d..cbfb7ef 100644 --- a/blipblop_darwin.spec +++ b/blipblop_darwin.spec @@ -10,6 +10,9 @@ a = Analysis(['blipblop_main.py'], ('docs/visual_task.md', "docs"), ('docs/auditory_task.md', "docs"), ('docs/license.md', "docs"), + ('sounds/bell.wav', "sounds"), + ('sounds/complete.wav', "sounds"), + ('sounds/message.wav', "sounds"), ], hiddenimports=[], hookspath=[], diff --git a/blipblop_linux.spec b/blipblop_linux.spec index 22f86b4..36cd04e 100644 --- a/blipblop_linux.spec +++ b/blipblop_linux.spec @@ -10,6 +10,9 @@ a = Analysis(['blipblop_main.py'], ('docs/visual_task.md', "docs"), ('docs/auditory_task.md', "docs"), ('docs/license.md', "docs"), + ('sounds/bell.wav', "sounds"), + ('sounds/complete.wav', "sounds"), + ('sounds/message.wav', "sounds"), ], hiddenimports=[], hookspath=[], diff --git a/resources.qrc b/resources.qrc index 24ddf5d..feb0ca0 100644 --- a/resources.qrc +++ b/resources.qrc @@ -18,11 +18,6 @@ icons/blipblop_table.png icons/settings.png - - sounds/bell.wav - sounds/message.wav - sounds/complete.wav - docs/index.md docs/visual_task.md From 7b233b8bbeb7d1be536d116044390f1ee7088fad Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Fri, 12 Mar 2021 12:15:10 +0100 Subject: [PATCH 14/23] [settings] fix storing of sound combo box index --- blipblop/ui/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blipblop/ui/settings.py b/blipblop/ui/settings.py index a417147..7535694 100644 --- a/blipblop/ui/settings.py +++ b/blipblop/ui/settings.py @@ -112,7 +112,7 @@ class AuditoryTaskSettings(TaskSettings): def store_settings(self): super().store_settings() - self.settings.setValue("auditory_task/sound_index", self._sound_combo.currentIndex) + self.settings.setValue("auditory_task/sound_index", self._sound_combo.currentIndex()) @property def sound(self): From 464e5421ae7295d6c7077a0fff6b2745f1612eac Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Fri, 12 Mar 2021 13:41:46 +0100 Subject: [PATCH 15/23] [docs] image test and some more info --- blipblop_darwin.spec | 3 +++ blipblop_linux.spec | 4 ++++ docs/images/blipblop_logo.png | Bin 0 -> 31010 bytes docs/images/blipblop_main.png | Bin 0 -> 46376 bytes docs/index.md | 21 ++++++++++++++------- docs/license.md | 10 +++++++++- docs/results.md | 1 + docs/tasks.md | 0 8 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 docs/images/blipblop_logo.png create mode 100644 docs/images/blipblop_main.png create mode 100644 docs/results.md create mode 100644 docs/tasks.md diff --git a/blipblop_darwin.spec b/blipblop_darwin.spec index cbfb7ef..9194b4c 100644 --- a/blipblop_darwin.spec +++ b/blipblop_darwin.spec @@ -10,6 +10,9 @@ a = Analysis(['blipblop_main.py'], ('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"), diff --git a/blipblop_linux.spec b/blipblop_linux.spec index 36cd04e..7bad729 100644 --- a/blipblop_linux.spec +++ b/blipblop_linux.spec @@ -10,6 +10,10 @@ a = Analysis(['blipblop_main.py'], ('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"), diff --git a/docs/images/blipblop_logo.png b/docs/images/blipblop_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9d38447bb98a30df0eb01a5c24040c310cedc923 GIT binary patch literal 31010 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 JR?|NEe*jaLDNz6b literal 0 HcmV?d00001 diff --git a/docs/images/blipblop_main.png b/docs/images/blipblop_main.png new file mode 100644 index 0000000000000000000000000000000000000000..c442c1f30bfcf081f1da02460655507e5f7229f8 GIT binary patch literal 46376 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 A&Hw-a literal 0 HcmV?d00001 diff --git a/docs/index.md b/docs/index.md index 7e92787..f53d5e8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,19 +1,26 @@ + # BlipBlop -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). +![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) -The main window has three views: -## Visual task +## 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](qrc:/docs/visual_task)) +Measure reaction times to a visual stimulus that pops in the center of the screen. ([more](visual_task.md)) -## Auditory task +### Auditory task -Measure reaction times to a auditory stimulus. ([more](qrc:/docs/auditory_task)) +Measure reaction times to a auditory stimulus. ([more](auditory_task.md)) -## Results View +### 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 index 96ead31..4c57d76 100644 --- a/docs/license.md +++ b/docs/license.md @@ -1 +1,9 @@ -#License \ No newline at end of file +# 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 From 43d7b9a620070e2871ffd5a5fc7f4a2008ba9421 Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Fri, 12 Mar 2021 13:42:22 +0100 Subject: [PATCH 16/23] [help] store size settings, flake8 --- blipblop/main.py | 2 -- blipblop/ui/help.py | 45 ++++++++++++++++++++++++++++++--------------- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/blipblop/main.py b/blipblop/main.py index 183aa1c..6929a56 100644 --- a/blipblop/main.py +++ b/blipblop/main.py @@ -14,7 +14,6 @@ except ImportError: pass def main(): - print("executing main.main") app = QApplication(sys.argv) app.setApplicationName(cnst.application_name) app.setApplicationVersion(str(cnst.application_version)) @@ -33,7 +32,6 @@ def main(): window.show() code = app.exec_() - print("Application exit!") pos = window.pos() settings.setValue("app/width", window.width()) settings.setValue("app/height", window.height()) diff --git a/blipblop/ui/help.py b/blipblop/ui/help.py index 362799d..de95caf 100644 --- a/blipblop/ui/help.py +++ b/blipblop/ui/help.py @@ -1,30 +1,39 @@ 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 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/docs_back"), "back") self.back_btn.setEnabled(False) self.back_btn.clicked.connect(self.help._edit.backward) - self.home_btn = QPushButton(QIcon(":/icons/docs_home"),"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/docs_fwd"),"forward") + self.fwd_btn = QPushButton(QIcon(":/icons/docs_fwd"), "forward") self.fwd_btn.setEnabled(False) self.fwd_btn.clicked.connect(self.help._edit.forward) - + empty = QWidget() empty.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) @@ -32,8 +41,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 +51,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 resource files doc_url = QUrl.fromLocalFile(cnst.DOCS_ROOT_FILE) self._edit = QTextBrowser() self._edit.setOpenLinks(True) From cfe359ceafb484f5cfb6023f295a16d79696f707 Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Fri, 12 Mar 2021 13:54:39 +0100 Subject: [PATCH 17/23] [spec/actions] update --- .github/workflows/python-app_linux.yml | 2 +- .github/workflows/python-app_macos.yml | 2 +- .github/workflows/python-app_win.yml | 4 +- blipblop_win.spec | 52 ++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 blipblop_win.spec diff --git a/.github/workflows/python-app_linux.yml b/.github/workflows/python-app_linux.yml index 95fe4dd..508460e 100644 --- a/.github/workflows/python-app_linux.yml +++ b/.github/workflows/python-app_linux.yml @@ -36,7 +36,7 @@ jobs: - name: create package run: | pyrcc5 resources.qrc -o resources.py - pyinstaller blipblop_linux.py + pyinstaller blipblop_linux.spec - name: Upload a Build Artifact uses: actions/upload-artifact@v2.2.2 diff --git a/.github/workflows/python-app_macos.yml b/.github/workflows/python-app_macos.yml index e94e5ea..ba25164 100644 --- a/.github/workflows/python-app_macos.yml +++ b/.github/workflows/python-app_macos.yml @@ -36,7 +36,7 @@ jobs: - name: create package run: | pyrcc5 resources.qrc -o resources.py - pyinstaller --onefile --windowed --osx-bundle-identifier="de.uni-tuebingen.neuroetho.blipblop" blipblop_dawin.py + pyinstaller --onefile --windowed --osx-bundle-identifier="de.uni-tuebingen.neuroetho.blipblop" blipblop_dawin.spec - name: Upload a Build Artifact uses: actions/upload-artifact@v2.2.2 diff --git a/.github/workflows/python-app_win.yml b/.github/workflows/python-app_win.yml index 3401d98..3a29d38 100644 --- a/.github/workflows/python-app_win.yml +++ b/.github/workflows/python-app_win.yml @@ -12,7 +12,7 @@ on: jobs: build: - runs-on: macos-10.15 + runs-on: windows-latest steps: - uses: actions/checkout@v2 @@ -36,7 +36,7 @@ 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 --onefile --windowed --name="BlipBlop" blipblop_win.spec - name: Upload a Build Artifact uses: actions/upload-artifact@v2.2.2 diff --git a/blipblop_win.spec b/blipblop_win.spec new file mode 100644 index 0000000..4298622 --- /dev/null +++ b/blipblop_win.spec @@ -0,0 +1,52 @@ +# -*- 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, + [], + exclude_binaries=True, + name='BlipBlop', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + icon='icons/blipblop_logo.ico' + ) + +coll = COLLECT(exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='BlipBlop') From 99aa6e95407551dd08bbd9b65abd0ea3db0e3850 Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Fri, 12 Mar 2021 13:56:34 +0100 Subject: [PATCH 18/23] [spec/actions] update --- .github/workflows/python-app_macos.yml | 2 +- .github/workflows/python-app_win.yml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/python-app_macos.yml b/.github/workflows/python-app_macos.yml index ba25164..4a75ec6 100644 --- a/.github/workflows/python-app_macos.yml +++ b/.github/workflows/python-app_macos.yml @@ -36,7 +36,7 @@ jobs: - name: create package run: | pyrcc5 resources.qrc -o resources.py - pyinstaller --onefile --windowed --osx-bundle-identifier="de.uni-tuebingen.neuroetho.blipblop" blipblop_dawin.spec + pyinstaller --onefile --windowed --osx-bundle-identifier="de.uni-tuebingen.neuroetho.blipblop" blipblop_darwin.spec - name: Upload a Build Artifact uses: actions/upload-artifact@v2.2.2 diff --git a/.github/workflows/python-app_win.yml b/.github/workflows/python-app_win.yml index 3a29d38..ee82500 100644 --- a/.github/workflows/python-app_win.yml +++ b/.github/workflows/python-app_win.yml @@ -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: | From 154ce5069cc2abe45617d3a62f24405615ad11b4 Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Fri, 12 Mar 2021 14:01:59 +0100 Subject: [PATCH 19/23] [actions] rename actions --- .github/workflows/python-app_linux.yml | 2 +- .github/workflows/python-app_macos.yml | 2 +- .github/workflows/python-app_win.yml | 2 +- blipblop_win.spec | 22 +++++++++++----------- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/python-app_linux.yml b/.github/workflows/python-app_linux.yml index 508460e..42371c7 100644 --- a/.github/workflows/python-app_linux.yml +++ b/.github/workflows/python-app_linux.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 linux on: push: diff --git a/.github/workflows/python-app_macos.yml b/.github/workflows/python-app_macos.yml index 4a75ec6..e24ec60 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: diff --git a/.github/workflows/python-app_win.yml b/.github/workflows/python-app_win.yml index ee82500..dee0fff 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: diff --git a/blipblop_win.spec b/blipblop_win.spec index 4298622..fb895ac 100644 --- a/blipblop_win.spec +++ b/blipblop_win.spec @@ -6,17 +6,17 @@ 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', '.') + 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=[], From c45a2f66bcfdba5b0521d58ad4f33d77e9384f8a Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Fri, 12 Mar 2021 17:19:06 +0100 Subject: [PATCH 20/23] [help] fix missing icon --- blipblop/ui/help.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/blipblop/ui/help.py b/blipblop/ui/help.py index de95caf..15f3854 100644 --- a/blipblop/ui/help.py +++ b/blipblop/ui/help.py @@ -1,7 +1,7 @@ from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QFrame, QHBoxLayout from PyQt5.QtWidgets import QPushButton, QSizePolicy, QTextBrowser, QVBoxLayout, QWidget -from PyQt5.QtCore import QSettings, QUrl +from PyQt5.QtCore import QSettings, QUrl, Qt import blipblop.constants as cnst @@ -30,7 +30,8 @@ class HelpDialog(QDialog): self.back_btn.clicked.connect(self.help._edit.backward) self.home_btn = QPushButton(QIcon(":/icons/docs_home"), "home") self.home_btn.clicked.connect(self.help._edit.home) - self.fwd_btn = QPushButton(QIcon(":/icons/docs_fwd"), "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) From d9a813f972106d255149be3fcda0a3560bc1ac36 Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Fri, 12 Mar 2021 17:19:45 +0100 Subject: [PATCH 21/23] [main] really reset upon new session --- blipblop/ui/centralwidget.py | 1 + blipblop/ui/resultsscreen.py | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) 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/resultsscreen.py b/blipblop/ui/resultsscreen.py index ad768a3..a439552 100644 --- a/blipblop/ui/resultsscreen.py +++ b/blipblop/ui/resultsscreen.py @@ -1,11 +1,10 @@ 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 @@ -54,7 +53,7 @@ class ResultsScreen(QWidget): 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): From 032efa1f555a5c49f57164146d6f38b6e560eac5 Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Fri, 12 Mar 2021 17:23:14 +0100 Subject: [PATCH 22/23] [specs] change darwin spec for onefile --- blipblop_darwin.spec | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/blipblop_darwin.spec b/blipblop_darwin.spec index 9194b4c..1289870 100644 --- a/blipblop_darwin.spec +++ b/blipblop_darwin.spec @@ -30,22 +30,18 @@ pyz = PYZ(a.pure, a.zipped_data, exe = EXE(pyz, a.scripts, + a.binaries, + a.zipfiles, + a.datas, [], - exclude_binaries=True, name='BlipBlop', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, + upx_exclude=[], + runtime_tmpdir=None, console=False, icon='icons/blipblop_logo.icns' ) -coll = COLLECT(exe, - a.binaries, - a.zipfiles, - a.datas, - strip=False, - upx=True, - upx_exclude=[], - name='BlipBlop') From 2c8a72a4167faf55e3abef7180ffd3cca014ef1e Mon Sep 17 00:00:00 2001 From: Jan Grewe Date: Fri, 12 Mar 2021 18:33:17 +0100 Subject: [PATCH 23/23] [specs] onefile for win --- .github/workflows/python-app_macos.yml | 2 +- .github/workflows/python-app_win.yml | 2 +- blipblop_darwin.spec | 7 ++++--- blipblop_win.spec | 17 +++++++---------- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/.github/workflows/python-app_macos.yml b/.github/workflows/python-app_macos.yml index e24ec60..a4f2761 100644 --- a/.github/workflows/python-app_macos.yml +++ b/.github/workflows/python-app_macos.yml @@ -36,7 +36,7 @@ jobs: - name: create package run: | pyrcc5 resources.qrc -o resources.py - pyinstaller --onefile --windowed --osx-bundle-identifier="de.uni-tuebingen.neuroetho.blipblop" blipblop_darwin.spec + pyinstaller --osx-bundle-identifier="de.uni-tuebingen.neuroetho.blipblop" blipblop_darwin.spec - name: Upload a Build Artifact uses: actions/upload-artifact@v2.2.2 diff --git a/.github/workflows/python-app_win.yml b/.github/workflows/python-app_win.yml index dee0fff..14ec40f 100644 --- a/.github/workflows/python-app_win.yml +++ b/.github/workflows/python-app_win.yml @@ -35,7 +35,7 @@ jobs: - name: create package run: | pyrcc5 resources.qrc -o resources.py - pyinstaller --onefile --windowed --name="BlipBlop" blipblop_win.spec + pyinstaller blipblop_win.spec - name: Upload a Build Artifact uses: actions/upload-artifact@v2.2.2 diff --git a/blipblop_darwin.spec b/blipblop_darwin.spec index 1289870..76e5105 100644 --- a/blipblop_darwin.spec +++ b/blipblop_darwin.spec @@ -16,6 +16,7 @@ a = Analysis(['blipblop_main.py'], ('sounds/bell.wav', "sounds"), ('sounds/complete.wav', "sounds"), ('sounds/message.wav', "sounds"), + ('icons/blipblop_logo.icns', "."), ], hiddenimports=[], hookspath=[], @@ -25,6 +26,7 @@ a = Analysis(['blipblop_main.py'], win_private_assemblies=False, cipher=block_cipher, noarchive=False) + pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) @@ -35,13 +37,12 @@ exe = EXE(pyz, a.datas, [], name='BlipBlop', - debug=False, + debug=True, bootloader_ignore_signals=False, strip=False, upx=True, upx_exclude=[], runtime_tmpdir=None, - console=False, + console=True, icon='icons/blipblop_logo.icns' ) - diff --git a/blipblop_win.spec b/blipblop_win.spec index fb895ac..cbe9f7d 100644 --- a/blipblop_win.spec +++ b/blipblop_win.spec @@ -31,22 +31,19 @@ pyz = PYZ(a.pure, a.zipped_data, exe = EXE(pyz, a.scripts, + a.binaries, + a.zipfiles, + a.datas, [], exclude_binaries=True, name='BlipBlop', - debug=False, + debug=True, bootloader_ignore_signals=False, strip=False, upx=True, - console=False, + console=True, + upx_exclude=[], + runtime_tmpdir=None, icon='icons/blipblop_logo.ico' ) -coll = COLLECT(exe, - a.binaries, - a.zipfiles, - a.datas, - strip=False, - upx=True, - upx_exclude=[], - name='BlipBlop')