From 668983e16d444377fa23b6ea3f41bf3351c3999a Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 06:46:11 -0400 Subject: [PATCH] Add polished system feedback overlays --- config/dot/hypr/keybinds.lua | 29 ++-- config/dot/quickshell/modules/osd/Osd.qml | 177 ++++++++++++++++++++ config/dot/quickshell/modules/osd/qmldir | 2 + config/dot/quickshell/services/OsdState.qml | 45 +++++ config/dot/quickshell/shell.qml | 25 +++ tests/quickshell/osd-ui-contract.sh | 2 + 6 files changed, 267 insertions(+), 13 deletions(-) create mode 100644 config/dot/quickshell/modules/osd/Osd.qml create mode 100644 config/dot/quickshell/modules/osd/qmldir create mode 100644 config/dot/quickshell/services/OsdState.qml diff --git a/config/dot/hypr/keybinds.lua b/config/dot/hypr/keybinds.lua index 083c9d3..bbf74d3 100644 --- a/config/dot/hypr/keybinds.lua +++ b/config/dot/hypr/keybinds.lua @@ -30,6 +30,9 @@ local sysmonitor = "flatpak run io.missioncenter.MissionCenter" -- Vicinae is the Raycast-style launcher. `vicinae toggle` shows/hides the -- window against the already-running server (started in autostart.lua). local launcher = "vicinae toggle" +local osd = function(action) + return "$HOME/.config/quickshell/scripts/panama-osd " .. action +end -- Quickshell IPC targets. See quickshell/shell.qml for the handlers. local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end @@ -252,23 +255,23 @@ bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { descri -- ── Media and volume ──────────────────────────────────────────────────────── -- locked = true keeps these working on the lock screen, as they do in GNOME. -- 6% steps match the GNOME volume-step setting. -bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 6%+"), { locked = true, repeating = true , description = "Volume up" }) -bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 6%-"), { locked = true, repeating = true , description = "Volume down" }) -bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true , description = "Mute" }) -bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true , description = "Mute microphone" }) +bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 6")), { locked = true, repeating = true , description = "Volume up" }) +bind("XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 6")), { locked = true, repeating = true , description = "Volume down" }) +bind("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , description = "Mute" }) +bind("XF86AudioMicMute", hl.dsp.exec_cmd(osd("microphone toggle")), { locked = true , description = "Mute microphone" }) -- Fine-grained steps, matching GNOME's shift/alt volume modifiers. -bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 1%+"), { locked = true, repeating = true , description = "Volume up (fine)" }) -bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-"), { locked = true, repeating = true , description = "Volume down (fine)" }) +bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 1")), { locked = true, repeating = true , description = "Volume up (fine)" }) +bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 1")), { locked = true, repeating = true , description = "Volume down (fine)" }) -bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" }) -bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" }) -bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true , description = "Next track" }) -bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true , description = "Previous track" }) -bind("XF86AudioStop", hl.dsp.exec_cmd("playerctl stop"), { locked = true , description = "Stop playback" }) +bind("XF86AudioPlay", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" }) +bind("XF86AudioPause", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" }) +bind("XF86AudioNext", hl.dsp.exec_cmd(osd("media next")), { locked = true , description = "Next track" }) +bind("XF86AudioPrev", hl.dsp.exec_cmd(osd("media previous")), { locked = true , description = "Previous track" }) +bind("XF86AudioStop", hl.dsp.exec_cmd(osd("media stop")), { locked = true , description = "Stop playback" }) -bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true , description = "Brightness up" }) -bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true , description = "Brightness down" }) +bind("XF86MonBrightnessUp", hl.dsp.exec_cmd(osd("brightness up 5")), { locked = true, repeating = true , description = "Brightness up" }) +bind("XF86MonBrightnessDown", hl.dsp.exec_cmd(osd("brightness down 5")), { locked = true, repeating = true , description = "Brightness down" }) -- Hardware keys GNOME mapped that have obvious equivalents. bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" }) diff --git a/config/dot/quickshell/modules/osd/Osd.qml b/config/dot/quickshell/modules/osd/Osd.qml new file mode 100644 index 0000000..0aa43db --- /dev/null +++ b/config/dot/quickshell/modules/osd/Osd.qml @@ -0,0 +1,177 @@ +// Compact, click-through system feedback. Repeated hardware-key presses update +// this one surface in place, avoiding a stack of notification cards. + +import Quickshell +import Quickshell.Wayland +import QtQuick +import qs.config +import qs.services +import qs.widgets + +PanelWindow { + id: root + + property var modelData: null + readonly property var presentation: OsdState.state + readonly property bool belongsHere: !OsdState.monitorName || !root.modelData + || root.modelData.name === OsdState.monitorName + readonly property bool requestedVisible: OsdState.active && root.belongsHere + readonly property int horizontalPadding: 18 + readonly property int contentGap: 14 + readonly property int progressWidth: 156 + readonly property int progressLabelWidth: 48 + readonly property int messageWidth: Math.min(300, Math.max(52, Math.ceil(labelMetrics.advanceWidth))) + readonly property int desiredWidth: root.horizontalPadding * 2 + 24 + root.contentGap + + (root.presentation.progress + ? root.progressWidth + root.contentGap + root.progressLabelWidth + : root.messageWidth) + + screen: root.modelData + anchors.bottom: true + margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3 + exclusiveZone: 0 + exclusionMode: ExclusionMode.Ignore + implicitWidth: root.desiredWidth + implicitHeight: 64 + color: "transparent" + mask: Region {} + + WlrLayershell.namespace: "qs-popover-osd" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + property bool mapped: false + visible: root.mapped + + onRequestedVisibleChanged: { + if (root.requestedVisible) { + unmapTimer.stop(); + root.mapped = true; + entrance.restart(); + } else if (root.mapped) { + unmapTimer.restart(); + } + } + + Component.onCompleted: { + if (root.requestedVisible) + root.mapped = true; + } + + Timer { + id: unmapTimer + interval: Theme.durNormal + onTriggered: root.mapped = false + } + + Rectangle { + id: surface + anchors.fill: parent + radius: Theme.popoverRadius + color: Theme.alpha(Theme.bgPopover, 0.9) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.1) + opacity: root.requestedVisible ? 1 : 0 + + Behavior on opacity { + NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } + } + + transform: Translate { + id: slide + y: root.requestedVisible ? 0 : 8 + Behavior on y { + NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } + } + } + + PrismEdge { + anchors.top: parent.top + anchors.topMargin: 1 + anchors.left: parent.left + anchors.right: parent.right + inset: parent.radius + } + + Row { + anchors.centerIn: parent + spacing: root.contentGap + + ThemedIcon { + anchors.verticalCenter: parent.verticalCenter + size: 24 + icon: root.presentation.icon + iconFallback: "dialog-information-symbolic" + tint: Theme.fg + } + + Item { + visible: root.presentation.progress + anchors.verticalCenter: parent.verticalCenter + width: visible ? root.progressWidth : 0 + height: 8 + + Rectangle { + anchors.fill: parent + radius: height / 2 + color: Theme.alpha(Theme.fg, 0.14) + } + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * root.presentation.ratio + radius: height / 2 + color: Theme.accent + + Behavior on width { + NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutCubic } + } + } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + width: root.presentation.progress ? root.progressLabelWidth : root.messageWidth + text: root.presentation.label + color: Theme.fg + elide: Text.ElideRight + maximumLineCount: 1 + horizontalAlignment: root.presentation.progress ? Text.AlignRight : Text.AlignLeft + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + } + } + } + + TextMetrics { + id: labelMetrics + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + text: root.presentation.label + } + + ParallelAnimation { + id: entrance + NumberAnimation { + target: surface + property: "opacity" + from: 0 + to: 1 + duration: Theme.durNormal + easing.type: Easing.OutCubic + } + NumberAnimation { + target: slide + property: "y" + from: 8 + to: 0 + duration: Theme.durNormal + easing.type: Easing.OutCubic + } + } +} diff --git a/config/dot/quickshell/modules/osd/qmldir b/config/dot/quickshell/modules/osd/qmldir new file mode 100644 index 0000000..ab9872c --- /dev/null +++ b/config/dot/quickshell/modules/osd/qmldir @@ -0,0 +1,2 @@ +module qs.modules.osd +Osd 1.0 Osd.qml diff --git a/config/dot/quickshell/services/OsdState.qml b/config/dot/quickshell/services/OsdState.qml new file mode 100644 index 0000000..aab2fde --- /dev/null +++ b/config/dot/quickshell/services/OsdState.qml @@ -0,0 +1,45 @@ +pragma Singleton + +import Quickshell +import Quickshell.Hyprland +import QtQuick +import "../modules/osd/OsdModel.js" as OsdModel + +Singleton { + id: root + + property bool active: false + property string monitorName: "" + property var state: OsdModel.messageState("dialog-information-symbolic", "", 1400) + + function present(next: var): void { + root.state = next; + root.monitorName = Hyprland.focusedMonitor?.name ?? ""; + root.active = true; + if (next.duration > 0) { + hideTimer.interval = next.duration; + hideTimer.restart(); + } else { + hideTimer.stop(); + } + } + + function progress(kind: string, value: int, maximum: int, label: string): void { + root.present(OsdModel.progressState(kind, value, maximum, label, 1400)); + } + + function message(kind: string, label: string): void { + root.present(OsdModel.messageState(kind, label, 1400)); + } + + function hide(): void { + hideTimer.stop(); + root.active = false; + } + + Timer { + id: hideTimer + interval: 1400 + onTriggered: root.active = false + } +} diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index cb4a691..fb78132 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -38,6 +38,7 @@ import qs.modules.datemenu import qs.modules.focus import qs.modules.signals import qs.modules.settings +import qs.modules.osd // Clipboard and datemenu ship explicit qmldir manifests because they were // added while this daily-driver shell was already running; that also keeps @@ -72,6 +73,11 @@ ShellRoot { SignalGlass {} } + Variants { + model: Quickshell.screens + Osd {} + } + // ── Single-instance overlays ──────────────────────────────────────────── // These are always constructed but only *visible* when ShellState says so. // They're cheap while hidden, and keeping them alive means opening the @@ -194,6 +200,25 @@ ShellRoot { } } + IpcHandler { + target: "osd" + + function progress(kind: string, value: int, maximum: int, label: string): void { + OsdState.progress(kind, value, maximum, label); + } + function message(kind: string, label: string): void { + OsdState.message(kind, label); + } + function hide(): void { OsdState.hide(); } + function status(): string { + return JSON.stringify({ + active: OsdState.active, + monitorName: OsdState.monitorName, + state: OsdState.state + }); + } + } + IpcHandler { target: "activity" diff --git a/tests/quickshell/osd-ui-contract.sh b/tests/quickshell/osd-ui-contract.sh index a5f0b87..b600846 100644 --- a/tests/quickshell/osd-ui-contract.sh +++ b/tests/quickshell/osd-ui-contract.sh @@ -17,6 +17,8 @@ fail() { [[ -f "$osd" ]] || fail 'Prism OSD surface is missing' [[ -f "$state" ]] || fail 'OSD presentation state is missing' [[ -f "$qs_dir/modules/osd/qmldir" ]] || fail 'OSD module manifest is missing' +rg -q '^import Quickshell$' "$state" \ + || fail 'OSD singleton does not import the Quickshell Singleton type' rg -Fq 'import qs.modules.osd' "$shell_file" || fail 'shell does not import the OSD module' [[ "$(rg -c '^[[:space:]]*Osd \{\}' "$shell_file")" -eq 1 ]] \