quickshell: move OSD into its own overlay window

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-08-16 13:43:29 +01:00
parent 52599fd24d
commit d1c92394e5
2 changed files with 133 additions and 110 deletions

View file

@ -261,6 +261,7 @@ in
-- Layer rules: blur behind bar and toasts -- 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-bar" }, blur = true, ignore_alpha = 0.3 })
hl.layer_rule({ match = { namespace = "quickshell-osd" }, blur = true, ignore_alpha = 0.3 })
-- Startup -- Startup
hl.on("hyprland.start", function() hl.on("hyprland.start", function()

View file

@ -1790,10 +1790,6 @@ in
required property var shellRoot required property var shellRoot
screen: modelData screen: modelData
WlrLayershell.namespace: "quickshell-bar" 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 // OnDemand + HyprlandFocusGrab is the working combination
// (caelestia's): the grab redirects focus to this window and // (caelestia's): the grab redirects focus to this window and
// OnDemand lets the layer surface accept it. Exclusive fights // OnDemand lets the layer surface accept it. Exclusive fights
@ -6948,119 +6944,145 @@ in
// is needed. Brightness arrives via shellRoot's signal instead, // is needed. Brightness arrives via shellRoot's signal instead,
// because sysfs backlight notifies with POLLPRI, which inotify // because sysfs backlight notifies with POLLPRI, which inotify
// never sees. Focused monitor only. // never sees. Focused monitor only.
Item { // Its own window, not part of the bar's surface: Hyprland
id: osdItem // doesn't render the top layer under a fullscreen window, so an
readonly property bool isFocused: Hyprland.focusedMonitor // OSD drawn into the bar is invisible exactly when a video is
&& Hyprland.focusedMonitor.name === bar.screen.name // playing. Overlay layer, sized to the bar strip, click-through,
// Every binding evaluates once at load; arm on a delay so // and only mapped while the OSD is on screen so a fullscreen
// a shell restart doesn't flash the slider on screen. // video gets the slider alone, no bar and no frame.
property bool armed: false PanelWindow {
property bool shown: false id: osdWin
property string icon: "volume-2" screen: bar.screen
property real value: 0 visible: osdItem.shown || osdItem.opacity > 0.01
property color fill: Theme.base0D 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 Item {
// dependence on how tall the workspace column has grown. id: osdItem
width: Theme.barWidth readonly property bool isFocused: Hyprland.focusedMonitor
height: 170 && Hyprland.focusedMonitor.name === bar.screen.name
anchors.horizontalCenter: barBgRect.horizontalCenter // Every binding evaluates once at load; arm on a delay so
anchors.verticalCenter: srvWidget.verticalCenter // a shell restart doesn't flash the slider on screen.
opacity: shown ? 1 : 0 property bool armed: false
visible: opacity > 0.01 property bool shown: false
property string icon: "volume-2"
property real value: 0
property color fill: Theme.base0D
Behavior on opacity { // Takes over the server glance's slot: fixed position, no
NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic } // 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
function show(ic, v, f) { Behavior on opacity {
if (!armed || !isFocused) return; NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic }
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);
}
}
// 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 { function show(ic, v, f) {
id: osdCol 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);
}
}
// Card body so the OSD reads as a surface, not floating
// marks on the bar. Scales in from slightly small.
Rectangle {
anchors.centerIn: parent anchors.centerIn: parent
spacing: 6 width: 36
height: osdCol.height + 2 * Theme.cardPad
SText { radius: Theme.radiusSmall
anchors.horizontalCenter: parent.horizontalCenter color: Theme.cardBg
text: Math.round(Math.max(0, Math.min(1, osdItem.value)) * 100) scale: osdItem.shown ? 1 : 0.85
color: Theme.base04 Behavior on scale {
font.pixelSize: 11 NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
} }
// Vertical track fill grows upward from the bottom. Column {
// PillSlider is horizontal-only, so this is its own id: osdCol
// rather than a rotated instance. anchors.centerIn: parent
Rectangle { spacing: 6
anchors.horizontalCenter: parent.horizontalCenter
width: 12
height: 96
radius: 6
color: Theme.base02
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 { Rectangle {
anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter
width: parent.width width: 12
height: Math.max(parent.width, height: 96
parent.height * Math.max(0, Math.min(1, osdItem.value))) radius: 6
radius: parent.radius color: Theme.base02
color: osdItem.fill
Behavior on height { Rectangle {
NumberAnimation { duration: 140; easing.type: Easing.OutCubic } 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 { SIcon {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
text: osdItem.icon text: osdItem.icon
color: Theme.base05 color: Theme.base05
font.pixelSize: 16 font.pixelSize: 16
}
} }
} }
} }