mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Dictation ends the same way every time: the words you said land in the window you were in. But half of what you say to a machine is not text to place, it is something to do, and the answer to "what is on my calendar today" is not that sentence written out. `claude -p` is that session run without a window: the same skills, the same connected services, the same account. So a second shortcut records exactly as the first one does and sends the transcript there instead, and what comes back is pasted where the transcript would have been. Which is what puts "save that to my calendar on Thursday at three" inside a text field that has nothing to do with Claude. Permissions are the part worth being deliberate about, because nothing here can answer a prompt: a mode that would have asked denies instead. It runs in `auto`, which decides for itself with the prompt-injection checks left on, and the other two modes are one setting away. What Claude was not allowed to touch is reported rather than swallowed, since a reply that reads perfectly normal is otherwise the only sign that the job did not happen. Commands close together stay in one conversation, so "and move that to Thursday" knows what "that" is; half an hour of silence ends it, as does the tray. The answer is written to be read where it lands: no headings, no lists, and a sentence naming what was done when something was done. Its output is read as it streams rather than waited out, so the corner names the tool it is on. A calendar lookup took 29 s in testing, which a still indicator would have made indistinguishable from a hang. The clock and the stop button are watched from a thread of their own, because the stream blocks between lines and a model that thinks for a minute sends none: they end the run by killing the process, which closes the stream and unwinds everything else on its own. Transcript cleanup is off on this path. Claude reads through "erm" and "hani" without help, and skipping it saves an API call and a second or two in front of a screen you are standing at.
330 lines
12 KiB
Python
330 lines
12 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
|
|
|
|
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
|
|
)
|