diff --git a/source/survey/ChoiceQuestion.py b/source/survey/ChoiceQuestion.py index 9e01a71..2a6252f 100644 --- a/source/survey/ChoiceQuestion.py +++ b/source/survey/ChoiceQuestion.py @@ -1,6 +1,7 @@ from typing import Any from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QFont from PyQt6.QtWidgets import QFrame, QVBoxLayout, QLabel, QRadioButton, QButtonGroup from source.survey.base import BaseSurvey @@ -20,6 +21,11 @@ class ChoiceQuestion(BaseSurvey): self.label_question.setText(title) self.label_question.setAlignment(Qt.AlignmentFlag.AlignCenter) + font_title = self.label_question.font() + font_title.setPointSize(24) + font_title.setWeight(QFont.Weight.Bold) + self.label_question.setFont(font_title) + # responses self.frame_responses = QFrame() self._layout.addWidget(self.frame_responses) diff --git a/source/survey/Empty.py b/source/survey/Empty.py index 600810d..e5e1bee 100644 --- a/source/survey/Empty.py +++ b/source/survey/Empty.py @@ -1,7 +1,7 @@ from typing import Any from PyQt6.QtCore import pyqtSignal -from survey.base import BaseSurvey +from source.survey.base import BaseSurvey class Empty(BaseSurvey): diff --git a/source/survey/Text.py b/source/survey/Text.py index 46ebeaa..8096d02 100644 --- a/source/survey/Text.py +++ b/source/survey/Text.py @@ -4,7 +4,7 @@ from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QFont from PyQt6.QtWidgets import QVBoxLayout, QLabel -from survey.base import BaseSurvey +from source.survey.base import BaseSurvey class Text(BaseSurvey): diff --git a/source/survey/TextQuestion.py b/source/survey/TextQuestion.py new file mode 100644 index 0000000..0631d49 --- /dev/null +++ b/source/survey/TextQuestion.py @@ -0,0 +1,54 @@ +from typing import Any + +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QFont +from PyQt6.QtWidgets import QFrame, QVBoxLayout, QLabel, QRadioButton, QButtonGroup, QTextEdit + +from source.survey.base import BaseSurvey + + +class TextQuestion(BaseSurvey): + def __init__(self, title: str, signals: dict[str, pyqtSignal]): + super().__init__() + + self.signals = signals + + # set layout + self._layout = QVBoxLayout() + self.setLayout(self._layout) + + # question title + self.label_question = QLabel() + self._layout.addWidget(self.label_question) + self.label_question.setText(title) + self.label_question.setAlignment(Qt.AlignmentFlag.AlignCenter) + + font_title = self.label_question.font() + font_title.setPointSize(24) + font_title.setWeight(QFont.Weight.Bold) + self.label_question.setFont(font_title) + + # response + self.entry_response = QTextEdit() + self._layout.addWidget(self.entry_response) + + @classmethod + def from_dict(cls, data: dict[str, Any], signals: dict[str, pyqtSignal]) -> "TextQuestion": + return cls( + title=data["title"], + signals=signals, + ) + + # events + + def on_show(self) -> None: + # immediately mark the survey as successful + if "success" in self.signals: + self.signals["success"].emit() # NOQA: emit exist + + # data collection + + def get_collected_data(self) -> dict: + return { + "choice": self.entry_response.toPlainText() + } diff --git a/source/survey/WebMission.py b/source/survey/WebMission.py index a9ac2cd..77c5e62 100644 --- a/source/survey/WebMission.py +++ b/source/survey/WebMission.py @@ -1,12 +1,12 @@ import time from typing import Optional, Any -from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QUrl -from PyQt6.QtGui import QFont +from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QUrl, QEvent, QObject +from PyQt6.QtGui import QFont, QMouseEvent, QResizeEvent from PyQt6.QtWidgets import QLabel, QVBoxLayout, QSizePolicy -from survey.base import BaseSurvey -from source.widget import DecoratedWebEngineView +from source.survey.base import BaseSurvey +from source.widget import Browser class WebMission(BaseSurvey): @@ -15,15 +15,15 @@ class WebMission(BaseSurvey): self.check_condition = check_condition self.default_url = url - self.signals = signals # TODO: default None ? + self.signals = signals if signals is not None else {} # set layout self._layout = QVBoxLayout() self.setLayout(self._layout) # data collection - self.initial_time = time.time() - self.collect_urls: list[tuple[float, str]] = [] # list of urls that the user went by + self.start_time = time.time() + self._collected_events: list[dict[str, Any]] = [] # mission title self.label_title = QLabel() @@ -37,11 +37,12 @@ class WebMission(BaseSurvey): self.label_title.setFont(font_title) # web page - self.web_view = DecoratedWebEngineView() - self._layout.addWidget(self.web_view) - self.web_view.urlChanged.connect(self._on_url_changed) # NOQA: connect exist + self.browser = Browser() + self._layout.addWidget(self.browser) + self.browser.web.focusProxy().installEventFilter(self) # capture the event in eventFilter + self.browser.web.urlChanged.connect(self._on_url_changed) # NOQA: connect exist - self.web_view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.browser.web.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # setup the timer for the check if self.check_condition is not None: @@ -59,31 +60,92 @@ class WebMission(BaseSurvey): signals=signals ) + # events + + def eventFilter(self, obj: QObject, event: QEvent) -> bool: + if obj is self.browser.web.focusProxy(): + # if the object is the content of the web engine widget + match event.type(): + case QEvent.Type.MouseMove: + # if this is a mouse movement + event: QMouseEvent + position = event.position() + + self._save_event( + type="mouse_move", + position=(position.x(), position.y()) + ) + + case QEvent.Type.MouseButtonPress: + # if this is a mouse click press + event: QMouseEvent + position = event.position() + + self._save_event( + type="mouse_press", + position=(position.x(), position.y()), + button=event.button(), + ) + + case QEvent.Type.MouseButtonRelease: + # if this is a mouse click release + event: QMouseEvent + position = event.position() + + self._save_event( + type="mouse_release", + position=(position.x(), position.y()), + button=event.button(), + ) + + case QEvent.Type.MouseButtonDblClick: + # if this is a mouse double click + event: QMouseEvent + position = event.position() + + self._save_event( + type="mouse_double_click", + position=(position.x(), position.y()), + ) + + case QEvent.Type.Resize: + # if the window got resized + event: QResizeEvent + size = event.size() + + self._save_event( + type="resize", + size=(size.width(), size.height()), + ) + + return super().eventFilter(obj, event) + def on_show(self) -> None: - self.web_view.setUrl(QUrl(self.default_url)) + # initialize the start time + self.start_time = time.time() + + # set the web view to the default url + self.browser.web.setUrl(QUrl(self.default_url)) if self.check_condition is not None: # enable the timer self.timer_check.start() else: - # call directly the success signal - if "success" in self.signals: - self.signals["success"].emit() # NOQA: emit exist + self._success() # call directly the success method def on_hide(self) -> None: self.timer_check.stop() - # data collection + def _success(self): + # TODO: animation or notification to clearly mark mission as succeeded - def get_collected_data(self) -> dict: - # TODO: more data to collect - return { - "collect_urls": self.collect_urls - } + # mark the success in the events + self._save_event(type="check") - def _on_url_changed(self): - self.collect_urls.append((time.time() - self.initial_time, self.web_view.url())) + # emit on the success signal + if "success" in self.signals: + self.signals["success"].emit() # NOQA: emit exist # condition @@ -93,8 +155,27 @@ class WebMission(BaseSurvey): """ def check_callback(result: bool): - if result and "success" in self.signals: - self.signals["success"].emit() # NOQA: emit exist + if result: + self._success() - page = self.web_view.page() + page = self.browser.web.page() page.runJavaScript(self.check_condition, resultCallback=check_callback) + + # data collection + + def _save_event(self, **data) -> None: + # save the data of the event and add the current time + data["time"] = round(time.time() - self.start_time, 3) + self._collected_events.append(data) + + def get_collected_data(self) -> dict: + return { + "event": self._collected_events, + } + + def _on_url_changed(self): + # log the new url + self._save_event( + type="url", + url=self.browser.web.url().toString() + ) diff --git a/source/survey/__init__.py b/source/survey/__init__.py index ee4b3d4..101a815 100644 --- a/source/survey/__init__.py +++ b/source/survey/__init__.py @@ -1,6 +1,7 @@ from .Empty import Empty from .Text import Text from .ChoiceQuestion import ChoiceQuestion +from .TextQuestion import TextQuestion from .WebMission import WebMission from .get import survey_get diff --git a/source/survey/get.py b/source/survey/get.py index 6f55ea9..3590da6 100644 --- a/source/survey/get.py +++ b/source/survey/get.py @@ -2,14 +2,18 @@ from typing import Type from PyQt6.QtCore import pyqtSignal -from . import Text, ChoiceQuestion, WebMission, Empty +from . import Text, ChoiceQuestion, WebMission, Empty, TextQuestion from .base import BaseSurvey all_survey: dict[str, Type[BaseSurvey]] = { + # base "empty": Empty, "text": Text, + # questions "question-choice": ChoiceQuestion, + "question-text": TextQuestion, + # missions "mission-web": WebMission, } diff --git a/source/widget/Browser.py b/source/widget/Browser.py new file mode 100644 index 0000000..228b266 --- /dev/null +++ b/source/widget/Browser.py @@ -0,0 +1,88 @@ +from typing import Optional + +from PyQt6.QtCore import QUrl, QObject, QEvent +from PyQt6.QtGui import QMouseEvent +from PyQt6.QtWebEngineWidgets import QWebEngineView +from PyQt6.QtWidgets import QVBoxLayout, QWidget, QProgressBar, QStyle, QToolBar + + +class Browser(QWidget): + """ + A version of the QWebEngineView class with integrated progress bar and navigations bar. + """ + + def __init__(self, url: Optional[QUrl] = None): + super().__init__() + + # layout + self._layout = QVBoxLayout() + self.setLayout(self._layout) + + # navigation bar + self.navigation = QToolBar() + self._layout.addWidget(self.navigation, 0) + + style = self.style() + + self._action_back = self.navigation.addAction( + style.standardIcon(QStyle.StandardPixmap.SP_ArrowBack), + "Back" + ) + self._action_reload = self.navigation.addAction( + style.standardIcon(QStyle.StandardPixmap.SP_BrowserReload), + "Reload" + ) + self._action_forward = self.navigation.addAction( + style.standardIcon(QStyle.StandardPixmap.SP_ArrowForward), + "Forward" + ) + + # web widget + self.web = QWebEngineView() + self._layout.addWidget(self.web, 1) + + if url is not None: + self.web.load(QUrl(url)) + + # loading bar + self.progress = QProgressBar() + self._layout.addWidget(self.progress, 0) + self.progress.setFixedHeight(6) + self.progress.setTextVisible(False) + self.progress.setMaximum(100) + self.progress.hide() + + # connect the signals + self.web.loadStarted.connect(self._load_started) # NOQA: connect exist + self.web.loadProgress.connect(self._load_progress) # NOQA: connect exist + self.web.loadFinished.connect(self._load_finished) # NOQA: connect exist + + self._action_back.triggered.connect(self.web.back) # NOQA: connect exist + self._action_reload.triggered.connect(self.web.reload) # NOQA: connect exist + self._action_forward.triggered.connect(self.web.forward) # NOQA: connect exist + + # finalize the initialisation + self.refresh_navigation_actions() + + # graphical methods + + def _load_started(self): + # update the progress bar + self.progress.setValue(0) + self.progress.show() + + def _load_progress(self, value: int): + # update the progress bar + self.progress.setValue(value) + + def _load_finished(self): + # update the progress bar + self.progress.hide() + # refresh the navigation buttons + self.refresh_navigation_actions() + + def refresh_navigation_actions(self): + history = self.web.history() + # enable the navigation button depending on the history + self._action_back.setEnabled(history.canGoBack()) + self._action_forward.setEnabled(history.canGoForward()) diff --git a/source/widget/DecoratedWebEngineView.py b/source/widget/DecoratedWebEngineView.py deleted file mode 100644 index a1f69cc..0000000 --- a/source/widget/DecoratedWebEngineView.py +++ /dev/null @@ -1,93 +0,0 @@ -from typing import Optional - -from PyQt6.QtCore import QUrl -from PyQt6.QtWebEngineWidgets import QWebEngineView -from PyQt6.QtWidgets import QVBoxLayout, QWidget, QProgressBar, QFrame, QHBoxLayout, QPushButton, QStyle - - -class DecoratedWebEngineView(QWidget): - """ - A version of the QWebEngineView class with integrated progress bar and navigations bar. - """ - - def __init__(self, url: Optional[QUrl] = None): - super().__init__() - - # layout - self._layout = QVBoxLayout() - self.setLayout(self._layout) - - # navigation bar - self.frame_navigation = QFrame() - self._layout.addWidget(self.frame_navigation, 0) - self._layout_navigation = QHBoxLayout() - self.frame_navigation.setLayout(self._layout_navigation) - - self.button_back = QPushButton() - self._layout_navigation.addWidget(self.button_back) - self.button_back.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowBack)) - - self.button_reload = QPushButton() - self._layout_navigation.addWidget(self.button_reload) - self.button_reload.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload)) - - self.button_forward = QPushButton() - self._layout_navigation.addWidget(self.button_forward) - self.button_forward.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowForward)) - - # force the navigation buttons to be on the left by adding a stretching element - self._layout_navigation.addStretch(0) - - # web widget - self.web_view = QWebEngineView() - self._layout.addWidget(self.web_view, 1) - - if url is not None: - self.web_view.load(QUrl(url)) - - # loading bar - self.progress_bar = QProgressBar() - self._layout.addWidget(self.progress_bar, 0) - self.progress_bar.setFixedHeight(6) - self.progress_bar.setTextVisible(False) - self.progress_bar.setMaximum(100) - self.progress_bar.hide() - - # connect the signals - self.web_view.loadStarted.connect(self._load_started) # NOQA: connect exist - self.web_view.loadProgress.connect(self._load_progress) # NOQA: connect exist - self.web_view.loadFinished.connect(self._load_finished) # NOQA: connect exist - - self.button_back.clicked.connect(self.back) # NOQA: connect exist - self.button_reload.clicked.connect(self.reload) # NOQA: connect exist - self.button_forward.clicked.connect(self.forward) # NOQA: connect exist - - # finalize the initialisation - self.refresh_navigation_actions() - - def __getattr__(self, name): - # if the member is not found in the class, look in the web view directly - return getattr(self.web_view, name) - - # graphical methods - - def _load_started(self): - # update the progress bar - self.progress_bar.setValue(0) - self.progress_bar.show() - - def _load_progress(self, value: int): - # update the progress bar - self.progress_bar.setValue(value) - - def _load_finished(self): - # update the progress bar - self.progress_bar.hide() - # refresh the navigation buttons - self.refresh_navigation_actions() - - def refresh_navigation_actions(self): - history = self.web_view.history() - # enable the navigation button depending on the history - self.button_back.setEnabled(history.canGoBack()) - self.button_forward.setEnabled(history.canGoForward()) diff --git a/source/widget/FrameSurvey.py b/source/widget/FrameSurvey.py index f58ab3f..c44e011 100644 --- a/source/widget/FrameSurvey.py +++ b/source/widget/FrameSurvey.py @@ -58,7 +58,7 @@ class FrameSurvey(QFrame): def load_file(self, survey_path: Path | str): # load the surveys screens - with open(survey_path) as file: + with open(survey_path, encoding="utf-8") as file: surveys_data = json.load(file) self.survey_screens = [ diff --git a/source/widget/__init__.py b/source/widget/__init__.py index bef3375..0746939 100644 --- a/source/widget/__init__.py +++ b/source/widget/__init__.py @@ -1,3 +1,3 @@ -from .DecoratedWebEngineView import DecoratedWebEngineView +from .Browser import Browser from .FrameSurvey import FrameSurvey from .MyMainWindow import MyMainWindow diff --git a/surveys.json b/surveys.json index 115f5ab..6f992a5 100644 --- a/surveys.json +++ b/surveys.json @@ -29,5 +29,10 @@ "title": "Rendez-vous sur la boutique des points.", "url": "https://steampowered.com/", "check": "true" + }, + + "question-experience": { + "type": "question-text", + "title": "Qu'avez vous pensé de l'interface de Steam ?" } } \ No newline at end of file