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.
This commit is contained in:
nvrl committed 2026-10-04 23:10:19 +02:00
1 parent 864420281b
commit c36a74e861
20 files changed
+396 -128

No files matched your search

+2 -1
View File
@@ -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")
+1
View File
@@ -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" })
+1 -8
View File
@@ -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
+18 -22
View File
@@ -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,
},
},
+2 -3
View File
@@ -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
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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"
+42 -4
View File
@@ -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 <path>` 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.
+90
View File
@@ -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
}
+41 -3
View File
@@ -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.
+13
View File
@@ -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
+12
View File
@@ -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
+39
View File
@@ -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
}
}
}
}
+3 -3
View File
@@ -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
+2
View File
@@ -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); }
}
}
+68
View File
@@ -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()
}
}
+17
View File
@@ -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 || ""
+24 -82
View File
@@ -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;
FileView {
id: store
path: Quickshell.env("HOME") + "/.config/quickshell/wallpaper"
watchChanges: true
printErrors: false
onFileChanged: reload()
onLoaded: {
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();
}
}
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"
watchChanges: true
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();
}
}
}
+16
View File
@@ -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 {}
}
+2
View File
@@ -0,0 +1,2 @@
# The wallpaper the shell draws (services/Wallpaper.qml). One path; ~ allowed.
~/.wallpapers/space-1.jpg