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()