Add on-screen display, sidebar keybinds, function keys, gh package

- Add Osd.qml: themed top-center popup for volume/mic/brightness/media
  changes. Volume and mic react directly to Pipewire so they fire for
  any change, not just our own keybinds; brightness is notified
  explicitly (no live service exists for DDC/CI writes); media is
  driven by two playerctl --follow watchers (status + metadata) so
  track changes, pause/resume, and stop all show correctly without
  double-firing
- Add Super+Shift+Z/X to toggle the Left/RightBar sidebars, via a new
  Quickshell IpcHandler wired to the same expand/collapse logic as the
  BottomBar arrows
- Add function-key binds for volume up/down/mute, mic mute, brightness
  up/down, and media play/pause/stop/next/prev (wpctl/playerctl);
  extend brightness-ctl with relative up/down steps for both the
  backlight and DDC/CI paths
- Add gh and playerctl to packages
This commit is contained in:
Nexoniarz 2026-08-30 16:08:44 +02:00
commit 1f5f9795bc
5 changed files with 267 additions and 1 deletions

199
dotfiles/quickshell/Osd.qml Normal file
View file

@ -0,0 +1,199 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Services.Pipewire
// On-screen display: a themed top-center popup (same placement/layer-shell
// pattern as PolkitDialog and mako notifications) for volume, mic mute,
// brightness, and media changes — the transient overlay every other DE
// shows instead of making you open a panel to check.
//
// Volume/mic react directly to Pipewire, so they fire for ANY change
// (function keys, the RightBar sliders, pavucontrol, etc.), not just ones
// that went through our own keybinds. Brightness has no such live service
// to bind to (DDC/CI writes aren't broadcast anywhere), so its two
// hyprland.conf binds explicitly call `quickshell ipc call osd
// brightness <pct>` after brightness-ctl. Media is driven by two
// long-running `playerctl --follow` processes (status, and metadata for
// track-change detection).
Item {
id: root
property string osdText: ""
property bool showBar: false
property real barValue: 0
property bool barDanger: false
function present(text, bar, value, danger) {
root.osdText = text;
root.showBar = bar;
root.barValue = value || 0;
root.barDanger = !!danger;
osdWindow.visible = true;
hideTimer.restart();
}
IpcHandler {
target: "osd"
function brightness(pct: string): void {
var v = parseInt(pct);
if (isNaN(v)) return;
root.present("Brightness " + v + "%", true, v / 100, false);
}
}
Timer {
id: hideTimer
interval: 1600
onTriggered: osdWindow.visible = false
}
// -- Volume / mic --
PwObjectTracker {
objects: {
var arr = [];
if (Pipewire.defaultAudioSink) arr.push(Pipewire.defaultAudioSink);
if (Pipewire.defaultAudioSource) arr.push(Pipewire.defaultAudioSource);
return arr;
}
}
// Pipewire reports the already-current volume/mute state once as soon
// as it connects — without these guards that fires an OSD on every
// Quickshell start/login, not just on real changes.
property bool sinkReady: false
property bool sourceReady: false
Connections {
target: Pipewire.defaultAudioSink ? Pipewire.defaultAudioSink.audio : null
function onVolumesChanged() {
if (!root.sinkReady) { root.sinkReady = true; return; }
var sink = Pipewire.defaultAudioSink;
if (sink.audio.muted) return; // covered by onMutedChanged instead
root.present("Volume " + Math.round(sink.audio.volume * 100) + "%", true, sink.audio.volume, false);
}
function onMutedChanged() {
if (!root.sinkReady) { root.sinkReady = true; return; }
var sink = Pipewire.defaultAudioSink;
root.present(sink.audio.muted ? "Volume Muted" : "Volume " + Math.round(sink.audio.volume * 100) + "%",
!sink.audio.muted, sink.audio.volume, sink.audio.muted);
}
}
Connections {
target: Pipewire.defaultAudioSource ? Pipewire.defaultAudioSource.audio : null
function onMutedChanged() {
if (!root.sourceReady) { root.sourceReady = true; return; }
var source = Pipewire.defaultAudioSource;
root.present(source.audio.muted ? "Microphone Muted" : "Microphone Unmuted",
false, 0, source.audio.muted);
}
}
// -- Media (MPRIS via playerctl) --
property string lastTitle: ""
property double lastMetaAt: 0
Process {
id: statusFollow
command: ["playerctl", "--follow", "status"]
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
var status = data.trim();
if (status === "Paused") {
root.present("⏸ Paused", false, 0, false);
} else if (status === "Stopped") {
root.present("⏹ Stopped", false, 0, false);
root.lastTitle = "";
} else if (status === "Playing") {
// A track change fires both a metadata line and a
// Playing status line in short order — the metadata
// handler below already popped the OSD for that case,
// so only announce "Resumed" if nothing else just did.
if (Date.now() - root.lastMetaAt > 400) {
root.present("▶ Resumed", false, 0, false);
}
}
}
}
}
Process {
id: metadataFollow
command: ["playerctl", "--follow", "metadata", "--format", "{{title}}\t{{artist}}"]
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
var parts = data.split("\t");
var title = (parts[0] || "").trim();
var artist = (parts[1] || "").trim();
if (!title || title === root.lastTitle) return;
root.lastTitle = title;
root.lastMetaAt = Date.now();
root.present("▶ " + title + (artist ? " — " + artist : ""), false, 0, false);
}
}
}
Component.onCompleted: {
statusFollow.running = true;
metadataFollow.running = true;
}
PanelWindow {
id: osdWindow
visible: false
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "osd"
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
focusable: false
anchors { top: true }
margins.top: 12
exclusiveZone: 0
color: "transparent"
implicitWidth: 300
implicitHeight: osdContent.implicitHeight + 24
Rectangle {
anchors.fill: parent
color: Theme.bg
border.width: 2
border.color: root.barDanger ? Theme.danger : Theme.accent
Column {
id: osdContent
x: 14
y: 12
width: parent.width - 28
spacing: 8
Text {
width: parent.width
text: root.osdText
color: Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 12
font.bold: true
elide: Text.ElideRight
}
Rectangle {
visible: root.showBar
width: parent.width
height: 6
color: Theme.bgAlt
border.width: 1
border.color: Theme.border
Rectangle {
width: parent.width * Math.max(0, Math.min(1, root.barValue))
height: parent.height
color: root.barDanger ? Theme.danger : Theme.accent
}
}
}
}
}
}

View file

@ -9,9 +9,11 @@
// menu silently does nothing.
import QtQuick
import Quickshell
import Quickshell.Io
ShellRoot {
PolkitDialog {}
Osd {}
Variants {
model: Quickshell.screens
@ -22,6 +24,22 @@ ShellRoot {
LeftBar { id: leftBarInstance; screen: modelData }
RightBar { id: rightBarInstance; screen: modelData }
BottomBar { screen: modelData; leftBar: leftBarInstance; rightBar: rightBarInstance }
// Lets Hyprland keybinds (Super+Shift+Z/X, see hyprland.conf)
// toggle the sidebars via `quickshell ipc call sidebars
// toggleLeft/toggleRight` — same open/close behavior as
// clicking the arrows on BottomBar.
IpcHandler {
target: "sidebars"
function toggleLeft(): void {
leftBarInstance.expanded = !leftBarInstance.expanded;
if (!leftBarInstance.expanded) leftBarInstance.activePanel = "";
}
function toggleRight(): void {
rightBarInstance.expanded = !rightBarInstance.expanded;
if (!rightBarInstance.expanded) rightBarInstance.activePanel = "";
}
}
}
}
}

View file

@ -123,6 +123,11 @@ bind = $mod, T, exec, $terminal
bind = $mod, E, exec, $fileManager
bind = $mod, A, exec, $launcher
bind = $mod, D, exec, show-desktop-toggle
# Sidebar toggles — mirrors clicking the arrows on BottomBar, via the
# IpcHandler registered in shell.qml.
bind = $mod SHIFT, Z, exec, quickshell ipc call sidebars toggleLeft
bind = $mod SHIFT, X, exec, quickshell ipc call sidebars toggleRight
bind = $mod, Q, killactive,
bind = $mod SHIFT, Q, exit,
bind = $mod, V, togglefloating,
@ -173,6 +178,24 @@ bind = $mod SHIFT, 8, movetoworkspace, 8
bind = $mod SHIFT, 9, movetoworkspace, 9
bind = $mod SHIFT, 0, movetoworkspace, 10
# Function keys — media/volume/brightness. bindl (not bind) so these still
# work with the screen locked; bindel additionally repeats while held, for
# the ones you'd actually want to hold down (volume/brightness ramps).
bindel = , XF86AudioRaiseVolume, exec, wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SINK@ 5%+
bindel = , XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-
bindl = , XF86AudioMute, exec, wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle
bindl = , XF86AudioMicMute, exec, wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle
# Chained with an OSD call: unlike volume/mic, brightness has no live
# service Quickshell can react to (DDC/CI writes aren't broadcast
# anywhere), so the keybind has to tell the OSD explicitly.
bindel = , XF86MonBrightnessUp, exec, brightness-ctl up && quickshell ipc call osd brightness "$(brightness-ctl get)"
bindel = , XF86MonBrightnessDown, exec, brightness-ctl down && quickshell ipc call osd brightness "$(brightness-ctl get)"
bindl = , XF86AudioPlay, exec, playerctl play-pause
bindl = , XF86AudioPause, exec, playerctl play-pause
bindl = , XF86AudioStop, exec, playerctl stop
bindl = , XF86AudioNext, exec, playerctl next
bindl = , XF86AudioPrev, exec, playerctl previous
# Mouse: drag/resize floating windows
bindm = $mod, mouse:272, movewindow
bindm = $mod, mouse:273, resizewindow

View file

@ -36,6 +36,7 @@ in
bibata-cursors # Cursor theme(s) for the cursor picker
xcur2png # Renders Xcursor files to PNG for the cursor picker's previews
imagemagick # `identify`, used to pick the right cursor-preview size
playerctl # MPRIS media control, for the function-key media binds
# Apps
blender
@ -66,6 +67,7 @@ in
# Global command-line utilities available everywhere
environment.systemPackages = with pkgs; [
git
gh # GitHub CLI — was only installed ad-hoc before (login didn't survive)
vim
fastfetch
gparted

View file

@ -25,8 +25,32 @@ case "${1:-get}" in
ddcutil setvcp 10 "$pct" > /dev/null
fi
;;
up|down)
# brightnessctl takes a relative step directly, but ddcutil's
# setvcp only takes an absolute value — so the DDC/CI path (this
# machine's AOC 27G2G8 has no backlight device, so it always takes
# this path) has to read the current level and clamp the step
# itself.
step="${2:-5}"
if has_backlight; then
if [ "$1" = "up" ]; then
brightnessctl set "+${step}%" > /dev/null
else
brightnessctl set "${step}%-" > /dev/null
fi
else
cur=$(ddcutil getvcp 10 --brief 2>/dev/null | awk '{print $4}')
cur="${cur:-50}"
if [ "$1" = "up" ]; then
new=$(( cur + step > 100 ? 100 : cur + step ))
else
new=$(( cur - step < 0 ? 0 : cur - step ))
fi
ddcutil setvcp 10 "$new" > /dev/null
fi
;;
*)
echo "usage: brightness-ctl [get|set <0-100>]" >&2
echo "usage: brightness-ctl [get|set <0-100>|up [step]|down [step]]" >&2
exit 1
;;
esac