"""The small recording indicator that appears in a screen corner without taking focus.""" import math from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QFontMetrics from PyQt6.QtWidgets import QWidget, QApplication BARS = 22 HEIGHT = 56 MIN_WIDTH = 210 MAX_WIDTH = 460 MARGIN = 28 BG = QColor(22, 24, 29, 238) BORDER = QColor(255, 255, 255, 28) TEXT = QColor(235, 237, 242) MUTED = QColor(150, 156, 168) REC = QColor(240, 78, 82) BUSY = QColor(120, 170, 255) OK = QColor(80, 205, 140) ERR = QColor(240, 100, 90) WARN = QColor(240, 180, 80) THEM = QColor(110, 190, 255) # the other side of a meeting ASK = QColor(150, 140, 255) # recording a command rather than a dictation STATE_COLORS = {"recording": REC, "asking": ASK, "meeting": REC, "busy": BUSY, "done": OK, "warning": WARN, "error": ERR} LIVE = ("recording", "asking", "meeting") class Overlay(QWidget): def __init__(self, corner="bottom-left"): super().__init__(None) self.corner = corner self.state = "idle" self.message = "" self.levels = [0.0] * BARS self.levels2 = [0.0] * BARS # the other side, while a meeting records self.seconds = 0.0 self._phase = 0.0 self._concealed = True self.setWindowFlags( Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool | Qt.WindowType.WindowDoesNotAcceptFocus | Qt.WindowType.X11BypassWindowManagerHint ) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating) self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.resize(MIN_WIDTH, HEIGHT) self._anim = QTimer(self) self._anim.setInterval(33) self._anim.timeout.connect(self._tick) self._hide_timer = QTimer(self) self._hide_timer.setSingleShot(True) self._hide_timer.timeout.connect(self._conceal) # ---- public API -------------------------------------------------- def show_recording(self, asking=False): """The same ribbon either way, in a different colour when what is being recorded is a command for Claude rather than something to paste.""" self.state = "asking" if asking else "recording" self.message = "" self.seconds = 0.0 self.levels = [0.0] * BARS self._hide_timer.stop() self._appear() def show_meeting(self): """Both channels at once: your voice up, the other side down.""" self.state = "meeting" self.message = "" self.seconds = 0.0 self.levels = [0.0] * BARS self.levels2 = [0.0] * BARS self._hide_timer.stop() self._appear() def show_busy(self, message): self.state = "busy" self.message = message self._hide_timer.stop() self._appear() def show_done(self, message="", msec=2000): self.state = "done" self.message = message self._appear() self._hide_timer.start(msec) def show_warning(self, message, msec=9000): """Finished, but something the user should know about went wrong.""" self.state = "warning" self.message = message self._appear() self._hide_timer.start(msec) def show_error(self, message, msec=6000): self.state = "error" self.message = message self._appear() self._hide_timer.start(msec) def dismiss(self): self._hide_timer.stop() self._conceal() def push_level(self, level): self.levels = self.levels[1:] + [level] def push_levels(self, mine, theirs): self.levels = self.levels[1:] + [mine] self.levels2 = self.levels2[1:] + [theirs] def set_seconds(self, seconds): self.seconds = seconds # ---- internals ----------------------------------------------------- def _appear(self): self._resize_to_content() self._reposition() if not self.isVisible(): self.show() if self._concealed: self.raise_() self._concealed = False if not self._anim.isActive(): self._anim.start() def _conceal(self): """Empty the window out instead of unmapping it. Unmapping tears the window down and the next dictation builds a new one, which makes the compositor repaint whatever sits underneath: on a tiled desktop the terminal behind visibly flinches every time the indicator goes away. So the window stays mapped and simply paints nothing. It has to be a real repaint, not just zero opacity: with the animation stopped nothing else damages the surface, and the stale frame would sit on the screen until some other event made the compositor redraw it. """ self._anim.stop() self.state = "hidden" self._concealed = True self.repaint() def _resize_to_content(self): if self.state in LIVE: # A meeting runs long enough to need an hours field. width = MIN_WIDTH + (24 if self.state == "meeting" else 0) else: metrics = QFontMetrics(self._label_font()) width = max(MIN_WIDTH, min(MAX_WIDTH, metrics.horizontalAdvance(self.message) + 76)) self.resize(width, HEIGHT) def _reposition(self): # On a multi-monitor setup, show up where the user actually is. screen = QApplication.screenAt(QCursor.pos()) or QApplication.primaryScreen() area = screen.availableGeometry() left = "left" in self.corner top = "top" in self.corner x = area.left() + MARGIN if left else area.right() - self.width() - MARGIN y = area.top() + MARGIN if top else area.bottom() - self.height() - MARGIN self.move(int(x), int(y)) def _tick(self): self._phase += 0.12 if self.state in LIVE: # keep the ribbon moving even through a pause in speech self.levels = self.levels[1:] + [self.levels[-1] * 0.72] if self.state == "meeting": self.levels2 = self.levels2[1:] + [self.levels2[-1] * 0.72] self.update() def _label_font(self): font = QFont(self.font()) font.setPointSizeF(10.5) return font # ---- painting -------------------------------------------------- def paintEvent(self, _event): if self.state == "hidden": return # translucent window, nothing drawn means nothing shown painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) rect = QRectF(0.5, 0.5, self.width() - 1, self.height() - 1) path = QPainterPath() path.addRoundedRect(rect, 15, 15) painter.fillPath(path, BG) painter.setPen(QPen(BORDER, 1)) painter.drawPath(path) accent = STATE_COLORS.get(self.state, MUTED) self._draw_indicator(painter, accent) if self.state in LIVE: self._draw_waveform(painter, accent) self._draw_time(painter) else: self._draw_message(painter) def _draw_indicator(self, painter, accent): cx, cy = 26.0, self.height() / 2 painter.setPen(Qt.PenStyle.NoPen) if self.state in LIVE: pulse = 0.62 + 0.38 * (0.5 + 0.5 * math.sin(self._phase * 1.6)) glow = QColor(accent) glow.setAlphaF(0.22 * pulse) painter.setBrush(glow) painter.drawEllipse(QPointF(cx, cy), 13 * pulse, 13 * pulse) painter.setBrush(accent) painter.drawEllipse(QPointF(cx, cy), 5.5, 5.5) elif self.state == "busy": painter.setBrush(Qt.BrushStyle.NoBrush) pen = QPen(QColor(accent), 2.4) pen.setCapStyle(Qt.PenCapStyle.RoundCap) painter.setPen(pen) span = 100 * 16 start = int(-self._phase * 320) % (360 * 16) painter.drawArc(QRectF(cx - 8, cy - 8, 16, 16), start, span) elif self.state == "done": pen = QPen(accent, 2.4) pen.setCapStyle(Qt.PenCapStyle.RoundCap) pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) painter.setPen(pen) painter.drawPolyline( QPointF(cx - 7, cy), QPointF(cx - 2, cy + 5.5), QPointF(cx + 7.5, cy - 6) ) elif self.state == "warning": pen = QPen(accent, 2.6) pen.setCapStyle(Qt.PenCapStyle.RoundCap) painter.setPen(pen) painter.drawLine(QPointF(cx, cy - 7), QPointF(cx, cy + 1.5)) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(accent) painter.drawEllipse(QPointF(cx, cy + 6), 1.5, 1.5) else: # error pen = QPen(accent, 2.4) pen.setCapStyle(Qt.PenCapStyle.RoundCap) painter.setPen(pen) painter.drawLine(QPointF(cx - 6, cy - 6), QPointF(cx + 6, cy + 6)) painter.drawLine(QPointF(cx + 6, cy - 6), QPointF(cx - 6, cy + 6)) def _bars(self): """(x of the first bar, bar width, distance between two bars).""" # A meeting's clock carries an hours field, so it needs more room and # the ribbon has to stop earlier. left = 46.0 right = self.width() - (74.0 if self.state == "meeting" else 58.0) bar_w = 2.6 gap = (right - left - BARS * bar_w) / max(1, BARS - 1) return left, bar_w, bar_w + gap @staticmethod def _bar_colour(shaped, accent): color = QColor(accent if shaped > 0.04 else MUTED) color.setAlphaF(0.35 + 0.65 * min(1.0, shaped * 2.2)) return color def _draw_waveform(self, painter, accent=REC): if self.state == "meeting": self._draw_dual_waveform(painter) return left, bar_w, step = self._bars() mid = self.height() / 2 painter.setPen(Qt.PenStyle.NoPen) for i, level in enumerate(self.levels): shaped = min(1.0, level ** 0.55) h = 3.0 + shaped * 26.0 painter.setBrush(self._bar_colour(shaped, accent)) painter.drawRoundedRect( QRectF(left + i * step, mid - h / 2, bar_w, h), 1.3, 1.3 ) def _draw_dual_waveform(self, painter): """Your microphone above the line, what the speakers play below it. Seeing both move is the whole check that a meeting is being captured properly: one silent half means that side is not reaching the recording. """ left, bar_w, step = self._bars() mid = self.height() / 2 painter.setPen(Qt.PenStyle.NoPen) for i, (mine, theirs) in enumerate(zip(self.levels, self.levels2)): x = left + i * step for level, accent, up in ((mine, REC, True), (theirs, THEM, False)): shaped = min(1.0, level ** 0.55) h = 2.0 + shaped * 12.0 y = mid - 1.5 - h if up else mid + 1.5 painter.setBrush(self._bar_colour(shaped, accent)) painter.drawRoundedRect(QRectF(x, y, bar_w, h), 1.3, 1.3) def _draw_time(self, painter): font = QFont(self.font()) font.setPointSizeF(10.0) font.setFamilies(["monospace"]) painter.setFont(font) painter.setPen(MUTED) mins, secs = divmod(int(self.seconds), 60) hours, mins = divmod(mins, 60) text = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}" width = 62 if hours else 44 painter.drawText( QRectF(self.width() - width - 12, 0, width, self.height()), int(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight), text, ) def _draw_message(self, painter): painter.setFont(self._label_font()) painter.setPen({"error": ERR, "warning": WARN}.get(self.state, TEXT)) box = QRectF(46, 0, self.width() - 60, self.height()) metrics = QFontMetrics(self._label_font()) text = metrics.elidedText(self.message, Qt.TextElideMode.ElideRight, int(box.width())) painter.drawText( box, int(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft), text )