From 1f5f9795bc6f952ff8e65be7276265bb6ae16704 Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Sun, 30 Aug 2026 16:08:44 +0200 Subject: [PATCH] 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 --- dotfiles/quickshell/Osd.qml | 199 ++++++++++++++++++++++++++++++ dotfiles/quickshell/shell.qml | 18 +++ hyprland/hyprland.conf | 23 ++++ modules/apps.nix | 2 + modules/scripts/brightness-ctl.sh | 26 +++- 5 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 dotfiles/quickshell/Osd.qml diff --git a/dotfiles/quickshell/Osd.qml b/dotfiles/quickshell/Osd.qml new file mode 100644 index 0000000..c02fc3d --- /dev/null +++ b/dotfiles/quickshell/Osd.qml @@ -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 ` 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 + } + } + } + } + } +} diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml index c83c16a..ceb178d 100644 --- a/dotfiles/quickshell/shell.qml +++ b/dotfiles/quickshell/shell.qml @@ -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 = ""; + } + } } } } diff --git a/hyprland/hyprland.conf b/hyprland/hyprland.conf index 7a1a105..0683b41 100644 --- a/hyprland/hyprland.conf +++ b/hyprland/hyprland.conf @@ -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 diff --git a/modules/apps.nix b/modules/apps.nix index c19753e..a2b5f60 100644 --- a/modules/apps.nix +++ b/modules/apps.nix @@ -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 diff --git a/modules/scripts/brightness-ctl.sh b/modules/scripts/brightness-ctl.sh index 85a4f27..bc58dfa 100755 --- a/modules/scripts/brightness-ctl.sh +++ b/modules/scripts/brightness-ctl.sh @@ -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