diff --git a/config/dot/quickshell/modules/dock/Dock.qml b/config/dot/quickshell/modules/dock/Dock.qml index d476b29..352c60f 100644 --- a/config/dot/quickshell/modules/dock/Dock.qml +++ b/config/dot/quickshell/modules/dock/Dock.qml @@ -210,6 +210,12 @@ PanelWindow { DockBody { id: body + onContextMenuRequested: (anchorItem, entry) => { + dockContextMenu.anchorItem = anchorItem; + dockContextMenu.entry = entry; + dockContextMenu.visible = true; + } + vertical: root.vertical leftSide: root.position === "left" @@ -260,4 +266,8 @@ PanelWindow { } } } + + DockContextMenu { + id: dockContextMenu + } } diff --git a/config/dot/quickshell/modules/dock/DockBody.qml b/config/dot/quickshell/modules/dock/DockBody.qml index 5aa097f..7b462c0 100644 --- a/config/dot/quickshell/modules/dock/DockBody.qml +++ b/config/dot/quickshell/modules/dock/DockBody.qml @@ -116,6 +116,7 @@ Rectangle { // The item the tooltip is currently describing, or null. property Item hoveredItem: null + signal contextMenuRequested(Item anchorItem, var entry) // Set by the Dock. A side dock runs the same strip down the screen instead // of across it. @@ -170,6 +171,7 @@ Rectangle { onEntered: root.hoveredItem = dockItem onExited: if (root.hoveredItem === dockItem) root.hoveredItem = null + onContextMenuRequested: root.contextMenuRequested(dockItem, dockItem.entry) } } } diff --git a/config/dot/quickshell/modules/dock/DockContextMenu.qml b/config/dot/quickshell/modules/dock/DockContextMenu.qml new file mode 100644 index 0000000..81ff24c --- /dev/null +++ b/config/dot/quickshell/modules/dock/DockContextMenu.qml @@ -0,0 +1,84 @@ +// The dock's app menu. Desktop-entry actions stay first; the shell-owned +// configuration route is deliberately last so it never displaces app actions. + +import Quickshell +import QtQuick +import qs.config +import qs.modules.bar +import qs.services +import qs.widgets + +PopupWindow { + id: root + + property Item anchorItem: null + property var entry: null + + anchor.item: root.anchorItem + anchor.edges: Edges.Top | Edges.Left + anchor.gravity: Edges.Top | Edges.Right + anchor.margins.bottom: 8 + + implicitWidth: Math.max(menu.implicitWidth + Theme.popoverPadding * 2, 240) + implicitHeight: menu.implicitHeight + Theme.popoverPadding * 2 + color: "transparent" + visible: false + grabFocus: true + + Rectangle { + anchors.fill: parent + radius: Theme.popoverRadius + color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.08) + + PrismEdge { + anchors.top: parent.top + anchors.topMargin: 1 + anchors.left: parent.left + anchors.right: parent.right + inset: parent.radius + } + + Column { + id: menu + anchors.fill: parent + anchors.margins: Theme.popoverPadding + spacing: 2 + + Repeater { + id: applicationActions + model: root.entry ? root.entry.actions : [] + + delegate: TrayMenuRow { + required property var modelData + + width: parent.width + label: modelData.name + onActivated: { + modelData.execute(); + root.visible = false; + } + } + } + + Rectangle { + width: parent.width + height: 1 + anchors.margins: 3 + visible: applicationActions.count > 0 + border.width: 0 + color: Theme.alpha(Theme.fg, 0.1) + } + + TrayMenuRow { + width: parent.width + label: "Dock settings" + onActivated: { + ShellState.openSettings("desktop"); + root.visible = false; + } + } + } + } +} diff --git a/config/dot/quickshell/modules/dock/DockItem.qml b/config/dot/quickshell/modules/dock/DockItem.qml index 26a46de..5685689 100644 --- a/config/dot/quickshell/modules/dock/DockItem.qml +++ b/config/dot/quickshell/modules/dock/DockItem.qml @@ -24,6 +24,7 @@ Item { // Emitted so DockBody can drive the single shared tooltip. signal entered signal exited + signal contextMenuRequested // The icon may grow past the cell on hover; the cell itself stays a fixed // size so the row doesn't reflow. @@ -115,7 +116,7 @@ Item { id: mouse anchors.fill: parent hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.MiddleButton + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton onEntered: root.entered() onExited: root.exited() @@ -126,6 +127,10 @@ Item { root.launch(); return; } + if (mev.button === Qt.RightButton) { + root.contextMenuRequested(); + return; + } if (root.running) root.focusNext(); else diff --git a/config/dot/quickshell/modules/notifications/NotificationCard.qml b/config/dot/quickshell/modules/notifications/NotificationCard.qml index 6b1c5f3..e3c9134 100644 --- a/config/dot/quickshell/modules/notifications/NotificationCard.qml +++ b/config/dot/quickshell/modules/notifications/NotificationCard.qml @@ -14,6 +14,7 @@ import Quickshell.Services.Notifications import qs.config import qs.services import qs.modules.quicksettings +import qs.modules.bar import qs.widgets Rectangle { @@ -127,11 +128,44 @@ Rectangle { onClicked: root.dismissed() } + IconButton { + id: settingsMenuButton + anchors.right: closeButton.left + anchors.rightMargin: 2 + anchors.top: parent.top + anchors.topMargin: 6 + size: 24 + iconSize: 14 + tint: Theme.fgDim + icon: "view-more-symbolic" + iconFallback: "open-menu-symbolic" + onClicked: settingsMenu.visible = !settingsMenu.visible + } + + Popover { + id: settingsMenu + anchorItem: settingsMenuButton + + Column { + implicitWidth: notificationSettings.implicitWidth + implicitHeight: notificationSettings.implicitHeight + + TrayMenuRow { + id: notificationSettings + label: "Notification settings" + onActivated: { + ShellState.openSettings("notifications"); + settingsMenu.visible = false; + } + } + } + } + Column { id: layout anchors.left: appIcon.visible ? appIcon.right : parent.left anchors.leftMargin: appIcon.visible ? 10 : 14 - anchors.right: image.visible ? image.left : closeButton.left + anchors.right: image.visible ? image.left : settingsMenuButton.left anchors.rightMargin: 8 anchors.top: parent.top anchors.topMargin: 12 diff --git a/config/dot/quickshell/modules/osd/Osd.qml b/config/dot/quickshell/modules/osd/Osd.qml index 0aa43db..0446d92 100644 --- a/config/dot/quickshell/modules/osd/Osd.qml +++ b/config/dot/quickshell/modules/osd/Osd.qml @@ -34,7 +34,14 @@ PanelWindow { implicitWidth: root.desiredWidth implicitHeight: 64 color: "transparent" - mask: Region {} + mask: Region { + item: inputMask + } + + Item { + id: inputMask + anchors.fill: parent + } WlrLayershell.namespace: "qs-popover-osd" WlrLayershell.layer: WlrLayer.Overlay @@ -145,6 +152,17 @@ PanelWindow { font.weight: Font.DemiBold } } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.RightButton + onClicked: event => { + if (event.button !== Qt.RightButton) + return; + ShellState.openSettings("accessibility"); + OsdState.hide(); + } + } } TextMetrics { diff --git a/config/dot/quickshell/modules/overview/WindowThumbnail.qml b/config/dot/quickshell/modules/overview/WindowThumbnail.qml index 4bff7ca..09534ad 100644 --- a/config/dot/quickshell/modules/overview/WindowThumbnail.qml +++ b/config/dot/quickshell/modules/overview/WindowThumbnail.qml @@ -176,33 +176,33 @@ Item { height: aspect > 0 ? width / aspect : parent.height opacity: hasContent ? 1 : 0 - // The capture context isn't ready the instant the layer surface is - // told to become visible — it needs the surface to actually map, - // which takes a frame or two. Calling captureFrame() before then - // logs "no recording context is ready" and yields nothing, so - // retry a few times and then give up quietly (the caption and app - // icon are still shown, so a missing thumbnail is cosmetic). - property int captureAttempts: 0 + // A ScreencopyView cannot tell us that its recording context is + // ready before a capture. The enclosing overview gets that + // context only after its first rendered frame, so defer the first + // one-shot capture to that event. Retrying before then only emits + // "no recording context is ready" warnings; if a capture later + // cannot produce content, the app icon remains the fallback. + property bool recordingReady: false function tryCapture(): void { - captureAttempts = 0; - captureRetry.restart(); + if (shot.hasContent) + return; + if (!shot.recordingReady) { + frameReady.restart(); + return; + } + shot.captureFrame(); } - // `shot`, not `parent`: Timer is a QtObject, so `parent` does not - // resolve to the enclosing ScreencopyView. - Timer { - id: captureRetry - interval: 80 - repeat: true + FrameAnimation { + id: frameReady running: false onTriggered: { - if (shot.hasContent || shot.captureAttempts >= 6) { - stop(); + running = false; + if (shot.hasContent) return; - } - shot.captureAttempts++; - shot.captureFrame(); + shot.recordingReady = true; + shot.tryCapture(); } } diff --git a/tests/quickshell/overview-thumbnail-contract.sh b/tests/quickshell/overview-thumbnail-contract.sh new file mode 100755 index 0000000..315b419 --- /dev/null +++ b/tests/quickshell/overview-thumbnail-contract.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +# A ScreencopyView only has content *after* capture. It is not a readiness +# signal, so an overview thumbnail must first wait for its enclosing layer +# surface to render a frame. This contract keeps that warning-prone boundary +# explicit without opening a QML test window. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +thumbnail="$repo_dir/config/dot/quickshell/modules/overview/WindowThumbnail.qml" + +fail() { + printf 'overview thumbnail contract: %s\n' "$1" >&2 + exit 1 +} + +[[ -r "$thumbnail" ]] || fail 'WindowThumbnail.qml is missing' + +grep -qF 'FrameAnimation {' "$thumbnail" \ + || fail 'capture has no frame-ready gate' +grep -qF 'property bool recordingReady: false' "$thumbnail" \ + || fail 'capture readiness is not tracked independently from captured content' +grep -qF 'if (!shot.recordingReady)' "$thumbnail" \ + || fail 'capture can start before the frame-ready gate' +grep -qF 'frameReady.restart();' "$thumbnail" \ + || fail 'a pending capture is not scheduled by the frame-ready gate' +grep -qF 'shot.recordingReady = true;' "$thumbnail" \ + || fail 'the frame-ready signal never releases capture' + +capture_calls="$(grep -cF 'shot.captureFrame()' "$thumbnail")" +[[ "$capture_calls" -eq 1 ]] \ + || fail "capture must have one bounded attempt after readiness, found $capture_calls" +! grep -qF 'captureAttempts' "$thumbnail" \ + || fail 'blind capture retry state remains' +! grep -qF 'captureRetry' "$thumbnail" \ + || fail 'blind capture retry timer remains' + +# The application icon remains visible until the one-shot capture succeeds. +grep -qF 'opacity: shotLoader.hasFrame ? 0 : 1' "$thumbnail" \ + || fail 'app-icon fallback no longer remains visible before a frame arrives' + +printf 'overview thumbnail contract: PASS (frame-gated one-shot capture with icon fallback)\n' diff --git a/tests/quickshell/settings-jump-contract b/tests/quickshell/settings-jump-contract index 02d356c..d591064 100755 --- a/tests/quickshell/settings-jump-contract +++ b/tests/quickshell/settings-jump-contract @@ -16,6 +16,9 @@ set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml" modules="$repo_dir/config/dot/quickshell/modules" +dock_menu="$modules/dock/DockContextMenu.qml" +notification_card="$modules/notifications/NotificationCard.qml" +osd="$modules/osd/Osd.qml" fail() { printf 'settings jump contract: %s\n' "$1" >&2 @@ -47,4 +50,31 @@ for widget in Clock WeatherWidget VitalsWidget StatusCluster MediaWidget; do || fail "$widget has no right-click jump; Pill routes right-click to secondaryActivated, so leaving it unconnected makes the gesture silently inert" done +# These surfaces do not have a bar-style secondary-click signal. Their +# contextual affordances must retain the original interaction and route to the +# setting page that owns the controls. +[[ -r "$dock_menu" ]] || fail 'dock has no contextual menu, so application actions cannot keep a final Dock settings action' +grep -qF 'ShellState.openSettings("desktop")' "$dock_menu" \ + || fail 'dock context menu does not open Desktop settings' +grep -qF 'entry.actions' "$dock_menu" \ + || fail 'dock context menu dropped application actions' +actions_line="$(grep -nF 'entry.actions' "$dock_menu" | head -1 | cut -d: -f1)" +settings_line="$(grep -nF 'Dock settings' "$dock_menu" | head -1 | cut -d: -f1)" +[[ -n "$actions_line" && -n "$settings_line" && "$actions_line" -lt "$settings_line" ]] \ + || fail 'Dock settings is not the final contextual action after application actions' + +grep -qF 'Notification settings' "$notification_card" \ + || fail 'notification card has no overflow Settings action' +grep -qF 'ShellState.openSettings("notifications")' "$notification_card" \ + || fail 'notification overflow does not open Notifications settings' +grep -qF 'Popover {' "$notification_card" \ + || fail 'notification Settings action is not contained in an overflow menu' + +grep -qF 'acceptedButtons: Qt.RightButton' "$osd" \ + || fail 'OSD does not accept its contextual secondary click' +grep -qF 'ShellState.openSettings("accessibility")' "$osd" \ + || fail 'OSD secondary click does not open Accessibility settings' +grep -qF 'OsdState.hide()' "$osd" \ + || fail 'OSD remains visible after its Settings jump' + printf 'settings jump contract: PASS (%d distinct destinations)\n' "$count"