Files
dikte/overlay.py
T
yusufipk e2551343e4 Run a dictation and a command to the agent at the same time
The two shared one state machine, which meant an agent thinking for a minute
was a minute in which nothing could be dictated. That is the wrong trade: the
work has nothing in common past the microphone, and the microphone is busy for
the seconds you are speaking, not the minute afterwards.

So they are two flows now, each with its own state, its own pipeline and its own
indicator. Either can be working while the other records. Only the recorder is
taken in turns, because there is one of it: whichever asks second is refused
while the first holds it, and the menu entry greys out to say so rather than
failing quietly. "Am I recording" is read off the two states rather than off the
recorder's owner, which outlives the recording by the moment it takes the audio
to arrive, and would otherwise report a microphone as busy after it was free.

Two indicators in one corner would sit on top of each other, so an indicator can
be told what it stacks on: it offsets by that one's height while it is showing
and drops back into the corner when it goes, which the animation timer notices
without anything having to tell it.

Pasting is now under a lock. It is three steps rather than one, read the
clipboard, write ours, press the key, and two runs finishing together would
paste one answer and restore the other's clipboard over it.

Calling off the agent is its own menu entry and its own command, instead of a
cancel that meant different things depending on what was running. The tray icon
still ends whichever recording is going.
2026-07-28 18:29:48 +07:00

352 lines
13 KiB
Python

"""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
GAP = 10 # between two indicators sharing a corner
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):
"""One indicator. Give it `below` and it stacks on top of that one instead
of covering it, which is what lets a dictation and a command to the agent be
under way at the same time and still both be visible."""
def __init__(self, corner="bottom-left", below=None):
super().__init__(None)
self.corner = corner
self.below = below
self._stacked = False
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()
@property
def showing(self):
"""Mapped and actually painting something. The window stays mapped while
idle, so isVisible() alone would always say yes."""
return self.isVisible() and not self._concealed
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
self._stacked = self.below is not None and self.below.showing
# Stack away from the edge the corner sits on, so the pair grows into the
# screen rather than off it.
step = (self.below.height() + GAP) if self._stacked else 0
x = area.left() + MARGIN if left else area.right() - self.width() - MARGIN
y = (area.top() + MARGIN + step if top
else area.bottom() - self.height() - MARGIN - step)
self.move(int(x), int(y))
def _tick(self):
self._phase += 0.12
# The one underneath can come and go while this one is up; drop back to
# the corner when it does rather than leaving a gap where it was.
if self.below is not None and self.below.showing != self._stacked:
self._reposition()
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
)