From 721c06b8d6b8bec6eef4bb88a2418684b5026b4a Mon Sep 17 00:00:00 2001 From: Neil Chudleigh <5580824+nchudleigh@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:00:19 -0500 Subject: [PATCH] Show Superwhisper's recording glow on the launcher While the launcher is open it holds the keyboard, so dictation lands in it, but Superwhisper drew its recording glow (or pill) on the window underneath. A small helper, services/superwhisper-frame.py, owns com.superwhisper.ShellOverlay while the launcher is open and publishes the card's monitor and geometry as its Frame property; Superwhisper's indicator then follows the card instead of the focused window, and returns to the window when the launcher closes. Without Superwhisper or PyGObject it idles. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +++ services/superwhisper-frame.py | 77 ++++++++++++++++++++++++++++++++++ ui/LauncherWindow.qml | 16 +++++++ 3 files changed, 98 insertions(+) create mode 100644 services/superwhisper-frame.py diff --git a/README.md b/README.md index 5900ced..f0bff37 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,11 @@ and `~/.local/state/omacast`. omacast needs only what Omarchy ships: `omarchy-shell` (Quickshell), Hyprland, bash, `jq` and `socat`. It adds no packages and no background services. +- **Superwhisper recording indicator.** When `python3` with PyGObject is installed, the + plugin runs `services/superwhisper-frame.py` alongside the shell. While the launcher is open it tells + [Superwhisper](https://superwhisper.com) where the card is over the session bus + (`com.superwhisper.ShellOverlay`), so Superwhisper's recording glow or pill draws on the launcher instead + of the window under it. It makes no network requests, and does nothing without Superwhisper. - **Currency rates.** When you open the launcher and its rates are over 12 hours old, the calculator downloads ECB exchange rates from `api.frankfurter.dev` (retrying 30 minutes after a failure) and caches them in `~/.cache/omacast/currencies.json`. This is the only network request omacast makes. Turn it off with diff --git a/services/superwhisper-frame.py b/services/superwhisper-frame.py new file mode 100644 index 0000000..b591fd1 --- /dev/null +++ b/services/superwhisper-frame.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Tells Superwhisper where the launcher's card is, so its recording glow (or +pill) draws on the launcher rather than on the window under it. + +stdin takes one line per change: "1 " while +the launcher is open (logical pixels from the monitor's top left), "0" when it +closes. While open this owns com.superwhisper.ShellOverlay and publishes the +card as the Frame property (siiii) of com.superwhisper.ShellOverlay at +/com/superwhisper/ShellOverlay. Exits when stdin closes, releasing the name. +""" +import os +import sys + +import gi + +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib # noqa: E402 + +NAME = INTERFACE = "com.superwhisper.ShellOverlay" +PATH = "/com/superwhisper/ShellOverlay" +XML = f""" + +""" +EMPTY = ("", 0, 0, 0, 0) + +bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) +loop = GLib.MainLoop() +frame = EMPTY +owner = 0 +pending = b"" + + +def value(): + return GLib.Variant("(siiii)", frame) + + +def get_property(*_): + return value() + + +def apply(line): + global frame, owner + words = line.split() + if words[:1] == ["1"] and len(words) == 6: + try: + new = (words[1], *(int(float(w)) for w in words[2:])) + except ValueError: + return + else: + new = EMPTY + if new != frame: + frame = new + bus.emit_signal(None, PATH, "org.freedesktop.DBus.Properties", "PropertiesChanged", + GLib.Variant("(sa{sv}as)", (INTERFACE, {"Frame": value()}, []))) + if frame != EMPTY and not owner: + owner = Gio.bus_own_name_on_connection(bus, NAME, Gio.BusNameOwnerFlags.NONE, None, None) + elif frame == EMPTY and owner: + Gio.bus_unown_name(owner) + owner = 0 + + +def command(fd, _condition): + global pending + data = os.read(fd, 4096) + if not data: + loop.quit() + return False + *lines, pending = (pending + data).split(b"\n") + if lines: + apply(lines[-1].decode(errors="replace")) + return True + + +info = Gio.DBusNodeInfo.new_for_xml(XML).interfaces[0] +bus.register_object(PATH, info, None, get_property, None) +GLib.io_add_watch(sys.stdin.fileno(), GLib.PRIORITY_DEFAULT, GLib.IO_IN | GLib.IO_HUP, command) +loop.run() diff --git a/ui/LauncherWindow.qml b/ui/LauncherWindow.qml index 618a8ad..ea8d357 100644 --- a/ui/LauncherWindow.qml +++ b/ui/LauncherWindow.qml @@ -1,5 +1,6 @@ import QtQuick import Quickshell +import Quickshell.Io import Quickshell.Wayland import qs.Commons import qs.Commons as Commons @@ -141,6 +142,21 @@ PanelWindow { else { popTimer.interval = seconds * 1000; popTimer.restart() } } + // Where the card is while open, for Superwhisper: its recording glow (or + // pill) then draws on the launcher, as on any focused window, instead of on + // the window under it. Idle without Superwhisper or PyGObject. + readonly property string overlayFrame: window.shown && window.screen + ? ["1", window.screen.name, Math.round(card.x), Math.round(card.y), Math.round(card.width), Math.round(card.height)].join(" ") + : "0" + onOverlayFrameChanged: superwhisperFrame.write(window.overlayFrame + "\n") + Process { + id: superwhisperFrame + command: ["python3", decodeURIComponent(Qt.resolvedUrl("../services/superwhisper-frame.py").toString().replace(/^file:\/\//, ""))] + running: true + stdinEnabled: true + onStarted: superwhisperFrame.write(window.overlayFrame + "\n") + } + Timer { id: popTimer onTriggered: window.popToRoot()