Compare commits

..

2 Commits

Author SHA1 Message Date
99376a8e12 Le test exerce enfin l'annulation de la garde, dans les deux interfaces
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 19:16:41 +02:00
82e0e16393 Ajoute l'overlay Qt, pour les plateformes sans Cocoa
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 19:05:25 +02:00
4 changed files with 431 additions and 0 deletions

View File

@ -15,4 +15,7 @@ def classe_overlay_pour(plateforme=None):
if plateforme.startswith("darwin"):
from interface.cocoa import PulseWindow
return PulseWindow
if plateforme.startswith("win32"):
from interface.qt import OverlayQt
return OverlayQt
raise NotImplementedError(f"Aucune interface pour : {plateforme}")

343
interface/qt.py Normal file
View File

@ -0,0 +1,343 @@
"""Overlay de dictée : barre sombre arrondie posée en bas de l'écran (Qt).
Copie conforme de `interface/cocoa.py`, pour les plateformes sans Cocoa
(Windows aujourd'hui). Mêmes trois zones, de gauche à droite : un bouton stop
rond rouge-orange, une forme d'onde en barres verticales, une icône de micro.
Au-dessus, une bulle sombre affiche l'état en cours.
Qt natif (PySide6). Tout le dessin a lieu sur le thread principal via un
QTimer ; les niveaux audio arrivent d'un thread de fond par set_level(), et
l'historique qui alimente la forme d'onde est protégé par un verrou même
disposition que Cocoa.
Repère : Qt place l'origine en HAUT à gauche (y croît vers le bas), alors que
Cocoa la place en BAS à gauche (y croît vers le haut). C'est la source
d'erreur la plus probable d'un portage Cocoa Qt : décaler un signe à un
seul endroit du dessin fait sortir une forme du mauvais côté sans qu'aucun
test ne le révèle (cf. les 42 tests verts du 21/08 pendant qu'une icône
sortait en tache blanche seule une capture d'écran l'a montré). On bascule
donc le repère une seule fois, ici, avant de dessiner les formes qui
raisonnent comme Cocoa ; le texte de la bulle est dessiné à part, hors de ce
repère basculé, pour ne pas sortir à l'envers.
"""
import threading
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer
from PySide6.QtGui import QColor, QFontMetrics, QGuiApplication, QPainter, QPainterPath, QPen
from PySide6.QtWidgets import QApplication, QWidget
from audio_level import bar_heights, push_level
from core import CONFIG, bar_metrics
_BOTTOM_MARGIN = 80.0 # au-dessus de la barre des tâches
_BUBBLE_GAP = 12.0 # espace entre la bulle et la barre
_BUBBLE_PAD = 12.0 # marge intérieure de la bulle
_TAIL = 7.0 # demi-largeur de la pointe de la bulle
_BOTTOM_INSET = 8.0 # évite que l'arrondi bas de la barre touche le bord
def _qcolor(triplet, alpha=1.0):
r, g, b = triplet[:3]
c = QColor()
c.setRgbF(r, g, b, alpha)
return c
class VueOverlay(QWidget):
"""Dessine la barre, la forme d'onde et la bulle d'état.
Widget Qt top-level SANS PARENT : Qt n'exige pas, contrairement à Cocoa,
une fenêtre (NSWindow) séparée de sa vue de contenu (NSView) un QWidget
sans parent EST déjà une fenêtre. Cette classe joue donc les deux rôles :
« vue qui dessine » ET « fenêtre affichée », la façade `OverlayQt`
n'ayant qu'à la montrer/masquer.
"""
def __init__(self):
super().__init__()
self._bars = []
self._status = ""
# Fenêtre sans cadre, toujours au-dessus, à fond transparent, et
# transparente aux clics : la souris doit traverser l'overlay jusqu'à
# l'application en dessous (Qt.WindowTransparentForInput).
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowStaysOnTopHint
| Qt.WindowType.Tool
| Qt.WindowType.WindowTransparentForInput
)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating, True)
def set_bars(self, bars):
self._bars = bars
def set_status(self, text):
self._status = text or ""
# --- morceaux de dessin (repère Cocoa : y croît vers le HAUT) ---------
def _draw_bar_background(self, painter, x, y, w, h):
painter.setBrush(_qcolor(CONFIG["bar_bg_rgba"], CONFIG["bar_bg_rgba"][3]))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(
QRectF(x, y, w, h), CONFIG["bar_radius"], CONFIG["bar_radius"]
)
def _draw_stop_button(self, painter, cx, cy, diametre):
painter.setBrush(_qcolor(CONFIG["stop_button_rgb"]))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(QPointF(cx, cy), diametre / 2, diametre / 2)
# carré arrondi blanc au centre = « arrêter »
side = diametre * 0.34
painter.setBrush(_qcolor(CONFIG["stop_glyph_rgb"]))
painter.drawRoundedRect(QRectF(cx - side / 2, cy - side / 2, side, side), 2.0, 2.0)
def _draw_waveform(self, painter, x0, cy, width):
bars = self._bars
if not bars:
return
n = len(bars)
step = width / float(n)
bw = max(2.0, step * 0.38)
half_max = CONFIG["bar_height"] * 0.30 # amplitude verticale max
painter.setBrush(_qcolor(CONFIG["waveform_rgb"]))
painter.setPen(Qt.PenStyle.NoPen)
for i, level in enumerate(bars):
cxi = x0 + step * (i + 0.5)
half = max(bw / 2.0, level * half_max) # jamais moins qu'un point rond
painter.drawRoundedRect(
QRectF(cxi - bw / 2, cy - half, bw, half * 2), bw / 2, bw / 2
)
def _draw_mic(self, painter, cx, cy, m):
painter.setBrush(_qcolor(CONFIG["mic_rgb"]))
largeur = m["mic_largeur"]
body_h = m["mic_corps_hauteur"]
body_y = cy - body_h / 2 + body_h * 0.23
# capsule (le micro lui-même)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(
QRectF(cx - largeur / 2, body_y, largeur, body_h), largeur / 2, largeur / 2
)
# arceau en U qui passe SOUS la capsule. PIÈGE vérifié par capture
# d'écran (cf tâche) : `drawArc` mesure ses angles dans le repère
# ÉCRAN natif de Qt (0° = 3 h, sens visuellement anti-horaire), et ne
# tient PAS compte du basculement `scale(1, -1)` appliqué plus haut —
# contrairement aux formes remplies (rect, ellipse), qui suivent la
# transformation courante. Un premier essai avec les bornes de Cocoa
# telles quelles (195°→345°) donnait un arc reflété verticalement
# (ouvert vers le BAS, au-dessus de la capsule au lieu de dessous).
# On compense en reflétant l'angle : depart' = -(depart+etendue),
# etendue' = etendue.
rayon = m["mic_arceau_rayon"]
arc_cx, arc_cy = cx, body_y + body_h * 0.23
depart_cocoa, etendue_cocoa = 195, 345 - 195
depart = -(depart_cocoa + etendue_cocoa) % 360
# QPen neuf : `painter.pen()` aurait hérité le style NoPen laissé par
# le dessin de la capsule juste au-dessus — un pen NoPen ignore
# silencieusement couleur et épaisseur, l'arc ne se serait jamais vu.
pen = QPen(_qcolor(CONFIG["mic_rgb"]))
pen.setWidthF(max(1.0, body_h * 0.12))
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
rect_arc = QRectF(arc_cx - rayon, arc_cy - rayon, rayon * 2, rayon * 2)
painter.drawArc(rect_arc, depart * 16, etendue_cocoa * 16)
# pied vertical sous l'arceau
pied = max(1.2, largeur * 0.18)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(_qcolor(CONFIG["mic_rgb"]))
painter.drawRoundedRect(
QRectF(cx - pied / 2, body_y - body_h * 0.62, pied, body_h * 0.31),
pied / 2, pied / 2,
)
def _bubble_box(self, bar_x, bar_top, bar_w):
"""Géométrie de la bulle (repère Cocoa), ou None si aucun texte."""
if not self._status:
return None
font = self.font()
font.setPointSizeF(CONFIG["bubble_font_size"])
metrics = QFontMetrics(font)
bounds = metrics.boundingRect(
QRectF(0, 0, CONFIG["bubble_max_width"], 400.0).toRect(),
int(Qt.TextFlag.TextWordWrap),
self._status,
)
tw, th = float(bounds.width()), float(bounds.height())
w, h = tw + 2 * _BUBBLE_PAD, th + 2 * _BUBBLE_PAD
x = bar_x + CONFIG["bar_height"] * 0.46 # alignée à gauche, comme la référence
y = bar_top + _BUBBLE_GAP
return {"x": x, "y": y, "w": w, "h": h, "tw": tw, "th": th, "font": font}
def _draw_bubble_shape(self, painter, box):
painter.setBrush(_qcolor(CONFIG["bar_bg_rgba"], CONFIG["bar_bg_rgba"][3]))
painter.setPen(Qt.PenStyle.NoPen)
x, y, w, h = box["x"], box["y"], box["w"], box["h"]
painter.drawRoundedRect(QRectF(x, y, w, h), 10.0, 10.0)
# petite pointe vers la barre, en bas à gauche
tail = QPainterPath()
tail.moveTo(x + 20.0 - _TAIL, y + 1.0)
tail.lineTo(x + 20.0 + _TAIL, y + 1.0)
tail.lineTo(x + 20.0, y - 7.0)
tail.closeSubpath()
painter.drawPath(tail)
def paintEvent(self, event):
# `with` ferme le QPainter (.end()) meme si une exception surgit en
# cours de dessin : sans ca, la fermeture ne tenait qu'au comptage de
# references de PySide6 — suffisant aujourd'hui, fragile demain.
with QPainter(self) as painter:
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
w = CONFIG["bar_width"]
h = CONFIG["bar_height"]
x = (self.width() - w) / 2.0
y = _BOTTOM_INSET
cy = y + h / 2.0
m = bar_metrics(h)
pad, stop_d, mic_w, ecart = (
m["marge_laterale"], m["stop_diametre"], m["mic_largeur"], m["ecart_interne"]
)
bubble = self._bubble_box(x, y + h, w)
# Bascule de repère : à partir d'ici, y croît vers le HAUT comme dans
# Cocoa (voir docstring du module). On la retire (`restore`) avant de
# dessiner le texte de la bulle plus bas, pour qu'il ne sorte pas à
# l'envers.
painter.save()
painter.translate(0, self.height())
painter.scale(1, -1)
self._draw_bar_background(painter, x, y, w, h)
self._draw_stop_button(painter, x + pad + stop_d / 2, cy, stop_d)
self._draw_mic(painter, x + w - pad - mic_w / 2, cy, m)
wave_x = x + pad + stop_d + ecart
wave_w = (x + w - pad - mic_w - ecart) - wave_x
self._draw_waveform(painter, wave_x, cy, wave_w)
if bubble is not None:
self._draw_bubble_shape(painter, bubble)
painter.restore()
if bubble is not None:
# Repère Qt normal (y vers le bas) : on convertit le coin bas-gauche
# « repère Cocoa » de la bulle en un rectangle Qt haut-gauche.
text_top_qt = self.height() - (bubble["y"] + _BUBBLE_PAD + bubble["th"])
painter.setFont(bubble["font"])
painter.setPen(_qcolor(CONFIG["bubble_text_rgb"]))
painter.drawText(
QRectF(bubble["x"] + _BUBBLE_PAD, text_top_qt, bubble["tw"], bubble["th"]),
int(Qt.TextFlag.TextWordWrap),
self._status,
)
class OverlayQt:
"""Fenêtre flottante Qt : show(), hide(), set_level(), set_status(). Thread-safe.
Contrairement à `PulseWindow` (NSObject, exige `alloc().init()`), c'est une
classe Python ordinaire : `classe_overlay_pour("win32")()` suffit.
"""
def __init__(self):
self._history = []
self._status = ""
self._lock = threading.Lock()
self._view = None
self._timer = None
self._garde = None # ferme l'overlay quoi qu'il arrive (cf show)
def _window_size(self):
return (CONFIG["bar_width"] + 80.0,
CONFIG["bar_height"] + _BOTTOM_INSET + 140.0)
def _ensure_view(self):
if self._view is not None:
return
# PySide6 est déjà exigé dès l'IMPORT de ce module (QWidget, QTimer...
# ci-dessus) : le report réel n'est pas ici, mais dans la fabrique
# (interface/__init__.py::classe_overlay_pour), qui n'importe
# `interface.qt` que pour la branche win32 — jamais sur ce Mac, où
# Cocoa suffit.
if QApplication.instance() is None:
QApplication([])
ww, wh = self._window_size()
self._view = VueOverlay()
self._view.resize(int(ww), int(wh))
ecran = QGuiApplication.primaryScreen().geometry()
px = ecran.left() + (ecran.width() - ww) / 2.0
py = ecran.top() + ecran.height() - _BOTTOM_MARGIN - wh
self._view.move(int(px), int(py))
def set_level(self, level):
"""Pousse le dernier niveau audio (appelable depuis un thread de fond)."""
with self._lock:
self._history = push_level(self._history, level, CONFIG["waveform_bars"])
def set_status(self, text):
"""Texte affiché dans la bulle (thread de fond autorisé)."""
with self._lock:
self._status = text or ""
def _tick(self):
with self._lock:
history, status = list(self._history), self._status
if self._view is not None:
self._view.set_bars(
bar_heights(history, CONFIG["waveform_bars"], CONFIG["waveform_floor"])
)
self._view.set_status(status)
self._view.update()
def show(self):
"""Affiche la fenêtre et démarre l'animation. Thread principal."""
self._ensure_view()
with self._lock:
self._history = []
self._view.show()
self._timer = QTimer()
self._timer.timeout.connect(self._tick)
self._timer.start(int(1000.0 / float(CONFIG["fps"])))
# Garde-fou : la fenêtre porte sa propre durée de vie. Si le contrôleur
# échoue à la refermer — transcription bloquée, thread mort, bug en
# amont — elle se ferme seule plutôt que de rester figée à l'écran
# (même correctif que Cocoa, 2026-08-21).
self._garde = QTimer()
self._garde.setSingleShot(True)
self._garde.timeout.connect(self._expiration)
self._garde.start(int(float(CONFIG["duree_max_overlay_s"]) * 1000.0))
def rearmer_garde(self, duree_s):
"""Remplace la garde en cours par une plus courte. Thread principal.
Appelée au passage en transcription : la garde longue couvre un
enregistrement de 2 min, mais une fois l'enregistrement fini il ne
reste que quelques secondes de calcul inutile de laisser un halo
trois minutes.
"""
if self._garde is not None:
self._garde.stop()
self._garde = QTimer()
self._garde.setSingleShot(True)
self._garde.timeout.connect(self._expiration)
self._garde.start(int(float(duree_s) * 1000.0))
def _expiration(self):
print("[garde] overlay ouvert trop longtemps — fermeture forcée")
self.hide()
def hide(self):
"""Arrête l'animation et masque la fenêtre. Thread principal."""
if self._timer is not None:
self._timer.stop()
self._timer = None
if self._garde is not None:
self._garde.stop()
self._garde = None
if self._view is not None:
self._view.hide()
with self._lock:
self._status = ""

View File

@ -10,3 +10,5 @@ pynput>=1.8.2
pyperclip>=1.11
pillow>=11
pytest>=8.0
# PySide6 : uniquement hors macOS (Cocoa/pyobjc y sert deja l'overlay) — cf interface/qt.py.
PySide6>=6.11

View File

@ -14,3 +14,86 @@ def test_une_plateforme_inconnue_est_refusee_clairement():
with pytest.raises(NotImplementedError) as e:
classe_overlay_pour("haiku-os")
assert "haiku-os" in str(e.value)
def test_la_fabrique_rend_overlayqt_pour_win32():
classe = classe_overlay_pour("win32")
from interface.qt import OverlayQt
assert classe is OverlayQt
def test_l_overlay_qt_declare_tout_le_contrat():
classe = classe_overlay_pour("win32")
for methode in ("show", "hide", "set_level", "set_status", "rearmer_garde"):
assert callable(getattr(classe, methode, None)), f"{methode} manque"
# --- garde de duree de vie : annulation reelle a hide() ---------------------
#
# Exception assumee au principe "aucune fenetre" du haut de ce fichier : la
# seule facon honnete de prouver qu'une garde est REELLEMENT annulee (et pas
# seulement oubliee dans le champ `_garde`) est d'interroger l'etat vrai de la
# minuterie native — NSTimer.isValid() cote Cocoa, QTimer.isActive() cote Qt.
# Une doublure (fake) ne fournirait aucune garantie sur ce point : elle ne
# ferait que rejouer ce que le code de production affirme deja faire.
#
# Piege du 21/08 (halo fige) : une garde dereferencee (`_garde = None`) mais
# jamais invalidee continue de courir en coulisse, et peut refermer un overlay
# rouvert entre-temps, en pleine dictee. D'ou : on capture l'objet minuterie
# AVANT hide()/rearmer_garde(), pour verifier SON etat a lui, pas seulement le
# champ qui le reference.
from interface.cocoa import PulseWindow
from interface.qt import OverlayQt
def _garde_active_cocoa(garde):
return bool(garde.isValid())
def _garde_active_qt(garde):
return garde.isActive()
_IMPLEMENTATIONS_REELLES = [
(lambda: PulseWindow.alloc().init(), _garde_active_cocoa),
(OverlayQt, _garde_active_qt),
]
_IDS_IMPLEMENTATIONS = ["cocoa", "qt"]
@pytest.fixture(params=_IMPLEMENTATIONS_REELLES, ids=_IDS_IMPLEMENTATIONS)
def overlay_et_garde_active(request):
"""Une vraie instance d'overlay, plus la fonction qui lit l'etat reel de sa
minuterie de garde. Ferme toujours la fenetre a la fin, meme si le test echoue."""
construire, garde_active = request.param
ov = construire()
try:
yield ov, garde_active
finally:
ov.hide()
def test_show_arme_une_garde_valide(overlay_et_garde_active):
ov, garde_active = overlay_et_garde_active
ov.show()
assert ov._garde is not None
assert garde_active(ov._garde)
def test_hide_annule_reellement_la_garde(overlay_et_garde_active):
ov, garde_active = overlay_et_garde_active
ov.show()
garde = ov._garde # reference gardee AVANT hide()
ov.hide()
assert ov._garde is None
assert not garde_active(garde), "la minuterie doit etre invalidee, pas seulement oubliee"
def test_rearmer_garde_ne_laisse_qu_une_seule_garde_active(overlay_et_garde_active):
ov, garde_active = overlay_et_garde_active
ov.show()
ancienne_garde = ov._garde
ov.rearmer_garde(5.0)
assert ov._garde is not ancienne_garde
assert not garde_active(ancienne_garde), "l'ancienne garde doit etre invalidee au remplacement"
assert garde_active(ov._garde)