Build the Panama Hyprland desktop

This commit is contained in:
Gabriel Brown
2026-08-17 10:32:55 -04:00
parent 67033f2a31
commit 5248883e4b
190 changed files with 18554 additions and 34 deletions
@@ -0,0 +1,39 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// Idle inhibit — the replacement for the GNOME Caffeine extension.
//
// Implemented with systemd-inhibit rather than the Wayland idle-inhibit
// protocol because the Wayland version is scoped to a surface (it would only
// hold while some window of ours is mapped) and it cannot block *sleep*, only
// the screensaver. A logind lock covers both, and outlives any UI.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
// Writable: the quick-settings tile and the bar indicator both bind to it.
property bool enabled: false
function toggle(): void {
root.enabled = !root.enabled;
}
// `sleep infinity` is the process the lock is held *for*: systemd releases
// the inhibitor when its child exits, so stopping this Process (SIGTERM)
// is all that's needed to drop it.
Process {
command: ["systemd-inhibit", "--what=idle:sleep", "--mode=block", "--who=Panama", "--why=Caffeine", "sleep", "infinity"]
running: root.enabled
onExited: (code, status) => {
// Only interesting if it died while we still wanted the lock.
if (root.enabled)
console.warn("Caffeine: systemd-inhibit exited unexpectedly, code", code);
}
}
}
+446
View File
@@ -0,0 +1,446 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// Screenshot / screen-recording engine.
//
// This is the whole non-visual half of the GNOME 42 screenshot experience:
// freeze the screen, hand the frozen frame to the picker UI, then run grim or
// wf-recorder against whatever region the user chose.
//
// The UI (modules/capture) only ever reads state from here and calls the
// shoot*/record* functions -- it never spawns a process itself.
//
// Verified against the installed tools on 2026-08-17:
// grim 1.x -g "<X>,<Y> <W>x<H>" <- SPACE before WxH, not a comma.
// "0,0,100x100" is rejected with
// "invalid geometry".
// -s <factor> output image scale
// -c include the cursor
// wf-recorder -g "<X>,<Y> <W>x<H>" same format as grim
// no cursor toggle in this build; the pointer is always
// recorded, so "Show Pointer" only affects screenshots.
// notify-send -A NAME=Label implies --wait and prints NAME on stdout,
// which is how the notification
// actions below are dispatched.
// satty 0.22 --filename / --output-filename / --early-exit
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import QtQuick
import qs.config
import qs.services
Singleton {
id: root
// ── Picker state (bound by modules/capture) ─────────────────────────────
// "screen" | "window" | "selection"
property string mode: "screen"
// Capture action. The booleans stay explicit because the existing picker
// reads them directly, while the setters guarantee only one special mode
// can be active at a time.
property bool recordMode: false
property bool intelligenceMode: false
// GNOME's "Show Pointer" checkbox. Screenshots only -- see the note above.
property bool showPointer: false
// file:// URL of the frozen frame the overlay paints as its backdrop, or
// "" while the grab is still in flight / after it failed.
readonly property string freezeUrl: root._freezePath === "" ? "" : "file://" + root._freezePath
// Top-left of the captured layout in Hyprland's global coordinate space.
// Everything the picker reports back is layout-global; the overlay
// subtracts these to get window-local pixels.
property real originX: 0
property real originY: 0
// Windows on the focused workspace, newest-focused first, as
// { address, class, title, x, y, w, h } in layout-global logical pixels.
property var windows: []
// ── Recording state (read by the bar / recording indicator) ─────────────
readonly property bool recording: recProc.running
property int recordingSeconds: 0
property string recordingPath: ""
// ── Coordinate space ────────────────────────────────────────────────────
// `hyprctl -j clients` reports `at`/`size` in Hyprland's *logical* layout
// coordinates (the 3000x2000 space on this 1.5x display), and grim's and
// wf-recorder's -g flags consume the same logical space -- so no scaling is
// needed anywhere and this stays 1.0. It exists because that is the one
// thing here that could not be verified without a live Hyprland session: if
// window outlines land at 2/3 size, set it to 1.5 and the whole pipeline
// (outlines and grim geometry alike) corrects together.
property real coordinateScale: 1.0
// ── Paths ───────────────────────────────────────────────────────────────
readonly property string _home: Quickshell.env("HOME") || "/home/gib"
readonly property string shotDir: root._home + "/" + Settings.screenshotDir
readonly property string recDir: root._home + "/" + Settings.recordingDir
property string _freezePath: ""
property string _prevFreezePath: ""
function _stamp(): string {
return Qt.formatDateTime(new Date(), "yyyy-MM-dd HH-mm-ss");
}
// grim/wf-recorder geometry string, or "" for "the whole thing".
function _geom(x: real, y: real, w: real, h: real): string {
const s = root.coordinateScale;
return Math.round(x * s) + "," + Math.round(y * s) + " " + Math.round(w * s) + "x" + Math.round(h * s);
}
// ── Public API (driven by shell.qml's IpcHandler) ───────────────────────
// Open the GNOME-style picker. The frozen frame is grabbed *before* the
// overlay is shown, otherwise the overlay would appear in its own backdrop.
function open(): void {
if (root.recording) {
// Print while recording = stop, matching the way GNOME's indicator
// behaves. The lead binds this to the same key.
root.stopRecording();
return;
}
if (ShellState.captureOpen) {
// Print again while the picker is up dismisses it. Re-grabbing here
// would freeze a screen with the picker already in it.
root.close();
return;
}
if (ScreenIntelligence.visible)
ScreenIntelligence.close();
root.refreshWindows();
root._grabFreeze();
}
function openIntelligence(): void {
root.mode = "selection";
root.selectIntelligence();
root.open();
}
function selectScreenshot(): void {
root.recordMode = false;
root.intelligenceMode = false;
}
function selectRecording(): void {
root.recordMode = true;
root.intelligenceMode = false;
}
function selectIntelligence(): void {
root.recordMode = false;
root.intelligenceMode = true;
}
function close(): void {
ShellState.close();
root._dropFreeze();
}
// Immediate whole-screen screenshot, no UI. (Shift+Print)
function screenNow(): void {
root.shootRegion("");
}
// Immediate active-window screenshot, no UI. (Alt+Print)
function windowNow(): void {
activeWindowProc.running = false;
activeWindowProc.running = true;
}
function stopRecording(): void {
if (!recProc.running)
return;
// SIGINT, never SIGKILL: wf-recorder needs to flush and write the
// container trailer or the file is unplayable.
recProc.signal(2);
}
// ── Execution ───────────────────────────────────────────────────────────
// geom: "" for the full output, otherwise a grim geometry string.
function shootRegion(geom: string): void {
// Capital "From" matches the files GNOME already left in
// ~/Pictures/Screenshots, so old and new shots sort together.
const name = "Screenshot From " + root._stamp() + ".png";
const args = ["sh", "-c", root._shotScript, "qs-capture", root.shotDir, name];
if (root.showPointer)
args.push("-c");
if (geom !== "")
args.push("-g", geom);
else if (root._outputName !== "")
args.push("-o", root._outputName);
Quickshell.execDetached(args);
}
function recordRegion(geom: string): void {
if (recProc.running)
return;
// .mp4 rather than GNOME's .webm because Settings.recorderArgs encodes
// h264 on the AMD VAAPI device.
const name = "Screencast From " + root._stamp() + ".mp4";
root.recordingPath = root.recDir + "/" + name;
// mkdir + exec so the PID we later SIGINT is wf-recorder itself and not
// the shell wrapping it.
let cmd = ["sh", "-c", 'mkdir -p "$1" && shift && exec "$@"', "qs-capture", root.recDir, "wf-recorder", "-y"];
cmd = cmd.concat(Settings.recorderArgs.split(" ").filter(a => a !== ""));
if (geom !== "")
cmd.push("-g", geom);
else if (root._outputName !== "")
cmd.push("-o", root._outputName);
cmd.push("-f", root.recordingPath);
root.recordingSeconds = 0;
recProc.command = cmd;
recProc.running = true;
}
// Convenience wrappers used by the overlay, in layout-global logical px.
function shootRect(x: real, y: real, w: real, h: real): void {
root.shootRegion(root._geom(x, y, w, h));
}
function recordRect(x: real, y: real, w: real, h: real): void {
root.recordRegion(root._geom(x, y, w, h));
}
// Fire whatever the picker currently has selected, then dismiss it.
// rect is null for "the whole output".
function commit(rect: var): void {
ShellState.close();
root._dropFreeze();
// Let the overlay actually unmap before anything reads the screen,
// otherwise the dimming layer ends up in the picture.
commitDelay.rect = rect;
commitDelay.record = root.recordMode;
commitDelay.intelligence = root.intelligenceMode;
commitDelay.restart();
}
// ── Window enumeration ──────────────────────────────────────────────────
// Quickshell's HyprlandToplevel exposes no geometry, so this shells out.
function refreshWindows(): void {
clientsProc.running = false;
clientsProc.running = true;
}
readonly property string _outputName: {
const m = Hyprland.focusedMonitor;
return m ? m.name : "";
}
// ── Internals ───────────────────────────────────────────────────────────
// Shot pipeline: capture, copy to the clipboard, then notify with the
// GNOME-style follow-up actions. Runs detached so a lingering
// `notify-send --wait` never blocks the shell.
// $1 = directory, $2 = filename, $3.. = extra grim args
readonly property string _shotScript: 'd=$1; n=$2; shift 2
mkdir -p "$d" || exit 1
p="$d/$n"
if ! grim "$@" "$p"; then
notify-send -a Screenshot -u critical "Screenshot failed" "grim could not capture the screen"
exit 1
fi
wl-copy --type image/png < "$p"
qs ipc call status-events screenshot "$p" >/dev/null 2>&1 || true
act=$(notify-send -a Screenshot -i "$p" -A open=Open -A annotate=Annotate -A folder=Folder "Screenshot captured" "$n")
case "$act" in
open) xdg-open "$p" ;;
annotate) satty --filename "$p" --output-filename "$p" --copy-command wl-copy --early-exit all ;;
folder) xdg-open "$d" ;;
esac'
readonly property string _recDoneScript: 'p=$1
d=$(dirname "$p")
act=$(notify-send -a Screencast -A open=Open -A folder=Folder "Screen recording saved" "$(basename "$p")")
case "$act" in
open) xdg-open "$p" ;;
folder) xdg-open "$d" ;;
esac'
function _grabFreeze(): void {
root._prevFreezePath = root._freezePath;
root._freezePath = "";
// Unique name per open: QML caches Image sources by URL, so reusing one
// path would repaint the previous frame.
const path = Quickshell.cachePath("capture-freeze-" + Date.now() + ".ppm");
// PPM, not PNG: this is a 4500x3000 grab and libpng would spend the
// better part of a second compressing an image we throw away seconds
// later. PPM is a raw dump, so the freeze appears immediately.
// -o pins the grab to the focused output so the picker's coordinate
// space is exactly this window's.
const cmd = ["sh", "-c", 'mkdir -p "$(dirname "$1")" && d=$1 && shift && exec grim -t ppm "$@" "$d"', "qs-capture", path];
if (root._outputName !== "")
cmd.push("-o", root._outputName);
freezeProc.pendingPath = path;
freezeProc.command = cmd;
freezeProc.running = false;
freezeProc.running = true;
}
function _dropFreeze(): void {
const stale = root._freezePath;
root._freezePath = "";
if (stale !== "")
Quickshell.execDetached(["rm", "-f", stale]);
}
Timer {
id: commitDelay
property var rect: null
property bool record: false
property bool intelligence: false
// A handful of frames at 60Hz, enough for the compositor to recomposite
// the output without the overlay on it.
interval: 90
onTriggered: {
const r = commitDelay.rect;
const geom = r ? root._geom(r.x, r.y, r.width, r.height) : "";
if (commitDelay.intelligence)
ScreenIntelligence.analyzeRegion(geom, root._outputName);
else if (commitDelay.record)
root.recordRegion(geom);
else
root.shootRegion(geom);
commitDelay.rect = null;
}
}
Process {
id: freezeProc
property string pendingPath: ""
onExited: (code, status) => {
if (code === 0) {
root._freezePath = freezeProc.pendingPath;
} else {
// No wlr-screencopy (or no compositor at all): still open the
// picker so Selection/Window mode remain usable, just without
// the frozen backdrop.
root._freezePath = "";
console.warn("Capture: grim freeze failed with exit code", code);
}
if (root._prevFreezePath !== "") {
Quickshell.execDetached(["rm", "-f", root._prevFreezePath]);
root._prevFreezePath = "";
}
ShellState.open("capture");
}
}
Process {
id: clientsProc
command: ["hyprctl", "-j", "clients"]
stdout: StdioCollector {
onStreamFinished: {
const focused = Hyprland.focusedWorkspace;
let out = [];
try {
const raw = JSON.parse(this.text);
for (const c of raw) {
if (!c.mapped || c.hidden)
continue;
if (!c.size || c.size[0] <= 0 || c.size[1] <= 0)
continue;
// Only the workspace the user is looking at. If
// Hyprland hasn't reported a focused workspace yet,
// show everything rather than nothing.
if (focused && c.workspace && c.workspace.id !== focused.id)
continue;
out.push({
address: c.address || "",
appId: c.class || "",
title: c.title || "",
order: c.focusHistoryID === undefined ? 999 : c.focusHistoryID,
x: c.at[0],
y: c.at[1],
w: c.size[0],
h: c.size[1]
});
}
// Ascending focusHistoryID == front to back, so a hit test
// walking this list picks the topmost window first.
out.sort((a, b) => a.order - b.order);
} catch (e) {
// Not running under Hyprland, or hyprctl printed its
// "HYPRLAND_INSTANCE_SIGNATURE not set!" error. Window mode
// simply shows nothing instead of throwing.
out = [];
}
root.windows = out;
}
}
}
Process {
id: activeWindowProc
command: ["hyprctl", "-j", "activewindow"]
stdout: StdioCollector {
onStreamFinished: {
try {
const c = JSON.parse(this.text);
if (c && c.at && c.size && c.size[0] > 0)
root.shootRect(c.at[0], c.at[1], c.size[0], c.size[1]);
else
root.shootRegion("");
} catch (e) {
// No Hyprland / no focused window: fall back to the whole
// screen rather than doing nothing.
root.shootRegion("");
}
}
}
}
Process {
id: recProc
onExited: (code, status) => {
recTimer.stop();
// SIGINT gives exit code 2 (or 130 through a shell); both mean the
// user pressed stop and the file was finalised normally.
if (root.recordingPath !== "") {
StatusEvents.publish({
key: "capture-recording",
glyph: "\u{F044A}",
title: "Screen recording saved",
detail: root.recordingPath.split("/").pop(),
tone: "ok",
priority: StatusEvents.importantPriority,
actionId: "open-path",
actionData: root.recordingPath
});
Quickshell.execDetached(["sh", "-c", root._recDoneScript, "qs-capture", root.recordingPath]);
}
root.recordingPath = "";
root.recordingSeconds = 0;
}
onStarted: {
root.recordingSeconds = 0;
recTimer.start();
StatusEvents.publish({
key: "capture-recording",
glyph: "\u{F044A}",
title: "Screen recording started",
detail: "Panama is capturing the selected area",
tone: "danger",
priority: StatusEvents.criticalPriority,
actionId: "open-activity"
});
}
}
// Only ticks while a recording is live -- nothing in this shell repaints
// when idle.
Timer {
id: recTimer
interval: 1000
repeat: true
onTriggered: root.recordingSeconds += 1
}
}
@@ -0,0 +1,240 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// Clipboard history — read out of Vicinae's store.
//
// Vicinae (the launcher) already runs a clipboard watcher, so this service is
// deliberately a *reader* and nothing else. A second `wl-paste --watch` would
// record every copy twice and give the desktop two disagreeing opinions about
// which pastes are sensitive enough to conceal.
//
// Two files back every entry:
// ~/.local/share/vicinae/clipboard.db metadata + a 50-char preview
// ~/.local/share/vicinae/clipboard-data/<offer> the full payload, text or binary
//
// The stored preview is truncated far too short to fill a row, so the query
// pulls the first few hundred bytes out of the payload file instead (sqlite's
// readfile()) and falls back to the stored preview if that file has been reaped.
//
// Read-only and on demand: the query runs when refresh() is called, never on a
// timer. Nothing in here wakes up while the popover is closed.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
// ── Entry kinds ─────────────────────────────────────────────────────────
// Vicinae's `selection.kind` column, confirmed empirically by copying one
// of each: plain text, a URL, a PNG and a file drag.
readonly property int kindText: 1
readonly property int kindLink: 2
readonly property int kindImage: 3
readonly property int kindFile: 4
readonly property string dbPath: Quickshell.env("HOME") + "/.local/share/vicinae/clipboard.db"
readonly property string dataDir: Quickshell.env("HOME") + "/.local/share/vicinae/clipboard-data"
// How many rows to load. The popover scrolls, so this is a memory budget
// rather than a display limit; anything older is a job for the launcher.
readonly property int limit: 50
// Newest first, pinned entries hoisted above them. Each element:
// { id, offerId, mime, kind, source, host, pinned, ts, size,
// preview, encrypted, textual, typeLabel, dataPath }
property var entries: []
// True between refresh() and the query finishing. The popover uses it to
// tell "nothing copied yet" apart from "not loaded yet".
property bool loading: false
// False if the query failed outright — a missing database, or Vicinae not
// installed. Distinct from an empty history.
property bool available: true
// Wall-clock seconds at the moment `entries` was filled. Relative times are
// rendered against this rather than against a live clock, so a row's label
// cannot change while the user is reading it and nothing has to tick.
property real queriedAt: 0
signal refreshed
// Reload the history. Cheap enough to call on every popover open; a call
// made while a query is already in flight is dropped rather than queued,
// because the in-flight result is already at most milliseconds stale.
function refresh(): void {
if (query.running)
return;
root.loading = true;
query.running = true;
}
// Put an entry back on the clipboard. The payload file is the source of
// truth — the preview in `entries` is truncated, so copying that back would
// silently hand the user a fragment of what they asked for.
function copyEntry(id: string): void {
const entry = root.entries.find(e => e.id === id);
if (!entry || entry.encrypted)
return;
// Arguments after the -c script become $0..$n, so neither the path nor
// the mime type is ever spliced into shell source. wl-copy has no
// read-from-file flag, hence the redirect.
copy.command = ["sh", "-c", 'exec wl-copy --type "$2" < "$1"', "qs-clipboard", entry.dataPath, entry.mime];
copy.running = true;
}
// "just now" / "5m" / "2h" / "Mon" / "Mar 4", against the time the list was
// loaded. Deliberately terse: this sits in the corner of a dense row.
function relativeTime(ts: real): string {
const delta = Math.max(0, root.queriedAt - ts);
if (delta < 60)
return "just now";
if (delta < 3600)
return Math.floor(delta / 60) + "m";
if (delta < 86400)
return Math.floor(delta / 3600) + "h";
const date = new Date(ts * 1000);
if (delta < 7 * 86400)
return Qt.formatDateTime(date, "ddd");
return Qt.formatDateTime(date, "MMM d");
}
// ── The query ───────────────────────────────────────────────────────────
// -readonly so a click in the shell can never take a write lock on a
// database another process owns; -json because clipboard text is full of
// newlines, pipes and quotes and any separator we picked would appear in it.
//
// The payload file is only read for unencrypted text: casting an encrypted
// blob or a PNG to text yields noise, and those rows carry a usable label in
// `text_preview` already ("Image (60x60)").
readonly property string sql: `
select s.id as id,
o.id as offerId,
s.preferred_mime_type as mime,
s.kind as kind,
o.encryption_type as enc,
coalesce(s.source, '') as source,
coalesce(o.url_host, '') as host,
s.pinned_at is not null as pinned,
s.updated_at as ts,
o.size as size,
case when s.preferred_mime_type like 'text/%' and o.encryption_type = 0
then coalesce(cast(substr(readfile('${root._sqlLiteral(root.dataDir)}/' || o.id), 1, 400) as text),
o.text_preview)
else o.text_preview
end as preview
from selection s
join data_offer o
on o.selection_id = s.id and o.mime_type = s.preferred_mime_type
where o.id = (
select min(o2.id)
from data_offer o2
where o2.selection_id = s.id
and o2.mime_type = s.preferred_mime_type
)
order by s.pinned_at is null, s.pinned_at desc, s.updated_at desc
limit ${root.limit}`
Process {
id: query
command: ["sqlite3", "-readonly", "-json", root.dbPath, root.sql]
stdout: StdioCollector {
onStreamFinished: root._parse(this.text)
}
onExited: exitCode => {
root.loading = false;
if (exitCode !== 0) {
root.available = false;
root.entries = [];
root.refreshed();
}
}
}
Process {
id: copy
onExited: exitCode => {
// Silent success, loud failure: a copy that quietly did nothing
// leaves the user pasting whatever was there before.
if (exitCode !== 0)
console.warn("Clipboard: wl-copy exited", exitCode);
}
}
function _parse(text: string): void {
root.available = true;
root.queriedAt = Date.now() / 1000;
// sqlite3 -json prints nothing at all for an empty result set.
if (!text || !text.trim()) {
root.entries = [];
root.refreshed();
return;
}
try {
root.entries = JSON.parse(text).map(row => root._normalise(row));
} catch (e) {
console.warn("Clipboard: could not parse history —", e);
root.entries = [];
root.available = false;
}
root.refreshed();
}
// Turn a raw row into what the UI actually asks questions of, so no
// delegate has to know about column names or Vicinae's integer kinds.
function _normalise(row: var): var {
const encrypted = row.enc !== 0;
const textual = row.kind === root.kindText || row.kind === root.kindLink;
const raw = row.preview || "";
return {
id: row.id,
offerId: row.offerId,
mime: row.mime,
kind: row.kind,
source: row.source,
host: row.host,
pinned: row.pinned === 1,
ts: row.ts,
size: row.size,
encrypted: encrypted,
textual: textual,
typeLabel: root._typeLabel(row.kind, row.mime),
// Newlines and tabs become spaces: a row is one line of a list, and
// a pasted shell script must not turn it into a paragraph.
preview: encrypted ? "" : raw.replace(/\s+/g, " ").trim(),
dataPath: root.dataDir + "/" + row.offerId
};
}
// Short chip text for entries with nothing readable to show.
function _typeLabel(kind: int, mime: string): string {
switch (kind) {
case root.kindImage:
// "image/png" -> "PNG". Falls back to the whole type if it is odd.
return (mime.split("/")[1] || mime).split(";")[0].toUpperCase();
case root.kindFile:
return "Files";
case root.kindLink:
return "Link";
default:
return "Text";
}
}
// Escape a path for embedding in a SQL string literal. HOME is not
// attacker-controlled, but a stray apostrophe would break the query.
function _sqlLiteral(value: string): string {
return value.replace(/'/g, "''");
}
}
@@ -0,0 +1,132 @@
pragma Singleton
// Transition-only device monitoring. Resting state is intentionally silent.
import Quickshell
import Quickshell.Bluetooth
import Quickshell.Io
import Quickshell.Services.Pipewire
import QtQuick
Singleton {
id: root
property bool outputInitialized: false
property bool bluetoothInitialized: false
property bool kdeInitialized: false
property string previousOutput: ""
property string previousBluetooth: ""
property string previousKde: ""
PwObjectTracker { objects: [Pipewire.defaultAudioSink] }
readonly property string outputName: {
const sink = Pipewire.defaultAudioSink;
return sink?.description || sink?.nickname || sink?.name || "";
}
readonly property string bluetoothNames: {
const devices = Bluetooth.devices?.values ?? [];
return devices.filter(device => device.connected)
.map(device => device.name || device.address)
.sort()
.join("\u001f");
}
onOutputNameChanged: {
if (!root.outputInitialized) {
root.previousOutput = root.outputName;
return;
}
if (root.outputInitialized && root.outputName && root.outputName !== root.previousOutput) {
StatusEvents.publish({
key: "device-output",
glyph: "\u{F07E7}",
title: root.outputName,
detail: "Audio output selected",
priority: StatusEvents.ambientPriority
});
}
root.previousOutput = root.outputName;
}
onBluetoothNamesChanged: {
if (!root.bluetoothInitialized) {
root.previousBluetooth = root.bluetoothNames;
return;
}
if (root.bluetoothInitialized && root.bluetoothNames !== root.previousBluetooth) {
const current = root.bluetoothNames ? root.bluetoothNames.split("\u001f") : [];
const previous = root.previousBluetooth ? root.previousBluetooth.split("\u001f") : [];
const connected = current.find(name => !previous.includes(name));
const disconnected = previous.find(name => !current.includes(name));
StatusEvents.publish({
key: "device-bluetooth",
glyph: "\u{F00B1}",
title: connected || disconnected || "Bluetooth device",
detail: connected ? "Connected" : "Disconnected",
tone: connected ? "accent" : "warn",
priority: StatusEvents.ambientPriority
});
}
root.previousBluetooth = root.bluetoothNames;
}
Timer {
id: discoverySettle
interval: 1800
onTriggered: {
root.previousOutput = root.outputName;
root.previousBluetooth = root.bluetoothNames;
root.outputInitialized = true;
root.bluetoothInitialized = true;
}
}
Timer {
interval: 15000
repeat: true
running: true
triggeredOnStart: true
onTriggered: {
if (!kdeProbe.running)
kdeProbe.running = true;
}
}
Process {
id: kdeProbe
command: ["kdeconnect-cli", "-a", "--name-only"]
stdout: StdioCollector {
onStreamFinished: {
const names = this.text.split("\n")
.map(line => line.trim())
.filter(line => line && !/^\d+ devices? found$/.test(line))
.sort()
.join("\u001f");
if (root.kdeInitialized && names !== root.previousKde) {
const current = names ? names.split("\u001f") : [];
const previous = root.previousKde ? root.previousKde.split("\u001f") : [];
const connected = current.find(name => !previous.includes(name));
const disconnected = previous.find(name => !current.includes(name));
StatusEvents.publish({
key: "device-kdeconnect",
glyph: "\u{F03F2}",
title: connected || disconnected || "Phone",
detail: connected ? "KDE Connect available" : "KDE Connect disconnected",
tone: connected ? "accent" : "warn",
priority: StatusEvents.ambientPriority
});
}
root.previousKde = names;
root.kdeInitialized = true;
}
}
}
Component.onCompleted: {
root.previousOutput = root.outputName;
root.previousBluetooth = root.bluetoothNames;
discoverySettle.start();
}
}
@@ -0,0 +1,252 @@
pragma Singleton
// A single focus session bound to a Hyprland workspace. The deadline and the
// few user decisions are persisted; the once-per-second display value is not,
// so an active session does not turn into a constant disk writer.
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property alias active: state.active
readonly property alias paused: state.paused
readonly property alias capsuleVisible: state.capsuleVisible
readonly property alias workspaceId: state.workspaceId
readonly property alias workspaceLabel: state.workspaceLabel
readonly property alias monitorName: state.monitorName
// Kept in memory only. The persisted deadline is enough to reconstruct it
// after a reload, and paused sessions persist their fixed remainder.
property double nowMs: Date.now()
readonly property int remainingSeconds: {
if (!state.active)
return 0;
if (state.paused)
return Math.max(0, state.pausedRemainingSeconds);
return Math.max(0, Math.ceil((state.deadlineMs - root.nowMs) / 1000));
}
readonly property string remainingText: {
const total = root.remainingSeconds;
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
const pad = value => String(value).padStart(2, "0");
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
}
readonly property string statusText: state.paused ? "Paused" : "Do Not Disturb · Caffeine";
FileView {
id: stateFile
path: Quickshell.stateDir + "/focus-session.json"
blockLoading: true
printErrors: false
atomicWrites: true
onLoaded: restoreTimer.restart()
JsonAdapter {
id: state
property bool active: false
property bool paused: false
property bool capsuleVisible: false
property int workspaceId: 0
property string workspaceLabel: ""
property string monitorName: ""
property double deadlineMs: 0
property int pausedRemainingSeconds: 0
property bool previousDnd: false
property bool previousCaffeine: false
}
}
Timer {
interval: 1000
repeat: true
running: state.active && !state.paused
triggeredOnStart: true
onTriggered: {
root.nowMs = Date.now();
if (state.deadlineMs > 0 && root.nowMs >= state.deadlineMs)
root.end(true);
}
}
// Defer restoration until the other service singletons have completed
// construction. An active focus session owns these states across reloads.
Timer {
id: restoreTimer
interval: 0
onTriggered: root.restore()
}
Component.onCompleted: {
// `blockLoading` usually makes this true immediately. Keep the loaded
// signal above as the authoritative path for slower storage.
if (stateFile.loaded)
restoreTimer.restart();
}
function start(minutes: int): void {
const duration = Math.max(1, minutes);
const workspace = root.currentPositiveWorkspace();
// Replacing an active session first releases exactly the state it owns.
if (state.active)
root.end(false);
state.previousDnd = Notifs.doNotDisturb;
state.previousCaffeine = Caffeine.enabled;
Notifs.doNotDisturb = true;
Caffeine.enabled = true;
state.workspaceId = workspace ? workspace.id : 1;
state.workspaceLabel = root.displayWorkspaceName(workspace);
state.monitorName = Hyprland.focusedMonitor?.name ?? "";
state.pausedRemainingSeconds = duration * 60;
state.deadlineMs = Date.now() + duration * 60 * 1000;
state.paused = false;
state.capsuleVisible = true;
state.active = true;
root.nowMs = Date.now();
stateFile.writeAdapter();
}
function startDefault(): void {
root.start(Settings.focusDurationMinutes);
}
// The discoverable "start or show" behavior used by quick settings and
// Super+Shift+F. A second invocation never destroys work accidentally.
function reveal(): void {
if (!state.active) {
root.startDefault();
return;
}
state.capsuleVisible = true;
stateFile.writeAdapter();
}
function dismiss(): void {
state.capsuleVisible = false;
stateFile.writeAdapter();
}
function pauseOrResume(): void {
if (!state.active)
return;
root.nowMs = Date.now();
if (state.paused) {
state.deadlineMs = root.nowMs + state.pausedRemainingSeconds * 1000;
state.paused = false;
} else {
state.pausedRemainingSeconds = root.remainingSeconds;
state.paused = true;
}
stateFile.writeAdapter();
}
function activateWorkspace(): void {
if (!state.active || state.workspaceId <= 0)
return;
const all = Hyprland.workspaces?.values ?? [];
for (const workspace of all) {
if (workspace && workspace.id === state.workspaceId) {
workspace.activate();
return;
}
}
// Dynamic workspaces can disappear while the session is running.
// Focusing the stored positive id recreates the destination cleanly.
Hyprland.dispatch(`hl.dsp.focus({ workspace = ${state.workspaceId} })`);
}
function end(completed: bool): void {
if (!state.active)
return;
const previousDnd = state.previousDnd;
const previousCaffeine = state.previousCaffeine;
const finishedWorkspaceId = state.workspaceId;
const finishedWorkspace = state.workspaceLabel;
state.active = false;
state.paused = false;
state.capsuleVisible = false;
state.workspaceId = 0;
state.workspaceLabel = "";
state.monitorName = "";
state.deadlineMs = 0;
state.pausedRemainingSeconds = 0;
stateFile.writeAdapter();
Notifs.doNotDisturb = previousDnd;
Caffeine.enabled = previousCaffeine;
if (completed) {
StatusEvents.publish({
key: "focus-complete",
glyph: "\u{F051F}",
title: "Focus complete",
detail: finishedWorkspace ? `${finishedWorkspace} session finished` : "Your focus session finished",
tone: "ok",
priority: StatusEvents.importantPriority,
actionId: "open-workspace",
actionData: String(finishedWorkspaceId)
});
Quickshell.execDetached([
"notify-send",
"-a", "Panama",
"-i", "appointment-soon-symbolic",
"Focus complete",
finishedWorkspace ? `${finishedWorkspace} session finished` : "Your focus session finished"
]);
}
}
function restore(): void {
root.nowMs = Date.now();
if (!state.active)
return;
if (!state.paused && state.deadlineMs <= root.nowMs) {
root.end(true);
return;
}
// Caffeine and the in-process notification server restart on a shell
// reload. The persisted session therefore has to reclaim both states.
Notifs.doNotDisturb = true;
Caffeine.enabled = true;
}
function currentPositiveWorkspace(): var {
const focused = Hyprland.focusedWorkspace;
if (focused && focused.id > 0)
return focused;
const all = Hyprland.workspaces?.values ?? [];
for (const workspace of all) {
if (workspace && workspace.id > 0)
return workspace;
}
return null;
}
function displayWorkspaceName(workspace: var): string {
if (!workspace)
return "Workspace 1";
const name = workspace.name || String(workspace.id);
return /^\d+$/.test(name) ? `Workspace ${name}` : name;
}
}
@@ -0,0 +1,106 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// Night light — a thin wrapper around hyprsunset (0.4.0).
//
// hyprsunset is a daemon: `hyprsunset -t <kelvin>` grabs wlr-gamma-control and
// holds it until it exits, and `hyprctl hyprsunset <request>` re-tunes the
// running instance over its socket. So the shape here is:
// * `active` drives whether the daemon process runs at all,
// * temperature changes are pushed to the *running* daemon rather than
// restarting it, which would flash the screen back to 6500K.
//
// Gamma is restored by the compositor when the daemon's gamma-control object
// dies, so stopping the Process is a complete "off" — no `-i` pass needed.
//
// Only works under Hyprland; there is no gamma protocol on GNOME, so the
// daemon exits immediately there. That is reported once, not retried.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
property bool initialized: false
// The manual switch. Ignored while `automatic` is on.
property bool enabled: Settings.nightLightEnabledByDefault
property int temperature: Settings.nightLightTemperature
// Follow Settings.nightLightFrom .. nightLightTo instead of the manual
// switch. Off by default because GNOME's schedule was disabled.
property bool automatic: DesktopPreferences.nightLightAutomatic
// What is actually applied right now.
readonly property bool active: root.automatic ? root.scheduled : root.enabled
// True while the wall clock is inside the scheduled window.
readonly property bool scheduled: root.inWindow(clock.hours + clock.minutes / 60)
// A manual toggle always wins: it drops out of the schedule rather than
// being silently reverted a minute later. Same as GNOME's behaviour when
// you flip night light off during a scheduled evening.
function toggle(): void {
if (root.automatic) {
root.automatic = false;
root.enabled = !root.scheduled;
} else {
root.enabled = !root.enabled;
}
}
// The window wraps midnight (17:00 → 10:00), so the comparison flips when
// `from` is later in the day than `to`.
function inWindow(hour: real): bool {
const from = Settings.nightLightFrom;
const to = Settings.nightLightTo;
return from <= to ? (hour >= from && hour < to) : (hour >= from || hour < to);
}
// Only ticks while the schedule is in charge — no idle work otherwise.
SystemClock {
id: clock
precision: SystemClock.Minutes
enabled: root.automatic
}
Process {
id: daemon
command: ["hyprsunset", "-t", String(root.temperature)]
running: root.active
onExited: (code, status) => {
if (root.active)
console.warn("NightLight: hyprsunset exited with", code, "- is Hyprland running?");
}
}
// Re-tune in place; restarting the daemon would flash the display.
onTemperatureChanged: {
DesktopPreferences.nightLightTemperature = root.temperature;
if (daemon.running)
Quickshell.execDetached(["hyprctl", "hyprsunset", "temperature", String(root.temperature)]);
}
onEnabledChanged: DesktopPreferences.nightLightEnabled = root.enabled
onAutomaticChanged: DesktopPreferences.nightLightAutomatic = root.automatic
onActiveChanged: {
if (!root.initialized)
return;
StatusEvents.publish({
key: "display-night-light",
glyph: "\u{F0F31}",
title: root.active ? "Night Light on" : "Night Light off",
detail: root.active ? String(root.temperature) + " K" : "Display colors restored",
tone: root.active ? "warn" : "accent",
priority: StatusEvents.ambientPriority
});
}
Component.onCompleted: Qt.callLater(() => root.initialized = true)
}
+176
View File
@@ -0,0 +1,176 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// The freedesktop notification server, plus the two lists the UI renders:
//
// popups — what Toasts.qml is currently showing (transient, timed)
// history — GNOME's message tray, what NotificationCenter.qml shows
//
// A notification lives exactly as long as `tracked` is true, so history holds
// the *live* objects rather than copies: that keeps actions and inline replies
// working from the tray, which is what GNOME does. The cost is that a
// notification an app closes itself (progress bars, "download finished"
// replacing "downloading") disappears from history too — correct behaviour,
// but the reason history is not append-only.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Services.Notifications
import QtQuick
import qs.config
Singleton {
id: root
readonly property alias server: server
// Live tracked set, straight from the server. Mostly useful for counting;
// the UI wants `popups` / `history`, which are ordered newest-first.
readonly property alias active: server.trackedNotifications
property var history: []
property var popups: []
// Suppresses toasts entirely. Notifications still reach history.
property bool doNotDisturb: false
// Cleared when the notification centre is opened. The bar binds to this.
property int unreadCount: 0
// Arrival times, keyed by notification id — the protocol carries no
// timestamp. Deliberately formatted once at arrival rather than shown as
// "5 minutes ago", which would need a clock ticking behind every card.
readonly property var arrivals: ({})
function timeText(id: int): string {
const at = root.arrivals[id];
return at ? Qt.formatDateTime(at, Settings.use24Hour ? "HH:mm" : "h:mm AP") : "";
}
readonly property bool hasNotifications: root.history.length > 0
// history grouped by app, in most-recent-app-first order — the shape
// NotificationCenter.qml renders directly.
readonly property var groups: {
const out = [];
const byApp = {};
for (const n of root.history) {
const key = n.appName || "Notifications";
let group = byApp[key];
if (!group) {
group = {
app: key,
icon: n.appIcon,
desktopEntry: n.desktopEntry,
items: []
};
byApp[key] = group;
out.push(group);
}
group.items.push(n);
}
return out;
}
NotificationServer {
id: server
keepOnReload: true
persistenceSupported: true
bodySupported: true
bodyMarkupSupported: true
bodyImagesSupported: true
imageSupported: true
actionsSupported: true
actionIconsSupported: true
inlineReplySupported: true
onNotification: notification => {
// Replayed from before a shell reload. Letting these through would
// re-toast and re-list everything on every edit, so they are left
// untracked and allowed to die.
if (notification.lastGeneration)
return;
// Without this the object is destroyed the instant this returns.
notification.tracked = true;
root.arrivals[notification.id] = new Date();
// The object may go away at any time (app-side close, dismiss()).
// Drop our references synchronously when it does.
notification.closed.connect(() => root.forget(notification));
// `transient` is the volume-OSD case: show it, never file it.
if (!notification.transient) {
root.pushHistory(notification);
root.unreadCount += 1;
}
if (!root.doNotDisturb)
root.popups = [notification].concat(root.popups);
}
}
// ── Mutation ────────────────────────────────────────────────────────────
function pushHistory(n: Notification): void {
const next = [n].concat(root.history);
// Anything past the cap is released, otherwise it stays tracked
// forever and the server's set grows without bound.
const evicted = next.splice(Settings.notificationHistoryLimit);
root.history = next;
for (const old of evicted)
old.dismiss();
}
// Hide a toast without filing the notification away as read. Mirrors
// GNOME: closing a banner leaves it in the tray.
function dropPopup(n: Notification): void {
const next = root.popups.filter(x => x !== n);
if (next.length === root.popups.length)
return;
root.popups = next;
// Transients were never in history, so nothing else holds them.
if (n.transient)
n.dismiss();
}
function dismiss(n: Notification): void {
n.dismiss();
}
function dismissAll(): void {
// Copy first: dismiss() re-enters through forget() and rewrites both
// lists while we iterate.
const all = root.history.slice();
root.history = [];
root.popups = [];
for (const n of all)
n.dismiss();
root.unreadCount = 0;
}
function dismissApp(appName: string): void {
const doomed = root.history.filter(n => (n.appName || "Notifications") === appName);
root.history = root.history.filter(n => doomed.indexOf(n) === -1);
root.popups = root.popups.filter(n => doomed.indexOf(n) === -1);
for (const n of doomed)
n.dismiss();
}
function markAllRead(): void {
root.unreadCount = 0;
}
// Called from the `closed` signal — the object is on its way out, so this
// only ever removes references, never touches the notification.
function forget(n: Notification): void {
delete root.arrivals[n.id];
if (root.history.indexOf(n) !== -1)
root.history = root.history.filter(x => x !== n);
if (root.popups.indexOf(n) !== -1)
root.popups = root.popups.filter(x => x !== n);
}
}
@@ -0,0 +1,178 @@
pragma Singleton
// Persistent privacy state is separate from transient Signal Glass events.
// The low-frequency PipeWire probe only assigns when state changes, so the bar
// remains completely idle when nothing is capturing.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property bool microphoneActive: false
property bool cameraActive: false
property bool screenSharingActive: false
readonly property bool recordingActive: Capture.recording
property string microphoneApp: ""
property string cameraApp: ""
property string screenSharingApp: ""
property bool initialized: false
property bool fixtureMode: false
property bool warned: false
readonly property bool anyActive: root.microphoneActive || root.cameraActive
|| root.screenSharingActive || root.recordingActive
readonly property var activeKinds: {
const kinds = [];
if (root.recordingActive)
kinds.push("Recording");
if (root.screenSharingActive)
kinds.push("Screen sharing");
if (root.cameraActive)
kinds.push("Camera");
if (root.microphoneActive)
kinds.push("Microphone");
return kinds;
}
function applyStates(microphone: bool, camera: bool, screen: bool,
micApp: string, cameraApp: string, screenApp: string,
announce: bool): void {
root.updateKind("microphone", microphone, micApp, announce);
root.updateKind("camera", camera, cameraApp, announce);
root.updateKind("screen", screen, screenApp, announce);
root.initialized = true;
}
function updateKind(kind: string, active: bool, app: string, announce: bool): void {
let previous = false;
let label = "";
if (kind === "microphone") {
previous = root.microphoneActive;
root.microphoneActive = active;
root.microphoneApp = app;
label = "Microphone";
} else if (kind === "camera") {
previous = root.cameraActive;
root.cameraActive = active;
root.cameraApp = app;
label = "Camera";
} else {
previous = root.screenSharingActive;
root.screenSharingActive = active;
root.screenSharingApp = app;
label = "Screen sharing";
}
if (!announce || previous === active)
return;
StatusEvents.publish({
key: "privacy-" + kind,
glyph: kind === "microphone" ? "\u{F036C}" : (kind === "camera" ? "\u{F0100}" : "\u{F0379}"),
title: active ? label + " in use" : label + " released",
detail: active ? (app || "An application is using " + label.toLowerCase()) : "No application is using " + label.toLowerCase(),
tone: active ? "danger" : "ok",
priority: StatusEvents.criticalPriority,
durationMs: active ? 3600 : 2400,
actionId: active ? "open-activity" : ""
});
}
function applyProbe(text: string): void {
if (root.fixtureMode)
return;
let nodes = [];
try {
nodes = JSON.parse(text);
} catch (error) {
if (!root.warned) {
console.warn("PrivacyState: could not parse PipeWire state");
root.warned = true;
}
return;
}
let microphone = false;
let camera = false;
let screen = false;
let micApp = "";
let cameraApp = "";
let screenApp = "";
for (const object of nodes) {
if (object?.type !== "PipeWire:Interface:Node" || object?.info?.state !== "running")
continue;
const props = object.info.props ?? {};
const mediaClass = String(props["media.class"] ?? "");
const nodeName = String(props["node.name"] ?? "").toLowerCase();
const role = String(props["media.role"] ?? "").toLowerCase();
const app = String(props["application.name"] ?? props["node.description"] ?? "");
const looksLikeScreen = role.includes("screen") || nodeName.includes("screencast")
|| nodeName.includes("screen-cast") || nodeName.includes("xdg-desktop-portal");
if (mediaClass === "Stream/Input/Audio") {
microphone = true;
micApp = micApp || app;
} else if (mediaClass === "Stream/Input/Video" && !looksLikeScreen) {
camera = true;
cameraApp = cameraApp || app;
} else if ((mediaClass === "Stream/Input/Video" || mediaClass === "Stream/Output/Video") && looksLikeScreen) {
screen = true;
screenApp = screenApp || app;
}
}
root.applyStates(microphone, camera, screen, micApp, cameraApp, screenApp, root.initialized);
}
function applyFixture(name: string): void {
root.fixtureMode = true;
if (name === "microphone-on")
root.applyStates(true, false, false, "Contract fixture", "", "", true);
else if (name === "camera-on")
root.applyStates(false, true, false, "", "Contract fixture", "", true);
else if (name === "screen-on")
root.applyStates(false, false, true, "", "", "Contract fixture", true);
else if (name === "all-off")
root.applyStates(false, false, false, "", "", "", true);
}
function clearFixture(): void {
root.fixtureMode = false;
// The next real probe is discovery, not a transition away from test
// data; otherwise contract cleanup would flash a bogus release event.
root.initialized = false;
probe.running = false;
probe.running = true;
}
Timer {
interval: 2500
repeat: true
running: true
triggeredOnStart: true
onTriggered: {
if (!root.fixtureMode && !probe.running)
probe.running = true;
}
}
Process {
id: probe
command: ["pw-dump"]
stdout: StdioCollector {
onStreamFinished: root.applyProbe(this.text)
}
onExited: (code, status) => {
if (code !== 0 && !root.warned) {
console.warn("PrivacyState: pw-dump exited with", code);
root.warned = true;
}
}
}
}
@@ -0,0 +1,209 @@
pragma Singleton
// Local screen recognition. Capture owns geometry; this service owns the
// Tesseract/ZBar process boundary, result state, explicit follow-up actions,
// and cleanup of temporary screen images.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/screen-intelligence"
// "idle" | "capturing" | "reading" | "ready" | "error"
property string phase: "idle"
// Do not map the result surface until grim has finished, or the overlay
// can appear in the very pixels it is trying to analyze.
readonly property bool visible: phase !== "idle" && phase !== "capturing"
readonly property bool busy: phase === "capturing" || phase === "reading"
property bool ocrReady: false
property bool codeReady: false
property bool englishReady: false
property string text: ""
property string codeType: ""
property string codeValue: ""
property string error: ""
property string imagePath: ""
property bool ownsImage: false
readonly property string primaryUrl: root._firstWebUrl(root.codeValue) || root._firstWebUrl(root.text)
function refresh(): void {
probeProc.running = false;
probeProc.running = true;
}
function analyzeRegion(geometry: string, outputName: string): void {
root._prepare();
root.imagePath = Quickshell.cachePath("screen-intelligence-" + Date.now() + ".png");
root.ownsImage = true;
root.phase = "capturing";
const command = ["grim"];
if (geometry !== "")
command.push("-g", geometry);
else if (outputName !== "")
command.push("-o", outputName);
command.push(root.imagePath);
captureProc.command = command;
captureProc.running = false;
captureProc.running = true;
}
function analyzeFile(path: string): void {
root._prepare();
root.imagePath = path;
root.ownsImage = false;
root._startRecognition();
}
function copyText(): void {
if (root.text !== "")
Quickshell.execDetached(["wl-copy", root.text]);
}
function copyCode(): void {
if (root.codeValue !== "")
Quickshell.execDetached(["wl-copy", root.codeValue]);
}
function search(): void {
if (root.text === "")
return;
Quickshell.execDetached(["xdg-open", "https://www.google.com/search?q=" + encodeURIComponent(root.text.slice(0, 4000))]);
}
function translate(): void {
if (root.text === "")
return;
Quickshell.execDetached(["xdg-open", "https://translate.google.com/?sl=auto&tl=en&text=" + encodeURIComponent(root.text.slice(0, 4000))]);
}
function openDetected(): void {
if (root.primaryUrl !== "")
Quickshell.execDetached(["xdg-open", root.primaryUrl]);
}
function close(): void {
captureProc.running = false;
recognizeProc.running = false;
root._dropOwnedImage();
root.phase = "idle";
root.text = "";
root.codeType = "";
root.codeValue = "";
root.error = "";
root.imagePath = "";
root.ownsImage = false;
}
function _prepare(): void {
captureProc.running = false;
recognizeProc.running = false;
root._dropOwnedImage();
root.text = "";
root.codeType = "";
root.codeValue = "";
root.error = "";
root.imagePath = "";
root.ownsImage = false;
}
function _startRecognition(): void {
root.phase = "reading";
recognizeProc.sawOutput = false;
recognizeProc.command = [root.helperPath, "analyze-file", root.imagePath];
recognizeProc.running = false;
recognizeProc.running = true;
}
function _consume(raw: string): void {
try {
const result = JSON.parse(raw);
if (!result.ok) {
root.error = result.error || "Panama could not read that capture.";
root.phase = "error";
return;
}
root.text = (result.text || "").trim();
root.codeType = result.codeType || "";
root.codeValue = result.codeValue || "";
root.error = "";
root.phase = "ready";
} catch (e) {
root.error = "The local recognition engine returned an unreadable result.";
root.phase = "error";
}
}
function _dropOwnedImage(): void {
if (root.ownsImage && root.imagePath !== "")
Quickshell.execDetached(["rm", "-f", root.imagePath]);
}
function _firstWebUrl(value: string): string {
if (!value)
return "";
const match = value.match(/https?:\/\/[^\s<>'\"]+/i);
if (!match)
return "";
return match[0].replace(/[),.;!?]+$/, "");
}
Process {
id: probeProc
command: [root.helperPath, "probe"]
stdout: StdioCollector {
onStreamFinished: {
try {
const result = JSON.parse(this.text);
root.ocrReady = result.tesseract === true;
root.codeReady = result.zbar === true;
root.englishReady = result.english === true;
} catch (e) {
root.ocrReady = false;
root.codeReady = false;
root.englishReady = false;
}
}
}
}
Process {
id: captureProc
onExited: (code, status) => {
if (root.phase !== "capturing")
return;
if (code !== 0) {
root.error = "Panama could not capture the selected area.";
root.phase = "error";
return;
}
root._startRecognition();
}
}
Process {
id: recognizeProc
property bool sawOutput: false
stdout: StdioCollector {
onStreamFinished: {
recognizeProc.sawOutput = this.text !== "";
root._consume(this.text);
}
}
onExited: (code, status) => {
if (!recognizeProc.sawOutput && root.phase === "reading") {
root.error = "The local recognition engine did not return a result.";
root.phase = "error";
}
}
}
Component.onCompleted: root.refresh()
}
@@ -0,0 +1,93 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// Shared UI state.
//
// Every overlay in the shell is mutually exclusive with the others -- opening
// the overview should close the quick settings, and so on. Centralising that
// here means no module needs a reference to any other module, and the IPC
// handlers in shell.qml have exactly one thing to talk to.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import QtQuick
import qs.config
Singleton {
id: root
// Exactly one of these may be non-empty at a time.
// "" | "overview" | "quicksettings" | "notifications" | "clipboard" | "capture" | "activity" | "powermenu"
property string activeOverlay: ""
readonly property bool overviewOpen: activeOverlay === "overview"
readonly property bool quickSettingsOpen: activeOverlay === "quicksettings"
readonly property bool notificationsOpen: activeOverlay === "notifications"
readonly property bool clipboardOpen: activeOverlay === "clipboard"
readonly property bool captureOpen: activeOverlay === "capture"
readonly property bool activityOpen: activeOverlay === "activity"
readonly property bool powerMenuOpen: activeOverlay === "powermenu"
// Settings is a normal application window rather than a transient overlay.
// It can stay open while Quick Settings or the notification center appears.
property bool settingsOpen: false
property string settingsPage: "home"
readonly property bool anyOverlayOpen: activeOverlay !== ""
// 0 means "use the currently focused workspace". A positive value lets a
// contextual surface, such as Focus, open Mission Control at its target.
property int overviewWorkspaceId: 0
property string overviewQuery: ""
function toggle(name: string): void {
root.activeOverlay = (root.activeOverlay === name) ? "" : name;
}
function open(name: string): void {
root.activeOverlay = name;
}
function openOverview(workspaceId: int): void {
root.overviewWorkspaceId = Math.max(0, workspaceId);
root.overviewQuery = "";
root.activeOverlay = "overview";
}
function searchOverview(query: string): void {
root.overviewWorkspaceId = 0;
root.overviewQuery = query;
root.activeOverlay = "overview";
}
function close(): void {
if (root.activeOverlay === "overview") {
root.overviewWorkspaceId = 0;
root.overviewQuery = "";
}
root.activeOverlay = "";
}
function openSettings(page: string): void {
const allowed = ["home", "appearance", "displays", "connectivity", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "services", "about"];
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
DesktopPreferences.lastPage = root.settingsPage;
root.settingsOpen = true;
}
function toggleSettings(): void {
if (root.settingsOpen) {
root.closeSettings();
return;
}
root.openSettings(DesktopPreferences.lastPage || "home");
}
function closeSettings(): void {
root.settingsOpen = false;
}
// Set by Dock.qml so the bar can avoid fighting it for pointer grabs, and
// read by the capture overlay so the dock isn't in the screenshot.
property bool dockRevealed: false
}
@@ -0,0 +1,151 @@
pragma Singleton
// Curated shell feedback. This is deliberately not a notification store: one
// event is visible, at most four wait behind it, and expired entries vanish.
import Quickshell
import Quickshell.Hyprland
import QtQuick
Singleton {
id: root
readonly property int ambientPriority: 10
readonly property int importantPriority: 50
readonly property int criticalPriority: 90
property var activeEvent: null
property var pendingEvents: []
property int sequence: 0
readonly property bool active: root.activeEvent !== null
readonly property int queueLength: root.pendingEvents.length
signal eventPublished(var event)
signal eventDismissed(string key)
Timer {
id: expiryTimer
repeat: false
onTriggered: root.dismiss()
}
function publish(candidate: var): bool {
const event = root.normalize(candidate);
if (!event)
return false;
// DND is allowed to quiet device ambience, but it must never hide a
// privacy transition or the result of something the user initiated.
if (Notifs.doNotDisturb && event.priority < root.importantPriority)
return false;
if (root.activeEvent && root.activeEvent.key === event.key) {
root.activeEvent = event;
root.armExpiry();
root.eventPublished(event);
return true;
}
const withoutEquivalent = root.pendingEvents.filter(item => item.key !== event.key);
if (!root.activeEvent || event.priority > root.activeEvent.priority) {
if (root.activeEvent)
withoutEquivalent.push(root.activeEvent);
root.pendingEvents = root.trimQueue(withoutEquivalent);
root.activeEvent = event;
root.armExpiry();
} else {
withoutEquivalent.push(event);
root.pendingEvents = root.trimQueue(withoutEquivalent);
}
root.eventPublished(event);
return true;
}
function dismiss(): void {
if (!root.activeEvent)
return;
const dismissedKey = root.activeEvent.key;
expiryTimer.stop();
root.activeEvent = null;
root.eventDismissed(dismissedKey);
root.advance();
}
function invoke(): void {
if (!root.activeEvent)
return;
const action = root.activeEvent.actionId;
const data = root.activeEvent.actionData;
root.dismiss();
if (action === "focus-workspace") {
FocusSession.activateWorkspace();
} else if (action === "open-workspace") {
const workspaceId = Number(data);
if (Number.isInteger(workspaceId) && workspaceId > 0)
Hyprland.dispatch(`hl.dsp.focus({ workspace = ${workspaceId} })`);
} else if (action === "open-activity") {
ShellState.open("activity");
} else if (action === "open-path" && data) {
Quickshell.execDetached(["xdg-open", data]);
}
}
function reset(): void {
expiryTimer.stop();
root.activeEvent = null;
root.pendingEvents = [];
}
function normalize(candidate: var): var {
if (!candidate || !candidate.key || !candidate.title)
return null;
root.sequence++;
const priority = Number(candidate.priority ?? root.ambientPriority);
const duration = Number(candidate.durationMs ?? 3200);
return {
key: String(candidate.key),
icon: String(candidate.icon ?? "dialog-information-symbolic"),
glyph: String(candidate.glyph ?? ""),
title: String(candidate.title),
detail: String(candidate.detail ?? ""),
tone: String(candidate.tone ?? "accent"),
priority: Number.isFinite(priority) ? priority : root.ambientPriority,
durationMs: Math.max(1200, Number.isFinite(duration) ? duration : 3200),
actionId: String(candidate.actionId ?? ""),
actionData: String(candidate.actionData ?? ""),
monitorName: String(candidate.monitorName ?? ""),
sequence: root.sequence
};
}
function trimQueue(events: var): var {
const sorted = events.slice().sort((a, b) => {
if (a.priority !== b.priority)
return b.priority - a.priority;
return a.sequence - b.sequence;
});
return sorted.slice(0, 4);
}
function advance(): void {
if (root.pendingEvents.length === 0)
return;
const next = root.pendingEvents[0];
root.pendingEvents = root.pendingEvents.slice(1);
root.activeEvent = next;
root.armExpiry();
}
function armExpiry(): void {
expiryTimer.stop();
if (!root.activeEvent)
return;
expiryTimer.interval = root.activeEvent.durationMs;
expiryTimer.start();
}
}
@@ -0,0 +1,238 @@
pragma Singleton
// The allow-listed machine boundary for Panama Settings. Visual pages call
// these methods; no UI text is ever interpolated into a shell command.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
property string monitorName: ""
property string monitorDescription: ""
property int monitorWidth: 0
property int monitorHeight: 0
property real monitorRefreshRate: 0
property real monitorScale: 1
property string monitorFormat: ""
property string colorPreset: ""
property bool monitorVrrActive: false
property bool nextcloudActive: false
property bool rustdeskActive: false
property bool kdeconnectActive: false
property bool hyprpaperActive: false
property bool hypridleActive: false
property bool vicinaeActive: false
property string hyprlandVersion: ""
property string quickshellVersion: "0.3.0"
property string lastError: ""
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|| autoHdrWrite.running || vrrWrite.running || directScanoutWrite.running
readonly property bool autoHdr: DesktopPreferences.autoHdr
readonly property int vrrPolicy: DesktopPreferences.vrrPolicy
readonly property int directScanoutPolicy: DesktopPreferences.directScanoutPolicy
Process {
id: monitorQuery
command: ["hyprctl", "-j", "monitors"]
stdout: StdioCollector {
onStreamFinished: root.parseMonitors(this.text)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "Could not read the active display.";
}
}
Process {
id: serviceQuery
command: [
"bash", "-lc",
"printf '{\"nextcloud\":%s,\"rustdesk\":%s,\"kdeconnect\":%s,\"hyprpaper\":%s,\"hypridle\":%s,\"vicinae\":%s}\\n' "
+ "$(pgrep -x nextcloud >/dev/null && printf true || printf false) "
+ "$(systemctl is-active --quiet rustdesk.service && printf true || printf false) "
+ "$(pgrep -x kdeconnectd >/dev/null && printf true || printf false) "
+ "$(systemctl --user is-active --quiet hyprpaper.service && printf true || printf false) "
+ "$(systemctl --user is-active --quiet hypridle.service && printf true || printf false) "
+ "$(systemctl --user is-active --quiet vicinae.service && printf true || printf false)"
]
stdout: StdioCollector {
onStreamFinished: root.parseServices(this.text)
}
}
Process {
id: versionQuery
command: ["Hyprland", "--version"]
stdout: StdioCollector {
onStreamFinished: {
const match = this.text.match(/Hyprland\s+([0-9.]+)/);
root.hyprlandVersion = match ? match[1] : this.text.trim().split("\n")[0];
}
}
}
Process {
id: autoHdrWrite
property bool requested: true
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
DesktopPreferences.autoHdr = requested;
root.lastError = "";
} else {
root.lastError = "Hyprland rejected the HDR policy.";
}
}
}
Process {
id: vrrWrite
property int requested: 3
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
DesktopPreferences.vrrPolicy = requested;
root.lastError = "";
} else {
root.lastError = "Hyprland rejected the VRR policy.";
}
}
}
Process {
id: directScanoutWrite
property int requested: 2
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
DesktopPreferences.directScanoutPolicy = requested;
root.lastError = "";
} else {
root.lastError = "Hyprland rejected the direct-scanout policy.";
}
}
}
Timer {
// Preferences load asynchronously from disk. Applying after one quiet
// second avoids racing their restore and runs only once per shell start.
interval: 1000
running: true
onTriggered: root.applyPersistedDisplayPolicy()
}
Component.onCompleted: root.refresh()
function refresh(): void {
root.lastError = "";
if (!monitorQuery.running)
monitorQuery.running = true;
if (!serviceQuery.running)
serviceQuery.running = true;
if (!versionQuery.running && !root.hyprlandVersion)
versionQuery.running = true;
}
function parseMonitors(text: string): void {
try {
const monitors = JSON.parse(text);
const monitor = monitors.find(item => item.focused) ?? monitors[0];
if (!monitor)
throw new Error("No active monitor");
root.monitorName = monitor.name ?? "";
root.monitorDescription = monitor.description ?? monitor.model ?? "Display";
root.monitorWidth = monitor.width ?? 0;
root.monitorHeight = monitor.height ?? 0;
root.monitorRefreshRate = monitor.refreshRate ?? 0;
root.monitorScale = monitor.scale ?? 1;
root.monitorFormat = monitor.currentFormat ?? "";
root.colorPreset = monitor.colorManagementPreset ?? "";
root.monitorVrrActive = monitor.vrr ?? false;
} catch (error) {
root.lastError = "The display response could not be read.";
}
}
function parseServices(text: string): void {
try {
const state = JSON.parse(text);
root.nextcloudActive = state.nextcloud === true;
root.rustdeskActive = state.rustdesk === true;
root.kdeconnectActive = state.kdeconnect === true;
root.hyprpaperActive = state.hyprpaper === true;
root.hypridleActive = state.hypridle === true;
root.vicinaeActive = state.vicinae === true;
} catch (error) {
root.lastError = "Startup-service status could not be read.";
}
}
function setAutoHdr(enabled: bool): void {
autoHdrWrite.requested = enabled;
autoHdrWrite.exec(["hyprctl", "keyword", "render:cm_auto_hdr", enabled ? "1" : "0"]);
}
function setVrrPolicy(policy: int): void {
if (policy !== 0 && policy !== 3) {
root.lastError = "Unsupported VRR policy.";
return;
}
vrrWrite.requested = policy;
vrrWrite.exec(["hyprctl", "keyword", "misc:vrr", String(policy)]);
}
function setDirectScanoutPolicy(policy: int): void {
if (policy !== 0 && policy !== 2) {
root.lastError = "Unsupported direct-scanout policy.";
return;
}
directScanoutWrite.requested = policy;
directScanoutWrite.exec(["hyprctl", "keyword", "render:direct_scanout", String(policy)]);
}
function applyPersistedDisplayPolicy(): void {
root.setAutoHdr(DesktopPreferences.autoHdr);
root.setVrrPolicy(DesktopPreferences.vrrPolicy);
root.setDirectScanoutPolicy(DesktopPreferences.directScanoutPolicy);
}
function isGnomePanelAllowed(panel: string): bool {
return [
"wifi", "network", "bluetooth", "sound", "power", "printers",
"online-accounts", "users", "mouse", "keyboard", "sharing"
].indexOf(panel) >= 0;
}
function openGnomePanel(panel: string): bool {
if (!root.isGnomePanelAllowed(panel)) {
root.lastError = "That GNOME Settings panel is not available.";
return false;
}
Quickshell.execDetached({
command: ["gnome-control-center", panel],
environment: { "XDG_CURRENT_DESKTOP": "GNOME" }
});
return true;
}
function openApplication(id: string): bool {
const commands = {
"nextcloud": ["nextcloud"],
"rustdesk": ["rustdesk"],
"kdeconnect": ["kdeconnect-app"],
"mission-center": ["flatpak", "run", "io.missioncenter.MissionCenter"]
};
const command = commands[id];
if (!command) {
root.lastError = "That application is not managed by Panama Settings.";
return false;
}
Quickshell.execDetached(command);
return true;
}
}
+128
View File
@@ -0,0 +1,128 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// System vitals — CPU, memory and GPU utilisation.
//
// Replaces the GNOME Vitals extension, which showed exactly these three in
// exactly this order. Everything comes from procfs/sysfs, so there are no
// subprocesses: one timer kicks three async re-reads of very small files and
// the parsing happens in the `loaded` handlers.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
// All three are percentages, 0-100.
property real cpu: 0
property real memory: 0
property real gpu: 0
// False when Settings.gpuBusyPath is missing or unreadable (no amdgpu, or a
// different card index). The widget hides the GPU field rather than
// reporting a permanent 0%.
property bool gpuAvailable: false
// /proc/stat is cumulative since boot, so utilisation is only meaningful as
// a delta between two samples. These hold the previous one.
property real _prevTotal: 0
property real _prevIdle: 0
Timer {
interval: Settings.vitalsIntervalMs
running: true
repeat: true
triggeredOnStart: true
onTriggered: {
cpuFile.reload();
memFile.reload();
if (Settings.showGpu)
gpuFile.reload();
}
}
// ── CPU ─────────────────────────────────────────────────────────────────
FileView {
id: cpuFile
path: "/proc/stat"
printErrors: false
onLoaded: root._parseCpu(text())
}
// ── Memory ──────────────────────────────────────────────────────────────
FileView {
id: memFile
path: "/proc/meminfo"
printErrors: false
onLoaded: root._parseMemory(text())
}
// ── GPU ─────────────────────────────────────────────────────────────────
// amdgpu exposes a bare integer 0-100 here.
FileView {
id: gpuFile
path: Settings.gpuBusyPath
printErrors: false
onLoaded: root._parseGpu(text())
onLoadFailed: root.gpuAvailable = false
}
function _parseCpu(text: string): void {
if (!text)
return;
// First line is the aggregate: "cpu user nice system idle iowait ..."
const fields = text.slice(0, text.indexOf("\n")).split(/\s+/).slice(1).map(parseFloat).filter(n => !isNaN(n));
if (fields.length < 5)
return;
// idle + iowait — the kernel counts both as "not doing work".
const idle = fields[3] + fields[4];
let total = 0;
for (let i = 0; i < fields.length; i++)
total += fields[i];
const dTotal = total - root._prevTotal;
const dIdle = idle - root._prevIdle;
root._prevTotal = total;
root._prevIdle = idle;
// The very first sample has no predecessor; reporting it would show the
// since-boot average, which is never what you want.
if (dTotal <= 0)
return;
root.cpu = Math.max(0, Math.min(100, (1 - dIdle / dTotal) * 100));
}
function _parseMemory(text: string): void {
// MemAvailable is the kernel's own estimate of what a new allocation
// could get; a much better "used" basis than MemFree.
const total = root._meminfoField(text, "MemTotal");
const available = root._meminfoField(text, "MemAvailable");
if (total <= 0 || available < 0)
return;
root.memory = Math.max(0, Math.min(100, (1 - available / total) * 100));
}
function _meminfoField(text: string, key: string): real {
const match = text.match(new RegExp("^" + key + ":\\s+(\\d+)", "m"));
return match ? parseFloat(match[1]) : -1;
}
function _parseGpu(text: string): void {
const value = parseInt(text, 10);
if (isNaN(value)) {
root.gpuAvailable = false;
return;
}
root.gpuAvailable = true;
root.gpu = Math.max(0, Math.min(100, value));
}
}
+192
View File
@@ -0,0 +1,192 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// Current conditions from Open-Meteo (no API key, no account).
//
// Fetched with curl rather than XMLHttpRequest: curl is guaranteed present and
// the response is a single small JSON blob, so there is nothing to stream.
//
// Failure is silent by design — a weather widget that can't reach the network
// must not produce error popups or retry storms. A failed fetch just leaves
// `available` false until the next scheduled refresh.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
// False until a fetch has succeeded, and again after one fails.
property bool available: false
property real temperature: 0
property int weatherCode: -1
// Nerd Font glyph and a short human label for `weatherCode`.
readonly property string icon: root._codeIcon(root.weatherCode)
readonly property string description: root._codeDescription(root.weatherCode)
readonly property string unitSuffix: Settings.temperatureUnit === "fahrenheit" ? "°F" : "°C"
readonly property string url: "https://api.open-meteo.com/v1/forecast" + "?latitude=" + Settings.latitude + "&longitude=" + Settings.longitude + "&current=temperature_2m,weather_code" + "&temperature_unit=" + Settings.temperatureUnit
// The timer is the *only* thing that starts a fetch, so a hard failure can
// never retry faster than the refresh interval.
Timer {
interval: Settings.weatherRefreshMinutes * 60 * 1000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.refresh()
}
Process {
id: fetch
// -s silences the progress meter, --max-time keeps a black-holed
// connection from leaving the process alive until the next refresh.
command: ["curl", "-s", "--max-time", "10", root.url]
stdout: StdioCollector {
onStreamFinished: root._parse(this.text)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.available = false;
}
}
function refresh(): void {
// Skip if the previous fetch is somehow still in flight.
if (!fetch.running)
fetch.running = true;
}
function _parse(text: string): void {
if (!text) {
root.available = false;
return;
}
try {
const current = JSON.parse(text).current;
if (!current || current.temperature_2m === undefined) {
root.available = false;
return;
}
root.temperature = current.temperature_2m;
root.weatherCode = current.weather_code;
root.available = true;
} catch (e) {
root.available = false;
}
}
// ── WMO 4677 weather codes ──────────────────────────────────────────────
// Open-Meteo's `weather_code` is the WMO set. Grouped here into the same
// buckets GNOME Weather uses, because the distinctions finer than this are
// not legible at bar size.
// Glyphs come from the nf-weather range. They are written as escapes rather
// than literal characters so tooling that mishandles private-use codepoints
// cannot silently eat them.
function _codeIcon(code: int): string {
switch (code) {
case 0:
return "\u{E30D}"; // weather-day_sunny
case 1:
return "\u{E30C}"; // weather-day_sunny_overcast
case 2:
return "\u{E302}"; // weather-day_cloudy
case 3:
return "\u{E312}"; // weather-cloudy
case 45:
case 48:
return "\u{E313}"; // weather-fog
case 51:
case 53:
case 55:
return "\u{E31B}"; // weather-sprinkle
case 56:
case 57:
case 66:
case 67:
return "\u{E316}"; // weather-rain_mix (freezing)
case 61:
case 63:
case 65:
return "\u{E318}"; // weather-rain
case 71:
case 73:
case 75:
case 77:
case 85:
case 86:
return "\u{E31A}"; // weather-snow
case 80:
case 81:
case 82:
return "\u{E319}"; // weather-showers
case 95:
case 96:
case 99:
return "\u{E31D}"; // weather-thunderstorm
default:
return "\u{E374}"; // weather-na — unknown, or nothing fetched yet
}
}
function _codeDescription(code: int): string {
switch (code) {
case 0:
return "Clear";
case 1:
return "Mainly clear";
case 2:
return "Partly cloudy";
case 3:
return "Overcast";
case 45:
return "Fog";
case 48:
return "Rime fog";
case 51:
case 53:
case 55:
return "Drizzle";
case 56:
case 57:
return "Freezing drizzle";
case 61:
case 63:
case 65:
return "Rain";
case 66:
case 67:
return "Freezing rain";
case 71:
case 73:
case 75:
return "Snow";
case 77:
return "Snow grains";
case 80:
case 81:
case 82:
return "Showers";
case 85:
case 86:
return "Snow showers";
case 95:
return "Thunderstorm";
case 96:
case 99:
return "Thunderstorm, hail";
default:
return "";
}
}
}