From 457f7b11e5cde40c1a65d7edea7024a2a8fa3620 Mon Sep 17 00:00:00 2001 From: Xarkam Date: Fri, 27 Mar 2026 17:19:46 +0100 Subject: [PATCH] Refactor main_window into multiples files --- src/ui/main_window.py | 474 +++++++------------------------ src/uitools/countdown_manager.py | 102 +++++++ src/uitools/queue_thread.py | 55 ++++ src/uitools/ui_builder.py | 100 +++++++ src/uitools/window_utility.py | 21 ++ 5 files changed, 381 insertions(+), 371 deletions(-) create mode 100644 src/uitools/countdown_manager.py create mode 100644 src/uitools/queue_thread.py create mode 100644 src/uitools/ui_builder.py create mode 100644 src/uitools/window_utility.py diff --git a/src/ui/main_window.py b/src/ui/main_window.py index eba3db8..7f66764 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -2,10 +2,10 @@ import webbrowser from os import environ, unlink from sys import platform -from PySide6.QtCore import QThread, Signal -from PySide6.QtCore import Qt, QTimer -from PySide6.QtGui import QIcon, QMouseEvent, QCursor, QGuiApplication -from PySide6.QtWidgets import QMainWindow, QSizePolicy +from PySide6.QtCore import QTimer +from PySide6.QtCore import Qt +from PySide6.QtGui import QIcon, QMouseEvent, QGuiApplication +from PySide6.QtWidgets import QMainWindow from config.config_manager import ConfigManager from config.constants import PlayerServerInfo, Urls @@ -13,29 +13,33 @@ from controllers.audio_controller import AudioController from controllers.glow_animator import GlowAnimator from controllers.window_dragger import WindowDragger from discord import discord_oauth -from fake_patch_notes import patch_note +from fake_patch_notes import patch_note # <- TO REMOVE USE API from fivemserver.auth_worker import AuthWorker from fivemserver.fivemlauncher import FiveMLauncher from fivemserver.get_server_token import GetServerTokenForDiscord -from fivemserver.queuemanager import QueueManager from fivemserver.whitelistmanager import WhiteList from tools.http_client import ApiError from ui.error_dialog import show_qt_error -from ui.hazard_stripes import HazardButton from ui.ui_mainwindow_vertical_pager import Ui_MainWindow +from uitools.countdown_manager import CountdownManager +from uitools.queue_thread import QueueThread +from uitools.ui_builder import set_en_chantier, replace_with_hazard_button, hide_staff_btn_and_recenter +from uitools.window_utility import center_window # For Linux Wayland to authorize moving window if platform.startswith('linux'): - environ["QT_QPA_PLATFORM"] = "xcb" + environ["QT_QPA_PLATFORM"] = "xcb" + class MainWindow(QMainWindow): """ MainWindow class for managing the main application window and its functionalities. This class is responsible for managing the UI, user interactions, and application state. - It provides mechanisms to handle user authentication, connection to external services, and queued tasks. - The class is designed to be used with the PyQt framework and provides logic for dynamically updating - the UI elements and interacting with other components. + It provides mechanisms to handle user authentication, connection to external services, + and queued tasks. The class is designed to be used with the PySide6 framework and + provides logic for dynamically updating the UI elements and interacting with other + components. :ivar ui: The main UI object for managing visual components and layouts. :type ui: Ui_MainWindow @@ -44,17 +48,10 @@ class MainWindow(QMainWindow): :ivar stored_user_id: The Discord user ID stored in the configuration. :type stored_user_id: str :ivar queue_thread: The thread handling queue operations. - :type queue_thread: Optional[Thread] + :type queue_thread: Optional[QueueThread] :ivar queue_position_value: The current queue position. :type queue_position_value: Optional[int] - :ivar close_timer: Timer object for handling the final closure of the application. - :type close_timer: QTimer - :ivar countdown_timer: Timer object for updating visual countdown timers. - :type countdown_timer: QTimer - :ivar remaining_time: The remaining time for queue or countdown operations, in seconds. - :type remaining_time: int """ - #update = Signal(str) # Reçoit les callbacks de QueueManager def __init__(self, bundle_dir: str, config_manager: ConfigManager): super().__init__() @@ -62,53 +59,51 @@ class MainWindow(QMainWindow): self.ui = Ui_MainWindow() self.ui.setupUi(self) - self.auth_worker = None # <--- Pour stocker l'instance du thread - + self.auth_worker = None self.config = config_manager self.stored_user_id = self.config.get_discord_user() self.queue_thread = None self.queue_position_value = None - self.close_timer = None - self.countdown_timer = None - self.remaining_time = 0 # en secondes - # Préparation du timer de fermeture finale - self.close_timer = QTimer(self) - self.close_timer.setSingleShot(True) - self.close_timer.timeout.connect(self.close) + # ------------------------------------------------------------------ + # Timers (owned here so they share the QObject tree of MainWindow) + # ------------------------------------------------------------------ + close_timer = QTimer(self) + close_timer.setSingleShot(True) + close_timer.timeout.connect(self.close) - # Préparation du timer de mise à jour visuelle (1s) - self.countdown_timer = QTimer(self) - self.countdown_timer.timeout.connect(self._update_countdown) + countdown_timer = QTimer(self) - # UI - # self.ui = QUiLoader().load(f"{bundle_dir}/ui/mainwindow_vertical_pager.ui", self) - #self.setCentralWidget(self.ui.centralWidget()) + self._countdown = CountdownManager( + close_timer=close_timer, + countdown_timer=countdown_timer, + label=self.ui.queue_lbl, + ) + + # ------------------------------------------------------------------ + # Window flags + # ------------------------------------------------------------------ self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Window) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - self.setWindowIcon(QIcon(str(bundle_dir / "assets" / "Icon.ico"))) - # Par défaut on affiche la page normal pour la connexion au serveur + # ------------------------------------------------------------------ + # Initial page / whitelist check + # ------------------------------------------------------------------ self.ui.stackedWidget.setCurrentIndex(0) - # On cache par défaut les infos liste d'attente self.ui.queue_lbl.hide() self.ui.queue_position.hide() - - # Si l'id discord = "" ou des espace, alors on affiche la page comme quoi faut être connecté à discord. if self.stored_user_id == "" or self.stored_user_id.isspace(): self.ui.stackedWidget.setCurrentIndex(1) else: try: - # on vérifie si le joueur est whitelisté WhiteList.check_whitelist(Urls.API_URL.value, self.stored_user_id) except ApiError as exc: show_qt_error(self, "La Tanière", f"Impossible de vérifier la whitelist.\n\n{exc}") PlayerServerInfo.is_whitelist = False PlayerServerInfo.is_staff = False - # si on est whitelisté, on démarre la file d'attente if PlayerServerInfo.is_whitelist: self.start_queue() self.ui.queue_lbl.show() @@ -116,65 +111,50 @@ class MainWindow(QMainWindow): else: self.ui.stackedWidget.setCurrentIndex(2) - # Test bouton en contruction + # ------------------------------------------------------------------ + # Under-construction button (delegated to ui_builder) + # ------------------------------------------------------------------ en_chantier = False - # on set la css du bouton en fonction de la valeur de la variable en_chantier - self.set_en_chantier(en_chantier) + set_en_chantier(self.ui.connexion_btn, en_chantier) + if en_chantier: - old_btn = self.ui.connexion_btn - parent_layout = self.ui.verticalLayout_6 # layout direct du bouton dans le .ui - - index = parent_layout.indexOf(old_btn) - - new_btn = HazardButton(old_btn.parentWidget()) - new_btn.setObjectName("connexion_btn") - new_btn.setText("EN MAINTENANCE") - new_btn.setIcon(old_btn.icon()) - new_btn.setIconSize(old_btn.iconSize()) - new_btn.setMinimumSize(old_btn.minimumSize()) - new_btn.set_hazard(True) - - parent_layout.takeAt(index) - old_btn.deleteLater() - parent_layout.insertWidget(index, new_btn) - + new_btn = replace_with_hazard_button( + self.ui.connexion_btn, + self.ui.verticalLayout_6, + ) self.ui.connexion_btn = new_btn self.ui.connexion_btn.clicked.connect(self._on_connexion) - # centrage vertical du bouton connexion + # ------------------------------------------------------------------ + # Staff button / spacer adjustment (delegated to ui_builder) + # ------------------------------------------------------------------ if not PlayerServerInfo.is_staff: - self.ui.staff_btn.hide() - layout = self.ui.verticalLayout_6 - # Trouver et modifier le spacer item - for i in range(layout.count()): - item = layout.itemAt(i) - if item.spacerItem(): # C'est un spacer - item.spacerItem().changeSize(20, 15, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) - layout.invalidate() # Forcer le recalcul du layout - break + hide_staff_btn_and_recenter(self.ui.staff_btn, self.ui.verticalLayout_6) + # ------------------------------------------------------------------ + # Misc UI + # ------------------------------------------------------------------ self.ui.info_text.setMarkdown(patch_note) - # Sous-systèmes + # ------------------------------------------------------------------ + # Sub-systems + # ------------------------------------------------------------------ self._audio = AudioController(self.config, self.ui.audio_volume_adjust, self.ui.mute_btn) self._glow = GlowAnimator(self.ui.connexion_btn) self._dragger = WindowDragger(self) self._connect_signals() - self._center_window() + center_window(self) # delegated to window_utils self.show() - # ------------------------------------------------------------------ # Setup # ------------------------------------------------------------------ def _connect_signals(self) -> None: """ - Establishes connections between UI elements and their corresponding - functionality. Each signal emitted by a user interaction with a specific - UI button is linked to its respective handler method. + Establishes connections between UI elements and their corresponding handler methods. :return: None """ @@ -186,23 +166,6 @@ class MainWindow(QMainWindow): self.ui.discord_auth_btn.clicked.connect(self._on_discord_auth_btn) self.ui.no_whitelist_btn.clicked.connect(self.close) - def _center_window(self) -> None: - """ - Centers the window on the screen that the cursor is currently located on. - If the cursor is not on any screen, it uses the primary screen for centering. - The window's geometry is adjusted and moved accordingly. - - :return: None - """ - self.adjustSize() - screen = ( - QGuiApplication.screenAt(QCursor.pos()) - or QGuiApplication.primaryScreen() - ) - rect = self.frameGeometry() - rect.moveCenter(screen.availableGeometry().center()) - self.move(rect.topLeft()) - # ------------------------------------------------------------------ # Button handlers # ------------------------------------------------------------------ @@ -210,33 +173,25 @@ class MainWindow(QMainWindow): def _on_connexion(self) -> None: """ Handles the user connection process, including verification of prerequisites, - session validation, and background authentication for the current user. This - method ensures a smooth user experience by implementing UI locking during - authentication and proceeding with appropriate actions if a valid session - already exists. + session validation, and background authentication for the current user. :return: None """ - # 1. Sécurités de base if not self.stored_user_id or self.stored_user_id.isspace(): show_qt_error(self, "Connexion impossible", "Identifiant Discord absent.") return - # 2. Si on a déjà une session valide, on passe directement à la suite if PlayerServerInfo.session_id: self._proceed_to_queue_or_launch() return - # 3. Sinon, on lance l'authentification en arrière-plan - # Verrouillage de l'UI pour éviter le double-clic self.ui.connexion_btn.setEnabled(False) self.ui.connexion_btn.setText("Authentification...") - QGuiApplication.setOverrideCursor(Qt.WaitCursor) # Curseur de chargement + QGuiApplication.setOverrideCursor(Qt.WaitCursor) - # Création et lancement du worker self.auth_worker = AuthWorker(self.stored_user_id) self.auth_worker.finished.connect(self._on_auth_finished) - self.auth_worker.finished.connect(self.auth_worker.deleteLater) # Nettoyage automatique + self.auth_worker.finished.connect(self.auth_worker.deleteLater) self.auth_worker.start() @staticmethod @@ -244,18 +199,13 @@ class MainWindow(QMainWindow): """ Opens the Discord URL in the default web browser. - This method is a static utility function that launches the Discord URL defined - in the Urls enumeration. It uses the `webbrowser.open` method to handle the - opening process. - - :return: Nothing is returned - :rtype: None + :return: None """ webbrowser.open(Urls.DISCORD.value) def _on_intranet(self) -> None: """ - Opens the intranet URL using the default web browser and starts the glow process. + Opens the intranet URL and starts the glow animation. :return: None """ @@ -266,32 +216,22 @@ class MainWindow(QMainWindow): """ Handles the authentication process for Discord when the auth button is clicked. - This method retrieves the Discord user ID and token via OAuth, updates the - local configuration, and initiates a background worker to handle server - registration. The UI button is disabled and the cursor is set to a waiting - state during the network operations to prevent multiple attempts. - :return: None """ try: - # 1. Récupération OAuth (souvent via navigateur, assez rapide) test = discord_oauth.get_discord_user_id() user_id = test[0] token = test[1] - # 2. Mise à jour locale immédiate self.config.set_discord_user(user_id) self.stored_user_id = user_id PlayerServerInfo.session_id = token self.config.save() - # 3. Lancement du Worker pour la partie réseau (Enregistrement serveur) - # On verrouille l'UI pour éviter les clics multiples self.ui.discord_auth_btn.setEnabled(False) QGuiApplication.setOverrideCursor(Qt.WaitCursor) self.auth_worker = AuthWorker(self.stored_user_id) - # On connecte le signal de fin à une nouvelle méthode dédiée self.auth_worker.finished.connect(self._on_discord_auth_finished) self.auth_worker.finished.connect(self.auth_worker.deleteLater) self.auth_worker.start() @@ -305,17 +245,9 @@ class MainWindow(QMainWindow): """ Handles the completion of the Discord authentication process. - This method is triggered once the Discord authentication flow is completed. - It determines the success or failure state of the process and accordingly - triggers user interface updates and calls associated actions, such as verifying - the whitelist status. - :param success: Indicates whether the authentication was successful. - :type success: bool :param session_id: The session ID obtained after the authentication process. - :type session_id: str :param error_message: The error message returned if the process failed. - :type error_message: str :return: None """ QGuiApplication.restoreOverrideCursor() @@ -323,16 +255,15 @@ class MainWindow(QMainWindow): if success: try: - # Maintenant que la session est OK côté serveur, on vérifie la Whitelist WhiteList.check_whitelist(Urls.API_URL.value, self.stored_user_id) if PlayerServerInfo.is_whitelist: self.start_queue() self.ui.queue_lbl.show() self.ui.queue_position.show() - self.ui.stackedWidget.setCurrentIndex(0) # Retour page principale + self.ui.stackedWidget.setCurrentIndex(0) else: - self.ui.stackedWidget.setCurrentIndex(2) # Page non-whitelisté + self.ui.stackedWidget.setCurrentIndex(2) except ApiError as exc: show_qt_error(self, "La Tanière", f"Impossible de vérifier la whitelist.\n\n{exc}") else: @@ -340,53 +271,20 @@ class MainWindow(QMainWindow): f"L'authentification a réussi mais l'enregistrement a échoué.\n\n{error_message}") # ------------------------------------------------------------------ - # Mouse events → délégués au WindowDragger + # Mouse events → delegated to WindowDragger # ------------------------------------------------------------------ def mousePressEvent(self, event: QMouseEvent) -> None: - """ - Handles mouse press event for the widget. - - This method is invoked when a mouse button is pressed over the widget. It - delegates the event to an internal dragger object for handling. If the event - is not marked as accepted by the dragger, the parent class implementation - of the mouse press event is called. - - :param event: The QMouseEvent instance representing the mouse press event. - :type event: QMouseEvent - :return: None - """ self._dragger.mouse_press(event) - # On ne remonte pas au parent si on a déjà "accepté" l'event if not event.isAccepted(): super().mousePressEvent(event) def mouseMoveEvent(self, event: QMouseEvent) -> None: - """ - Handle the mouse move event. - - This method processes the mouse movement event and delegates the functionality - to the associated dragger. If the event is not accepted after processing, it - passes the event up to the superclass implementation. - - :param event: The mouse event being handled. - :type event: QMouseEvent - :return: None - """ self._dragger.mouse_move(event) if not event.isAccepted(): super().mouseMoveEvent(event) def mouseReleaseEvent(self, event: QMouseEvent) -> None: - """ - Handles the mouse release event. This function processes the event, allowing - custom handling logic through a dragger object, and then delegates to the parent - class's handler if the event is not already accepted. - - :param event: The mouse release event to be processed. - :type event: QMouseEvent - :return: None - """ self._dragger.mouse_release(event) if not event.isAccepted(): super().mouseReleaseEvent(event) @@ -397,39 +295,24 @@ class MainWindow(QMainWindow): def closeEvent(self, event) -> None: """ - Handles the close event of the application. + Ensures the background thread is stopped and configuration is saved before closing. - This method is executed when the application window is being closed. It ensures - the termination of any running threads, saves the application configuration, and - performs necessary cleanup routines before invoking the superclass's close event - handler. - - :param event: The close event instance that provides event-related data and allows - control over the closing process. :return: None """ if self.queue_thread and self.queue_thread.isRunning(): self.queue_thread.stop() - self.queue_thread.wait() # Attend que le thread se termine proprement + self.queue_thread.wait() self.config.save() self.cleanup() super().closeEvent(event) - def cleanup(self): + def cleanup(self) -> None: """ - Stops all active timers and performs cleanup operations. + Stops all active timers and removes temporary files. - This method ensures that any active resources, such as timers or temporary - files, are properly stopped or removed to avoid resource leaks. It is a - utility to clean up and reset components associated with the instance. - - :raises OSError: If an error occurs during the removal of the temporary file. + :return: None """ - if self.close_timer: - self.close_timer.stop() - - if self.countdown_timer: - self.countdown_timer.stop() + self._countdown.stop() if hasattr(self, '_sound'): self._sound.stop() @@ -440,120 +323,44 @@ class MainWindow(QMainWindow): pass # ------------------------------------------------------------------ - # Change ui on runtime + # Scheduled close (delegated to CountdownManager) # ------------------------------------------------------------------ - def set_en_chantier(self, valeur: bool): - self.en_chantier = valeur # ta variable Python - self.ui.connexion_btn.setProperty("en_chantier", valeur) # propriété Qt - self.ui.connexion_btn.style().unpolish(self.ui.connexion_btn) - self.ui.connexion_btn.style().polish(self.ui.connexion_btn) - # ------------------------------------------------------------------ - # Schedule de fermeture du launcher - # ------------------------------------------------------------------ - def schedule_close(self, delay_ms: int = 60000): + def schedule_close(self, delay_ms: int = 60_000) -> None: """ - Schedules a process to close after a specified delay in milliseconds. The countdown - timer also starts to reflect the remaining time in seconds. Timers already running - are reset to prevent duplicate behavior, and the display is updated immediately. + Schedules the launcher to close after *delay_ms* milliseconds and starts + the visual countdown. - :param delay_ms: The delay in milliseconds after which the process should - be closed. Defaults to 60000 milliseconds (1 minute) if not specified - or an invalid value is provided. + :param delay_ms: Delay in milliseconds (default: 60 000). :type delay_ms: int :return: None """ - # 1. Sécurité sur les entrées - if not isinstance(delay_ms, int) or delay_ms <= 0: - delay_ms = 60000 - - # 2. Reset du temps restant (conversion ms -> s) - self.remaining_time = delay_ms // 1000 - - # 3. On stop les timers s'ils tournaient déjà (évite les doublons) - self.close_timer.stop() - self.countdown_timer.stop() - - # 4. On lance - self.close_timer.start(delay_ms) - self.countdown_timer.start(1000) - - # 5. Mise à jour immédiate de l'affichage (évite d'attendre 1s) - self._update_countdown_display() - - def _update_countdown(self): - """ - Decreases the remaining time by one and updates the countdown display. - - This method is responsible for decrementing the `remaining_time` attribute - on each invocation. If the remaining time reaches zero or below, the - `countdown_timer` is stopped immediately and no further action is - performed in this method. Otherwise, it proceeds to update the countdown - display to reflect the current remaining time. - - :return: None - """ - self.remaining_time -= 1 - - if self.remaining_time <= 0: - self.countdown_timer.stop() - return - - self._update_countdown_display() - - def _update_countdown_display(self): - """ - Updates the countdown display on the user interface with the remaining time - in minutes and seconds format. The remaining time will always be formatted - as a positive value, even if it is less than zero. - - :return: None - """ - minutes = max(0, self.remaining_time // 60) - seconds = max(0, self.remaining_time % 60) - self.ui.queue_lbl.setText(f"⏳ Fermeture dans {minutes:02d}:{seconds:02d}") + self._countdown.schedule(delay_ms) # ------------------------------------------------------------------ - # Queue managment + # Queue management # ------------------------------------------------------------------ - def start_queue(self): + + def start_queue(self) -> None: """ - Starts a separate thread for managing a queue and connects it to the relevant - update handler. This method initializes and starts a `QueueThread` instance - using the stored user ID and links it with the update handling functionality - to ensure real-time queue updates are properly handled. + Initialises and starts the :class:`~tools.queue_thread.QueueThread`. - All threading and queue update mechanisms are encapsulated within this method. - - :param self: Reference to the calling object. :return: None """ - self.queue_thread = QueueThread(self.stored_user_id, parent=self) # ← parent=self + self.queue_thread = QueueThread(self.stored_user_id, parent=self) self.queue_thread.update.connect(self.handle_update) self.queue_thread.start() - # 🧪 TEMP - Simule une position en queue pour tester l'UI - # self.handle_update("position:3:10") - - def handle_update(self, message: str): + def handle_update(self, message: str) -> None: """ - Handles updates based on a given message and performs relevant UI actions. + Reacts to queue status messages and updates the relevant UI labels. - :param message: The message received, which determines the update logic. Accepts: - - "ok": Indicates that it's the user's turn, updating the UI accordingly. - - "ready": Equivalent to "ok", indicating the user's turn. - - Messages starting with "position:": Provide the current position and total - in the queue in the format "position::", updating the queue - information in the UI. + :param message: Status string – ``"ok"``, ``"ready"``, or + ``"position::"``. + :type message: str :return: None """ - if message == "ok": - self.ui.queue_lbl.setVisible(True) - self.ui.queue_position.setVisible(False) - self.ui.queue_lbl.setText("🚀 C'est votre tour !") - self.queue_position_value = 0 - - elif message == "ready": + if message in ("ok", "ready"): self.ui.queue_lbl.setVisible(True) self.ui.queue_position.setVisible(False) self.ui.queue_lbl.setText("🚀 C'est votre tour !") @@ -569,43 +376,28 @@ class MainWindow(QMainWindow): def launch_fivem(self): pass + # ------------------------------------------------------------------ + # Session helpers + # ------------------------------------------------------------------ + def _ensure_server_session(self) -> bool: """ - Ensures the establishment of a server session, handling authentication - and session registration for a Discord user. + Ensures a valid server session exists, authenticating if necessary. - The method first checks if a valid stored user ID exists. If there is - no stored user ID or it is blank, the session creation will fail. If a - session token (`session_id`) is already present, it will return - immediately with success. Otherwise, it proceeds to authenticate with - the server to obtain a session token and registers the Discord user - against this session. - - Errors during registration are caught and handled separately, and the - method logs any issues encountered during the process. If the - authentication and registration are successful, the session is - considered established. - - :return: A boolean indicating whether the server session was successfully - established. + :return: ``True`` if the session was established successfully. :rtype: bool """ if not self.stored_user_id or self.stored_user_id.isspace(): return False try: - # 1. Si on a déjà la session, on ne fait rien if PlayerServerInfo.session_id: return True - # 2. Authentification (avec protection timeout) - # On suppose que authenticate renvoie le token token = GetServerTokenForDiscord.authenticate(Urls.API_URL.value) if token: PlayerServerInfo.session_id = token - # 3. L'enregistrement est souvent la partie qui bloque (SSL handshake) - # On l'entoure d'un try spécifique try: GetServerTokenForDiscord.register_discord_user( self.stored_user_id, @@ -623,107 +415,47 @@ class MainWindow(QMainWindow): def _on_auth_finished(self, success: bool, session_id: str, error_message: str): """ - Handles the completion of the authentication process by updating the UI, - cleaning up the worker, and proceeding with subsequent logic based on the - authentication result. + Restores the UI and proceeds after the background authentication completes. - :param success: A boolean indicating whether the authentication was successful. - :param session_id: The session identifier returned upon successful authentication. - :param error_message: A descriptive error message in case authentication failed. + :param success: Whether authentication succeeded. + :param session_id: Session token returned on success. + :param error_message: Human-readable error on failure. :return: None """ - # 1. Restauration de l'UI QGuiApplication.restoreOverrideCursor() self.ui.connexion_btn.setEnabled(True) - self.ui.connexion_btn.setText("SE CONNECTER") # Remettre le texte par défaut + self.ui.connexion_btn.setText("SE CONNECTER") - # Nettoyage du worker if self.auth_worker: self.auth_worker.deleteLater() self.auth_worker = None if success: - # 2. Mise à jour des infos globales PlayerServerInfo.session_id = session_id - # 3. On continue la logique normale self._proceed_to_queue_or_launch() else: - # 4. Affichage de l'erreur propre (le handshake SSL a sûrement timeout) show_qt_error(self, "Erreur d'Authentification", - f"Impossible de se connecter au serveur.\n\n{error_message}") + f"Impossible de se connecter au serveur.\n\n{error_message}") - def _proceed_to_queue_or_launch(self): + def _proceed_to_queue_or_launch(self) -> None: """ - Handles the process of either starting the queue, updating the user's - position in the queue, or launching the FiveM application if all conditions - are met. This function first checks the queue position. If there is none, - the queue is started. If the current position in the queue is not at the - front (position 0), an error message is displayed. If the user is at the - front of the queue, the application launches, and further connections are - disabled. - - :raises Exception: If an error occurs during the launching process. + Starts the queue if not yet entered, shows the position, or launches FiveM + when the user reaches position 0. :return: None """ try: - # Si on n'a pas encore de position en file d'attente, on la lance if self.queue_position_value is None: self.start_queue() return - # Si on est en file d'attente mais pas au début if self.queue_position_value != 0: show_qt_error(self, "Attente", f"Position actuelle : {self.queue_position_value}") return - # Si tout est OK (Position 0) FiveMLauncher.launch() self.ui.connexion_btn.setEnabled(False) self.schedule_close() except Exception as exc: show_qt_error(self, "Erreur de Lancement", f"Détails : {exc}") - -class QueueThread(QThread): - """ - QueueThread class. - - Implements a thread that manages a queue operation using a `QueueManager`. The - class emits a signal to update external components whenever a relevant change - occurs in the queue. Designed to run processing tasks in a separate thread. - - :ivar update: Signal emitted with a string message to notify about updates. - :type update: Signal - """ - update = Signal(str) - - def __init__(self, user_id: str, parent=None): # ← parent=None - super().__init__(parent) # ← passé à QThread - self.manager = QueueManager( - user_id=user_id, - on_update=self.update.emit - ) - - def run(self): - """ - Initiates the start operation for the associated manager. This method - is responsible for invoking the `start` method of the `manager` object, - ensuring that the necessary operations managed by the `manager` commence - execution. - - :return: None - :rtype: None - """ - self.manager.start() - - def stop(self): - """ - Stops the operation managed by the associated manager instance. - - This method delegates the stop operation to the `manager` object, ensuring - the termination of any ongoing processes or operations it controls. - - :return: None - """ - self.manager.stop() diff --git a/src/uitools/countdown_manager.py b/src/uitools/countdown_manager.py new file mode 100644 index 0000000..697bf96 --- /dev/null +++ b/src/uitools/countdown_manager.py @@ -0,0 +1,102 @@ +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QLabel + + +class CountdownManager: + """ + Manages a visual countdown that triggers a closing callback when it expires. + + Designed to be composed into a QMainWindow (or any QObject owner) rather than + subclassed. The owner must supply: + - a ``close_timer`` (QTimer, singleShot) + - a ``countdown_timer`` (QTimer, repeating 1 s) + - a ``queue_lbl`` (QLabel) on which the remaining time is displayed + - a ``on_timeout`` callable invoked when the close timer fires + + Usage:: + + self._countdown = CountdownManager( + close_timer=self.close_timer, + countdown_timer=self.countdown_timer, + label=self.ui.queue_lbl, + ) + self.close_timer.timeout.connect(self.close) # wire your own close action + self._countdown.schedule(delay_ms=60_000) + """ + + def __init__(self, close_timer: QTimer, countdown_timer: QTimer, label: QLabel) -> None: + self._close_timer = close_timer + self._countdown_timer = countdown_timer + self._label = label + self.remaining_time: int = 0 + + self._countdown_timer.timeout.connect(self._tick) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def schedule(self, delay_ms: int = 60_000) -> None: + """ + Starts (or restarts) the countdown for *delay_ms* milliseconds. + + :param delay_ms: Duration in milliseconds. Must be a positive integer; + invalid values silently default to 60 000 ms. + :type delay_ms: int + :return: None + """ + if not isinstance(delay_ms, int) or delay_ms <= 0: + delay_ms = 60_000 + + self.remaining_time = delay_ms // 1000 + + # Reset running timers to avoid duplicates + self._close_timer.stop() + self._countdown_timer.stop() + + self._close_timer.start(delay_ms) + self._countdown_timer.start(1_000) + + # Immediate display refresh (avoids a 1 s blank) + self._refresh_display() + + def stop(self) -> None: + """Stops both timers without triggering the close callback.""" + self._close_timer.stop() + self._countdown_timer.stop() + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _tick(self) -> None: + """ + Decrements the remaining time by one and updates the countdown timer's state. + + If the remaining time reaches zero or below, the countdown timer stops + and no further operations are performed. If time remains, the display + is refreshed to reflect the updated state. + + :return: None + """ + self.remaining_time -= 1 + + if self.remaining_time <= 0: + self._countdown_timer.stop() + return + + self._refresh_display() + + def _refresh_display(self) -> None: + """ + Updates the display label with the formatted remaining time. + + The method calculates the remaining minutes and seconds from the total + remaining time and updates the label's text with a countdown timer + format. + + :return: None + """ + minutes = max(0, self.remaining_time // 60) + seconds = max(0, self.remaining_time % 60) + self._label.setText(f"⏳ Fermeture dans {minutes:02d}:{seconds:02d}") diff --git a/src/uitools/queue_thread.py b/src/uitools/queue_thread.py new file mode 100644 index 0000000..bd4c3b7 --- /dev/null +++ b/src/uitools/queue_thread.py @@ -0,0 +1,55 @@ +from PySide6.QtCore import QThread, Signal + +from fivemserver.queuemanager import QueueManager + + +class QueueThread(QThread): + """ + A :class:`QThread` that drives a :class:`QueueManager` in a background thread + and forwards its updates via a Qt signal. + + :ivar update: Emitted with a plain-text status string whenever the queue + state changes (e.g. ``"position:3:10"``, ``"ok"``). + :type update: Signal(str) + + Example:: + + thread = QueueThread(user_id="123456789", parent=self) + thread.update.connect(self.handle_update) + thread.start() + # … later … + thread.stop() + thread.wait() + """ + + update = Signal(str) + + def __init__(self, user_id: str, parent=None) -> None: + super().__init__(parent) + self.manager = QueueManager( + user_id=user_id, + on_update=self.update.emit, + ) + + # ------------------------------------------------------------------ + # QThread interface + # ------------------------------------------------------------------ + + def run(self) -> None: + """ + Entry point for the background thread: starts the queue manager loop. + + :return: None + """ + self.manager.start() + + def stop(self) -> None: + """ + Requests a graceful stop of the queue manager. + + Call :py:meth:`wait` afterwards to ensure the thread has fully exited + before destroying this object. + + :return: None + """ + self.manager.stop() diff --git a/src/uitools/ui_builder.py b/src/uitools/ui_builder.py new file mode 100644 index 0000000..f1bfcf6 --- /dev/null +++ b/src/uitools/ui_builder.py @@ -0,0 +1,100 @@ +""" +uitools/ui_builder.py +------------------- +Helpers for dynamic UI mutations at runtime. +These functions operate on PySide6 widgets and layouts but carry no dependency +on MainWindow, making them independently testable and reusable. +""" + +from PySide6.QtWidgets import QAbstractButton, QLayout, QSizePolicy + +from ui.hazard_stripes import HazardButton + + +def set_en_chantier(button: QAbstractButton, valeur: bool) -> None: + """ + Toggles the *en_chantier* (under-construction) Qt property on *button* and + forces a style refresh so QSS selectors relying on that property take effect + immediately. + + :param button: The push-button whose visual state must be updated. + :type button: QAbstractButton + :param valeur: ``True`` to enable the under-construction style, ``False`` to + restore the normal style. + :type valeur: bool + :return: None + """ + button.setProperty("en_chantier", valeur) + button.style().unpolish(button) + button.style().polish(button) + + +def replace_with_hazard_button( + old_btn: QAbstractButton, + parent_layout: QLayout, + label: str = "EN MAINTENANCE", +) -> HazardButton: + """ + Swaps *old_btn* in *parent_layout* for a :class:`HazardButton` that keeps the + same geometry, icon, and object name, then returns the new button. + + The caller is responsible for connecting signals on the returned button. + + :param old_btn: The existing button widget to replace. + :type old_btn: QAbstractButton + :param parent_layout: The layout that directly owns *old_btn*. + :type parent_layout: QLayout + :param label: Text displayed on the replacement button. + :type label: str + :return: The newly inserted :class:`HazardButton`. + :rtype: HazardButton + + Example:: + + new_btn = replace_with_hazard_button( + self.ui.connexion_btn, + self.ui.verticalLayout_6, + ) + self.ui.connexion_btn = new_btn + self.ui.connexion_btn.clicked.connect(self._on_connexion) + """ + index = parent_layout.indexOf(old_btn) + + new_btn = HazardButton(old_btn.parentWidget()) + new_btn.setObjectName(old_btn.objectName()) + new_btn.setText(label) + new_btn.setIcon(old_btn.icon()) + new_btn.setIconSize(old_btn.iconSize()) + new_btn.setMinimumSize(old_btn.minimumSize()) + new_btn.set_hazard(True) + + parent_layout.takeAt(index) + old_btn.deleteLater() + parent_layout.insertWidget(index, new_btn) + + return new_btn + + +def hide_staff_btn_and_recenter(staff_btn: QAbstractButton, layout: QLayout) -> None: + """ + Hides *staff_btn* and adjusts the first spacer found in *layout* to + vertically re-centre the remaining connexion button. + + :param staff_btn: The staff-only button to hide. + :type staff_btn: QAbstractButton + :param layout: The vertical layout that contains both the spacer and + *staff_btn*. + :type layout: QLayout + :return: None + """ + staff_btn.hide() + for i in range(layout.count()): + item = layout.itemAt(i) + if item and item.spacerItem(): + item.spacerItem().changeSize( + 20, 15, + QSizePolicy.Policy.Fixed, + QSizePolicy.Policy.Fixed, + ) + layout.invalidate() + break diff --git a/src/uitools/window_utility.py b/src/uitools/window_utility.py new file mode 100644 index 0000000..0bea62f --- /dev/null +++ b/src/uitools/window_utility.py @@ -0,0 +1,21 @@ +from PySide6.QtGui import QCursor, QGuiApplication +from PySide6.QtWidgets import QWidget + + +def center_window(window: QWidget) -> None: + """ + Centers the given window on the screen where the cursor is currently located. + Falls back to the primary screen if the cursor is not on any screen. + + :param window: The Qt window/widget to center. + :type window: QWidget + :return: None + """ + window.adjustSize() + screen = ( + QGuiApplication.screenAt(QCursor.pos()) + or QGuiApplication.primaryScreen() + ) + rect = window.frameGeometry() + rect.moveCenter(screen.availableGeometry().center()) + window.move(rect.topLeft())