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>
240 lines
8.6 KiB
Python
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 = ""
|