Suppression du .git embarqué dans agent_v0/ — le code est maintenant tracké normalement dans le repo principal. Inclut : agent_v1 (client), server_v1 (streaming), lea_ui (chat client) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
355 lines
11 KiB
Python
355 lines
11 KiB
Python
# 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}",
|
|
)
|