From ae9a80c1f012686c1f65fce7ce76fdd1b77b0b71 Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Wed, 23 Sep 2026 21:08:35 +0200 Subject: ... --- .../shell/quickshell/Services/Brightness.qml | 107 ++++++++++ .../shell/quickshell/Services/GpgAgent.qml | 18 ++ .../shell/quickshell/Services/InhibitorState.qml | 23 +++ .../shell/quickshell/Services/MprisProxy.qml | 8 + .../shell/quickshell/Services/NiriService.qml | 217 +++++++++++++++++++++ .../quickshell/Services/NotificationManager.qml | 162 +++++++++++++++ .../shell/quickshell/Services/Privacy.qml | 63 ++++++ .../shell/quickshell/Services/ScreenRecord.qml | 63 ++++++ .../quickshell/Services/WallpaperSelector.qml | 8 + .../shell/quickshell/Services/Worktime.qml | 84 ++++++++ 10 files changed, 753 insertions(+) create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/Brightness.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/GpgAgent.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/InhibitorState.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/MprisProxy.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/NiriService.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/NotificationManager.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/Privacy.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/ScreenRecord.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/WallpaperSelector.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/Worktime.qml (limited to 'accounts/gkleen@skadhi/shell/quickshell/Services') diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/Brightness.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/Brightness.qml new file mode 100644 index 00000000..f3ae6804 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/Brightness.qml @@ -0,0 +1,107 @@ +pragma Singleton + +import QtQml +import Quickshell +import Quickshell.Io +import Custom as Custom +import qs.Services + +Singleton { + id: root + + property string subsystem: "backlight" + property string device: "intel_backlight" + + property real currBrightness + property real exponent: 4 + + function calcCurrBrightness() { + if (!currFile.loaded || !maxFile.loaded) + return undefined; + const curr = Number(currFile.text()); + const max = Number(maxFile.text()); + const val = Math.pow(curr / max, 1 / root.exponent); + return val; + } + + Connections { + target: currFile + function onLoaded() { + const b = root.calcCurrBrightness(); + if (typeof b !== 'undefined') + root.currBrightness = b; + } + } + Connections { + target: maxFile + function onLoaded() { + const b = root.calcCurrBrightness(); + if (typeof b !== 'undefined') + root.currBrightness = b; + } + } + + onCurrBrightnessChanged: { + root.currBrightness = Math.max(0, Math.min(1, root.currBrightness)); + + const prev = root.calcCurrBrightness(); + if (typeof prev === 'undefined' || Math.abs(root.currBrightness - prev) < 0.01) + return; + + const max = Number(maxFile.text()); + const actual = Number(currFile.text()); + let curr = Math.max(0, Math.min(max, Math.pow(root.currBrightness, root.exponent) * max)); + if (Math.round(curr) == actual && curr < actual) + curr = Math.max(0, actual - 1); + else if (Math.round(curr) == actual && curr > actual) + curr = Math.min(max, actual + 1); + // root.currBrightness = Math.pow(curr / max, 1 / root.exponent); + Custom.Systemd.setBrightness(root.subsystem, root.device, Math.round(curr)); + } + + FileView { + id: currFile + path: `/sys/class/${root.subsystem}/${root.device}/brightness` + blockAllReads: true + watchChanges: true + onFileChanged: reload() + } + FileView { + id: maxFile + path: `/sys/class/${root.subsystem}/${root.device}/max_brightness` + blockAllReads: true + watchChanges: true + onFileChanged: reload() + } + + + Timer { + id: startupDelay + interval: 500 + running: true + repeat: false + } + property bool onlyInternal: true + Connections { + target: NiriService + function onOutputsChanged() { + root.onlyInternal = Object.keys(NiriService.outputs).every(oname => oname == "eDP-1"); + } + } + onOnlyInternalChanged: { + if (startupDelay.running) + return; + + if (!root.onlyInternal) + root.currBrightness = 1 + } + + + IpcHandler { + target: "Brightness" + + function set(brightness: real): void { + root.currBrightness = brightness; + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/GpgAgent.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/GpgAgent.qml new file mode 100644 index 00000000..3de69535 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/GpgAgent.qml @@ -0,0 +1,18 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io + +Singleton { + id: root + + Socket { + id: agentSocket + connected: true + path: `${Quickshell.env("XDG_RUNTIME_DIR")}/gnupg/S.gpg-agent` + } + + function reloadAgent() { + agentSocket.write("RELOADAGENT\n") + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/InhibitorState.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/InhibitorState.qml new file mode 100644 index 00000000..60202a29 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/InhibitorState.qml @@ -0,0 +1,23 @@ +pragma Singleton + +import Quickshell +import Custom as Custom + +Singleton { + id: inhibitorState + + property bool waylandIdleInhibited: false + property alias lidSwitchInhibited: lidSwitchInhibitor.enabled + property bool lockscreenInhibited: false + + Custom.SystemdInhibitor { + id: lidSwitchInhibitor + + enabled: false + + what: Custom.SystemdInhibitorParams.HandleLidSwitch + who: "quickshell" + why: "User request" + mode: Custom.SystemdInhibitorParams.BlockWeak + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/MprisProxy.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/MprisProxy.qml new file mode 100644 index 00000000..e3ab9755 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/MprisProxy.qml @@ -0,0 +1,8 @@ +pragma Singleton + +import Quickshell +import Quickshell.Services.Mpris + +Scope { + property list players: Mpris.players.values +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/NiriService.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/NiriService.qml new file mode 100644 index 00000000..e9e86f95 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/NiriService.qml @@ -0,0 +1,217 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property var workspaces: [] + property var outputs: {} + property var keyboardLayouts: {} + property var windows: [] + property var casts: [] + readonly property string socketPath: Quickshell.env("NIRI_SOCKET") + + function refreshOutputs() { + commandSocket.sendCommand("Outputs", data => { + outputs = data.Ok.Outputs; + }); + } + + function sendCommand(command, callback) { + commandSocket.sendCommand(command, callback); + } + + Socket { + id: eventStreamSocket + path: root.socketPath + connected: true + + property bool acked: false + + onConnectionStateChanged: { + if (connected) { + acked = false; + write('"EventStream"\n'); + } + } + + parser: SplitParser { + onRead: line => { + try { + const event = JSON.parse(line) + + // console.log(JSON.stringify(event)) + + if (event.WorkspacesChanged) { + root.workspaces = event.WorkspacesChanged.workspaces + root.refreshOutputs(); + } else if (event.WorkspaceActivated) + eventWorkspaceActivated(event.WorkspaceActivated); + else if (event.WorkspaceUrgencyChanged) + eventWorkspaceUrgencyChanged(event.WorkspaceUrgencyChanged); + else if (event.WorkspaceActiveWindowChanged) + eventWorkspaceActiveWindowChanged(event.WorkspaceActiveWindowChanged); + else if (event.KeyboardLayoutsChanged) + root.keyboardLayouts = event.KeyboardLayoutsChanged.keyboard_layouts; + else if (event.KeyboardLayoutSwitched) + root.keyboardLayouts = Object.assign({}, root.keyboardLayouts, {"current_idx": event.KeyboardLayoutSwitched.idx }); + else if (event.WindowsChanged) + root.windows = event.WindowsChanged.windows + else if (event.WindowOpenedOrChanged) + eventWindowOpenedOrChanged(event.WindowOpenedOrChanged); + else if (event.WindowClosed) + eventWindowClosed(event.WindowClosed); + else if (event.WindowFocusChanged) + eventWindowFocusChanged(event.WindowFocusChanged); + else if (event.WindowUrgencyChanged) + eventWindowUrgencyChanged(event.WindowUrgencyChanged); + else if (event.WindowLayoutsChanged) + eventWindowLayoutsChanged(event.WindowLayoutsChanged); + else if (event.WindowFocusTimestampChanged) + eventWindowFocusTimestampChanged(event.WindowFocusTimestampChanged); + else if (event.CastsChanged) + root.casts = event.CastsChanged.casts + else if (event.CastStartedOrChanged) + eventCastStartedOrChanged(event.CastStartedOrChanged); + else if (event.CastStopped) + eventCastStopped(event.CastStopped); + else if (event.Ok && !eventStreamSocket.acked) { eventStreamSocket.acked = true; } + else if (event.OverviewOpenedOrClosed) {} + else if (event.ScreenshotCaptured) {} + else if (event.ConfigLoaded) {} + else + console.log(JSON.stringify(event)); + } catch (e) { + console.warn("NiriService: Failed to parse event:", line, e) + } + } + } + } + + Socket { + id: commandSocket + path: root.socketPath + connected: true + + property var awaitingAnswer: null + property var cmdQueue: [] + + parser: SplitParser { + onRead: line => { + if (commandSocket.awaitingAnswer === null) + return; + + try { + const response = JSON.parse(line); + commandSocket.awaitingAnswer.callback(response); + commandSocket.awaitingAnswer = null; + } catch (e) { + console.warn("NiriService: Failed to parse response:", line, e) + } + commandSocket._handleQueue(); + } + } + + onCmdQueueChanged: { + _handleQueue(); + } + onAwaitingAnswerChanged: { + _handleQueue(); + } + + function _handleQueue() { + if (cmdQueue.length <= 0 || awaitingAnswer !== null) + return; + + let localQueue = Array.from(cmdQueue); + awaitingAnswer = localQueue.shift(); + cmdQueue = localQueue; + write(JSON.stringify(awaitingAnswer.command) + '\n'); + } + + function sendCommand(command, callback) { + cmdQueue = Array.from(cmdQueue).concat([{ "command": command, "callback": callback }]) + } + } + + function eventWorkspaceActivated(data) { + let relevant_output = null; + Array.from(root.workspaces).forEach(ws => { + if (data.id === ws.id) + relevant_output = ws.output; + }); + root.workspaces = Array.from(root.workspaces).map(ws => { + if (data.focused) + ws.is_focused = false; + if (ws.output === relevant_output) + ws.is_active = false; + if (data.id === ws.id) { + ws.is_active = true; + ws.is_focused = data.focused; + } + return ws; + }); + } + function eventWorkspaceUrgencyChanged(data) { + root.workspaces = Array.from(root.workspaces).map(ws => { + if (data.id == ws.id) + ws.is_urgent = data.urgent; + return ws; + }); + } + function eventWorkspaceActiveWindowChanged(data) { + root.workspaces = Array.from(root.workspaces).map(ws => { + if (data.workspace_id === ws.id) + ws.active_window_id = data.active_window_id; + return ws; + }); + } + function eventWindowOpenedOrChanged(data) { + root.windows = Array.from(root.windows).map(win => { + if (data.window.is_focused) + win.is_focused = false; + return win; + }).filter(win => win.id !== data.window.id).concat([data.window]); + } + function eventWindowClosed(data) { + root.windows = Array.from(root.windows).filter(win => win.id !== data.id); + } + function eventWindowFocusChanged(data) { + root.windows = Array.from(root.windows).map(win => { + win.is_focused = win.id === data.id; + return win; + }); + } + function eventWindowUrgencyChanged(data) { + root.windows = Array.from(root.windows).map(win => { + if (win.id === data.id) + win.is_urgent = data.urgent; + return win; + }); + } + function eventWindowLayoutsChanged(data) { + root.windows = Array.from(root.windows).map(win => { + Array.from(data.changes).forEach(change => { + if (win.id === change[0]) + win.layout = change[1]; + }); + return win; + }); + } + function eventWindowFocusTimestampChanged(data) { + root.windows = Array.from(root.windows).map(win => { + if (win.id === data.id) + win.focus_timestamp = data.focus_timestamp; + return win; + }); + } + function eventCastStartedOrChanged(data) { + root.casts = [...Array.from(root.casts).filter(cast => cast.stream_id !== data.cast.stream_id), data.cast]; + } + function eventCastStopped(data) { + root.casts = Array.from(root.casts).filter(cast => cast.stream_id !== data.stream_id); + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/NotificationManager.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/NotificationManager.qml new file mode 100644 index 00000000..5f8ff419 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/NotificationManager.qml @@ -0,0 +1,162 @@ +pragma Singleton + +import QtQml +import Quickshell +import Quickshell.Services.Notifications + +Singleton { + id: root + + readonly property bool active: !root.lockscreenActive && !root.displayInhibited + property bool lockscreenActive: false + property bool displayInhibited: false + property alias trackedNotifications: server.trackedNotifications + readonly property var groups: { + function matchesGroupKey(notif, groupKey) { + var matches = true; + for (const prop in groupKey.test) { + if (notif[prop] !== groupKey.test[prop]) { + matches = false; + break; + } + } + return matches; + } + + var groups = new Map(); + var notifs = new Array(); + for (const [ix, notif] of server.trackedNotifications.values.entries()) { + var didGroup = false; + for (const groupKey of root.groupKeys) { + if (!matchesGroupKey(notif, groupKey)) + continue; + + const key = JSON.stringify({ + "key": groupKey, + "values": Object.assign({}, ...(Array.from(groupKey["group-by"]).map(prop => { + var res = {}; + res[prop] = notif[prop]; + return res; + }))) + }); + if (!groups.has(key)) + groups.set(key, new Array()); + groups.get(key).push({ "ix": ix, "notif": notif }); + didGroup = true; + break; + } + + if (!didGroup) + notifs.push([{ "ix": ix, "notif": notif }]); + } + notifs.push(...groups.values()); + notifs.sort((as, bs) => Math.min(...(as.map(o => o.ix))) - Math.min(...(bs.map(o => o.ix)))); + return notifs.map(ns => ns.map(n => n.notif)); + } + + property var groupKeys: [ + { "test": { "appName": "Element" }, "group-by": [ "summary" ] } + ]; + + property int historyLimit: 100 + property var history: [] + + Component { + id: expirationTimer + + QtObject { + id: timer + + required property QtObject parent + required property int expirationTime + + property list data: [ + Timer { + running: root.active && !timer.expired + interval: timer.expirationTime + onTriggered: { + timer.parent.expirationTimer.destroy(); + timer.parent.expirationTimer = null; + timer.parent.expire(); + } + } + ] + } + } + + Component { + id: notificationLock + + RetainableLock {} + } + + readonly property SystemClock clock: SystemClock { + precision: SystemClock.Minutes + } + + function formatTime(time) { + const now = root.clock.date; + const diff = now - time; + const minutes = Math.ceil(diff / 60000); + const hours = Math.floor(minutes / 60); + + if (hours < 1) { + if (minutes < 1) + return "now"; + if (minutes == 1) + return "1 minute"; + return `${minutes} minutes`; + } + + const nowDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const timeDate = new Date(time.getFullYear(), time.getMonth(), time.getDate()) + const days = Math.floor((nowDate - timeDate) / (1000 * 86400)) + + const timeStr = time.toLocaleTimeString(Qt.locale(), "HH:mm"); + + if (days === 0) + return timeStr; + if (days === 1) + return `yesterday ${timeStr}`; + + const dateStr = time.toLocaleDateString(Qt.locale(), "YYYY-MM-DD"); + return `${dateStr} ${timeStr}`; + } + + NotificationServer { + id: server + + bodySupported: true + actionsSupported: true + actionIconsSupported: true + imageSupported: true + bodyMarkupSupported: true + bodyImagesSupported: true + + onNotification: notification => { + var timeout = notification.expireTimeout * 1000; + if (notification.appName == "poweralertd") + timeout = 2000; + if (timeout > 0) { + Object.defineProperty(notification, "expirationTimer", { configurable: true, enumerable: true, writable: true }); + notification.expirationTimer = expirationTimer.createObject(notification, { parent: notification, expirationTime: timeout }); + } + Object.defineProperty(notification, "receivedTime", { configurable: true, enumerable: true, writable: true }); + notification.receivedTime = root.clock.date; + notification.closed.connect((reason) => server.onNotificationClosed(notification, reason)); + notification.tracked = true; + } + + function onNotificationClosed(notification, reason) { + while (root.history.length >= root.historyLimit) { + root.history[0].lock.locked = false; + root.history.shift(); + } + + root.history.push({ + lock: notificationLock.createObject(root, { locked: true, object: notification }), + notification: notification + }); + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/Privacy.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/Privacy.qml new file mode 100644 index 00000000..9c813e49 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/Privacy.qml @@ -0,0 +1,63 @@ +pragma Singleton + +import QtQml +import Quickshell +import Quickshell.Services.Pipewire + +Singleton { + id: root + + PwObjectTracker { + objects: Pipewire.nodes.values + } + + enum Item { + Microphone, + Screensharing + } + + readonly property list activeItems: { + var items = []; + if (microphoneActive) + items.push(Privacy.Item.Microphone); + if (screensharingActive) + items.push(Privacy.Item.Screensharing); + return items; + } + + readonly property bool microphoneActive: { + if (!Pipewire.ready || !Pipewire.nodes?.values) { + return false + } + + for (const node of Pipewire.nodes.values) { + if (!node || (node.type & PwNodeType.AudioInStream) != PwNodeType.AudioInStream) + continue; + + if (node.properties?.["stream.monitor"] === "true") + continue; + + if (node.audio?.muted) + continue; + + return true; + } + + return false; + } + + readonly property bool screensharingActive: { + if (!Pipewire.ready || !Pipewire.nodes?.values) { + return false + } + + for (const node of Pipewire.nodes.values) { + if (!node || (node.type & PwNodeType.VideoInStream) != PwNodeType.VideoInStream) + continue; + + return true; + } + + return false; + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/ScreenRecord.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/ScreenRecord.qml new file mode 100644 index 00000000..eb415452 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/ScreenRecord.qml @@ -0,0 +1,63 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io + +Singleton { + id: root + property bool active: false + property bool slurpSuccess: false + + onActiveChanged: { + if (!active) { + slurp.running = false; + screenRecorder.running = false; + } + if (active) + slurp.running = true; + } + + Process { + id: screenRecorder + running: false + onRunningChanged: { + console.log("wf-recorder running: ", screenRecorder.running); + + if (!screenRecorder.running && !slurp.running) + root.active = false; + } + stderr: SplitParser { + onRead: line => console.log("wf-recorder stderr: ", line) + } + stdout: SplitParser { + onRead: line => console.log("wf-recorder stdout: ", line) + } + } + + Process { + id: slurp + running: false + command: [ @slurp@, "-o", "-d" ] + stdout: StdioCollector {} + stderr: SplitParser { + onRead: line => console.log("slurp stderr: ", line) + } + onExited: exitCode => { + if (exitCode !== 0) { + console.log("slurp failed: ", exitCode); + root.active = false; + return; + } + console.log("slurp succeeded: ", slurp.stdout.text); + + const nowDate = new Date(); + + screenRecorder.command = [ + @wf-recorder@, + "-g", slurp.stdout.text, + "-f", `${Quickshell.env("HOME")}/screenshots/${nowDate.toLocaleString(Qt.locale(), "yyyy-MM-ddThh:mm:ss")}.mkv`, + ]; + screenRecorder.running = true; + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/WallpaperSelector.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/WallpaperSelector.qml new file mode 100644 index 00000000..c71a9cca --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/WallpaperSelector.qml @@ -0,0 +1,8 @@ +import Custom as Custom + +Custom.FileSelector { + id: root + + directory: @wallpapers@ + epoch: 79200000 +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/Worktime.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/Worktime.qml new file mode 100644 index 00000000..d98378f1 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/Worktime.qml @@ -0,0 +1,84 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import QtQml + +Singleton { + id: root + + property alias time: timeState + property alias today: todayState + + CommandState { + id: timeState + command: "time" + } + CommandState { + id: todayState + command: "today" + } + + IpcHandler { + target: "Worktime" + + function refresh(): void { + time.running = true; + today.running = true; + } + } + + component CommandState : Scope { + id: commandState + + required property string command + property var state: null + + property bool strikeout: !strikeoutTimer.running + property alias running: process.running + property alias updating: updateTimer.running + + Process { + id: process + running: true + command: [ @worktime@, commandState.command, "--waybar" ] + stdout: StdioCollector { + id: processCollector + onStreamFinished: { + try { + commandState.state = JSON.parse(processCollector.text); + strikeoutTimer.restart(); + } catch (e) { + console.warn("Worktime: Failed to parse output:", processCollector.text, e); + } + } + } + } + + Timer { + id: updateTimer + running: commandState.state?.class == "running" || commandState.state?.class == "over" + interval: 60000 + repeat: true + onTriggered: process.running = true + } + + Timer { + id: strikeoutTimer + running: false + interval: 5 * updateTimer.interval + repeat: false + } + } + + Timer { + running: Boolean(timeState.state) && Boolean(todayState.state) && timeState.strikeout && todayState.strikeout + interval: 1000 + repeat: false + onTriggered: { + timeState.state = null; + todayState.state = null; + } + } +} -- cgit v1.2.3