Build the Panama Hyprland desktop
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 + "¤t=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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user