Complete contextual desktop controls
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user