From c36a74e861a99446dd9bde3855717bd2bd0044e2 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 4 Oct 2026 23:10:19 +0200 Subject: [PATCH] feat(quickshell): idle chain, drifting wallpaper, and a few fixes - Idle is handled in the shell instead of hypridle: the screen dims slowly at 4 minutes, locks at 5, powers down at 6, and suspends at 15 on battery. Idle inhibitors hold every stage off; Super+Shift+I does the same by hand and puts a cup on the rail. - The shell draws the wallpaper (backdrop/), so hyprpaper is gone. The picture is drawn a little larger than the screen and drifts with the workspace a beat after the windows, and dims a shade while windows are open. The path lives in quickshell/wallpaper. - The lock screen and power menu say "Take your time." instead of the NieR slogan. - hyprpaper is no longer launched twice, the media module keeps the player that played last instead of an empty browser tab, and grouped windows show thin untitled tabs with their place in the group (2/3) in the bar title. - qt6ct uses Darkly; the package list gains adw-gtk-theme and darkly-bin. --- hypr/lua/autostart.lua | 3 +- hypr/lua/binds/shell.lua | 1 + hypr/lua/lid.lua | 9 +-- hypr/lua/look.lua | 40 +++++------ hypr/lua/mirror.lua | 5 +- packages | 3 +- qt6ct/qt6ct.conf | 2 +- quickshell/README.md | 46 +++++++++++-- quickshell/backdrop/Backdrop.qml | 90 +++++++++++++++++++++++++ quickshell/bar/ActiveWindow.qml | 44 ++++++++++++- quickshell/bar/Rail.qml | 13 ++++ quickshell/lock/LockFace.qml | 12 ++++ quickshell/overlays/IdleDim.qml | 39 +++++++++++ quickshell/overlays/PowerMenu.qml | 6 +- quickshell/services/Actions.qml | 2 + quickshell/services/Idle.qml | 68 +++++++++++++++++++ quickshell/services/Media.qml | 17 +++++ quickshell/services/Wallpaper.qml | 106 +++++++----------------------- quickshell/shell.qml | 16 +++++ quickshell/wallpaper | 2 + 20 files changed, 396 insertions(+), 128 deletions(-) create mode 100644 quickshell/backdrop/Backdrop.qml create mode 100644 quickshell/overlays/IdleDim.qml create mode 100644 quickshell/services/Idle.qml create mode 100644 quickshell/wallpaper diff --git a/hypr/lua/autostart.lua b/hypr/lua/autostart.lua index b6471ef..531f01c 100644 --- a/hypr/lua/autostart.lua +++ b/hypr/lua/autostart.lua @@ -10,7 +10,8 @@ hl.on("hyprland.start", function() hl.exec_cmd("uwsm app -- qs") hl.exec_cmd("uwsm app -- nm-applet --indicator") - hl.exec_cmd("uwsm app -- hyprpaper") + -- hyprpaper is not started here: its systemd unit is enabled for + -- graphical-session.target, and a second copy here ran alongside it. -- clipboard history, text and images hl.exec_cmd("wl-paste --type text --watch cliphist store") diff --git a/hypr/lua/binds/shell.lua b/hypr/lua/binds/shell.lua index 899e5bb..18fd522 100644 --- a/hypr/lua/binds/shell.lua +++ b/hypr/lua/binds/shell.lua @@ -11,6 +11,7 @@ shell("power", { key = mod .. " + P", description = "power menu" }) shell("ask", { key = mod .. " + A", description = "ask claude" }) shell("dashboard", { key = mod .. " + D", description = "dashboard" }) shell("toggleDnd", { key = mod .. " + SHIFT + N", description = "toggle do not disturb" }) +shell("toggleStayAwake", { key = mod .. " + SHIFT + I", description = "keep the screen awake" }) shell("clearNotifications", { key = mod .. " + CTRL + SHIFT + N", description = "clear notifications" }) shell("reloadConfig", { key = mod .. " + SHIFT + R", description = "reload shell config" }) diff --git a/hypr/lua/lid.lua b/hypr/lua/lid.lua index def67d6..ac197eb 100644 --- a/hypr/lua/lid.lua +++ b/hypr/lua/lid.lua @@ -79,9 +79,6 @@ local function reconcile(ignore) return end - -- First decision of the session, and it agrees with what lua/monitors.lua - -- already applied: nothing actually moved, so no daemon needs poking. - local settled = panel_off == nil and not want_off panel_off = want_off if want_off then @@ -91,11 +88,7 @@ local function reconcile(ignore) end monitors.apply_external() - -- Quickshell follows monitor hotplug by itself; only the wallpaper daemon - -- needs a kick. - if not settled then - hl.exec_cmd("systemctl restart --user hyprpaper") - end + -- Quickshell follows monitor hotplug by itself, wallpaper included. end -- ------------------------------------------------------------------ triggers diff --git a/hypr/lua/look.lua b/hypr/lua/look.lua index e6ba1c6..abc23cb 100644 --- a/hypr/lua/look.lua +++ b/hypr/lua/look.lua @@ -74,6 +74,10 @@ hl.config({ font_family = "FiraCode Nerd Font", background_color = c.base, focus_on_activate = true, + -- The shell powers displays down after idle (quickshell/services/Idle.qml) + -- and back up when idle ends; these wake them even if it is not running. + key_press_enables_dpms = true, + mouse_move_enables_dpms = true, }, group = { @@ -82,37 +86,29 @@ hl.config({ border_inactive = c.overlay, }, - -- Tabs are solid slabs of the hull, not titles painted over the - -- wallpaper. Hyprland paints a tab's fill and its indicator from the - -- same colour, so the current tab is told apart by being lifted a step - -- and by its title taking the light, not by an accent fill — a light - -- fill would swallow its own title. + -- Tabs are thin rounded lines, not titled slabs. The bar already + -- writes the focused title and its place in the group ("2/3", in + -- quickshell/bar/ActiveWindow.qml), so titles here only repeated it in + -- a second font. Hyprland paints a tab's indicator in the same colour + -- as its fill, so with no fill the current tab is simply the light one. groupbar = { - font_family = "Noto Sans", - font_size = 10, - font_weight_active = "medium", - font_weight_inactive = "normal", + render_titles = false, + gradients = false, - height = 22, - indicator_height = 1, + indicator_height = 3, indicator_gap = 0, - gradients = true, - rounding = 6, - gradient_rounding = 6, - round_only_edges = true, - gradient_round_only_edges = true, + rounding = 2, + round_only_edges = false, keep_upper_gap = false, - gaps_in = 2, + gaps_in = 4, gaps_out = 0, col = { - active = c.overlay, - inactive = c.surface, + active = c.accent, + inactive = c.overlay, locked_active = c.alarm, - locked_inactive = c.surface, + locked_inactive = c.overlay, }, - text_color = c.accent, - text_color_inactive = c.muted, }, }, diff --git a/hypr/lua/mirror.lua b/hypr/lua/mirror.lua index 8b5eaaf..18b0996 100644 --- a/hypr/lua/mirror.lua +++ b/hypr/lua/mirror.lua @@ -49,10 +49,9 @@ local function mirror_all(panel) end end --- The bar and popouts stay on the outputs they were created for, so --- respawn the shell the same way lua/autostart.lua starts it. +-- The bar, popouts and wallpaper stay on the outputs they were created for, +-- so respawn the shell the same way lua/autostart.lua starts it. local function restart_clients() - hl.exec_cmd("systemctl restart --user hyprpaper") hl.exec_cmd("sh -c 'qs kill; exec uwsm app -- qs'") end diff --git a/packages b/packages index c04ae2e..2219047 100644 --- a/packages +++ b/packages @@ -3,6 +3,7 @@ aalib abseil-cpp acl acpi +adw-gtk-theme adwaita-cursors adwaita-fonts adwaita-icon-theme @@ -87,7 +88,6 @@ calibre cargo-audit catppuccin-gtk-theme-mocha catppuccin-qt5ct-git -catppuccinifier-cli-git cbindgen cblas cdparanoia @@ -111,6 +111,7 @@ cups-pk-helper curl cxxbridge cython +darkly-bin dav1d db5.3 dbus diff --git a/qt6ct/qt6ct.conf b/qt6ct/qt6ct.conf index 9a80c11..4e54836 100644 --- a/qt6ct/qt6ct.conf +++ b/qt6ct/qt6ct.conf @@ -3,7 +3,7 @@ color_scheme_path=/home/narl/.config/qt6ct/colors/yorha.conf custom_palette=true icon_theme=Papirus-Dark standard_dialogs=default -style=Fusion +style=Darkly [Fonts] fixed="Sans Serif,9,-1,5,400,0,0,0,0,0,0,0,0,0,0,1" diff --git a/quickshell/README.md b/quickshell/README.md index 5a73c88..5357432 100644 --- a/quickshell/README.md +++ b/quickshell/README.md @@ -66,6 +66,7 @@ bar/ the two surfaces and their modules popouts/ hover panels hanging off rail and bar modules overlays/ power menu, notification toasts, volume/brightness OSD lock/ session lock +backdrop/ the wallpaper, drawn by the shell assets/ halftone and stripe tiles, generated with ImageMagick ``` @@ -143,6 +144,7 @@ Set in `~/.config/hypr/lua/binds/shell.lua`: - `Super+Ctrl+L` — lock - `Super+P` — power menu - `Super+Shift+N` — toggle do not disturb +- `Super+Shift+I` — stay awake (no dim, lock or screen-off until toggled back) - `Super+Ctrl+Shift+N` — clear notifications - `Super+A` — ask panel - `Super+Shift+R` — reload the shell @@ -153,6 +155,8 @@ All of these go through Quickshell's IPC, so they work from any script: qs ipc call shell lock qs ipc call shell power qs ipc call shell toggleDnd +qs ipc call shell toggleStayAwake +qs ipc call shell wallpaper ~/.wallpapers/space-1.jpg qs ipc call shell ask qs ipc call shell askAbout "why is the sky blue" qs ipc call shell clearNotifications @@ -211,6 +215,34 @@ NetworkManager for Wi-Fi, UPower for battery, MPRIS for media, and Quickshell's own notification server. These are event-driven, so the bar and OSD react the instant a volume key is pressed instead of on the next poll. +## Wallpaper + +`backdrop/Backdrop.qml` draws it on the background layer; there is no +hyprpaper. The picture is drawn 8% larger than the screen and drifts with the +workspace: 1 sits at the top of it and each step down moves it a fixed +distance, a little slower than Hyprland's own slide, so the windows arrive +first and the scene behind catches up. It never moves on its own, so an idle +desktop costs the compositor nothing. While the workspace holds windows it +settles a shade darker. + +The choice is one line in `quickshell/wallpaper` (`~` allowed), watched live. +`qs ipc call shell wallpaper ` rewrites it. + +## Idle + +`services/Idle.qml`, on Wayland's idle-notify protocol instead of hypridle: + +| After | Does | +|---|---| +| 4 min | the screen dims slowly (`overlays/IdleDim.qml`); any input lifts it | +| 5 min | lock | +| 6 min | displays off; any input wakes them | +| 15 min | suspend, on battery only | + +Every stage respects idle inhibitors, so a fullscreen video holds them all +off. `Super+Shift+I` does the same by hand and puts a cup on the rail while it +is on. + ## Lock screen Authenticates through `/etc/pam.d/hyprlock`, which on this box is just @@ -219,7 +251,8 @@ styled in an ordinary window (`qs -p lockpreview.qml`) without locking the session. It is the wallpaper, barely softened, with the face set in the dark of the -porthole: the mark, the time in light display type, the date, a status log +porthole: the mark, the time in light display type, the date, "Take your +time.", a status log (battery, network, what is playing, unread notices), and a rounded field that fills with dots as you type. The dots breathe while PAM checks; a wrong password turns them and the field's rim to rust and nudges the field once. @@ -231,6 +264,9 @@ means restarting Hyprland from a TTY. ## What was retired - `waybar.service` — disabled. Its config is still in `~/.config/waybar`. +- `hyprpaper` — replaced by `backdrop/`. Turn its unit off with + `systemctl --user disable --now hyprpaper`. +- `hypridle` — never installed; `services/Idle.qml` does the job. - `dunst.service` — masked, so it cannot be D-Bus activated and steal `org.freedesktop.Notifications`. Undo with `systemctl --user unmask dunst.service`. @@ -311,8 +347,9 @@ network and bluetooth, but keep running as the secret and pairing agents. the opening's 18px corners at a 10px gap (`Theme.frameRadius - gaps_out`); change one and the others follow. No blur, both opacities at `1.0`, 1px borders running from the light into the suit's grey. The shadow is soft and - wide, so windows float in the opening. Group tabs are solid hull slabs, the - current one lifted a step with its title in the light. Motion + wide, so windows float in the opening. Group tabs are thin rounded lines with + no titles, the current one in the light; the bar writes the title once and + adds the window's place in the group (`2/3`). Motion (`hypr/lua/animations.lua`) glides and settles with no overshoot: windows resolve in from a hair smaller, workspaces drift a short way vertically and fade (sliding behind the hull), the `SUPER+S` scratchpad lowers out from @@ -335,7 +372,8 @@ network and bluetooth, but keep running as the secret and pairing agents. The theme name is also in `xsettingsd/xsettingsd.conf` and has to be set with `gsettings` for portal-aware apps. - **Qt** — qt6ct with `qt6ct/colors/yorha.conf`, the same palette in - `QPalette` roles. The style is Fusion unless Darkly is installed. + `QPalette` roles, drawn with the Darkly style (package `darkly-bin`) for + rounded controls. Fusion is the fallback if Darkly is missing. - **zathura** — `zathura/yorha`, included from `zathurarc`. Pages are recoloured onto the hull with text in bone; images keep their colours. Search hits are a soft bone wash, and the current hit is sand. diff --git a/quickshell/backdrop/Backdrop.qml b/quickshell/backdrop/Backdrop.qml new file mode 100644 index 0000000..575a710 --- /dev/null +++ b/quickshell/backdrop/Backdrop.qml @@ -0,0 +1,90 @@ +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import "root:/config" +import "root:/services" + +// The wallpaper, seen through the porthole. It is drawn a little larger than +// the screen and drifts when the workspace changes: windows slide past +// vertically (hypr/lua/animations.lua) and the scene behind them moves a few +// pixels the same way and a little later, the way distant things do out of a +// moving window. Only on switches — it never moves on its own, so an idle +// desktop costs the compositor nothing. +// +// It also settles a shade darker while the workspace holds windows, so the +// band of wallpaper round them reads as the far side of the glass rather than +// as part of the desktop. +PanelWindow { + id: root + + required property var modelData + screen: modelData + + WlrLayershell.layer: WlrLayer.Background + WlrLayershell.namespace: "takemi-backdrop" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + exclusionMode: ExclusionMode.Ignore + anchors { top: true; bottom: true; left: true; right: true } + color: Theme.ink + mask: Region {} + + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.screen) + readonly property HyprlandWorkspace workspace: root.monitor?.activeWorkspace ?? null + + // Overscan: how much bigger than the screen the picture is drawn, which is + // the room it has to drift in. + readonly property real overscan: 1.08 + readonly property real slack: root.height * (root.overscan - 1) + + // Workspaces 1–10 spread across the slack, so 1 sits at the top of the + // picture and each step down moves it a fixed distance. Special and + // named workspaces (negative ids) leave it where it was. + property real drift: 0 + readonly property int wsId: root.workspace?.id ?? 1 + onWsIdChanged: if (root.wsId > 0) root.drift = Math.min(root.wsId - 1, 9) / 9 + + readonly property bool occupied: (root.workspace?.toplevels?.values?.length ?? 0) > 0 + + Image { + id: picture + width: root.width * root.overscan + height: root.height * root.overscan + x: -(width - root.width) / 2 + y: -root.drift * root.slack + source: Wallpaper.forMonitor(root.screen?.name ?? "") + sourceSize: Qt.size(width, height) + fillMode: Image.PreserveAspectCrop + asynchronous: true + smooth: true + cache: false + + opacity: status === Image.Ready ? 1 : 0 + + Behavior on opacity { + NumberAnimation { duration: Theme.durLazy; easing.type: Easing.OutCubic } + } + + // Longer than Hyprland's 550ms workspace slide, on the same curve, so + // the windows arrive first and the scene catches up. + Behavior on y { + NumberAnimation { + duration: 900 + easing.type: Easing.BezierSpline + easing.bezierCurve: [0.35, 0.0, 0.15, 1.0, 1.0, 1.0] + } + } + } + + Rectangle { + anchors.fill: parent + color: Theme.ink + opacity: root.occupied ? 0.18 : 0 + + Behavior on opacity { + NumberAnimation { duration: Theme.durLazy; easing.type: Easing.InOutSine } + } + } + + Component.onCompleted: root.drift = Math.min(Math.max(root.wsId, 1) - 1, 9) / 9 +} diff --git a/quickshell/bar/ActiveWindow.qml b/quickshell/bar/ActiveWindow.qml index 35be165..1df4a63 100644 --- a/quickshell/bar/ActiveWindow.qml +++ b/quickshell/bar/ActiveWindow.qml @@ -20,6 +20,31 @@ Item { || toplevel.monitor.id === root.monitor.id) readonly property string incomingTitle: mine && toplevel.title ? toplevel.title : "—" + + // Where the focused window sits in its group, as "2/3", or "" when it is + // not grouped. Hyprland's group bar draws only indicator lines (see + // hypr/lua/look.lua), so the bar is the one place the title is written. + readonly property string groupLabel: { + const ipc = root.mine ? root.toplevel.lastIpcObject : null; + const members = ipc?.grouped ?? []; + if (members.length < 2) return ""; + const bare = a => String(a).replace(/^0x/, ""); + const at = members.findIndex(m => bare(m) === bare(root.toplevel.address)); + return (at < 0 ? "?" : at + 1) + "/" + members.length; + } + + // lastIpcObject is a snapshot, refreshed only on request. Ask again + // whenever focus or group membership may have changed. + Connections { + target: Hyprland + function onRawEvent(event) { + const n = event.name; + if (n === "activewindowv2" || n === "togglegroup" + || n === "moveintogroup" || n === "moveoutofgroup" + || n === "closewindow" || n === "openwindow") + Hyprland.refreshToplevels(); + } + } property string displayedTitle: "—" // Which toplevel the landing transition last played for. The landing is a @@ -30,7 +55,8 @@ Item { // The bar shrinks this when the three clusters would otherwise collide. property real maxWidth: 240 - implicitWidth: Math.min(maxWidth, Math.max(70, label.implicitWidth + 4)) + implicitWidth: Math.min(maxWidth, + Math.max(70, label.implicitWidth + 4 + (groupTag.visible ? groupTag.width + Theme.padS : 0))) implicitHeight: 20 Behavior on implicitWidth { @@ -45,7 +71,7 @@ Item { Text { id: label anchors.verticalCenter: parent.verticalCenter - width: parent.width + width: parent.width - (groupTag.visible ? groupTag.width + Theme.padS : 0) text: root.displayedTitle color: root.mine ? Theme.subtext : Theme.muted elide: Text.ElideRight @@ -61,7 +87,7 @@ Item { Text { id: ghost anchors.verticalCenter: parent.verticalCenter - width: parent.width + width: label.width text: root.displayedTitle color: Theme.accent opacity: 0 @@ -69,6 +95,18 @@ Item { font: label.font renderType: Text.NativeRendering } + + Text { + id: groupTag + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: root.groupLabel !== "" + text: root.groupLabel + color: Theme.muted + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + renderType: Text.NativeRendering + } } // Adopt the settled caption, and stamp it only when focus actually moved. diff --git a/quickshell/bar/Rail.qml b/quickshell/bar/Rail.qml index 3fdfafa..ba2fd08 100644 --- a/quickshell/bar/Rail.qml +++ b/quickshell/bar/Rail.qml @@ -137,6 +137,19 @@ PanelWindow { Divider { visible: tray.visible } + // Only there while the idle chain is held off by hand, so it is + // obvious why the screen never locked. + RailIcon { + id: awakeIcon + visible: Idle.stayAwake + icon: "󰅶" + label: "Stay awake" + statusText: "Screen will not dim or lock" + hint: "Click to allow sleep" + live: true + onClicked: Idle.toggleStayAwake() + } + RailIcon { id: bellIcon icon: Notifs.icon diff --git a/quickshell/lock/LockFace.qml b/quickshell/lock/LockFace.qml index e01e573..a41f80b 100644 --- a/quickshell/lock/LockFace.qml +++ b/quickshell/lock/LockFace.qml @@ -131,6 +131,18 @@ Item { renderType: Text.NativeRendering } + // The one line the face says to you. Nothing here is in a hurry. + Text { + topPadding: Math.round(Theme.padL * surface.uiScale) + leftPadding: Math.round(4 * surface.uiScale) + text: "Take your time." + color: Theme.subtext + font.family: Theme.fontDisplay + font.pixelSize: Math.round(Theme.fsLarge * surface.uiScale) + font.weight: Font.Light + renderType: Text.NativeRendering + } + Item { width: 1; height: Math.round(Theme.padXL * surface.uiScale) } // ------------------------------------------------------ status log diff --git a/quickshell/overlays/IdleDim.qml b/quickshell/overlays/IdleDim.qml new file mode 100644 index 0000000..da6df67 --- /dev/null +++ b/quickshell/overlays/IdleDim.qml @@ -0,0 +1,39 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import "root:/config" +import "root:/services" + +// The first stage of idle (services/Idle.qml): the screen dims the way a room +// does when the lights go down, slowly enough that you notice it before the +// lock does. Any input lifts it at once. Input passes straight through. +PanelWindow { + id: root + + required property var modelData + screen: modelData + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "takemi-dim" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + exclusionMode: ExclusionMode.Ignore + anchors { top: true; bottom: true; left: true; right: true } + color: "transparent" + mask: Region {} + + visible: Idle.dimmed || shade.opacity > 0 + + Rectangle { + id: shade + anchors.fill: parent + color: Theme.ink + opacity: Idle.dimmed ? 0.6 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Idle.dimmed ? 8000 : Theme.durBase + easing.type: Idle.dimmed ? Easing.InOutSine : Easing.OutCubic + } + } + } +} diff --git a/quickshell/overlays/PowerMenu.qml b/quickshell/overlays/PowerMenu.qml index a0153c6..3e44750 100644 --- a/quickshell/overlays/PowerMenu.qml +++ b/quickshell/overlays/PowerMenu.qml @@ -100,19 +100,19 @@ PanelWindow { Text { topPadding: Theme.padM - text: "GLORY TO MANKIND." + text: "Take your time." color: Theme.accent font.family: Theme.fontDisplay font.pixelSize: Theme.fsHuge font.weight: Font.Light - font.letterSpacing: 6 + font.letterSpacing: 1 renderType: Text.NativeRendering } Text { topPadding: Theme.padS leftPadding: 2 - text: ["YORHA", (Quickshell.env("USER") || "user") + "@" + hostFile.text().trim(), + text: [(Quickshell.env("USER") || "user") + "@" + hostFile.text().trim(), Sys.uptime ? "UP " + Sys.uptime : ""].filter(t => t !== "").join(" · ") color: Theme.muted font.family: Theme.fontMono diff --git a/quickshell/services/Actions.qml b/quickshell/services/Actions.qml index fd30c3d..e8bb8fa 100644 --- a/quickshell/services/Actions.qml +++ b/quickshell/services/Actions.qml @@ -163,5 +163,7 @@ Singleton { function reloadConfig(): void { root.reloadShell(); } function toggleDnd(): void { Notifs.toggleDnd(); } function clearNotifications(): void { Notifs.clearAll(); } + function toggleStayAwake(): void { Idle.toggleStayAwake(); } + function wallpaper(path: string): void { Wallpaper.set(path); } } } diff --git a/quickshell/services/Idle.qml b/quickshell/services/Idle.qml new file mode 100644 index 0000000..dabab34 --- /dev/null +++ b/quickshell/services/Idle.qml @@ -0,0 +1,68 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import "root:/services" + +// What happens when nobody is at the machine. There is no hypridle: the shell +// already owns the lock screen, so it owns the clock that leads up to it. +// +// dim the screen slowly darkens, so the lock does not arrive unannounced +// lock the session lock (lock/Lock.qml) +// off the displays power down; any input wakes them +// suspend only on battery, and only after a long while +// +// Every stage respects idle inhibitors, so a fullscreen video or a call that +// asks the compositor to stay awake holds all of them off. `stayAwake` is the +// same thing by hand (`qs ipc call shell toggleStayAwake`). +Singleton { + id: root + + // Seconds of no input before each stage. + readonly property int dimAfter: 240 + readonly property int lockAfter: 300 + readonly property int offAfter: 360 + readonly property int suspendAfter: 900 + + property bool stayAwake: false + + readonly property bool dimmed: dimMon.isIdle && !Actions.lockRequested + + function toggleStayAwake() { root.stayAwake = !root.stayAwake; } + + IdleMonitor { + id: dimMon + enabled: !root.stayAwake + respectInhibitors: true + timeout: root.dimAfter + } + + IdleMonitor { + id: lockMon + enabled: !root.stayAwake + respectInhibitors: true + timeout: root.lockAfter + onIsIdleChanged: if (isIdle) Actions.lock() + } + + IdleMonitor { + id: offMon + enabled: !root.stayAwake + respectInhibitors: true + timeout: root.offAfter + // Input ends the idle period, so the displays come back on the same + // event that would have woken them anyway. + onIsIdleChanged: Hyprland.dispatch( + "hl.dsp.dpms({ action = \"" + (isIdle ? "off" : "on") + "\" })") + } + + IdleMonitor { + id: suspendMon + enabled: !root.stayAwake && Battery.onBattery + respectInhibitors: true + timeout: root.suspendAfter + onIsIdleChanged: if (isIdle) Actions.suspend() + } +} diff --git a/quickshell/services/Media.qml b/quickshell/services/Media.qml index 73e3e64..3a5d933 100644 --- a/quickshell/services/Media.qml +++ b/quickshell/services/Media.qml @@ -10,6 +10,9 @@ Singleton { id: root property string preferredId: "" + // The player that was playing most recently, so pausing it keeps it in + // the bar instead of handing the slot to whichever player is listed first. + property string lastPlayingId: "" readonly property var players: Mpris.players?.values ?? [] readonly property bool hasPlayer: active !== null @@ -22,9 +25,23 @@ Singleton { for (const p of players) if (p.dbusName === root.preferredId) return p; } for (const p of players) if (p.isPlaying) return p; + if (root.lastPlayingId !== "") { + for (const p of players) if (p.dbusName === root.lastPlayingId) return p; + } + // Browsers register a player for every tab with a media element, most + // of them with no track at all. Prefer one that has something to show. + for (const p of players) if (p.trackTitle) return p; return players[0]; } + // Remember whichever player was playing last. Bound through the active + // player, so it updates whenever playback starts anywhere. + readonly property string playingId: { + for (const p of players) if (p.isPlaying) return p.dbusName; + return ""; + } + onPlayingIdChanged: if (root.playingId !== "") root.lastPlayingId = root.playingId + readonly property string title: active?.trackTitle || "" readonly property string artist: active?.trackArtist || "" readonly property string album: active?.trackAlbum || "" diff --git a/quickshell/services/Wallpaper.qml b/quickshell/services/Wallpaper.qml index a48ce49..b4e42b6 100644 --- a/quickshell/services/Wallpaper.qml +++ b/quickshell/services/Wallpaper.qml @@ -5,24 +5,17 @@ import Quickshell import Quickshell.Io import "root:/config" -// The wallpaper currently on screen, per monitor. +// The wallpaper, owned by the shell. backdrop/Backdrop.qml draws it and the lock +// screen fades in from the same picture, so there is one answer to "what is +// on screen" and nothing to ask a separate daemon (this used to poll +// hyprpaper every ten seconds). // -// The lock screen used to point at a hardcoded path in Theme.qml, which meant -// it showed whatever picture was correct on the day that line was written — -// takemi_2 while hyprpaper had been switched to takemi_1. Anything that puts -// the wallpaper on screen should ask hyprpaper what it is actually displaying, -// which is what this does. -// -// hyprpaper is the source of truth at runtime; hyprpaper.conf is the fallback -// for the window between the shell starting and hyprpaper answering, and for -// the case where hyprpaper is not running at all. +// The choice lives in ~/.config/quickshell/wallpaper — one line, a path, `~` +// allowed — so it survives restarts and can be edited by hand. Change it live +// with `qs ipc call shell wallpaper ~/.wallpapers/other.jpg`. Singleton { id: root - // monitor name -> file path. Monitors hyprpaper set with a blank `monitor =` - // come back under the empty-string key. - property var byMonitor: ({}) - // The wallpaper to use when no particular monitor is being asked about. property string path: "" @@ -30,18 +23,19 @@ Singleton { ? Qt.resolvedUrl("file://" + root.path) : Theme.fallbackWallpaper - // Path set from hyprpaper.conf, used until (and unless) hyprctl answers. - property string configPath: "" - signal changed() + // Every monitor shows the same picture. Kept as a per-monitor lookup so + // callers do not have to change if that stops being true. function forMonitor(name: string): url { - const p = root.byMonitor[name]; - return p ? Qt.resolvedUrl("file://" + p) : root.source; + return root.source; } - function refresh() { - reader.running = true; + function set(p: string) { + const next = root._expand(p); + if (next === "") return; + store.setText("# The wallpaper the shell draws (services/Wallpaper.qml). One path; ~ allowed.\n" + + next + "\n"); } function _expand(p: string): string { @@ -50,71 +44,19 @@ Singleton { return s; } - function _adopt(map: var, fallback: string) { - root.byMonitor = map; - - // Prefer the focused monitor's wallpaper, then any monitor's, then the - // config. Object.values order is insertion order here, which is the - // order hyprpaper listed them in. - const vals = Object.keys(map).map(k => map[k]).filter(v => v !== ""); - const next = vals.length > 0 ? vals[0] : fallback; - - if (next !== root.path) { - root.path = next; - root.changed(); - } - } - - Process { - id: reader - command: ["hyprctl", "hyprpaper", "listactive"] - - stdout: StdioCollector { - onStreamFinished: { - const map = {}; - for (const line of text.split("\n")) { - // "eDP-1: /home/narl/.wallpapers/takemi_1.jpg". Split on the - // first colon only — the path may contain one. - const at = line.indexOf(":"); - if (at === -1) continue; - const mon = line.slice(0, at).trim(); - const file = line.slice(at + 1).trim(); - if (file === "" || file.startsWith("invalid")) continue; - map[mon] = file; - } - root._adopt(map, root.configPath); - } - } - - // hyprpaper down, or hyprctl missing. Fall back to the config file. - onExited: code => { - if (code !== 0 && root.path === "") root._adopt({}, root.configPath); - } - } - - // hyprpaper has no change signal to subscribe to, so poll — but slowly. A - // wallpaper changes when a person decides to change it, not continuously. - Timer { - interval: 10000 - running: true - repeat: true - triggeredOnStart: true - onTriggered: root.refresh() - } - - // Editing the config is the other way the wallpaper changes here, and that - // one we can watch properly. FileView { - id: conf - path: Quickshell.env("HOME") + "/.config/hypr/hyprpaper.conf" + id: store + path: Quickshell.env("HOME") + "/.config/quickshell/wallpaper" watchChanges: true + printErrors: false onFileChanged: reload() onLoaded: { - const m = /^\s*path\s*=\s*(.+)$/m.exec(text()); - if (m) root.configPath = root._expand(m[1]); - // A config edit is usually followed by a hyprpaper reload, so go - // ask what actually took effect rather than trusting the file. - root.refresh(); + const line = text().split("\n").map(l => l.trim()).find(l => l !== "" && !l.startsWith("#")); + const next = line ? root._expand(line) : ""; + if (next !== root.path) { + root.path = next; + root.changed(); + } } } } diff --git a/quickshell/shell.qml b/quickshell/shell.qml index 315daa8..ad7fdb1 100644 --- a/quickshell/shell.qml +++ b/quickshell/shell.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import "root:/config" import "root:/services" +import "root:/backdrop" import "root:/bar" import "root:/launcher" import "root:/dashboard" @@ -12,6 +13,7 @@ import "root:/lock" // Takemi — a YoRHa-dark Quickshell desktop: NieR:Automata's interface // language, lit like the wallpaper. // +// backdrop/ the wallpaper, drifting with the workspace // bar/ the hull, the top bar, the rail and their modules // popouts/ hover panels hanging off bar modules // launcher/ the application launcher @@ -36,6 +38,14 @@ ShellRoot { void Bt.available; void Actions.powerMenuOpen; void Apps.index; + void Idle.stayAwake; + void Wallpaper.path; + } + + Variants { + model: Quickshell.screens + + Backdrop {} } Variants { @@ -92,5 +102,11 @@ ShellRoot { Osd {} } + Variants { + model: Quickshell.screens + + IdleDim {} + } + Lock {} } diff --git a/quickshell/wallpaper b/quickshell/wallpaper new file mode 100644 index 0000000..a6873d5 --- /dev/null +++ b/quickshell/wallpaper @@ -0,0 +1,2 @@ +# The wallpaper the shell draws (services/Wallpaper.qml). One path; ~ allowed. +~/.wallpapers/space-1.jpg