From d1c92394e55574ad70a782a2450d326382b31494 Mon Sep 17 00:00:00 2001 From: rope Date: Sun, 16 Aug 2026 13:43:29 +0100 Subject: [PATCH] quickshell: move OSD into its own overlay window Co-Authored-By: Claude Opus 5 --- settings/hyprland.nix | 1 + settings/quickshell.nix | 242 ++++++++++++++++++++++------------------ 2 files changed, 133 insertions(+), 110 deletions(-) diff --git a/settings/hyprland.nix b/settings/hyprland.nix index 8a936e4..5160fe6 100644 --- a/settings/hyprland.nix +++ b/settings/hyprland.nix @@ -261,6 +261,7 @@ in -- Layer rules: blur behind bar and toasts hl.layer_rule({ match = { namespace = "quickshell-bar" }, blur = true, ignore_alpha = 0.3 }) + hl.layer_rule({ match = { namespace = "quickshell-osd" }, blur = true, ignore_alpha = 0.3 }) -- Startup hl.on("hyprland.start", function() diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 30ebb4e..f1aab08 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -1790,10 +1790,6 @@ in required property var shellRoot screen: modelData WlrLayershell.namespace: "quickshell-bar" - // Hyprland doesn't render the top layer under a fullscreen - // window, so the OSD would be invisible exactly when a video - // is playing. Ride the overlay layer while it's on screen. - WlrLayershell.layer: osdItem.shown ? WlrLayer.Overlay : WlrLayer.Top // OnDemand + HyprlandFocusGrab is the working combination // (caelestia's): the grab redirects focus to this window and // OnDemand lets the layer surface accept it. Exclusive fights @@ -6948,119 +6944,145 @@ in // is needed. Brightness arrives via shellRoot's signal instead, // because sysfs backlight notifies with POLLPRI, which inotify // never sees. Focused monitor only. - Item { - id: osdItem - readonly property bool isFocused: Hyprland.focusedMonitor - && Hyprland.focusedMonitor.name === bar.screen.name - // Every binding evaluates once at load; arm on a delay so - // a shell restart doesn't flash the slider on screen. - property bool armed: false - property bool shown: false - property string icon: "volume-2" - property real value: 0 - property color fill: Theme.base0D + // Its own window, not part of the bar's surface: Hyprland + // doesn't render the top layer under a fullscreen window, so an + // OSD drawn into the bar is invisible exactly when a video is + // playing. Overlay layer, sized to the bar strip, click-through, + // and only mapped while the OSD is on screen — so a fullscreen + // video gets the slider alone, no bar and no frame. + PanelWindow { + id: osdWin + screen: bar.screen + visible: osdItem.shown || osdItem.opacity > 0.01 + WlrLayershell.namespace: "quickshell-osd" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + // Ignore the bar's exclusive zone so this surface starts at + // y=0 like the bar's does — osdItem then reuses the bar's + // own coordinates to line up with the server glance slot. + exclusionMode: ExclusionMode.Ignore + anchors { top: true; left: true; bottom: true } + implicitWidth: Theme.barWidth + color: "transparent" + // Empty region: no input, clicks reach the video underneath. + mask: Region {} - // Takes over the server glance's slot: fixed position, no - // dependence on how tall the workspace column has grown. - width: Theme.barWidth - height: 170 - anchors.horizontalCenter: barBgRect.horizontalCenter - anchors.verticalCenter: srvWidget.verticalCenter - opacity: shown ? 1 : 0 - visible: opacity > 0.01 - - Behavior on opacity { - NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic } - } - - function show(ic, v, f) { - if (!armed || !isFocused) return; - icon = ic; - value = v; - fill = f; - shown = true; - osdTimer.restart(); - } - - Timer { id: osdTimer; interval: 1600; onTriggered: osdItem.shown = false } - Timer { id: osdArm; interval: 1500; running: true; onTriggered: osdItem.armed = true } - - function showVolume() { - // The volume dropdown already shows the level live. - if (bar.activeDropdown === volDropdown) return; - show(volWidget.volIcon, volWidget.vol / 100, - volWidget.muted ? Theme.base03 : Theme.base0D); - } - - Connections { - target: volWidget.sink && volWidget.sink.audio ? volWidget.sink.audio : null - // `volume` notifies through volumesChanged, not - // volumeChanged — it is a view over the per-channel - // volumes vector. - function onVolumesChanged() { osdItem.showVolume(); } - function onMutedChanged() { osdItem.showVolume(); } - } - - Connections { - target: bar.shellRoot - function onOsdBrightness(icon, value) { - osdItem.show(icon, value, Theme.base0D); + Item { + id: osdItem + readonly property bool isFocused: Hyprland.focusedMonitor + && Hyprland.focusedMonitor.name === bar.screen.name + // Every binding evaluates once at load; arm on a delay so + // a shell restart doesn't flash the slider on screen. + property bool armed: false + property bool shown: false + property string icon: "volume-2" + property real value: 0 + property color fill: Theme.base0D + + // Takes over the server glance's slot: fixed position, no + // dependence on how tall the workspace column has grown. + width: Theme.barWidth + height: 170 + anchors.horizontalCenter: parent.horizontalCenter + // Both surfaces span the full screen height from y=0, so + // the bar's coordinates carry over unchanged. + y: srvWidget.y + srvWidget.height / 2 - height / 2 + opacity: shown ? 1 : 0 + visible: opacity > 0.01 + + Behavior on opacity { + NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic } } - } - - // Card body so the OSD reads as a surface, not floating - // marks on the bar. Scales in from slightly small. - Rectangle { - anchors.centerIn: parent - width: 36 - height: osdCol.height + 2 * Theme.cardPad - radius: Theme.radiusSmall - color: Theme.cardBg - scale: osdItem.shown ? 1 : 0.85 - Behavior on scale { - NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } + + function show(ic, v, f) { + if (!armed || !isFocused) return; + icon = ic; + value = v; + fill = f; + shown = true; + osdTimer.restart(); } - - Column { - id: osdCol - anchors.centerIn: parent - spacing: 6 - - SText { - anchors.horizontalCenter: parent.horizontalCenter - text: Math.round(Math.max(0, Math.min(1, osdItem.value)) * 100) - color: Theme.base04 - font.pixelSize: 11 + + Timer { id: osdTimer; interval: 1600; onTriggered: osdItem.shown = false } + Timer { id: osdArm; interval: 1500; running: true; onTriggered: osdItem.armed = true } + + function showVolume() { + // The volume dropdown already shows the level live. + if (bar.activeDropdown === volDropdown) return; + show(volWidget.volIcon, volWidget.vol / 100, + volWidget.muted ? Theme.base03 : Theme.base0D); + } + + Connections { + target: volWidget.sink && volWidget.sink.audio ? volWidget.sink.audio : null + // `volume` notifies through volumesChanged, not + // volumeChanged — it is a view over the per-channel + // volumes vector. + function onVolumesChanged() { osdItem.showVolume(); } + function onMutedChanged() { osdItem.showVolume(); } + } + + Connections { + target: bar.shellRoot + function onOsdBrightness(icon, value) { + osdItem.show(icon, value, Theme.base0D); } - - // Vertical track — fill grows upward from the bottom. - // PillSlider is horizontal-only, so this is its own - // rather than a rotated instance. - Rectangle { - anchors.horizontalCenter: parent.horizontalCenter - width: 12 - height: 96 - radius: 6 - color: Theme.base02 - + } + + // Card body so the OSD reads as a surface, not floating + // marks on the bar. Scales in from slightly small. + Rectangle { + anchors.centerIn: parent + width: 36 + height: osdCol.height + 2 * Theme.cardPad + radius: Theme.radiusSmall + color: Theme.cardBg + scale: osdItem.shown ? 1 : 0.85 + Behavior on scale { + NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } + } + + Column { + id: osdCol + anchors.centerIn: parent + spacing: 6 + + SText { + anchors.horizontalCenter: parent.horizontalCenter + text: Math.round(Math.max(0, Math.min(1, osdItem.value)) * 100) + color: Theme.base04 + font.pixelSize: 11 + } + + // Vertical track — fill grows upward from the bottom. + // PillSlider is horizontal-only, so this is its own + // rather than a rotated instance. Rectangle { - anchors.bottom: parent.bottom - width: parent.width - height: Math.max(parent.width, - parent.height * Math.max(0, Math.min(1, osdItem.value))) - radius: parent.radius - color: osdItem.fill - Behavior on height { - NumberAnimation { duration: 140; easing.type: Easing.OutCubic } + anchors.horizontalCenter: parent.horizontalCenter + width: 12 + height: 96 + radius: 6 + color: Theme.base02 + + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: Math.max(parent.width, + parent.height * Math.max(0, Math.min(1, osdItem.value))) + radius: parent.radius + color: osdItem.fill + Behavior on height { + NumberAnimation { duration: 140; easing.type: Easing.OutCubic } + } } } - } - - SIcon { - anchors.horizontalCenter: parent.horizontalCenter - text: osdItem.icon - color: Theme.base05 - font.pixelSize: 16 + + SIcon { + anchors.horizontalCenter: parent.horizontalCenter + text: osdItem.icon + color: Theme.base05 + font.pixelSize: 16 + } } } }