Un module plat par plateforme, pour les trois frontieres
interface/cocoa/overlay.py etait un sous-paquet quand systeme/macos.py et moteur/mlx.py sont des modules plats. Rien n'expliquait la difference : la couche Windows aurait eu a deviner lequel des deux motifs imiter, et aurait reproduit l'incoherence dans un sens ou dans l'autre. interface/cocoa.py est donc mis a plat, contenu inchange (diff vide), et la regle est ecrite dans interface/__init__.py : un module plat par plateforme, sous-paquet seulement le jour ou une plateforme exige plusieurs fichiers. 105 tests inchanges. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
"""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, bar_metrics
|
||||
|
||||
_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
|
||||
_BOTTOM_INSET = 8.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, diametre):
|
||||
_rgba(CONFIG["stop_button_rgb"]).set()
|
||||
NSBezierPath.bezierPathWithOvalInRect_(
|
||||
NSMakeRect(cx - diametre / 2, cy - diametre / 2, diametre, diametre)
|
||||
).fill()
|
||||
# carré arrondi blanc au centre = « arrêter »
|
||||
side = diametre * 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, m):
|
||||
col = _rgba(CONFIG["mic_rgb"])
|
||||
col.set()
|
||||
largeur = m["mic_largeur"]
|
||||
body_h = m["mic_corps_hauteur"]
|
||||
body_y = cy - body_h / 2 + body_h * 0.23
|
||||
# capsule (le micro lui-meme)
|
||||
_rounded(cx - largeur / 2, body_y, largeur, body_h, largeur / 2).fill()
|
||||
# arceau en U qui passe SOUS la capsule
|
||||
arc = NSBezierPath.bezierPath()
|
||||
arc.appendBezierPathWithArcWithCenter_radius_startAngle_endAngle_(
|
||||
(cx, body_y + body_h * 0.23), m["mic_arceau_rayon"], 195.0, 345.0
|
||||
)
|
||||
arc.setLineWidth_(max(1.0, body_h * 0.12))
|
||||
arc.setLineCapStyle_(1) # bouts arrondis
|
||||
arc.stroke()
|
||||
# pied vertical sous l'arceau
|
||||
pied = max(1.2, largeur * 0.18)
|
||||
_rounded(cx - pied / 2, body_y - body_h * 0.62, pied, body_h * 0.31, pied / 2).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 + CONFIG["bar_height"] * 0.46 # 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
|
||||
|
||||
m = bar_metrics(h)
|
||||
pad, stop_d, mic_w, ecart = (m["marge_laterale"], m["stop_diametre"],
|
||||
m["mic_largeur"], m["ecart_interne"])
|
||||
self._draw_bar_background(x, y, w, h)
|
||||
self._draw_stop_button(x + pad + stop_d / 2, cy, stop_d)
|
||||
self._draw_mic(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(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
|
||||
self._garde = None # ferme l'overlay quoi qu'il arrive (cf show)
|
||||
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
|
||||
)
|
||||
# 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 (2026-08-21).
|
||||
self._garde = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
|
||||
float(CONFIG["duree_max_overlay_s"]), self, "_expiration:", None, False
|
||||
)
|
||||
|
||||
def rearmer_garde(self, duree_s):
|
||||
"""Remplace la garde en cours par une plus courte. Thread principal.
|
||||
|
||||
Appelee 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.invalidate()
|
||||
self._garde = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
|
||||
float(duree_s), self, "_expiration:", None, False
|
||||
)
|
||||
|
||||
def _expiration_(self, timer):
|
||||
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.invalidate()
|
||||
self._timer = None
|
||||
if self._garde is not None:
|
||||
self._garde.invalidate()
|
||||
self._garde = None
|
||||
if self._window is not None:
|
||||
self._window.orderOut_(None)
|
||||
with self._lock:
|
||||
self._status = ""
|
||||
Reference in New Issue
Block a user