# agent_v0/lea_ui/overlay.py """ Overlay de feedback visuel pour le replay. Fenetre transparente plein ecran, click-through, qui affiche : - Cercle rouge pulsant autour de la cible du clic - Texte descriptif de l'action en cours - Fleche pointant vers la cible - Barre de progression etape X/Y Le overlay ne capture JAMAIS les clics (Qt.WA_TransparentForMouseEvents). """ from __future__ import annotations import logging import math from typing import Optional, Tuple from PyQt5.QtCore import ( QPoint, QPropertyAnimation, QRect, QRectF, QSize, Qt, QTimer, pyqtProperty, pyqtSignal, ) from PyQt5.QtGui import ( QBrush, QColor, QFont, QFontMetrics, QPainter, QPainterPath, QPen, QPolygonF, ) from PyQt5.QtWidgets import QApplication, QDesktopWidget, QWidget from . import styles logger = logging.getLogger("lea_ui.overlay") class OverlayWidget(QWidget): """Overlay plein ecran transparent pour le feedback visuel du replay. Flags critiques : - WindowStaysOnTopHint : toujours au-dessus - FramelessWindowHint : pas de decoration - Tool : n'apparait pas dans la barre des taches - WA_TranslucentBackground : fond transparent - WA_TransparentForMouseEvents : CLICK-THROUGH COMPLET """ # Signal emis quand l'animation d'une action est terminee action_display_finished = pyqtSignal() def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent) # Flags de fenetre pour click-through complet self.setWindowFlags( Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint | Qt.Tool ) self.setAttribute(Qt.WA_TranslucentBackground, True) self.setAttribute(Qt.WA_TransparentForMouseEvents, True) # Etat de l'affichage self._target_pos: Optional[Tuple[int, int]] = None self._action_text: str = "" self._progress_current: int = 0 self._progress_total: int = 0 self._action_done: bool = False self._visible = False # Animation du cercle pulsant self._pulse_radius: float = 30.0 self._pulse_growing = True self._pulse_opacity: float = 0.8 # Timer d'animation self._anim_timer = QTimer(self) self._anim_timer.timeout.connect(self._animate_pulse) self._anim_timer.setInterval(30) # ~33 FPS # Timer d'effacement automatique self._fade_timer = QTimer(self) self._fade_timer.setSingleShot(True) self._fade_timer.timeout.connect(self._on_fade) # Couvrir tout l'ecran self._update_geometry() def _update_geometry(self) -> None: """Positionner l'overlay sur tout l'ecran principal.""" desktop = QApplication.desktop() if desktop: screen_rect = desktop.screenGeometry(desktop.primaryScreen()) self.setGeometry(screen_rect) # --------------------------------------------------------------------------- # API publique # --------------------------------------------------------------------------- def show_action( self, target_x: int, target_y: int, text: str, step_current: int = 0, step_total: int = 0, duration_ms: int = 1500, ) -> None: """Afficher le feedback pour une action de replay. Args: target_x: position X du clic cible (pixels ecran) target_y: position Y du clic cible (pixels ecran) text: description de l'action (ex: "Je clique sur [Valider]") step_current: etape courante (1-indexed) step_total: nombre total d'etapes duration_ms: duree d'affichage en ms (defaut 1500ms) """ self._target_pos = (target_x, target_y) self._action_text = text self._progress_current = step_current self._progress_total = step_total self._action_done = False self._pulse_radius = 30.0 self._pulse_opacity = 0.8 self._visible = True self._update_geometry() self.show() self.raise_() self._anim_timer.start() # Programmer l'effacement self._fade_timer.start(duration_ms) self.update() def show_done(self, text: Optional[str] = None) -> None: """Marquer l'action courante comme terminee (coche verte).""" self._action_done = True if text: self._action_text = text self.update() # Effacer apres 800ms self._fade_timer.start(800) def hide_overlay(self) -> None: """Masquer immediatement l'overlay.""" self._anim_timer.stop() self._fade_timer.stop() self._visible = False self._target_pos = None self.hide() # --------------------------------------------------------------------------- # Animations # --------------------------------------------------------------------------- def _animate_pulse(self) -> None: """Animer le cercle pulsant.""" if self._action_done: # Pas d'animation en mode "done" return pulse_speed = 0.8 if self._pulse_growing: self._pulse_radius += pulse_speed if self._pulse_radius >= 45.0: self._pulse_growing = False else: self._pulse_radius -= pulse_speed if self._pulse_radius <= 25.0: self._pulse_growing = True # Opacite qui suit le pulse self._pulse_opacity = 0.5 + 0.3 * ( (self._pulse_radius - 25.0) / 20.0 ) self.update() def _on_fade(self) -> None: """Callback apres le timer d'effacement.""" self._anim_timer.stop() self._visible = False self._target_pos = None self.hide() self.action_display_finished.emit() # --------------------------------------------------------------------------- # Rendu # --------------------------------------------------------------------------- def paintEvent(self, event) -> None: # noqa: N802 """Dessiner l'overlay.""" if not self._visible or not self._target_pos: return painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing, True) tx, ty = self._target_pos if self._action_done: self._draw_done_indicator(painter, tx, ty) else: self._draw_pulse_circle(painter, tx, ty) self._draw_arrow(painter, tx, ty) self._draw_action_text(painter, tx, ty) self._draw_progress_bar(painter) painter.end() def _draw_pulse_circle(self, painter: QPainter, cx: int, cy: int) -> None: """Dessiner le cercle rouge pulsant autour de la cible.""" # Cercle exterieur (pulsant, semi-transparent) color = QColor(styles.COLOR_OVERLAY_PULSE) color.setAlphaF(self._pulse_opacity * 0.4) painter.setBrush(QBrush(color)) painter.setPen(Qt.NoPen) painter.drawEllipse( QPoint(cx, cy), int(self._pulse_radius), int(self._pulse_radius), ) # Cercle interieur (fixe, plus opaque) color_inner = QColor(styles.COLOR_OVERLAY_PULSE) color_inner.setAlphaF(0.7) pen = QPen(color_inner, 3) painter.setPen(pen) painter.setBrush(Qt.NoBrush) painter.drawEllipse(QPoint(cx, cy), 20, 20) # Point central painter.setPen(Qt.NoPen) painter.setBrush(QBrush(QColor(styles.COLOR_OVERLAY_PULSE))) painter.drawEllipse(QPoint(cx, cy), 4, 4) def _draw_done_indicator(self, painter: QPainter, cx: int, cy: int) -> None: """Dessiner l'indicateur de succes (cercle vert + coche).""" # Cercle vert color = QColor(styles.COLOR_SUCCESS) color.setAlphaF(0.8) painter.setBrush(QBrush(color)) painter.setPen(Qt.NoPen) painter.drawEllipse(QPoint(cx, cy), 25, 25) # Coche blanche pen = QPen(QColor(styles.COLOR_TEXT_ON_ACCENT), 3) pen.setCapStyle(Qt.RoundCap) pen.setJoinStyle(Qt.RoundJoin) painter.setPen(pen) painter.setBrush(Qt.NoBrush) path = QPainterPath() path.moveTo(cx - 10, cy) path.lineTo(cx - 3, cy + 8) path.lineTo(cx + 12, cy - 8) painter.drawPath(path) def _draw_arrow(self, painter: QPainter, tx: int, ty: int) -> None: """Dessiner une fleche pointant vers la cible depuis le texte.""" # Position du texte (au-dessus ou en dessous selon l'espace) text_y = ty - 80 if ty > 120 else ty + 80 text_x = max(100, min(tx, self.width() - 200)) # Ligne de la fleche color = QColor(styles.COLOR_OVERLAY_PULSE) color.setAlphaF(0.6) pen = QPen(color, 2, Qt.DashLine) painter.setPen(pen) painter.drawLine(text_x, text_y + (15 if text_y < ty else -15), tx, ty) def _draw_action_text(self, painter: QPainter, tx: int, ty: int) -> None: """Dessiner le texte descriptif de l'action.""" if not self._action_text: return # Positionner le texte au-dessus ou en dessous de la cible text_y = ty - 90 if ty > 140 else ty + 70 font = QFont(styles.FONT_FAMILY, styles.FONT_SIZE_LARGE, QFont.Bold) painter.setFont(font) metrics = QFontMetrics(font) # Mesurer le texte text_rect = metrics.boundingRect(self._action_text) text_width = text_rect.width() + 30 text_height = text_rect.height() + 16 # Centrer horizontalement sur la cible (avec limites d'ecran) box_x = max(10, min(tx - text_width // 2, self.width() - text_width - 10)) box_y = text_y - text_height // 2 # Fond semi-transparent arrondi bg_color = QColor(31, 41, 55, 200) # Gris fonce semi-transparent painter.setBrush(QBrush(bg_color)) painter.setPen(Qt.NoPen) painter.drawRoundedRect(box_x, box_y, text_width, text_height, 8, 8) # Texte blanc painter.setPen(QPen(QColor(styles.COLOR_OVERLAY_TEXT))) painter.drawText( QRect(box_x, box_y, text_width, text_height), Qt.AlignCenter, self._action_text, ) def _draw_progress_bar(self, painter: QPainter) -> None: """Dessiner la barre de progression en bas de l'ecran.""" if self._progress_total <= 0: return bar_width = 300 bar_height = 6 bar_x = (self.width() - bar_width) // 2 bar_y = self.height() - 50 # Fond bg_color = QColor(255, 255, 255, 80) painter.setBrush(QBrush(bg_color)) painter.setPen(Qt.NoPen) painter.drawRoundedRect(bar_x, bar_y, bar_width, bar_height, 3, 3) # Progression progress_pct = self._progress_current / self._progress_total fill_width = int(bar_width * progress_pct) accent_color = QColor(styles.COLOR_ACCENT) accent_color.setAlphaF(0.9) painter.setBrush(QBrush(accent_color)) painter.drawRoundedRect(bar_x, bar_y, fill_width, bar_height, 3, 3) # Label "Etape X/Y" label_font = QFont(styles.FONT_FAMILY, styles.FONT_SIZE_SMALL) painter.setFont(label_font) painter.setPen(QPen(QColor(255, 255, 255, 200))) painter.drawText( QRect(bar_x, bar_y + bar_height + 4, bar_width, 20), Qt.AlignCenter, f"Etape {self._progress_current}/{self._progress_total}", )