chore: ajouter agent_v0/ au tracking git (était un repo embarqué)
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>
This commit is contained in:
354
agent_v0/lea_ui/overlay.py
Normal file
354
agent_v0/lea_ui/overlay.py
Normal file
@@ -0,0 +1,354 @@
|
||||
# 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}",
|
||||
)
|
||||
Reference in New Issue
Block a user