From 82e0e16393825fe02703fb317a5287140c3bdda7 Mon Sep 17 00:00:00 2001 From: Ralph Mayola Date: Sun, 23 Aug 2026 19:05:25 +0200 Subject: [PATCH] Ajoute l'overlay Qt, pour les plateformes sans Cocoa Co-Authored-By: Claude Opus 5 --- interface/__init__.py | 3 + interface/qt.py | 342 ++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + tests/test_interface.py | 12 ++ 4 files changed, 359 insertions(+) create mode 100644 interface/qt.py diff --git a/interface/__init__.py b/interface/__init__.py index 4ad0c5e..ad4e70a 100644 --- a/interface/__init__.py +++ b/interface/__init__.py @@ -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}") diff --git a/interface/qt.py b/interface/qt.py new file mode 100644 index 0000000..18cf82e --- /dev/null +++ b/interface/qt.py @@ -0,0 +1,342 @@ +"""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, QPainter, QPainterPath, QPen +from PySide6.QtWidgets import 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): + painter = QPainter(self) + 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 + # Import local : PySide6 ne doit être exigé QUE quand cette classe est + # réellement instanciée (cf interface/__init__.py::classe_overlay_pour), + # jamais au chargement du module — sinon les tests réclameraient Qt sur + # toute machine, y compris ce Mac où Cocoa suffit. + from PySide6.QtGui import QGuiApplication + from PySide6.QtWidgets import QApplication + + 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 = "" diff --git a/requirements.txt b/requirements.txt index 3d05395..3bced72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/test_interface.py b/tests/test_interface.py index 9f40b79..7b90929 100644 --- a/tests/test_interface.py +++ b/tests/test_interface.py @@ -14,3 +14,15 @@ 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"