"""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 = ""