quickshell: native volume/brightness OSD, drop swayosd + wob

Sixth surface in the SDF chrome shader, melting out of the bottom
frame. Volume is reactive via Pipewire so the OSD also fires for
pavucontrol/mixer changes; brightness routes through a new osd IPC
handler since sysfs backlight only notifies via POLLPRI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-07-28 12:35:44 +01:00
parent 3b0133ff3c
commit efd1331823
3 changed files with 160 additions and 42 deletions

View file

@ -50,7 +50,6 @@ in
wl-clipboard
cliphist
brightnessctl
swayosd
playerctl
hypridle
hyprshot
@ -196,18 +195,8 @@ in
extraConfig =
let
kbdBrightUp = pkgs.writeShellScript "kbd-bright-up" ''
${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight set +10%
brightness=$(${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight get)
max=$(${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight max)
echo $(( brightness * 100 / max )) > "$XDG_RUNTIME_DIR/wob.fifo"
'';
kbdBrightDown = pkgs.writeShellScript "kbd-bright-down" ''
${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight set 10%-
brightness=$(${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight get)
max=$(${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight max)
echo $(( brightness * 100 / max )) > "$XDG_RUNTIME_DIR/wob.fifo"
'';
wpctl = "${pkgs.wireplumber}/bin/wpctl";
qs = "${pkgs.quickshell}/bin/qs";
in
''
-- Environment
@ -244,7 +233,7 @@ in
hl.exec_cmd("wl-paste --type text --watch cliphist store")
hl.exec_cmd("wl-paste --type image --watch cliphist store")
hl.exec_cmd("hyprctl setcursor Bibata-Modern-Ice 24")
hl.exec_cmd("swayosd-server")
-- swayosd removed; volume/brightness OSD handled by quickshell
${lib.optionalString isMacbook ''hl.exec_cmd("hypridle")''}
end)
@ -363,15 +352,18 @@ in
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
-- Volume / brightness (repeating)
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("swayosd-client --output-volume raise"), { repeating = true })
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("swayosd-client --output-volume lower"), { repeating = true })
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("swayosd-client --output-volume mute-toggle"), { repeating = true })
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("swayosd-client --brightness raise"), { repeating = true })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("swayosd-client --brightness lower"), { repeating = true })
-- Volume / brightness (repeating). The OSD overlay is quickshell's
-- (see settings/quickshell.nix) swayosd and wob are both gone.
-- Volume needs no IPC: quickshell watches Pipewire, so the OSD
-- also shows for changes made in pavucontrol or a mixer.
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("${wpctl} set-volume @DEFAULT_AUDIO_SINK@ 5%+ -l 1.0"), { repeating = true })
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("${wpctl} set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { repeating = true })
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("${wpctl} set-mute @DEFAULT_AUDIO_SINK@ toggle"))
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("${qs} ipc call osd brightnessUp"), { repeating = true })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("${qs} ipc call osd brightnessDown"), { repeating = true })
${lib.optionalString isMacbook ''
hl.bind("XF86KbdBrightnessUp", hl.dsp.exec_cmd("${kbdBrightUp}"), { repeating = true })
hl.bind("XF86KbdBrightnessDown", hl.dsp.exec_cmd("${kbdBrightDown}"), { repeating = true })
hl.bind("XF86KbdBrightnessUp", hl.dsp.exec_cmd("${qs} ipc call osd kbdUp"), { repeating = true })
hl.bind("XF86KbdBrightnessDown", hl.dsp.exec_cmd("${qs} ipc call osd kbdDown"), { repeating = true })
''}
-- Media keys (locked work through lockscreen)

View file

@ -70,6 +70,7 @@ in
vec4 toast; // cx, cy, hw, hh toast (hw <= 0: none)
vec4 session; // cx, cy, hw, hh session menu (hw <= 0: none)
vec4 launcher; // cx, cy, hw, hh bottom launcher (hw <= 0: none)
vec4 osd; // cx, cy, hw, hh volume/brightness OSD (hw <= 0: none)
vec4 fillColor; // straight (non-premultiplied) rgba
vec4 borderColor;
vec2 res;
@ -104,6 +105,8 @@ in
d = smin(d, sdRoundedBox(p, session.xy, session.zw, panelR), meltK);
if (launcher.z > 0.5)
d = smin(d, sdRoundedBox(p, launcher.xy, launcher.zw, panelR), meltK);
if (osd.z > 0.5)
d = smin(d, sdRoundedBox(p, osd.xy, osd.zw, panelR), meltK);
float fw = fwidth(d);
// 1 inside the union, 0 outside (antialiased)
@ -463,6 +466,44 @@ in
function unpin(): void { if (root.mainBar) root.mainBar.setScreenshotPin(false); }
}
// OSD brightness (replaces swayosd). Volume needs nothing
// here the bar watches Pipewire directly. Backlight has no
// watchable change event, so the keybind routes through here:
// brightnessctl -m prints the resulting level, which becomes
// the OSD value. Bars pick this up on the focused monitor.
signal osdBrightness(string icon, real value)
Process {
id: brightProc
property string osdIcon: "brightness_6"
stdout: StdioCollector {
// machine-readable: device,class,current,percent%,max
onStreamFinished: {
let f = text.trim().split("\n")[0].split(",");
if (f.length < 5) return;
root.osdBrightness(brightProc.osdIcon, parseInt(f[3]) / 100);
}
}
}
function setBrightness(args, icon) {
// Key repeat outpaces the process; dropping a step is
// invisible, rewriting a live command is not.
if (brightProc.running) return;
brightProc.osdIcon = icon;
brightProc.command = ["brightnessctl", "-m"].concat(args);
brightProc.running = true;
}
IpcHandler {
target: "osd"
// floor 1%: `set 0%` turns the panel fully off
function brightnessUp(): void { root.setBrightness(["set", "5%+"], "brightness_6"); }
function brightnessDown(): void { root.setBrightness(["-n", "1", "set", "5%-"], "brightness_6"); }
function kbdUp(): void { root.setBrightness(["-d", "smc::kbd_backlight", "set", "10%+"], "keyboard"); }
function kbdDown(): void { root.setBrightness(["-d", "smc::kbd_backlight", "set", "10%-"], "keyboard"); }
}
// Night light: quickshell owns the schedule, the hyprsunset
// daemon just applies the gamma (hyprctl hyprsunset ).
// sunrise/sunset default to 7:00/21:00 and are replaced with
@ -946,6 +987,11 @@ in
? Qt.vector4d(launcherPanel.x + launcherPanel.width / 2, (launcherPanel.y + launchBot) / 2,
launcherPanel.width / 2, (launchBot - launcherPanel.y) / 2)
: Qt.vector4d(0, 0, 0, 0)
// OSD shares the launcher's bottom melt line.
property vector4d osd: osdItem.visible
? Qt.vector4d(osdItem.x + osdItem.width / 2, (osdItem.y + launchBot) / 2,
osdItem.width / 2, (launchBot - osdItem.y) / 2)
: Qt.vector4d(0, 0, 0, 0)
property vector4d fillColor: Qt.vector4d(Theme.barBg.r, Theme.barBg.g, Theme.barBg.b, Theme.barBg.a)
property vector4d borderColor: Qt.vector4d(Theme.base03.r, Theme.base03.g, Theme.base03.b, 1)
property vector2d res: Qt.vector2d(width, height)
@ -3187,6 +3233,106 @@ in
}
}
// OSD: volume / brightness overlay melting out of the
// bottom frame, on the focused monitor only. Volume is
// reactive Pipewire reports changes from any source (media
// keys, pavucontrol, a hardware mixer), so no IPC is needed.
// Brightness arrives via shellRoot's signal instead: sysfs
// backlight notifies with POLLPRI, which inotify never sees.
// Deliberately absent from the window `mask` above, unlike
// every other surface a transient overlay must not eat
// clicks on whatever it covers.
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 an OSD on screen.
property bool armed: false
property bool shown: false
property string icon: "volume_up"
property real value: 0
property color fill: Theme.base0D
width: 260
height: shown ? 52 : 0
x: Math.round((bar.width - width) / 2)
y: bar.height - Theme.frameWidth - height
visible: height > 0.5
clip: true
Behavior on height {
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();
}
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.base0A);
}
}
Row {
anchors.centerIn: parent
width: parent.width - 4 * Theme.panelGap
spacing: 10
SIcon {
id: osdIcon
anchors.verticalCenter: parent.verticalCenter
width: 22
text: osdItem.icon
color: Theme.base05
font.pixelSize: 20
}
PillSlider {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - osdIcon.width - osdPct.width - 2 * parent.spacing
value: osdItem.value
fillColor: osdItem.fill
}
SText {
id: osdPct
anchors.verticalCenter: parent.verticalCenter
width: 34
horizontalAlignment: Text.AlignRight
text: Math.round(osdItem.value * 100) + "%"
color: Theme.base04
font.pixelSize: 12
}
}
}
// Notification Toast (only on primary screen)
Item {
id: toastItem