commit b4dff9a2e710c2a2438b9fc278571974eb8babca Author: Faraphel Date: Thu Dec 21 16:46:53 2023 +0100 Initial commit diff --git a/main.py b/main.py new file mode 100644 index 0000000..3c1aa2e --- /dev/null +++ b/main.py @@ -0,0 +1,13 @@ +import sys +from PyQt6.QtWidgets import QApplication + +from source.widget import MyMainWindow + + +if __name__ == "__main__": + application = QApplication(sys.argv) + + window = MyMainWindow() + window.show() + + application.exec() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8da5982 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PyQt6 +PyQt6-WebEngine diff --git a/source/__init__.py b/source/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/source/survey/ChoiceQuestion.py b/source/survey/ChoiceQuestion.py new file mode 100644 index 0000000..9e01a71 --- /dev/null +++ b/source/survey/ChoiceQuestion.py @@ -0,0 +1,59 @@ +from typing import Any + +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtWidgets import QFrame, QVBoxLayout, QLabel, QRadioButton, QButtonGroup + +from source.survey.base import BaseSurvey + + +class ChoiceQuestion(BaseSurvey): + def __init__(self, title: str, choices: dict[Any, str], signals: dict[str, pyqtSignal]): + super().__init__() + + # 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) + + # responses + self.frame_responses = QFrame() + self._layout.addWidget(self.frame_responses) + + self._layout_responses = QVBoxLayout() + self.frame_responses.setLayout(self._layout_responses) + + self.group_responses = QButtonGroup() + + if "success" in signals: + # checking any button allow the user to go to the next step + self.group_responses.buttonClicked.connect(signals["success"].emit) # NOQA: connect and emit exists + + for choice_id, choice_text in choices.items(): + # create a radio button for that choice + button = QRadioButton() + button.setText(choice_text) + + # add the button to the frame + self._layout_responses.addWidget(button) + + # add the button to the group + self.group_responses.addButton(button, int(choice_id)) + + @classmethod + def from_dict(cls, data: dict[str, Any], signals: dict[str, pyqtSignal]) -> "ChoiceQuestion": + return cls( + title=data["title"], + choices=data["choices"], + + signals=signals, + ) + + def get_collected_data(self) -> dict: + return { + "choice": self.group_responses.checkedId() + } diff --git a/source/survey/Empty.py b/source/survey/Empty.py new file mode 100644 index 0000000..600810d --- /dev/null +++ b/source/survey/Empty.py @@ -0,0 +1,10 @@ +from typing import Any + +from PyQt6.QtCore import pyqtSignal +from survey.base import BaseSurvey + + +class Empty(BaseSurvey): + @classmethod + def from_dict(cls, data: dict[str, Any], signals: dict[str, pyqtSignal]) -> "BaseSurvey": + return Empty() diff --git a/source/survey/Text.py b/source/survey/Text.py new file mode 100644 index 0000000..46ebeaa --- /dev/null +++ b/source/survey/Text.py @@ -0,0 +1,52 @@ +from typing import Optional, Any + +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QFont +from PyQt6.QtWidgets import QVBoxLayout, QLabel + +from survey.base import BaseSurvey + + +class Text(BaseSurvey): + def __init__(self, title: str, description: Optional[str], signals: dict[str, pyqtSignal]): + super().__init__() + + self.signals = signals + + # set the layout + self._layout = QVBoxLayout() + self.setLayout(self._layout) + + # prepare the title + self.label_title = QLabel() + self._layout.addWidget(self.label_title) + self.label_title.setText(title) + self.label_title.setAlignment(Qt.AlignmentFlag.AlignCenter) + + font_title = self.label_title.font() + font_title.setPointSize(32) + font_title.setWeight(QFont.Weight.Bold) + self.label_title.setFont(font_title) + + if description is not None: + # prepare the description + self.label_description = QLabel() + self._layout.addWidget(self.label_description) + self.label_description.setText(description) + self.label_description.setAlignment(Qt.AlignmentFlag.AlignCenter) + + @classmethod + def from_dict(cls, data: dict[str, Any], signals: dict[str, pyqtSignal]) -> "Text": + return cls( + title=data["title"], + description=data.get("description"), + + signals=signals + ) + + def on_show(self) -> None: + if "success" in self.signals: + # the user can skip a text whenever he wants to, directly signal a success + self.signals["success"].emit() # NOQA: emit exist + + diff --git a/source/survey/WebMission.py b/source/survey/WebMission.py new file mode 100644 index 0000000..a9ac2cd --- /dev/null +++ b/source/survey/WebMission.py @@ -0,0 +1,100 @@ +import time +from typing import Optional, Any + +from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QUrl +from PyQt6.QtGui import QFont +from PyQt6.QtWidgets import QLabel, QVBoxLayout, QSizePolicy + +from survey.base import BaseSurvey +from source.widget import DecoratedWebEngineView + + +class WebMission(BaseSurvey): + def __init__(self, title: str, url: str, signals: dict[str, pyqtSignal], check_condition: Optional[str] = None): + super().__init__() + + self.check_condition = check_condition + self.default_url = url + self.signals = signals # TODO: default None ? + + # 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 + + # mission title + self.label_title = QLabel() + self._layout.addWidget(self.label_title) + self.label_title.setText(title) + self.label_title.setAlignment(Qt.AlignmentFlag.AlignCenter) + + font_title = self.label_title.font() + font_title.setPointSize(24) + font_title.setWeight(QFont.Weight.Bold) + 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.web_view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + # setup the timer for the check + if self.check_condition is not None: + self.timer_check = QTimer() + self.timer_check.setInterval(1000) + self.timer_check.timeout.connect(self.check) # NOQA: connect exist + + @classmethod + def from_dict(cls, data: dict[str, Any], signals: dict[str, pyqtSignal]) -> "WebMission": + return cls( + title=data["title"], + url=data.get("url"), + check_condition=data.get("check"), + + signals=signals + ) + + def on_show(self) -> None: + self.web_view.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 + + def on_hide(self) -> None: + self.timer_check.stop() + + # data collection + + def get_collected_data(self) -> dict: + # TODO: more data to collect + return { + "collect_urls": self.collect_urls + } + + def _on_url_changed(self): + self.collect_urls.append((time.time() - self.initial_time, self.web_view.url())) + + # condition + + def check(self) -> None: + """ + Check if the checking condition have been completed + """ + + def check_callback(result: bool): + if result and "success" in self.signals: + self.signals["success"].emit() # NOQA: emit exist + + page = self.web_view.page() + page.runJavaScript(self.check_condition, resultCallback=check_callback) diff --git a/source/survey/__init__.py b/source/survey/__init__.py new file mode 100644 index 0000000..ee4b3d4 --- /dev/null +++ b/source/survey/__init__.py @@ -0,0 +1,6 @@ +from .Empty import Empty +from .Text import Text +from .ChoiceQuestion import ChoiceQuestion +from .WebMission import WebMission + +from .get import survey_get diff --git a/source/survey/base/BaseSurvey.py b/source/survey/base/BaseSurvey.py new file mode 100644 index 0000000..d8afafe --- /dev/null +++ b/source/survey/base/BaseSurvey.py @@ -0,0 +1,38 @@ +from abc import abstractmethod +from typing import Optional, Any + +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import QFrame + + +class BaseSurvey(QFrame): + """ + A type of survey survey that can be in the user interface + """ + + @classmethod + @abstractmethod + def from_dict(cls, data: dict[str, Any], signals: dict[str, pyqtSignal]) -> "BaseSurvey": + """ + Create an instance from a configuration dictionary + :return: the instance + """ + + def on_show(self) -> None: + """ + Called when the survey is shown + """ + + def on_hide(self) -> None: + """ + Called when the survey is hidden + :return: + """ + + def get_collected_data(self) -> Optional[dict]: + """ + Return the data collected for the survey + :return: the data collected for the survey + """ + + return None diff --git a/source/survey/base/__init__.py b/source/survey/base/__init__.py new file mode 100644 index 0000000..89e6393 --- /dev/null +++ b/source/survey/base/__init__.py @@ -0,0 +1 @@ +from .BaseSurvey import BaseSurvey diff --git a/source/survey/get.py b/source/survey/get.py new file mode 100644 index 0000000..6f55ea9 --- /dev/null +++ b/source/survey/get.py @@ -0,0 +1,26 @@ +from typing import Type + +from PyQt6.QtCore import pyqtSignal + +from . import Text, ChoiceQuestion, WebMission, Empty +from .base import BaseSurvey + + +all_survey: dict[str, Type[BaseSurvey]] = { + "empty": Empty, + "text": Text, + "question-choice": ChoiceQuestion, + "mission-web": WebMission, +} + + +def survey_get(data: dict[str, ...], signals: dict[str, pyqtSignal]) -> BaseSurvey: + """ + Return a Survey object from the data + :param data: the data of the survey + :param signals: signal that the survey survey can react to + :return: a Survey object + """ + + survey_class = all_survey[data["type"]] + return survey_class.from_dict(data, signals=signals) diff --git a/source/widget/DecoratedWebEngineView.py b/source/widget/DecoratedWebEngineView.py new file mode 100644 index 0000000..a1f69cc --- /dev/null +++ b/source/widget/DecoratedWebEngineView.py @@ -0,0 +1,93 @@ +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 new file mode 100644 index 0000000..f58ab3f --- /dev/null +++ b/source/widget/FrameSurvey.py @@ -0,0 +1,117 @@ +import json +from pathlib import Path + +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QPushButton + +from source.survey.base import BaseSurvey +from source.survey import Empty, survey_get + + +class FrameSurvey(QFrame): + signal_success = pyqtSignal() + + def __init__(self, survey_path: Path | str): + super().__init__() + + # signals + self.signal_success.connect(self._on_signal_success) # NOQA: connect exist + + # prepare the survey screen data + self.survey_screens: list[tuple[str, BaseSurvey]] = [] + self.current_survey_index = 0 + + # prepare the survey collected data + self.collected_datas: dict[str, dict] = {} + + # set the layout + self._layout = QVBoxLayout() + self.setLayout(self._layout) + + # prepare the frame for the survey elements + self.frame_survey: BaseSurvey = Empty() + self._layout.addWidget(self.frame_survey) + + # navigations actions + self.frame_navigation = QFrame() + self._layout.addWidget(self.frame_navigation) + + self._layout_navigation = QHBoxLayout() + self.frame_navigation.setLayout(self._layout_navigation) + + self._layout_navigation.addStretch(0) + + self.button_forward = QPushButton() + self._layout_navigation.addWidget(self.button_forward) + self.button_forward.setText("Suivant") + self.button_forward.clicked.connect(self.next_survey) # NOQA: connect exist + + # load the survey configuration file + self.load_file(survey_path) + + # finalize the initialisation + self.update_survey() + + def _on_signal_success(self): + # on success, show the button to go forward + self.button_forward.show() + + def load_file(self, survey_path: Path | str): + # load the surveys screens + with open(survey_path) as file: + surveys_data = json.load(file) + + self.survey_screens = [ + ( + survey_id, + survey_get( + survey_data, + signals={"success": self.signal_success} + ) + ) + for survey_id, survey_data in surveys_data.items() + ] + self.current_survey_index = 0 + + def next_survey(self): + # get the collected data from the survey + collected_data = self.frame_survey.get_collected_data() + if collected_data is not None: + # if there is data, get the current survey id + survey_id, survey = self.survey_screens[self.current_survey_index] + # save the response in the data + self.collected_datas[survey_id] = collected_data + + print(collected_data) + + self.current_survey_index += 1 + + if self.current_survey_index < len(self.survey_screens): + self.update_survey() + else: + self.finish_survey() + + def update_survey(self): + # disable the forward button + self.button_forward.hide() + + # mark the actual survey as the old one + old_frame_survey = self.frame_survey + # call the old survey event + old_frame_survey.on_hide() + # get the currently selected survey + survey_id, survey = self.survey_screens[self.current_survey_index] + # update it to the new one + self.frame_survey = survey + # change the widget on the layout + self._layout.replaceWidget(old_frame_survey, self.frame_survey) + # call the new survey event + survey.on_show() + # delete the old frame + old_frame_survey.deleteLater() + + def finish_survey(self): + # TODO: send the collected data as a file somewhere + print(self.collected_datas) + + self.window().close() diff --git a/source/widget/MyMainWindow.py b/source/widget/MyMainWindow.py new file mode 100644 index 0000000..738a5ad --- /dev/null +++ b/source/widget/MyMainWindow.py @@ -0,0 +1,10 @@ +from PyQt6.QtWidgets import QMainWindow + +from source import widget + + +class MyMainWindow(QMainWindow): + def __init__(self): + super().__init__() + + self.setCentralWidget(widget.FrameSurvey("./surveys.json")) diff --git a/source/widget/__init__.py b/source/widget/__init__.py new file mode 100644 index 0000000..bef3375 --- /dev/null +++ b/source/widget/__init__.py @@ -0,0 +1,3 @@ +from .DecoratedWebEngineView import DecoratedWebEngineView +from .FrameSurvey import FrameSurvey +from .MyMainWindow import MyMainWindow diff --git a/surveys.json b/surveys.json new file mode 100644 index 0000000..115f5ab --- /dev/null +++ b/surveys.json @@ -0,0 +1,33 @@ +{ + "text-welcome": { + "type": "text", + "title": "Bienvenue !", + "description": "Nous réalisons une étude sur le logiciel Steam afin de déterminer son ergonomie.\nVous serez invité à naviguer sur la plateforme Steam d'une page à l'autre." + }, + + "question-usage": { + "type": "question-choice", + "title": "A quel point êtes-vous familier avec Steam ?", + "choices": { + "0": "Ne connait pas", + "1": "Connait de nom", + "2": "Utilisation rare", + "3": "Utilisation moyenne", + "4": "Utilisation fréquente" + } + }, + + "web-language": { + "type": "mission-web", + "title": "Changer la langue en français.", + "url": "https://steampowered.com/", + "check": "document.getElementsByTagName('html')[0].lang == 'fr'" + }, + + "web-point-shop": { + "type": "mission-web", + "title": "Rendez-vous sur la boutique des points.", + "url": "https://steampowered.com/", + "check": "true" + } +} \ No newline at end of file