zonza/pulse_window.py
Ralph Mayola 55c860c5b9 L'overlay devient une barre de dictee au lieu d'un cercle
Reprend le design demande par Ralph, d'apres une capture : barre sombre
arrondie posee en bas de l'ecran, et dedans, de gauche a droite, un bouton stop
rond rouge-orange a carre blanc, une forme d'onde en barres verticales dont la
traine retombe en pointilles, une icone de micro. Au-dessus, une bulle sombre a
pointe.

La forme d'onde demandait un HISTORIQUE de niveaux, la ou l'ancien cercle ne
lisait que le niveau courant : push_level et bar_heights sont ajoutes a
audio_level.py comme unites pures (le plus recent a gauche, les positions vides
ou silencieuses retombent sur un plancher — c'est lui qui donne les pointilles).

La bulle ne peut pas afficher le texte en direct comme la reference : Zonza
transcrit APRES l'arret, il n'y a pas de flux continu. Elle porte donc l'etape
en cours, et la fenetre reste affichee jusqu'au collage au lieu de disparaitre
pendant les secondes de calcul — l'ecran ne se vide plus sans explication.

Rendu verifie par capture d'ecran, pas seulement par les tests : le micro a ete
redessine apres coup (il sortait en tache blanche) et une marge basse ajoutee,
l'arrondi de la barre etant rogne par le bord de la fenetre.

45 tests passent. Le cdhash du stub est inchange, donc les autorisations
d'Accessibilite survivent a cette mise a jour (verifie : accordee = True).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 00:46:56 +02:00

240 lines
8.6 KiB
Python

"""Overlay de dictée : barre sombre arrondie posée en bas de l'écran.
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.
Cocoa natif (pyobjc). Tout le dessin a lieu sur le thread principal via un
NSTimer ; 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.
"""
import threading
from objc import super
from AppKit import (
NSAttributedString,
NSBackingStoreBuffered,
NSBezierPath,
NSColor,
NSFont,
NSFontAttributeName,
NSForegroundColorAttributeName,
NSScreen,
NSTimer,
NSView,
NSWindow,
NSWindowStyleMaskBorderless,
)
from Foundation import NSMakeRect, NSMakeSize, NSObject
from audio_level import bar_heights, push_level
from core import CONFIG
_BOTTOM_MARGIN = 80.0 # au-dessus du Dock
_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
_SIDE_PAD = 22.0 # marge intérieure gauche/droite de la barre
_STOP_D = 28.0 # diamètre du bouton stop
_MIC_W = 9.0 # largeur de la capsule du micro
_BOTTOM_INSET = 10.0 # evite que l'arrondi bas de la barre touche le bord
def _rounded(x, y, w, h, radius):
return NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_(
NSMakeRect(x, y, w, h), radius, radius
)
def _rgba(triplet, alpha=1.0):
r, g, b = triplet[:3]
return NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, alpha)
class PulseView(NSView):
"""Dessine la barre, la forme d'onde et la bulle d'état."""
def initWithFrame_(self, frame):
self = super().initWithFrame_(frame)
if self is None:
return None
self._bars = []
self._status = ""
return self
def setBars_(self, bars):
self._bars = bars
def setStatus_(self, text):
self._status = text or ""
# --- morceaux de dessin ------------------------------------------------
def _draw_bar_background(self, x, y, w, h):
_rgba(CONFIG["bar_bg_rgba"], CONFIG["bar_bg_rgba"][3]).set()
_rounded(x, y, w, h, CONFIG["bar_radius"]).fill()
def _draw_stop_button(self, cx, cy):
_rgba(CONFIG["stop_button_rgb"]).set()
NSBezierPath.bezierPathWithOvalInRect_(
NSMakeRect(cx - _STOP_D / 2, cy - _STOP_D / 2, _STOP_D, _STOP_D)
).fill()
# carré arrondi blanc au centre = « arrêter »
side = _STOP_D * 0.34
_rgba(CONFIG["stop_glyph_rgb"]).set()
_rounded(cx - side / 2, cy - side / 2, side, side, 2.0).fill()
def _draw_waveform(self, 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
_rgba(CONFIG["waveform_rgb"]).set()
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
_rounded(cxi - bw / 2, cy - half, bw, half * 2, bw / 2).fill()
def _draw_mic(self, cx, cy):
col = _rgba(CONFIG["mic_rgb"])
col.set()
body_h = 13.0
body_y = cy - body_h / 2 + 3.0
# capsule (le micro lui-meme)
_rounded(cx - _MIC_W / 2, body_y, _MIC_W, body_h, _MIC_W / 2).fill()
# arceau en U qui passe SOUS la capsule
arc = NSBezierPath.bezierPath()
arc.appendBezierPathWithArcWithCenter_radius_startAngle_endAngle_(
(cx, body_y + 3.0), 7.0, 195.0, 345.0
)
arc.setLineWidth_(1.6)
arc.setLineCapStyle_(1) # bouts arrondis
arc.stroke()
# pied vertical sous l'arceau
_rounded(cx - 0.8, body_y - 8.0, 1.6, 4.0, 0.8).fill()
def _draw_bubble(self, bar_x, bar_top, bar_w):
if not self._status:
return
attrs = {
NSFontAttributeName: NSFont.systemFontOfSize_(CONFIG["bubble_font_size"]),
NSForegroundColorAttributeName: _rgba(CONFIG["bubble_text_rgb"]),
}
text = NSAttributedString.alloc().initWithString_attributes_(self._status, attrs)
avail = NSMakeSize(CONFIG["bubble_max_width"], 400.0)
bounds = text.boundingRectWithSize_options_(avail, 1 << 0) # WordWrap
tw, th = bounds.size.width, bounds.size.height
w, h = tw + 2 * _BUBBLE_PAD, th + 2 * _BUBBLE_PAD
x = bar_x + 24.0 # alignée à gauche, comme la référence
y = bar_top + _BUBBLE_GAP
_rgba(CONFIG["bar_bg_rgba"], CONFIG["bar_bg_rgba"][3]).set()
_rounded(x, y, w, h, 10.0).fill()
# petite pointe vers la barre, en bas à gauche
tail = NSBezierPath.bezierPath()
tail.moveToPoint_((x + 20.0 - _TAIL, y + 1.0))
tail.lineToPoint_((x + 20.0 + _TAIL, y + 1.0))
tail.lineToPoint_((x + 20.0, y - 7.0))
tail.closePath()
tail.fill()
text.drawInRect_(NSMakeRect(x + _BUBBLE_PAD, y + _BUBBLE_PAD, tw, th))
def drawRect_(self, rect):
NSColor.clearColor().set()
NSBezierPath.fillRect_(rect)
w = CONFIG["bar_width"]
h = CONFIG["bar_height"]
x = (rect.size.width - w) / 2.0
y = _BOTTOM_INSET
cy = y + h / 2.0
self._draw_bar_background(x, y, w, h)
self._draw_stop_button(x + _SIDE_PAD + _STOP_D / 2, cy)
self._draw_mic(x + w - _SIDE_PAD - _MIC_W / 2, cy)
wave_x = x + _SIDE_PAD + _STOP_D + 18.0
wave_w = (x + w - _SIDE_PAD - _MIC_W - 18.0) - wave_x
self._draw_waveform(wave_x, cy, wave_w)
self._draw_bubble(x, y + h, w)
class PulseWindow(NSObject):
"""Fenêtre flottante : show(), hide(), set_level(), set_status(). Thread-safe."""
def init(self):
self = super().init()
if self is None:
return None
self._history = []
self._status = ""
self._lock = threading.Lock()
self._window = None
self._view = None
self._timer = None
return self
def _window_size(self):
return (CONFIG["bar_width"] + 80.0,
CONFIG["bar_height"] + _BOTTOM_INSET + 140.0)
def _ensure_window(self):
if self._window is not None:
return
ww, wh = self._window_size()
screen = NSScreen.mainScreen().frame()
frame = NSMakeRect((screen.size.width - ww) / 2.0, _BOTTOM_MARGIN, ww, wh)
win = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
frame, NSWindowStyleMaskBorderless, NSBackingStoreBuffered, False
)
win.setOpaque_(False)
win.setBackgroundColor_(NSColor.clearColor())
win.setLevel_(25) # au-dessus des fenêtres normales
win.setIgnoresMouseEvents_(True)
win.setHasShadow_(False)
view = PulseView.alloc().initWithFrame_(NSMakeRect(0, 0, ww, wh))
win.setContentView_(view)
self._window = win
self._view = view
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, timer):
with self._lock:
history, status = list(self._history), self._status
if self._view is not None:
self._view.setBars_(
bar_heights(history, CONFIG["waveform_bars"], CONFIG["waveform_floor"])
)
self._view.setStatus_(status)
self._view.setNeedsDisplay_(True)
def show(self):
"""Affiche la fenêtre et démarre l'animation. Thread principal."""
self._ensure_window()
with self._lock:
self._history = []
self._window.orderFrontRegardless()
self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
1.0 / float(CONFIG["fps"]), self, "_tick:", None, True
)
def hide(self):
"""Arrête l'animation et masque la fenêtre. Thread principal."""
if self._timer is not None:
self._timer.invalidate()
self._timer = None
if self._window is not None:
self._window.orderOut_(None)
with self._lock:
self._status = ""