From 6addb504e668623c44abad801716715c48cece89 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 9 Oct 2026 04:17:36 +0200 Subject: [PATCH] some qs fixes --- hypr/hyprland.lua | 10 +- hypr/lua/binds/media.lua | 5 +- hypr/lua/gamemode.lua | 124 ++++++++++++++++++ hypr/lua/rules/windows.lua | 5 + quickshell/README.md | 14 ++ quickshell/bar/MediaPill.qml | 2 +- quickshell/bar/Rail.qml | 2 +- quickshell/bar/Workspaces.qml | 3 +- quickshell/components/NotifCard.qml | 10 +- quickshell/dashboard/Dashboard.qml | 13 +- quickshell/lock/Lock.qml | 1 + quickshell/overlays/IdleDim.qml | 25 +++- quickshell/overlays/Toasts.qml | 42 ++++-- quickshell/popouts/NetPopout.qml | 46 ++++++- quickshell/services/Actions.qml | 38 +++++- quickshell/services/Ask.qml | 18 ++- quickshell/services/Audio.qml | 5 +- quickshell/services/Brightness.qml | 32 ++++- quickshell/services/GameMode.qml | 24 ++++ quickshell/services/Media.qml | 2 +- quickshell/services/Net.qml | 42 +++--- quickshell/services/Notifs.qml | 106 ++++++++++++--- quickshell/services/Sys.qml | 12 +- quickshell/services/Wallpaper.qml | 4 +- quickshell/shell.qml | 7 + .../proton-drive.service | 0 26 files changed, 510 insertions(+), 82 deletions(-) create mode 100644 hypr/lua/gamemode.lua create mode 100644 quickshell/services/GameMode.qml rename systemd/user/{default.target.wants => graphical-session.target.wants}/proton-drive.service (100%) diff --git a/hypr/hyprland.lua b/hypr/hyprland.lua index 2f9024f..9a92b98 100644 --- a/hypr/hyprland.lua +++ b/hypr/hyprland.lua @@ -4,9 +4,10 @@ -- lua/, and each require() is its own scope: an error in one file does not stop -- the others from loading. -- --- Order matters only in two places: palette must exist before anything paints --- with it, and monitors must exist before the lid handler binds to them. Both --- are handled by the requires below, top to bottom. +-- Order matters only in three places: palette must exist before anything +-- paints with it, monitors must exist before the lid handler binds to them, +-- and game mode reads the look and the animations as configured. All three are +-- handled by the requires below, top to bottom. -- what the desktop looks like require("lua/palette") @@ -27,6 +28,9 @@ require("lua/autostart") require("lua/rules/windows") require("lua/rules/layers") +-- after look and animations: it reads their values to put back +require("lua/gamemode") + -- how it is driven require("lua/binds/apps") require("lua/binds/windows") diff --git a/hypr/lua/binds/media.lua b/hypr/lua/binds/media.lua index 3d21505..c3c7e6a 100644 --- a/hypr/lua/binds/media.lua +++ b/hypr/lua/binds/media.lua @@ -4,8 +4,9 @@ -- Volume and brightness repeat when held. local held = { locked = true, repeating = true } -hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("fluxo vol up 5"), held) -hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("fluxo vol down 5"), held) +-- wpctl rather than `fluxo vol`, which goes up to 150%; `-l 1` stops at 100%. +hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+"), held) +hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), held) hl.bind("XF86AudioMute", hl.dsp.exec_cmd("fluxo vol mute"), held) hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("fluxo mic mute"), held) hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl s +10%"), held) diff --git a/hypr/lua/gamemode.lua b/hypr/lua/gamemode.lua new file mode 100644 index 0000000..8fbe36e --- /dev/null +++ b/hypr/lua/gamemode.lua @@ -0,0 +1,124 @@ +-- Game mode: while a fullscreen game is on screen, neither the compositor nor +-- the shell spends anything on the desktop it covers. +-- +-- on <=> some monitor shows a game, fullscreen, on its active workspace +-- +-- Like lua/lid.lua, the answer is re-derived from live state on every trigger +-- and nothing is remembered, so a reload mid-game simply finds the game again. +-- +-- A game is a window that says so through the content-type protocol +-- (gamescope does), or one whose class gives it away: Proton names every Steam +-- game steam_app_. Add to GAME_CLASSES for anything else. +-- +-- The shell is told through its IPC and pauses its own polling and animation +-- (quickshell/services/GameMode.qml). + +-- Lua patterns, matched against the window class. +local GAME_CLASSES = { + "^steam_app_%d+$", + "^gamescope$", +} + +-- What game mode sets. What it puts back is read from the config as loaded, so +-- lua/look.lua and lua/animations.lua stay the only place those are decided. A +-- reload resets every option before the config runs, so this never reads game +-- mode's own values back. +local GAME = { + -- Nothing under a fullscreen window is worth animating, and leaving the + -- game should be instant. + ["animations.enabled"] = false, + ["decoration.shadow.enabled"] = false, + -- Hand the game's frames straight to the display instead of compositing + -- them every refresh. Only while playing: with it on all the time, some + -- ordinary fullscreen apps flicker or show the wrong colours. + ["render.direct_scanout"] = 1, +} + +local NORMAL = {} +for option in pairs(GAME) do + NORMAL[option] = hl.get_config(option) +end + +-- ------------------------------------------------------------- reading state + +local function is_game(win) + if win.content_type == "game" then + return true + end + local class = win.class or "" + for _, pattern in ipairs(GAME_CLASSES) do + if class:match(pattern) then + return true + end + end + return false +end + +-- `ignore` is a window being closed: during window.close it can still be +-- listed, and counting it would keep game mode on after the game has gone. +-- Fullscreen 2 is real fullscreen; a maximised game (1) still shows the hull. +local function playing(ignore) + for _, win in ipairs(hl.get_windows()) do + if win ~= ignore + and win.mapped + and win.fullscreen >= 2 + and win.monitor + and win.monitor.active_workspace == win.workspace + and is_game(win) + then + return true + end + end + return false +end + +-- ---------------------------------------------------------------- reconciling + +-- A fresh config is the normal one, so only a game found at load needs +-- applying. The shell heard the last "false" before the reload. +local active = false + +local function tell_shell() + hl.exec_cmd("qs ipc call shell gameMode " .. tostring(active)) +end + +local function reconcile(ignore) + local want = playing(ignore) + if want == active then + return + end + + active = want + hl.config(active and GAME or NORMAL) + tell_shell() +end + +-- ------------------------------------------------------------------ triggers + +for _, event in ipairs({ + "window.open", + "window.fullscreen", + "window.move_to_workspace", + -- Proton sets the class after the window opens. + "window.class", + "workspace.active", + "monitor.removed", +}) do + hl.on(event, function() + reconcile() + end) +end + +hl.on("window.close", function(win) + reconcile(win) +end) + +-- A shell started mid-game (Super+Shift+R, or a crash) begins with game mode +-- off. Its bar mapping is the sign it is up and listening. +hl.on("layer.opened", function(layer) + if active and layer.namespace == "takemi-bar" then + tell_shell() + end +end) + +reconcile() diff --git a/hypr/lua/rules/windows.lua b/hypr/lua/rules/windows.lua index 2671ab6..26e6f5f 100644 --- a/hypr/lua/rules/windows.lua +++ b/hypr/lua/rules/windows.lua @@ -30,6 +30,11 @@ local GAMESCOPE = "^(gamescope)$" -- }) hl.window_rule({ match = { class = "^$", title = "^$" }, no_blur = true }) +-- Hold idle off while any window is fullscreen. Players do not all ask for +-- that themselves (Brave goes through D-Bus, which nothing here listens to), +-- and the shell's dim and lock (quickshell/services/Idle.qml) respect inhibitors. +hl.window_rule({ match = { class = ".*" }, idle_inhibit = "fullscreen" }) + -- ========================================== -- FLOATING & CENTERED DIALOGS -- ========================================== diff --git a/quickshell/README.md b/quickshell/README.md index fca937e..e5d6ce1 100644 --- a/quickshell/README.md +++ b/quickshell/README.md @@ -119,6 +119,20 @@ not disturb and stay awake, plus night light when `hyprsunset` is installed (it runs at 4200 K for as long as the switch is on). On is the light slab with ink type, the same as a selection everywhere else; off is a hairline. +## Game mode + +While a fullscreen game is on screen, Hyprland switches off its animations and +shadows and turns on direct scanout (`~/.config/hypr/lua/gamemode.lua`). It +also tells the shell through `qs ipc call shell gameMode true`. The game covers +the hull, but covered surfaces still render, so the shell stops what it does by +itself: the fluxo stream, the backlight poll, the media tick and equaliser, and +the rail and workspace pulses. Toasts wait in the log, except critical ones. +Both sides come back when the game leaves fullscreen, closes or is switched +away from. + +A game is a window with the `game` content type, or one whose class matches +`GAME_CLASSES` in `gamemode.lua` (`steam_app_` and `gamescope` so far). + ## Modules | Module | Where | Left click | Right click | Middle | Scroll | Hover | diff --git a/quickshell/bar/MediaPill.qml b/quickshell/bar/MediaPill.qml index 1e76e33..a9393d3 100644 --- a/quickshell/bar/MediaPill.qml +++ b/quickshell/bar/MediaPill.qml @@ -170,7 +170,7 @@ BarPill { Timer { interval: eq.tickMs - running: Media.playing && eq.visible && (eq.Window.window?.visible ?? true) + running: Media.playing && eq.visible && (eq.Window.window?.visible ?? true) && !GameMode.active repeat: true onTriggered: eq.phase++ // Restart the cycle from its trough, so playback always begins diff --git a/quickshell/bar/Rail.qml b/quickshell/bar/Rail.qml index c9e9429..06ad146 100644 --- a/quickshell/bar/Rail.qml +++ b/quickshell/bar/Rail.qml @@ -130,7 +130,7 @@ PanelWindow { statusText: Math.round(Battery.percent) + "% · " + Battery.timeLabel suppressTooltip: battPopout.detailActive alarm: Battery.critical - pulsing: Battery.critical + pulsing: Battery.critical && !GameMode.active onClicked: battPopout.togglePinned() } } diff --git a/quickshell/bar/Workspaces.qml b/quickshell/bar/Workspaces.qml index d86c442..5f8bf46 100644 --- a/quickshell/bar/Workspaces.qml +++ b/quickshell/bar/Workspaces.qml @@ -3,6 +3,7 @@ import Quickshell import Quickshell.Hyprland import "root:/config" import "root:/components" +import "root:/services" // Workspace indicator: soft pills that stretch open when focused. Occupied // workspaces fill in, urgent ones snap to the alarm colour. @@ -104,7 +105,7 @@ Item { // Urgent is the one pip that has to be seen peripherally. SequentialAnimation on opacity { - running: pip.urgent && !pip.focused + running: pip.urgent && !pip.focused && !GameMode.active loops: Animation.Infinite alwaysRunToEnd: true NumberAnimation { to: 0.4; duration: 520; easing.type: Easing.InOutQuad } diff --git a/quickshell/components/NotifCard.qml b/quickshell/components/NotifCard.qml index 541a11e..bb45113 100644 --- a/quickshell/components/NotifCard.qml +++ b/quickshell/components/NotifCard.qml @@ -206,7 +206,10 @@ Item { Row { x: Theme.marker + Theme.padS - visible: root.showActions && (root.notif?.actions?.length ?? 0) > 0 + // Gone once the app has closed the notification: nothing is + // listening for the action any more. + visible: root.showActions && !Notifs.isRetracted(root.notif) + && (root.notif?.actions?.length ?? 0) > 0 spacing: Theme.padS topPadding: 4 @@ -265,10 +268,7 @@ Item { } TapHandler { - onTapped: { - actionChip.modelData.invoke(); - root.dismissed(); - } + onTapped: Notifs.invoke(root.notif, actionChip.modelData) } } } diff --git a/quickshell/dashboard/Dashboard.qml b/quickshell/dashboard/Dashboard.qml index 0506312..209118d 100644 --- a/quickshell/dashboard/Dashboard.qml +++ b/quickshell/dashboard/Dashboard.qml @@ -65,7 +65,18 @@ PanelWindow { implicitHeight: root.bodyHeight + 1 color: "transparent" - mask: Region { item: body } + // A Region follows its item's own geometry, not its parents', so masking + // to the body, which rides on the sliding sheet, would freeze the mask + // wherever the sheet was when it was built — above the window, letting + // every click through to whatever is below and dropping the focus grab. + mask: Region { item: hitbox } + + Item { + id: hitbox + x: root.fillet + width: root.bodyWidth + height: parent.height + } HyprlandFocusGrab { active: root.open diff --git a/quickshell/lock/Lock.qml b/quickshell/lock/Lock.qml index f746dc6..538c51a 100644 --- a/quickshell/lock/Lock.qml +++ b/quickshell/lock/Lock.qml @@ -13,6 +13,7 @@ WlSessionLock { id: lock locked: Actions.lockRequested + onSecureChanged: Actions.lockSecure = lock.secure WlSessionLockSurface { id: lockSurface diff --git a/quickshell/overlays/IdleDim.qml b/quickshell/overlays/IdleDim.qml index da6df67..2cdaec0 100644 --- a/quickshell/overlays/IdleDim.qml +++ b/quickshell/overlays/IdleDim.qml @@ -27,13 +27,26 @@ PanelWindow { id: shade anchors.fill: parent color: Theme.ink - opacity: Idle.dimmed ? 0.6 : 0 + opacity: 0 - Behavior on opacity { - NumberAnimation { - duration: Idle.dimmed ? 8000 : Theme.durBase - easing.type: Idle.dimmed ? Easing.InOutSine : Easing.OutCubic - } + // States, not a Behavior: a Behavior whose duration is bound to + // Idle.dimmed can start before that binding updates, so waking up + // would replay the slow fade in reverse. + states: State { + name: "dimmed" + when: Idle.dimmed + PropertyChanges { shade.opacity: 0.6 } } + + transitions: [ + Transition { + to: "dimmed" + NumberAnimation { property: "opacity"; duration: 8000; easing.type: Easing.InOutSine } + }, + Transition { + from: "dimmed" + NumberAnimation { property: "opacity"; duration: Theme.durBase; easing.type: Easing.OutCubic } + } + ] } } diff --git a/quickshell/overlays/Toasts.qml b/quickshell/overlays/Toasts.qml index e30831b..6c01b07 100644 --- a/quickshell/overlays/Toasts.qml +++ b/quickshell/overlays/Toasts.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Shapes import Quickshell +import Quickshell.Hyprland import Quickshell.Wayland import Quickshell.Services.Notifications import "root:/config" @@ -15,6 +16,10 @@ import "root:/services" // with each notification an entry in it divided by its own header hairline. // The drawer grows and shrinks with the entries and slides back behind the // frame when the last one goes. +// +// A fullscreen window covers the bar and the rail, so there is no frame to +// lower the drawer out of. Then it floats free in the corner as a plain +// rounded panel instead. PanelWindow { id: root @@ -30,17 +35,20 @@ PanelWindow { WlrLayershell.keyboardFocus: WlrKeyboardFocus.None exclusionMode: ExclusionMode.Ignore + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.screen) + readonly property bool floating: root.monitor?.activeWorkspace?.hasFullscreen ?? false + // One pixel over the bar and the rail, so the drawer covers the rim the // hull draws along both edges instead of sitting inside it. anchors { top: true; right: true } margins { - top: Theme.barHeight - 1 - right: Theme.railWidth - 1 + top: root.floating ? Theme.padM : Theme.barHeight - 1 + right: root.floating ? Theme.padM : Theme.railWidth - 1 } - readonly property real fillet: Theme.frameRadius + readonly property real fillet: root.floating ? 0 : Theme.frameRadius readonly property real corner: Theme.frameRadius - readonly property real seam: 1 + readonly property real seam: root.floating ? 0 : 1 readonly property real drawerWidth: 400 // Height of the entries, eased, so the drawer grows rather than jumps. @@ -75,6 +83,10 @@ PanelWindow { if (Actions.focusedScreenName() !== root.modelData.name) return; root.live = [notif, ...root.live].slice(0, root.maxLive); } + // Closed by its app or from the log: take it off screen too. + function onNotifRetracted(notif) { + root.retire(notif); + } } function retire(notif) { @@ -99,6 +111,7 @@ PanelWindow { // onto the rail through the bottom fillet — Popout's "topRight". Shape { id: silhouette + visible: !root.floating anchors.fill: parent preferredRendererType: Shape.CurveRenderer @@ -125,6 +138,15 @@ PanelWindow { } } + Rectangle { + visible: root.floating + anchors.fill: parent + radius: root.corner + color: Theme.hull + border.width: 1 + border.color: Theme.rim + } + Column { id: stack x: root.fillet @@ -132,8 +154,11 @@ PanelWindow { width: root.drawerWidth bottomPadding: Theme.padS + // A ScriptModel, not the bare list: it hands the Repeater only + // what changed, so a new toast does not rebuild the others and + // restart their fuses. Repeater { - model: root.live + model: ScriptModel { values: [...root.live] } Item { id: toast @@ -146,10 +171,9 @@ PanelWindow { id: card width: parent.width notif: toast.modelData - onDismissed: { - Notifs.dismiss(toast.modelData); - root.retire(toast.modelData); - } + // Closing it retracts it, which retires the toast; + // nothing may run here after that, the entry is gone. + onDismissed: Notifs.dismiss(toast.modelData) } // Countdown: a hairline draining along the entry's diff --git a/quickshell/popouts/NetPopout.qml b/quickshell/popouts/NetPopout.qml index f27d4ab..0e9db5d 100644 --- a/quickshell/popouts/NetPopout.qml +++ b/quickshell/popouts/NetPopout.qml @@ -5,12 +5,12 @@ import "root:/config" import "root:/components" import "root:/services" -// Link state, throughput, and the Wi-Fi picker. +// Link state, throughput, the wired ports, and the Wi-Fi picker. Popout { id: root contentWidth: 360 - contentHeight: 300 + contentHeight: 300 + (wired.visible ? wired.height + Theme.padM : 0) // Scan only while the popout is actually on screen. onShownChanged: Net.setScanning(shown) @@ -76,13 +76,49 @@ Popout { } } - SectionRule { - id: apsLabel + // One row per Ethernet port; a click brings the link up or down. + Column { + id: wired anchors.top: controls.bottom anchors.topMargin: Theme.padM anchors.left: parent.left anchors.right: parent.right - text: Net.scanning ? "Scanning…" : "Networks" + visible: Net.wiredDevices.length > 0 + spacing: 2 + + SectionRule { + width: parent.width + text: "Wired" + } + + Item { + width: parent.width + height: Theme.padS - 2 + } + + Repeater { + model: Net.wiredDevices + + delegate: ListRow { + required property var modelData + + width: wired.width + icon: modelData.hasLink ? "󰈀" : "󰈂" + label: "Ethernet" + detail: Net.wiredDetail(modelData) + current: modelData.connected + onClicked: Net.toggleWired(modelData) + } + } + } + + SectionRule { + id: apsLabel + anchors.top: wired.visible ? wired.bottom : controls.bottom + anchors.topMargin: Theme.padM + anchors.left: parent.left + anchors.right: parent.right + text: Net.scanning ? "Scanning…" : "Wi-Fi" } ListView { diff --git a/quickshell/services/Actions.qml b/quickshell/services/Actions.qml index 61ec1c8..6ff4072 100644 --- a/quickshell/services/Actions.qml +++ b/quickshell/services/Actions.qml @@ -168,8 +168,41 @@ Singleton { function logout() { root.run(["uwsm", "stop"]); } function reboot() { root.run(["systemctl", "reboot"]); } function shutdown() { root.run(["systemctl", "poweroff"]); } - function suspend() { root.lock(); root.run(["systemctl", "suspend"]); } - function hibernate() { root.lock(); root.run(["systemctl", "hibernate"]); } + function suspend() { root.sleep(["systemctl", "suspend"]); } + function hibernate() { root.sleep(["systemctl", "hibernate"]); } + + // Set by lock/Lock.qml once the compositor has the lock surfaces up. + property bool lockSecure: false + // A sleep waiting for the lock. Suspending first would leave the desktop + // on screen for a moment on wake, before the lock had drawn. + property var pendingSleep: null + + function sleep(argv: var) { + root.lock(); + if (root.lockSecure) { + root.run(argv); + return; + } + root.pendingSleep = argv; + sleepFallback.restart(); + } + + function _sleepNow() { + if (!root.pendingSleep) return; + const argv = root.pendingSleep; + root.pendingSleep = null; + sleepFallback.stop(); + root.run(argv); + } + + onLockSecureChanged: if (root.lockSecure) root._sleepNow() + + // A lock that never comes up must not keep the machine awake on battery. + Timer { + id: sleepFallback + interval: 3000 + onTriggered: root._sleepNow() + } function reloadShell() { Quickshell.reload(true); } // ------------------------------------------------------------- helpers @@ -195,6 +228,7 @@ Singleton { function toggleDnd(): void { Notifs.toggleDnd(); } function clearNotifications(): void { Notifs.clearAll(); } function toggleStayAwake(): void { Idle.toggleStayAwake(); } + function gameMode(on: bool): void { GameMode.active = on; } function overview(): void { root.toggleOverview(); } function overviewNext(): void { root.overviewStep(1); } function overviewPrev(): void { root.overviewStep(-1); } diff --git a/quickshell/services/Ask.qml b/quickshell/services/Ask.qml index 8553192..f90fee6 100644 --- a/quickshell/services/Ask.qml +++ b/quickshell/services/Ask.qml @@ -107,8 +107,15 @@ Singleton { root.lastCost = 0; } + // Set while a run is being killed on purpose, so its exit is not reported + // as a failure. + property bool stopping: false + function stop(): void { - if (proc.running) proc.running = false; + if (proc.running) { + root.stopping = true; + proc.running = false; + } root.busy = false; } @@ -125,7 +132,10 @@ Singleton { root.exitCode = 0; root.stderrText = ""; - proc.running = false; + if (proc.running) { + root.stopping = true; + proc.running = false; + } // stdin is closed explicitly. Inherited from the shell it never ends, // and the CLI waits three seconds for input that is never coming // before it starts on the question. @@ -236,6 +246,10 @@ Singleton { } onExited: (code, status) => { + if (root.stopping) { + root.stopping = false; + return; + } root.busy = false; root.exitCode = code; if (code === 0 || root.error !== "") return; diff --git a/quickshell/services/Audio.qml b/quickshell/services/Audio.qml index 090e28a..1073073 100644 --- a/quickshell/services/Audio.qml +++ b/quickshell/services/Audio.qml @@ -49,9 +49,10 @@ Singleton { onMicPercentChanged: micBumped() onMicMutedChanged: micBumped() + // Capped at 100%: past it PipeWire only amplifies in software, and clips. function setVolume(v: real) { if (!sink?.audio) return; - sink.audio.volume = Math.max(0, Math.min(1.5, v)); + sink.audio.volume = Math.max(0, Math.min(1, v)); } function changeVolume(delta: real) { @@ -65,7 +66,7 @@ Singleton { function setMicVolume(v: real) { if (!source?.audio) return; - source.audio.volume = Math.max(0, Math.min(1.5, v)); + source.audio.volume = Math.max(0, Math.min(1, v)); } function toggleMicMute() { diff --git a/quickshell/services/Brightness.qml b/quickshell/services/Brightness.qml index 820a9ad..0b05a00 100644 --- a/quickshell/services/Brightness.qml +++ b/quickshell/services/Brightness.qml @@ -37,6 +37,8 @@ Singleton { const v = parseInt(text().trim()); if (!isNaN(v) && v !== root.raw) { root.raw = v; + // Settled, or moved by something else: step from here again. + if (!setter.running && root.pending < 0) root.target = -1; root.bumped(); } } @@ -45,24 +47,44 @@ Singleton { // sysfs writes do not always fire inotify for backlight, so poll gently too. Timer { interval: 1000 - running: true + running: !GameMode.active repeat: true onTriggered: currentFile.reload() } + // The level last asked for, until the backlight reports it. Steps build on + // it rather than on `percent`, which only catches up on the next read, so + // a fast scroll adds up instead of repeating one step. + property int target: -1 + // A level asked for while a write is still running. Restarting the write + // would kill it half done, so the newest request waits and the rest drop. + property int pending: -1 + Process { id: setter + onExited: { + currentFile.reload(); + if (root.pending < 0) return; + const next = root.pending; + root.pending = -1; + root._write(next); + } + } + + function _write(p: int) { + setter.command = ["brightnessctl", "-d", root.device, "-q", "set", p + "%"]; + setter.running = true; } function setPercent(p: real) { const clamped = Math.max(1, Math.min(100, Math.round(p))); - setter.running = false; - setter.command = ["brightnessctl", "-d", root.device, "-q", "set", clamped + "%"]; - setter.running = true; + root.target = clamped; + if (setter.running) root.pending = clamped; + else root._write(clamped); } function change(delta: real) { - setPercent(percent + delta); + setPercent((root.target >= 0 ? root.target : percent) + delta); } readonly property string icon: { diff --git a/quickshell/services/GameMode.qml b/quickshell/services/GameMode.qml new file mode 100644 index 0000000..1d3be82 --- /dev/null +++ b/quickshell/services/GameMode.qml @@ -0,0 +1,24 @@ +pragma Singleton + +import QtQuick +import Quickshell + +// A fullscreen game is on screen. Hyprland decides (~/.config/hypr/lua/gamemode.lua) +// and says so with `qs ipc call shell gameMode true|false`. +// +// The game covers the hull, but a covered surface still renders: Hyprland keeps +// sending it frame callbacks, so anything that moves keeps redrawing at the +// panel's refresh rate with nobody watching. While a game runs the shell stops +// what it does on its own: +// +// Sys the fluxo stream, so no telemetry wakes the shell +// Brightness the backlight poll +// Media the position tick, and the equaliser on the bar +// Rail the critical battery's pulse +// Workspaces the urgent pip's pulse +// Notifs toasts, except critical ones; everything still reaches the log +// +// Anything you ask for yourself, the OSD included, still shows. +Singleton { + property bool active: false +} diff --git a/quickshell/services/Media.qml b/quickshell/services/Media.qml index 3a5d933..1e976ed 100644 --- a/quickshell/services/Media.qml +++ b/quickshell/services/Media.qml @@ -74,7 +74,7 @@ Singleton { // MPRIS position is pull-only; tick it so the seek bar moves. Timer { interval: 1000 - running: root.playing && root.positionSupported + running: root.playing && root.positionSupported && !GameMode.active repeat: true onTriggered: if (root.active) root.active.positionChanged() } diff --git a/quickshell/services/Net.qml b/quickshell/services/Net.qml index 64f5732..885d52d 100644 --- a/quickshell/services/Net.qml +++ b/quickshell/services/Net.qml @@ -16,10 +16,13 @@ Singleton { readonly property var devices: Networking.devices?.values ?? [] readonly property var wifiDevices: devices.filter(d => d.type === DeviceType.Wifi) - readonly property var wiredDevices: devices.filter(d => d.type === DeviceType.Wired) + // Docker's veth pairs show up as wired too; NetworkManager leaves them + // unmanaged, and so does the popout. + readonly property var wiredDevices: devices.filter(d => d.type === DeviceType.Wired && d.nmManaged) readonly property WifiDevice wifi: wifiDevices.length > 0 ? wifiDevices[0] : null - readonly property WiredDevice wired: wiredDevices.length > 0 ? wiredDevices[0] : null + // The port in use, or failing that the first one there is. + readonly property WiredDevice wired: wiredDevices.find(d => d.connected) ?? wiredDevices[0] ?? null readonly property bool wiredUp: wired?.connected ?? false @@ -55,14 +58,8 @@ Singleton { if (!root.wifiEnabled) return "󰤭"; if (!root.activeWifi) return "󰤯"; const s = root.activeWifi.signalStrength ?? 0; - // Strength is only sampled while a scan is running, which the shell - // does on demand. Connected-but-unsampled should read as full, not dead. - if (s <= 0) return "󰤨"; - if (s >= 80) return "󰤨"; - if (s >= 60) return "󰤥"; - if (s >= 40) return "󰤢"; - if (s >= 20) return "󰤟"; - return "󰤯"; + // Connected but not yet sampled should read as full, not dead. + return s <= 0 ? "󰤨" : root.signalIcon(s); } readonly property string label: { @@ -72,14 +69,29 @@ Singleton { return "Offline"; } - function signalIcon(strength: int): string { - if (strength >= 80) return "󰤨"; - if (strength >= 60) return "󰤥"; - if (strength >= 40) return "󰤢"; - if (strength >= 20) return "󰤟"; + // Strength is a fraction, 0 to 1, not a percentage. + function signalIcon(strength: real): string { + if (strength >= 0.8) return "󰤨"; + if (strength >= 0.6) return "󰤥"; + if (strength >= 0.4) return "󰤢"; + if (strength >= 0.2) return "󰤟"; return "󰤯"; } + // "1 Gb/s" when up, otherwise why not. NetworkManager reports Mb/s. + function wiredDetail(d): string { + if (!d.hasLink) return d.name + " · no cable"; + if (!d.connected) return d.name + " · disconnected"; + const mbps = d.linkSpeed ?? 0; + if (mbps <= 0) return d.name; + return d.name + " · " + (mbps >= 1000 ? (mbps / 1000) + " Gb/s" : mbps + " Mb/s"); + } + + function toggleWired(d) { + if (d.connected) d.disconnect(); + else if (d.network) d.network.connect(); + } + function secured(n): bool { return n && n.security !== undefined && n.security !== WifiSecurityType.Open diff --git a/quickshell/services/Notifs.qml b/quickshell/services/Notifs.qml index edc2f4c..a0a45c3 100644 --- a/quickshell/services/Notifs.qml +++ b/quickshell/services/Notifs.qml @@ -16,7 +16,7 @@ Singleton { property list history: [] property int maxHistory: 60 - readonly property int count: history.length + readonly property int count: history.filter(n => n).length // The history by sender, for the log: one entry per app, newest first // inside each, the apps ordered by their newest notification. @@ -24,14 +24,21 @@ Singleton { const byApp = {}; const order = []; for (const n of root.history) { + if (!n) continue; const app = n.appName || "System"; if (!(app in byApp)) { byApp[app] = []; order.push(app); } byApp[app].push(n); } return order.map(app => ({ app: app, items: byApp[app] })); } + + // The server hands out ids in rising order, so everything in the log above + // the newest id seen when the log was last opened is unread. Derived from + // the history rather than counted, so dismissing an entry anywhere takes + // it off the count too. + property int readId: 0 + readonly property int unread: history.filter(n => n && n.id > root.readId).length readonly property bool hasUnread: unread > 0 - property int unread: 0 NotificationServer { id: server @@ -49,42 +56,103 @@ Singleton { onNotification: notif => { // Hold the notification open until we drop it ourselves. notif.tracked = true; + root.adopt(notif); - root.history = [notif, ...root.history].slice(0, root.maxHistory); - root.unread++; + const next = [notif, ...root.history.filter(n => n)]; + for (const n of next.slice(root.maxHistory)) root.release(n); + root.history = next.slice(0, root.maxHistory); - if (!root.dnd) root.popupRequested(notif); + // A game holds every toast but a critical one; the log has them all. + const held = GameMode.active && notif.urgency !== NotificationUrgency.Critical; + if (!root.dnd && !held) root.popupRequested(notif); } } + // The server outlives a reload, and keeps what it was tracking; this + // object does not. Take those back into the log, already read. + Component.onCompleted: { + const kept = server.trackedNotifications.values.slice().sort((a, b) => b.id - a.id); + for (const n of kept) root.adopt(n); + root.history = kept.slice(0, root.maxHistory); + for (const n of kept.slice(root.maxHistory)) root.release(n); + root.markRead(); + } + + // An app may close its own notification at any time, and the server then + // destroys the object, leaving a null in the history. The lock keeps it + // alive, frozen as it was, until the log lets it go; closing it only takes + // it off screen. + function adopt(notif: Notification) { + root.holds[notif.id] = hold.createObject(root, { object: notif }); + notif.closed.connect(() => { + root.retracted = Object.assign({}, root.retracted, { [notif.id]: true }); + root.notifRetracted(notif); + }); + } + + Component { + id: hold + RetainableLock { locked: true } + } + + // One lock per notification in the history, by id. + property var holds: ({}) + // Ids of the notifications whose app has closed them. They stay in the log + // but can no longer be acted on. + property var retracted: ({}) + signal popupRequested(Notification notif) + signal notifRetracted(Notification notif) + + function isRetracted(notif: Notification): bool { + return !notif || root.retracted[notif.id] === true; + } + + // Close a notification if its app has not, and let the object go. + function release(notif: Notification) { + if (!notif) return; + const id = notif.id; + if (!root.isRetracted(notif)) notif.dismiss(); + root.holds[id]?.destroy(); + delete root.holds[id]; + if (id in root.retracted) { + const next = Object.assign({}, root.retracted); + delete next[id]; + root.retracted = next; + } + } function dismiss(notif: Notification) { if (!notif) return; - const idx = root.history.indexOf(notif); - if (idx !== -1) { - const next = root.history.slice(); - next.splice(idx, 1); - root.history = next; - } - notif.dismiss(); + root.history = root.history.filter(n => n && n !== notif); + root.release(notif); } function dismissApp(app: string) { - const gone = root.history.filter(n => (n.appName || "System") === app); - root.history = root.history.filter(n => (n.appName || "System") !== app); - for (const n of gone) n.dismiss(); + const from = n => n.appName || "System"; + const gone = root.history.filter(n => n && from(n) === app); + root.history = root.history.filter(n => n && from(n) !== app); + for (const n of gone) root.release(n); } function clearAll() { - const all = root.history.slice(); + const all = root.history.filter(n => n); root.history = []; - root.unread = 0; - for (const n of all) n.dismiss(); + for (const n of all) root.release(n); } function markRead() { - root.unread = 0; + root.readId = Math.max(root.readId, ...root.history.filter(n => n).map(n => n.id)); + } + + // Run one of a notification's actions and take it out of the log. Done + // here rather than in the card: invoking closes a non-resident + // notification, which takes its toast, and the card with it, off screen + // before the card's handler has finished. + function invoke(notif: Notification, action: NotificationAction) { + if (!notif || root.isRetracted(notif)) return; + action.invoke(); + root.dismiss(notif); } function toggleDnd() { diff --git a/quickshell/services/Sys.qml b/quickshell/services/Sys.qml index 1235a6e..4d1b4d6 100644 --- a/quickshell/services/Sys.qml +++ b/quickshell/services/Sys.qml @@ -167,7 +167,17 @@ Singleton { id: retry interval: 2000 repeat: false - onTriggered: source.running = true + onTriggered: source.running = !GameMode.active + } + + // Nothing reads the meters while a game covers the bar. Drop the stream + // and pick it up again afterwards; the first lines back refresh every value. + Connections { + target: GameMode + + function onActiveChanged() { + source.running = !GameMode.active; + } } // ------------------------------------------------------------ formatting diff --git a/quickshell/services/Wallpaper.qml b/quickshell/services/Wallpaper.qml index b4e42b6..74b966e 100644 --- a/quickshell/services/Wallpaper.qml +++ b/quickshell/services/Wallpaper.qml @@ -19,8 +19,10 @@ Singleton { // The wallpaper to use when no particular monitor is being asked about. property string path: "" + // Encoded segment by segment: a `#` or `?` in a file name would otherwise + // be read as the start of a fragment or a query. readonly property url source: root.path !== "" - ? Qt.resolvedUrl("file://" + root.path) + ? "file://" + root.path.split("/").map(encodeURIComponent).join("/") : Theme.fallbackWallpaper signal changed() diff --git a/quickshell/shell.qml b/quickshell/shell.qml index 16cea91..12fbf34 100644 --- a/quickshell/shell.qml +++ b/quickshell/shell.qml @@ -1,3 +1,10 @@ +// The Samsung on DP-1 drops off the bus when its display powers down, so DPMS +// off (services/Idle.qml) removes an output under every window on it. With +// Qt's threaded render loop the render thread can still be swapping a surface +// the GUI thread just destroyed, which segfaults in Mesa and takes the lock +// screen down with it. The basic loop renders on the GUI thread, so it cannot. +//@ pragma Env QSG_RENDER_LOOP=basic + import QtQuick import Quickshell import "root:/config" diff --git a/systemd/user/default.target.wants/proton-drive.service b/systemd/user/graphical-session.target.wants/proton-drive.service similarity index 100% rename from systemd/user/default.target.wants/proton-drive.service rename to systemd/user/graphical-session.target.wants/proton-drive.service