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