Added pinning to dock & right click menu to dock

Dock: display pinned apps on the left even when not running (lower
opacity)
DockMenu: Let users close, activate and pin/unpin apps
Settings: add pinned list for docks
This commit is contained in:
Ly-sec
2025-09-22 16:09:25 +02:00
parent 879d9ec879
commit 21c6c5a610
4 changed files with 465 additions and 20 deletions
+5 -3
View File
@@ -1,5 +1,5 @@
{
"settingsVersion": 3,
"settingsVersion": 4,
"bar": {
"position": "top",
"backgroundOpacity": 1,
@@ -116,7 +116,8 @@
"exclusive": false,
"backgroundOpacity": 1,
"floatingRatio": 1,
"monitors": []
"monitors": [],
"pinnedApps": []
},
"network": {
"wifiEnabled": true,
@@ -125,6 +126,7 @@
"notifications": {
"doNotDisturb": false,
"monitors": [],
"location": "top_right",
"lastSeenTs": 0,
"lowUrgencyDuration": 3,
"normalUrgencyDuration": 8,
@@ -149,7 +151,7 @@
},
"colorSchemes": {
"useWallpaperColors": false,
"predefinedScheme": "",
"predefinedScheme": "Noctalia (default)",
"darkMode": true
},
"matugen": {
+3 -1
View File
@@ -113,7 +113,7 @@ Singleton {
JsonAdapter {
id: adapter
property int settingsVersion: 3
property int settingsVersion: 4
// bar
property JsonObject bar: JsonObject {
@@ -233,6 +233,8 @@ Singleton {
property real backgroundOpacity: 1.0
property real floatingRatio: 1.0
property list<string> monitors: []
// Desktop entry IDs pinned to the dock (e.g., "org.kde.konsole", "firefox.desktop")
property list<string> pinnedApps: []
}
// network
+194 -16
View File
@@ -13,6 +13,7 @@ Variants {
model: Quickshell.screens
delegate: Item {
id: root
required property ShellScreen modelData
property real scaling: ScalingService.getScreenScale(modelData)
@@ -25,6 +26,47 @@ Variants {
}
}
// Update dock apps when toplevels change
Connections {
target: ToplevelManager ? ToplevelManager.toplevels : null
function onValuesChanged() {
Logger.log("Dock", "ToplevelManager.toplevels.onValuesChanged triggered")
updateDockApps()
}
}
// Also listen to model changes (for ObjectModel)
Connections {
target: ToplevelManager ? ToplevelManager.toplevels : null
function onCountChanged() {
Logger.log("Dock", "ToplevelManager.toplevels.onCountChanged triggered, count:", ToplevelManager.toplevels.count)
updateDockApps()
}
}
// Update dock apps when pinned apps change
Connections {
target: Settings.data.dock
function onPinnedAppsChanged() {
updateDockApps()
}
}
// Initial update when component is ready
Component.onCompleted: {
if (Settings.isLoaded && ToplevelManager) {
updateDockApps()
}
}
// Update when Settings are loaded
Connections {
target: Settings
function onSettingsLoaded() {
updateDockApps()
}
}
// Shared properties between peek and dock windows
readonly property bool autoHide: Settings.data.dock.autoHide
readonly property int hideDelay: 500
@@ -43,12 +85,78 @@ Variants {
// Shared state between windows
property bool dockHovered: false
property bool anyAppHovered: false
property bool menuHovered: false
property bool hidden: autoHide
property bool peekHovered: false
// Separate property to control Loader - stays true during animations
property bool dockLoaded: !autoHide // Start loaded if autoHide is off
// Track the currently open context menu
property var currentContextMenu: null
// Combined model of running apps and pinned apps
property var dockApps: []
// Function to close any open context menu
function closeAllContextMenus() {
if (currentContextMenu && currentContextMenu.visible) {
currentContextMenu.hide()
}
}
// Function to update the combined dock apps model
function updateDockApps() {
const runningApps = ToplevelManager ? (ToplevelManager.toplevels.values || []) : []
const pinnedApps = Settings.data.dock.pinnedApps || []
const combined = []
Logger.log("Dock", "Updating dock apps. Running:", runningApps.length, "Pinned:", pinnedApps.length)
// First, add pinned apps (both running and non-running) in their pinned order
pinnedApps.forEach(pinnedAppId => {
const runningApp = runningApps.find(toplevel => toplevel && toplevel.appId === pinnedAppId)
if (runningApp) {
// Pinned app that is currently running
combined.push({
"type": "pinned-running",
"toplevel": runningApp,
"appId": runningApp.appId,
"title": runningApp.title
})
Logger.log("Dock", "Added pinned-running app:", runningApp.appId)
} else {
// Pinned app that is not running
combined.push({
"type": "pinned",
"toplevel": null,
"appId": pinnedAppId,
"title": pinnedAppId
})
Logger.log("Dock", "Added pinned app:", pinnedAppId)
}
})
// Then, add running apps that are not pinned
runningApps.forEach(toplevel => {
if (toplevel && toplevel.appId) {
const isPinned = pinnedApps.includes(toplevel.appId)
if (!isPinned) {
combined.push({
"type": "running",
"toplevel": toplevel,
"appId": toplevel.appId,
"title": toplevel.title
})
Logger.log("Dock", "Added running app:", toplevel.appId)
}
}
})
Logger.log("Dock", "Total dock apps:", combined.length)
dockApps = combined
}
// Timer to unload dock after hide animation completes
Timer {
id: unloadTimer
@@ -65,7 +173,7 @@ Variants {
id: hideTimer
interval: hideDelay
onTriggered: {
if (autoHide && !dockHovered && !anyAppHovered && !peekHovered) {
if (autoHide && !dockHovered && !anyAppHovered && !peekHovered && !menuHovered) {
hidden = true
unloadTimer.restart() // Start unload timer when hiding
}
@@ -137,7 +245,7 @@ Variants {
onExited: {
peekHovered = false
if (!hidden && !dockHovered && !anyAppHovered) {
if (!hidden && !dockHovered && !anyAppHovered && !menuHovered) {
hideTimer.restart()
}
}
@@ -147,7 +255,7 @@ Variants {
// DOCK WINDOW
Loader {
active: Settings.isLoaded && modelData && Settings.data.dock.monitors.includes(modelData.name) && dockLoaded && ToplevelManager && (ToplevelManager.toplevels.values.length > 0)
active: Settings.isLoaded && modelData && Settings.data.dock.monitors.includes(modelData.name) && dockLoaded && ToplevelManager && (dockApps.length > 0)
sourceComponent: PanelWindow {
id: dockWindow
@@ -235,10 +343,15 @@ Variants {
onExited: {
dockHovered = false
if (autoHide && !anyAppHovered && !peekHovered) {
if (autoHide && !anyAppHovered && !peekHovered && !menuHovered) {
hideTimer.restart()
}
}
onClicked: {
// Close any open context menu when clicking on the dock background
closeAllContextMenus()
}
}
Item {
@@ -247,10 +360,10 @@ Variants {
height: parent.height - (Style.marginM * 2 * scaling)
anchors.centerIn: parent
function getAppIcon(toplevel: Toplevel): string {
if (!toplevel)
function getAppIcon(appData): string {
if (!appData || !appData.appId)
return ""
return AppIcons.iconForAppId(toplevel.appId?.toLowerCase())
return AppIcons.iconForAppId(appData.appId?.toLowerCase())
}
RowLayout {
@@ -260,7 +373,7 @@ Variants {
anchors.centerIn: parent
Repeater {
model: ToplevelManager ? ToplevelManager.toplevels : null
model: dockApps
delegate: Item {
id: appButton
@@ -268,10 +381,20 @@ Variants {
Layout.preferredHeight: iconSize
Layout.alignment: Qt.AlignCenter
property bool isActive: ToplevelManager.activeToplevel && ToplevelManager.activeToplevel === modelData
property bool isActive: modelData.toplevel && ToplevelManager.activeToplevel && ToplevelManager.activeToplevel === modelData.toplevel
property bool hovered: appMouseArea.containsMouse
property string appId: modelData ? modelData.appId : ""
property string appTitle: modelData ? modelData.title : ""
property string appTitle: modelData ? (modelData.title || modelData.appId) : ""
property bool isRunning: modelData && (modelData.type === "running" || modelData.type === "pinned-running")
// Listen for the toplevel being closed
Connections {
target: modelData?.toplevel
function onClosed() {
Logger.log("Dock", "Toplevel closed signal received for:", appButton.appId)
Qt.callLater(root.updateDockApps)
}
}
// Individual tooltip for this app
NTooltip {
@@ -296,6 +419,9 @@ Variants {
fillMode: Image.PreserveAspectFit
cache: true
// Dim pinned apps that aren't running
opacity: appButton.isRunning ? 1.0 : 0.6
scale: appButton.hovered ? 1.15 : 1.0
Behavior on scale {
@@ -305,6 +431,13 @@ Variants {
easing.overshoot: 1.2
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutQuad
}
}
}
// Fall back if no icon
@@ -314,6 +447,7 @@ Variants {
icon: "question-mark"
font.pointSize: iconSize * 0.7
color: appButton.isActive ? Color.mPrimary : Color.mOnSurfaceVariant
opacity: appButton.isRunning ? 1.0 : 0.6
scale: appButton.hovered ? 1.15 : 1.0
Behavior on scale {
@@ -323,6 +457,29 @@ Variants {
easing.overshoot: 1.2
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutQuad
}
}
}
// Context menu popup
DockMenu {
id: contextMenu
scaling: root.scaling
onHoveredChanged: menuHovered = hovered
onRequestClose: contextMenu.hide()
onAppClosed: root.updateDockApps // Force immediate dock update when app is closed
onVisibleChanged: {
if (visible) {
root.currentContextMenu = contextMenu
} else if (root.currentContextMenu === contextMenu) {
root.currentContextMenu = null
}
}
}
MouseArea {
@@ -330,7 +487,7 @@ Variants {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
onEntered: {
anyAppHovered = true
@@ -347,17 +504,38 @@ Variants {
onExited: {
anyAppHovered = false
appTooltip.hide()
if (autoHide && !dockHovered && !peekHovered) {
if (autoHide && !dockHovered && !peekHovered && !menuHovered) {
hideTimer.restart()
}
}
onClicked: function (mouse) {
if (mouse.button === Qt.MiddleButton && modelData?.close) {
modelData.close()
// Close any existing context menu first
if (mouse.button !== Qt.RightButton || root.currentContextMenu !== contextMenu) {
root.closeAllContextMenus()
}
if (mouse.button === Qt.LeftButton && modelData?.activate) {
modelData.activate()
// Check if toplevel is still valid (not a stale reference)
const isValidToplevel = modelData?.toplevel && ToplevelManager && ToplevelManager.toplevels.values.includes(modelData.toplevel)
if (mouse.button === Qt.MiddleButton && isValidToplevel && modelData.toplevel.close) {
Logger.log("Dock", "Middle-click closing app:", modelData.appId)
modelData.toplevel.close()
Qt.callLater(root.updateDockApps) // Force immediate dock update
} else if (mouse.button === Qt.LeftButton) {
if (isValidToplevel && modelData.toplevel.activate) {
// Running app - activate it
Logger.log("Dock", "Activating running app:", modelData.appId)
modelData.toplevel.activate()
} else if (modelData?.appId) {
// Pinned app not running - launch it
Logger.log("Dock", "Launching pinned app:", modelData.appId)
Quickshell.execDetached(["gtk-launch", modelData.appId])
}
} else if (mouse.button === Qt.RightButton) {
// Hide tooltip when showing context menu
appTooltip.hide()
contextMenu.show(appButton, modelData.toplevel || modelData)
}
}
}
+263
View File
@@ -0,0 +1,263 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Commons
import qs.Services
import qs.Widgets
PopupWindow {
id: root
property var toplevel: null
property Item anchorItem: null
property real scaling: 1.0
property bool hovered: menuMouseArea.containsMouse || activateMouseArea.containsMouse || pinMouseArea.containsMouse || closeMouseArea.containsMouse
property var onAppClosed: null // Callback function for when an app is closed
signal requestClose
implicitWidth: 160 * scaling
implicitHeight: contextMenuColumn.implicitHeight + (Style.marginM * scaling * 2)
color: Color.transparent
visible: false
// Helper functions for pin/unpin functionality
function isAppPinned(appId) {
if (!appId)
return false
const pinnedApps = Settings.data.dock.pinnedApps || []
return pinnedApps.includes(appId)
}
function toggleAppPin(appId) {
if (!appId)
return
let pinnedApps = (Settings.data.dock.pinnedApps || []).slice() // Create a copy
const isPinned = pinnedApps.includes(appId)
if (isPinned) {
// Unpin: remove from array
pinnedApps = pinnedApps.filter(id => id !== appId)
} else {
// Pin: add to array
pinnedApps.push(appId)
}
// Update the settings
Settings.data.dock.pinnedApps = pinnedApps
Logger.log("DockMenu", isPinned ? "Unpinned" : "Pinned", "app:", appId)
}
anchor.item: anchorItem
anchor.rect.x: anchorItem ? (anchorItem.width - implicitWidth) / 2 : 0
anchor.rect.y: anchorItem ? -implicitHeight - (Style.marginM * scaling) : 0
function show(item, toplevelData) {
if (!item) {
Logger.warn("DockMenu", "anchorItem is undefined, won't show menu.")
return
}
anchorItem = item
toplevel = toplevelData
visible = true
}
function hide() {
visible = false
}
// Close menu when clicking on background, track hover for the whole menu area
MouseArea {
id: menuMouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: root.hide() // Close when clicking on the background (outside menu content)
}
Shortcut {
sequences: ["Escape"]
enabled: root.visible
onActivated: root.hide()
}
Rectangle {
anchors.fill: parent
color: Color.mSurface
radius: Style.radiusS * scaling
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
// Prevent clicks inside the menu from closing it
MouseArea {
anchors.fill: parent
onClicked: {
} // Do nothing, just consume the click
}
Column {
id: contextMenuColumn
anchors.fill: parent
anchors.margins: Style.marginM * scaling
spacing: 0
// Activate/Focus item
Rectangle {
width: parent.width
height: 32 * scaling
color: activateMouseArea.containsMouse ? Qt.alpha(Color.mSecondary, 0.2) : Color.transparent
radius: Style.radiusXS * scaling
Row {
anchors.left: parent.left
anchors.leftMargin: Style.marginS * scaling
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginS * scaling
NIcon {
icon: "eye"
font.pointSize: 12 * scaling
color: Color.mOnSurface
anchors.verticalCenter: parent.verticalCenter
}
NText {
text: {
if (!root.toplevel)
return "Activate"
// Check if this toplevel is active by comparing with ToplevelManager.activeToplevel
const isActive = ToplevelManager.activeToplevel && ToplevelManager.activeToplevel === root.toplevel
return isActive ? "Focus" : "Activate"
}
font.pointSize: Style.fontSizeS * scaling
color: Color.mOnSurface
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: activateMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.toplevel?.activate) {
root.toplevel.activate()
}
root.hide()
}
}
}
// Pin/Unpin item
Rectangle {
width: parent.width
height: 32 * scaling
color: pinMouseArea.containsMouse ? Qt.alpha(Color.mTertiary, 0.2) : Color.transparent
radius: Style.radiusXS * scaling
Row {
anchors.left: parent.left
anchors.leftMargin: Style.marginS * scaling
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginS * scaling
NIcon {
icon: {
if (!root.toplevel)
return "pin"
return root.isAppPinned(root.toplevel.appId) ? "pinned-off" : "pin"
}
font.pointSize: 12 * scaling
color: Color.mOnSurface
anchors.verticalCenter: parent.verticalCenter
}
NText {
text: {
if (!root.toplevel)
return "Pin"
return root.isAppPinned(root.toplevel.appId) ? "Unpin" : "Pin"
}
font.pointSize: Style.fontSizeS * scaling
color: Color.mOnSurface
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: pinMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.toplevel?.appId) {
root.toggleAppPin(root.toplevel.appId)
}
root.hide()
}
}
}
// Close item
Rectangle {
width: parent.width
height: 32 * scaling
color: closeMouseArea.containsMouse ? Qt.alpha(Color.mPrimary, 0.2) : Color.transparent
radius: Style.radiusXS * scaling
Row {
anchors.left: parent.left
anchors.leftMargin: Style.marginS * scaling
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginS * scaling
NIcon {
icon: "x"
font.pointSize: 12 * scaling
color: closeMouseArea.containsMouse ? Color.mPrimary : Color.mOnSurface
anchors.verticalCenter: parent.verticalCenter
}
NText {
text: "Close"
font.pointSize: Style.fontSizeS * scaling
color: closeMouseArea.containsMouse ? Color.mPrimary : Color.mOnSurface
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: closeMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
// Check if toplevel is still valid before trying to close it
const isValidToplevel = root.toplevel && ToplevelManager && ToplevelManager.toplevels.values.includes(root.toplevel)
if (isValidToplevel && root.toplevel.close) {
Logger.log("DockMenu", "Closing app via menu:", root.toplevel.appId)
root.toplevel.close()
// Trigger immediate dock update callback if provided
if (root.onAppClosed && typeof root.onAppClosed === "function") {
Qt.callLater(root.onAppClosed)
}
} else {
Logger.warn("DockMenu", "Cannot close app - invalid toplevel reference")
}
root.hide()
}
}
}
}
}
}