Files
dikte/overlay.py
T
yusufipk c3f22f0794 Stop the indicator from making the window behind it flinch
Hiding the indicator unmapped its window, so KWin tore that window down and
built a new one for the next dictation. On a tiled desktop the compositor
repainted whatever sat underneath as it went, and the terminal behind
visibly flinched every time a transcript landed. Measured with a KWin
script: five show-and-hide rounds raised five windowAdded and five
windowRemoved events, where there is now one of each for the life of the
process.

So the window stays mapped and paints nothing while idle. That has to be a
real repaint rather than zero opacity: with the animation timer stopped
nothing else damages the surface, and the stale frame sat on the screen
until something unrelated, moving the mouse, made the compositor redraw it.

The timer was also left running after the indicator went away, ticking
thirty times a second over a window nobody could see. It stops now, and the
finished message lingers for two seconds rather than one.
2026-07-26 12:54:23 +07:00

270 lines
9.6 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)
STATE_COLORS = {"recording": REC, "busy": BUSY, "done": OK,
"warning": WARN, "error": ERR}
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.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):
self.state = "recording"
self.message = ""
self.seconds = 0.0
self.levels = [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 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 == "recording":
width = MIN_WIDTH
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 == "recording":
# keep the ribbon moving even through a pause in speech
self.levels = self.levels[1:] + [self.levels[-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 == "recording":
self._draw_waveform(painter)
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 == "recording":
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 _draw_waveform(self, painter):
left, right = 46.0, self.width() - 58.0
span = right - left
bar_w = 2.6
gap = (span - BARS * bar_w) / max(1, BARS - 1)
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
x = left + i * (bar_w + gap)
color = QColor(REC if shaped > 0.04 else MUTED)
color.setAlphaF(0.35 + 0.65 * min(1.0, shaped * 2.2))
painter.setBrush(color)
painter.drawRoundedRect(QRectF(x, mid - h / 2, 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)
painter.drawText(
QRectF(self.width() - 56, 0, 44, self.height()),
int(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight),
f"{mins}:{secs:02d}",
)
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
)