Complete contextual desktop controls

This commit is contained in:
Gabriel Brown
2026-08-20 22:00:53 -04:00
parent a90f6eb357
commit 69ecec7ecf
9 changed files with 249 additions and 23 deletions
@@ -210,6 +210,12 @@ PanelWindow {
DockBody { DockBody {
id: body id: body
onContextMenuRequested: (anchorItem, entry) => {
dockContextMenu.anchorItem = anchorItem;
dockContextMenu.entry = entry;
dockContextMenu.visible = true;
}
vertical: root.vertical vertical: root.vertical
leftSide: root.position === "left" leftSide: root.position === "left"
@@ -260,4 +266,8 @@ PanelWindow {
} }
} }
} }
DockContextMenu {
id: dockContextMenu
}
} }
@@ -116,6 +116,7 @@ Rectangle {
// The item the tooltip is currently describing, or null. // The item the tooltip is currently describing, or null.
property Item hoveredItem: 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 // Set by the Dock. A side dock runs the same strip down the screen instead
// of across it. // of across it.
@@ -170,6 +171,7 @@ Rectangle {
onEntered: root.hoveredItem = dockItem onEntered: root.hoveredItem = dockItem
onExited: if (root.hoveredItem === dockItem) onExited: if (root.hoveredItem === dockItem)
root.hoveredItem = null root.hoveredItem = null
onContextMenuRequested: root.contextMenuRequested(dockItem, dockItem.entry)
} }
} }
} }
@@ -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;
}
}
}
}
}
@@ -24,6 +24,7 @@ Item {
// Emitted so DockBody can drive the single shared tooltip. // Emitted so DockBody can drive the single shared tooltip.
signal entered signal entered
signal exited signal exited
signal contextMenuRequested
// The icon may grow past the cell on hover; the cell itself stays a fixed // The icon may grow past the cell on hover; the cell itself stays a fixed
// size so the row doesn't reflow. // size so the row doesn't reflow.
@@ -115,7 +116,7 @@ Item {
id: mouse id: mouse
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
onEntered: root.entered() onEntered: root.entered()
onExited: root.exited() onExited: root.exited()
@@ -126,6 +127,10 @@ Item {
root.launch(); root.launch();
return; return;
} }
if (mev.button === Qt.RightButton) {
root.contextMenuRequested();
return;
}
if (root.running) if (root.running)
root.focusNext(); root.focusNext();
else else
@@ -14,6 +14,7 @@ import Quickshell.Services.Notifications
import qs.config import qs.config
import qs.services import qs.services
import qs.modules.quicksettings import qs.modules.quicksettings
import qs.modules.bar
import qs.widgets import qs.widgets
Rectangle { Rectangle {
@@ -127,11 +128,44 @@ Rectangle {
onClicked: root.dismissed() 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 { Column {
id: layout id: layout
anchors.left: appIcon.visible ? appIcon.right : parent.left anchors.left: appIcon.visible ? appIcon.right : parent.left
anchors.leftMargin: appIcon.visible ? 10 : 14 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.rightMargin: 8
anchors.top: parent.top anchors.top: parent.top
anchors.topMargin: 12 anchors.topMargin: 12
+19 -1
View File
@@ -34,7 +34,14 @@ PanelWindow {
implicitWidth: root.desiredWidth implicitWidth: root.desiredWidth
implicitHeight: 64 implicitHeight: 64
color: "transparent" color: "transparent"
mask: Region {} mask: Region {
item: inputMask
}
Item {
id: inputMask
anchors.fill: parent
}
WlrLayershell.namespace: "qs-popover-osd" WlrLayershell.namespace: "qs-popover-osd"
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
@@ -145,6 +152,17 @@ PanelWindow {
font.weight: Font.DemiBold 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 { TextMetrics {
@@ -176,34 +176,34 @@ Item {
height: aspect > 0 ? width / aspect : parent.height height: aspect > 0 ? width / aspect : parent.height
opacity: hasContent ? 1 : 0 opacity: hasContent ? 1 : 0
// The capture context isn't ready the instant the layer surface is // A ScreencopyView cannot tell us that its recording context is
// told to become visible — it needs the surface to actually map, // ready before a capture. The enclosing overview gets that
// which takes a frame or two. Calling captureFrame() before then // context only after its first rendered frame, so defer the first
// logs "no recording context is ready" and yields nothing, so // one-shot capture to that event. Retrying before then only emits
// retry a few times and then give up quietly (the caption and app // "no recording context is ready" warnings; if a capture later
// icon are still shown, so a missing thumbnail is cosmetic). // cannot produce content, the app icon remains the fallback.
property int captureAttempts: 0 property bool recordingReady: false
function tryCapture(): void { function tryCapture(): void {
captureAttempts = 0; if (shot.hasContent)
captureRetry.restart(); return;
} if (!shot.recordingReady) {
frameReady.restart();
// `shot`, not `parent`: Timer is a QtObject, so `parent` does not
// resolve to the enclosing ScreencopyView.
Timer {
id: captureRetry
interval: 80
repeat: true
running: false
onTriggered: {
if (shot.hasContent || shot.captureAttempts >= 6) {
stop();
return; return;
} }
shot.captureAttempts++;
shot.captureFrame(); shot.captureFrame();
} }
FrameAnimation {
id: frameReady
running: false
onTriggered: {
running = false;
if (shot.hasContent)
return;
shot.recordingReady = true;
shot.tryCapture();
}
} }
Component.onCompleted: tryCapture() Component.onCompleted: tryCapture()
+43
View File
@@ -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'
+30
View File
@@ -16,6 +16,9 @@ set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml" shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
modules="$repo_dir/config/dot/quickshell/modules" 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() { fail() {
printf 'settings jump contract: %s\n' "$1" >&2 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" || fail "$widget has no right-click jump; Pill routes right-click to secondaryActivated, so leaving it unconnected makes the gesture silently inert"
done 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" printf 'settings jump contract: PASS (%d distinct destinations)\n' "$count"