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