This commit is contained in:
2026-05-28 21:45:21 +02:00
parent 653439ac24
commit a356a3d369
610 changed files with 205679 additions and 0 deletions
@@ -0,0 +1,951 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Commons
import qs.Services.Compositor
import qs.Services.System
import qs.Services.UI
import qs.Widgets
Loader {
active: Settings.data.dock.enabled
sourceComponent: Variants {
model: Quickshell.screens
delegate: Item {
id: root
required property ShellScreen modelData
property bool barIsReady: modelData ? BarService.isBarReady(modelData.name) : false
Connections {
target: BarService
function onBarReadyChanged(screenName) {
if (screenName === modelData.name) {
barIsReady = true;
}
}
}
// Update dock apps when window list change
Connections {
target: CompositorService
function onWindowListChanged() {
updateDockApps();
}
}
// Update dock apps when toplevels change
Connections {
target: ToplevelManager ? ToplevelManager.toplevels : null
function onValuesChanged() {
updateDockApps();
}
}
// Update dock apps when pinned apps change
Connections {
target: Settings.data.dock
function onPinnedAppsChanged() {
updateDockApps();
}
function onOnlySameOutputChanged() {
updateDockApps();
}
function onGroupAppsChanged() {
updateDockApps();
}
}
// Initial update when component is ready
Component.onCompleted: {
if (ToplevelManager) {
updateDockApps();
}
}
// Refresh icons and names when DesktopEntries becomes available (or updates)
Connections {
target: DesktopEntries.applications
function onValuesChanged() {
root.iconRevision++;
root._desktopEntryIdCache = {};
updateDockApps();
}
}
// Shared properties between peek and dock windows
readonly property string displayMode: Settings.data.dock.displayMode
readonly property bool autoHide: displayMode === "auto_hide"
readonly property bool exclusive: displayMode === "exclusive"
readonly property bool isAttachedMode: Settings.data.dock.dockType === "attached"
readonly property int hideDelay: 500
readonly property int showDelay: 100
readonly property int hideAnimationDuration: Math.max(0, Math.round(Style.animationFast / (Settings.data.dock.animationSpeed || 1.0)))
readonly property int showAnimationDuration: Math.max(0, Math.round(Style.animationFast / (Settings.data.dock.animationSpeed || 1.0)))
readonly property int peekThickness: 1
readonly property int indicatorThickness: Settings.data.dock.indicatorThickness || 3
readonly property string indicatorColorKey: Settings.data.dock.indicatorColor || "primary"
readonly property real indicatorOpacity: Settings.data.dock.indicatorOpacity !== undefined ? Settings.data.dock.indicatorOpacity : 0.6
readonly property int iconSize: Math.round(12 + 24 * (Settings.data.dock.size ?? 1))
readonly property int floatingMargin: Settings.data.dock.floatingRatio * Style.marginL
readonly property int maxWidth: modelData ? modelData.width * 0.8 : 1000
readonly property int maxHeight: modelData ? modelData.height * 0.8 : 1000
// Dock position properties
readonly property string dockPosition: Settings.data.dock.position
readonly property bool isVertical: dockPosition === "left" || dockPosition === "right"
// Bar detection and positioning properties
readonly property bool hasBar: modelData && modelData.name ? (Settings.data.bar.monitors.includes(modelData.name) || (Settings.data.bar.monitors.length === 0)) : false
readonly property bool barAtSameEdge: hasBar && Settings.getBarPositionForScreen(modelData?.name) === dockPosition
readonly property string barPosition: Settings.getBarPositionForScreen(modelData?.name)
readonly property bool barIsVertical: barPosition === "left" || barPosition === "right"
readonly property bool barIsFramed: Settings.data.bar.barType === "framed" && hasBar
readonly property bool barFloating: Settings.data.bar.barType === "floating"
readonly property real barMarginH: barFloating ? Math.ceil(Settings.data.bar.marginHorizontal) : 0
readonly property real barMarginV: barFloating ? Math.ceil(Settings.data.bar.marginVertical) : 0
readonly property int barHeight: Style.getBarHeightForScreen(modelData?.name)
readonly property bool staticPanelOpen: {
if (!isAttachedMode)
return false;
var panel = getStaticDockPanel();
if (panel && panel.isPanelOpen !== undefined)
return panel.isPanelOpen;
return false;
}
readonly property int peekEdgeLength: {
const edgeSize = isVertical ? Math.round(modelData?.height || maxHeight) : Math.round(modelData?.width || maxWidth);
const minLength = Math.max(1, Math.round(edgeSize * (Settings.data.dock.showDockIndicator ? 0.1 : 0.25)));
return Math.max(minLength, dockIndicatorLength);
}
readonly property int peekCenterOffsetX: {
if (isVertical)
return 0;
const edgeSize = Math.round(modelData?.width || maxWidth);
if (barIsVertical) {
if (barPosition === "left") {
const availableStart = (barIsFramed ? 0 : barMarginH) + barHeight;
const availableWidth = edgeSize - availableStart - (barIsFramed ? Settings.data.bar.frameThickness : 0);
return Math.max(0, Math.round(availableStart + (availableWidth - peekEdgeLength) / 2));
}
if (barPosition === "right") {
const availableWidth = edgeSize - (barIsFramed ? 0 : barMarginH) - barHeight - (barIsFramed ? Settings.data.bar.frameThickness : 0);
return Math.max(0, Math.round((barIsFramed ? Settings.data.bar.frameThickness : 0) + (availableWidth - peekEdgeLength) / 2));
}
}
return Math.max(0, Math.round((edgeSize - peekEdgeLength) / 2));
}
readonly property int peekCenterOffsetY: {
if (!isVertical)
return 0;
const edgeSize = Math.round(modelData?.height || maxHeight);
if (!barIsVertical) {
if (barPosition === "top") {
const availableStart = (barIsFramed ? 0 : barMarginV) + barHeight;
const availableHeight = edgeSize - availableStart - (barIsFramed ? Settings.data.bar.frameThickness : 0);
return Math.max(0, Math.round(availableStart + (availableHeight - peekEdgeLength) / 2));
}
if (barPosition === "bottom") {
const availableHeight = edgeSize - (barIsFramed ? 0 : barMarginV) - barHeight - (barIsFramed ? Settings.data.bar.frameThickness : 0);
return Math.max(0, Math.round((barIsFramed ? Settings.data.bar.frameThickness : 0) + (availableHeight - peekEdgeLength) / 2));
}
}
return Math.max(0, Math.round((edgeSize - peekEdgeLength) / 2));
}
readonly property bool showDockIndicator: {
if (!Settings.data.dock.showDockIndicator || (!autoHide && !isAttachedMode) || !hidden)
return false;
return !staticPanelOpen;
}
readonly property int dockItemCount: dockApps.length + (Settings.data.dock.showLauncherIcon ? 1 : 0)
readonly property bool indicatorVisible: showDockIndicator && dockIndicatorLength > 0
readonly property int dockIndicatorLength: {
if (dockItemCount <= 0)
return 0;
const spacing = Style.marginS;
const layoutLength = (iconSize * dockItemCount) + (spacing * Math.max(0, dockItemCount - 1));
const padded = layoutLength + Style.marginXL;
return Math.min(padded, isVertical ? maxHeight : maxWidth);
}
// 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: []
property var groupCycleIndices: ({})
// Track the session order of apps (transient reordering)
property var sessionAppOrder: []
// Drag and Drop state for visual feedback
property int dragSourceIndex: -1
property int dragTargetIndex: -1
// when dragging ended but the cursor is outside the dock area, restart the timer
onDragSourceIndexChanged: {
if (dragSourceIndex === -1) {
if (autoHide && !dockHovered && !anyAppHovered && !peekHovered && !menuHovered) {
hideTimer.restart();
}
}
}
// Revision counter to force icon re-evaluation
property int iconRevision: 0
// Function to close any open context menu
function closeAllContextMenus() {
if (currentContextMenu && currentContextMenu.visible) {
currentContextMenu.hide();
}
}
function getStaticDockPanel() {
return PanelService.getPanel("staticDockPanel", modelData, false);
}
function getAppKey(appData) {
if (!appData)
return null;
if (Settings.data.dock.groupApps) {
return appData.appId;
}
// Use stable appId for pinned apps to maintain their slot regardless of running state
if (appData.type === "pinned" || appData.type === "pinned-running") {
return appData.appId;
}
// prefer toplevel object identity for unpinned running apps to distinguish instances
if (appData.toplevel)
return appData.toplevel;
// fallback to appId
return appData.appId;
}
function sortDockApps(apps) {
if (!sessionAppOrder || sessionAppOrder.length === 0) {
return apps;
}
const sorted = [];
const remaining = [...apps];
// Pick apps that are in the session order
for (let i = 0; i < sessionAppOrder.length; i++) {
const key = sessionAppOrder[i];
// Pick ALL matching apps (e.g. all instances of a pinned app)
while (true) {
const idx = remaining.findIndex(app => getAppKey(app) === key);
if (idx !== -1) {
sorted.push(remaining[idx]);
remaining.splice(idx, 1);
} else {
break;
}
}
}
// Append any new/remaining apps
remaining.forEach(app => sorted.push(app));
return sorted;
}
function reorderApps(fromIndex, toIndex) {
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= dockApps.length || toIndex >= dockApps.length)
return;
const list = [...dockApps];
const item = list.splice(fromIndex, 1)[0];
list.splice(toIndex, 0, item);
dockApps = list;
sessionAppOrder = dockApps.map(getAppKey);
savePinnedOrder();
}
function savePinnedOrder() {
const currentPinned = Settings.data.dock.pinnedApps || [];
const newPinned = [];
const seen = new Set();
// Extract pinned apps in their current visual order
dockApps.forEach(app => {
if (app.appId && !seen.has(app.appId)) {
const isPinned = currentPinned.some(p => normalizeAppId(p) === normalizeAppId(app.appId));
if (isPinned) {
newPinned.push(app.appId);
seen.add(app.appId);
}
}
});
// Check if any pinned apps were missed (unlikely if dockApps is correct)
currentPinned.forEach(p => {
if (!seen.has(p)) {
newPinned.push(p);
seen.add(p);
}
});
if (JSON.stringify(currentPinned) !== JSON.stringify(newPinned)) {
Settings.data.dock.pinnedApps = newPinned;
}
}
// Helper function to normalize app IDs for case-insensitive matching
function normalizeAppId(appId) {
if (!appId || typeof appId !== 'string')
return "";
let id = appId.toLowerCase().trim();
if (id.endsWith(".desktop"))
id = id.substring(0, id.length - 8);
return id;
}
// Helper function to check if an app ID matches a pinned app (case-insensitive)
function isAppIdPinned(appId, pinnedApps) {
if (!appId || !pinnedApps || pinnedApps.length === 0)
return false;
const normalizedId = normalizeAppId(appId);
// Direct match
if (pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedId))
return true;
// Resolve via desktop entry lookup (handles StartupWMClass != .desktop filename)
const resolved = resolveToDesktopEntryId(appId);
if (resolved !== appId) {
const normalizedResolved = normalizeAppId(resolved);
return pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedResolved);
}
return false;
}
// Desktop entry ID resolution cache (cleared when DesktopEntries change)
property var _desktopEntryIdCache: ({})
// Resolve a toplevel appId to its canonical .desktop entry ID via heuristic lookup.
// This handles cases where the Wayland appId (e.g. "zen" from StartupWMClass)
// differs from the .desktop filename (e.g. "zen-browser-bin").
function resolveToDesktopEntryId(appId) {
if (!appId)
return appId;
if (_desktopEntryIdCache.hasOwnProperty(appId))
return _desktopEntryIdCache[appId];
try {
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) {
const entry = DesktopEntries.heuristicLookup(appId);
if (entry && entry.id) {
_desktopEntryIdCache[appId] = entry.id;
return entry.id;
}
}
} catch (e) {}
_desktopEntryIdCache[appId] = appId;
return appId;
}
// Helper function to get app name from desktop entry
function getAppNameFromDesktopEntry(appId) {
if (!appId)
return appId;
try {
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) {
const entry = DesktopEntries.heuristicLookup(appId);
if (entry && entry.name) {
return entry.name;
}
}
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.byId) {
const entry = DesktopEntries.byId(appId);
if (entry && entry.name) {
return entry.name;
}
}
} catch (e)
// Fall through to return original appId
{}
// Return original appId if we can't find a desktop entry
return appId;
}
function getToplevelsForEntry(appData) {
if (!appData)
return [];
if (appData.toplevels && appData.toplevels.length > 0) {
return appData.toplevels.filter(toplevel => toplevel && (!Settings.data.dock.onlySameOutput || !toplevel.screens || toplevel.screens.includes(modelData)));
}
if (!appData.toplevel)
return [];
if (Settings.data.dock.onlySameOutput && appData.toplevel.screens && !appData.toplevel.screens.includes(modelData))
return [];
return [appData.toplevel];
}
function getPrimaryToplevelForEntry(appData) {
const toplevels = getToplevelsForEntry(appData);
if (toplevels.length === 0)
return null;
if (ToplevelManager && ToplevelManager.activeToplevel && toplevels.includes(ToplevelManager.activeToplevel))
return ToplevelManager.activeToplevel;
return toplevels[0];
}
// Build grouped render model without mutating the raw toplevel list.
function buildGroupedDockApps(apps) {
if (!Settings.data.dock.groupApps) {
return apps.map(app => {
const entry = Object.assign({}, app);
entry.toplevels = getToplevelsForEntry(app);
return entry;
});
}
const grouped = [];
const groupedById = new Map();
apps.forEach(app => {
const appId = app.appId;
const toplevels = getToplevelsForEntry(app);
const existing = groupedById.get(appId);
if (existing) {
toplevels.forEach(toplevel => {
if (!existing.toplevels.includes(toplevel)) {
existing.toplevels.push(toplevel);
}
});
if (app.type === "pinned" || app.type === "pinned-running") {
existing.isPinned = true;
}
} else {
const entry = {
"type": app.type,
"appId": appId,
"title": app.title,
"toplevels": toplevels.slice(),
"isPinned": app.type === "pinned" || app.type === "pinned-running"
};
grouped.push(entry);
groupedById.set(appId, entry);
}
});
grouped.forEach(entry => {
entry.toplevel = getPrimaryToplevelForEntry(entry);
if (entry.toplevels.length > 0 && entry.isPinned) {
entry.type = "pinned-running";
} else if (entry.toplevels.length > 0) {
entry.type = "running";
} else {
entry.type = "pinned";
}
if (entry.toplevel && entry.toplevel.title && entry.toplevel.title.trim() !== "") {
entry.title = entry.toplevel.title;
}
});
return grouped;
}
// Function to update the combined dock apps model
function updateDockApps() {
const runningApps = ToplevelManager ? (ToplevelManager.toplevels.values || []) : [];
const pinnedApps = Settings.data.dock.pinnedApps || [];
const combined = [];
const processedToplevels = new Set();
const processedPinnedAppIds = new Set();
//push an app onto combined with the given appType
function pushApp(appType, toplevel, appId, title) {
// Use canonical ID for pinned apps to ensure key stability
const canonicalId = isAppIdPinned(appId, pinnedApps) ? (pinnedApps.find(p => normalizeAppId(p) === normalizeAppId(appId)) || appId) : appId;
// For running apps, track by toplevel object to allow multiple instances
if (toplevel) {
if (processedToplevels.has(toplevel)) {
return; // Already processed this toplevel instance
}
if (Settings.data.dock.onlySameOutput && toplevel.screens && !toplevel.screens.includes(modelData)) {
return; // Filtered out by onlySameOutput setting
}
combined.push({
"type": appType,
"toplevel": toplevel,
"toplevels": toplevel ? [toplevel] : [],
"appId": canonicalId,
"title": title
});
processedToplevels.add(toplevel);
} else {
// For pinned apps that aren't running, track by appId to avoid duplicates
if (processedPinnedAppIds.has(canonicalId)) {
return; // Already processed this pinned app
}
combined.push({
"type": appType,
"toplevel": toplevel,
"toplevels": [],
"appId": canonicalId,
"title": title
});
processedPinnedAppIds.add(canonicalId);
}
}
function pushRunning(first) {
runningApps.forEach(toplevel => {
if (toplevel) {
// Use robust matching to check if pinned
const isPinned = isAppIdPinned(toplevel.appId, pinnedApps);
if (!first && isPinned && processedToplevels.has(toplevel)) {
return; // Already added by pushPinned()
}
pushApp((first && isPinned) ? "pinned-running" : "running", toplevel, toplevel.appId, toplevel.title);
}
});
}
function pushPinned() {
pinnedApps.forEach(pinnedAppId => {
// Find all running instances of this pinned app using robust matching
// Also resolve toplevel appId via desktop entry lookup to handle
// StartupWMClass != .desktop filename (e.g. zen -> zen-browser-bin)
const normalizedPinned = normalizeAppId(pinnedAppId);
const matchingToplevels = runningApps.filter(app => {
if (!app)
return false;
if (normalizeAppId(app.appId) === normalizedPinned)
return true;
const resolved = resolveToDesktopEntryId(app.appId);
return resolved !== app.appId && normalizeAppId(resolved) === normalizedPinned;
});
if (matchingToplevels.length > 0) {
// Add all running instances as pinned-running
matchingToplevels.forEach(toplevel => {
pushApp("pinned-running", toplevel, pinnedAppId, toplevel.title);
});
} else {
// App is pinned but not running - add once
pushApp("pinned", null, pinnedAppId, getAppNameFromDesktopEntry(pinnedAppId) || pinnedAppId);
}
});
}
//if pinnedStatic then push all pinned and then all remaining running apps
if (Settings.data.dock.pinnedStatic) {
pushPinned();
pushRunning(false);
//else add all running apps and then remaining pinned apps
} else {
pushRunning(true);
pushPinned();
}
const sortedApps = sortDockApps(combined);
dockApps = buildGroupedDockApps(sortedApps);
const cycleState = root.groupCycleIndices || {};
const nextCycleState = {};
dockApps.forEach(app => {
if (app && app.appId && cycleState[app.appId] !== undefined) {
nextCycleState[app.appId] = cycleState[app.appId];
}
});
root.groupCycleIndices = nextCycleState;
// Sync session order if needed
// Instead of resetting everything when length changes, we reconcile the keys
if (!sessionAppOrder || sessionAppOrder.length === 0) {
sessionAppOrder = dockApps.map(getAppKey);
} else {
const currentKeys = new Set(dockApps.map(getAppKey));
const existingKeys = new Set();
const newOrder = [];
// Keep existing keys that are still present
sessionAppOrder.forEach(key => {
if (currentKeys.has(key)) {
newOrder.push(key);
existingKeys.add(key);
}
});
// Add new keys at the end
dockApps.forEach(app => {
const key = getAppKey(app);
if (!existingKeys.has(key)) {
newOrder.push(key);
existingKeys.add(key);
}
});
if (JSON.stringify(newOrder) !== JSON.stringify(sessionAppOrder)) {
sessionAppOrder = newOrder;
}
}
}
// Timer to unload dock after hide animation completes
Timer {
id: unloadTimer
interval: hideAnimationDuration + 50 // Add small buffer
onTriggered: {
if (hidden && autoHide) {
dockLoaded = false;
}
}
}
property alias hideTimer: hideTimer
property alias showTimer: showTimer
property alias unloadTimer: unloadTimer
// Timer for auto-hide delay
Timer {
id: hideTimer
interval: hideDelay
onTriggered: {
// do not hide if dragging
if (root.dragSourceIndex !== -1) {
return;
}
// Force menuHovered to false if no menu is current or visible
if (!root.currentContextMenu || !root.currentContextMenu.visible) {
menuHovered = false;
}
if (autoHide && !dockHovered && !anyAppHovered && !peekHovered && !menuHovered) {
if (isAttachedMode) {
const panel = getStaticDockPanel();
if (panel && (panel.menuHovered || (panel.currentContextMenu && panel.currentContextMenu.visible))) {
restart();
return;
}
if (panel && (panel.isDockHovered || panel.dockHovered || panel.anyAppHovered)) {
restart();
return;
}
if (panel)
panel.close();
} else {
closeAllContextMenus();
}
hidden = true;
unloadTimer.restart(); // Start unload timer when hiding
} else if (autoHide && !dockHovered && !peekHovered) {
// Restart timer if menu is closing (handles race condition)
restart();
}
}
}
// Timer for show delay
Timer {
id: showTimer
interval: showDelay
onTriggered: {
if (autoHide) {
if (!isAttachedMode) {
dockLoaded = true; // Load dock immediately
}
hidden = false; // Then trigger show animation
unloadTimer.stop(); // Cancel any pending unload
}
}
}
// Watch for autoHide setting changes
onAutoHideChanged: {
if (!autoHide) {
hidden = false;
dockLoaded = true;
hideTimer.stop();
showTimer.stop();
unloadTimer.stop();
} else {
hidden = true;
unloadTimer.restart(); // Schedule unload after animation
}
}
// PEEK WINDOW — only needed when dock can auto-hide or is in attached mode
Loader {
active: (autoHide || isAttachedMode) && (barIsReady || !hasBar) && modelData && (Settings.data.dock.monitors.length === 0 || Settings.data.dock.monitors.includes(modelData.name))
sourceComponent: PanelWindow {
id: peekWindow
screen: modelData
// Dynamic anchors based on dock position
anchors.top: dockPosition === "top" || isVertical
anchors.bottom: dockPosition === "bottom"
anchors.left: dockPosition === "left" || !isVertical
anchors.right: dockPosition === "right"
focusable: false
color: "transparent"
margins.top: peekCenterOffsetY
margins.left: peekCenterOffsetX
WlrLayershell.namespace: "noctalia-dock-peek-" + (screen?.name || "unknown")
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.exclusionMode: ExclusionMode.Ignore
// Larger peek area when bar is at same edge, normal 1px otherwise
implicitHeight: isVertical ? peekEdgeLength : peekThickness
implicitWidth: isVertical ? peekThickness : peekEdgeLength
MouseArea {
id: peekArea
anchors.fill: parent
hoverEnabled: true
onEntered: {
peekHovered = true;
if (isAttachedMode) {
if (dockItemCount <= 0)
return;
const panel = getStaticDockPanel();
if (panel && !panel.isPanelOpen)
panel.open();
return;
}
if (hidden) {
showTimer.start();
}
}
onExited: {
peekHovered = false;
showTimer.stop();
if (isAttachedMode) {
// Start hideTimer which checks panel.isDockHovered before closing
if (!dockHovered && !anyAppHovered && !menuHovered) {
hideTimer.restart();
}
} else if (!hidden && !dockHovered && !anyAppHovered && !menuHovered) {
hideTimer.restart();
}
}
}
}
}
// DOCK INDICATOR WINDOW — only needed when dock can auto-hide/attach and indicator is enabled
Loader {
active: (autoHide || isAttachedMode) && Settings.data.dock.showDockIndicator && (barIsReady || !hasBar) && modelData && (Settings.data.dock.monitors.length === 0 || Settings.data.dock.monitors.includes(modelData.name))
sourceComponent: PanelWindow {
id: dockIndicatorWindow
screen: modelData
// Dynamic anchors based on dock position
anchors.top: dockPosition === "top" || isVertical
anchors.bottom: dockPosition === "bottom"
anchors.left: dockPosition === "left" || !isVertical
anchors.right: dockPosition === "right"
focusable: false
color: "transparent"
margins.top: peekCenterOffsetY
margins.left: peekCenterOffsetX
WlrLayershell.namespace: "noctalia-dock-indicator-" + (screen?.name || "unknown")
WlrLayershell.layer: WlrLayer.Top
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
implicitHeight: isVertical ? peekEdgeLength : indicatorThickness
implicitWidth: isVertical ? indicatorThickness : peekEdgeLength
// Hide the window surface when indicator is not visible, so the compositor
// can skip compositing this layer-shell surface entirely (saves GPU on NVIDIA)
visible: indicatorRect.opacity > 0 || indicatorVisible
Rectangle {
id: indicatorRect
anchors.fill: parent
radius: indicatorThickness
color: Qt.alpha(Color.resolveColorKey(indicatorColorKey), indicatorOpacity)
opacity: indicatorVisible ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.InOutQuad
}
}
}
}
}
// Force dock reload when position changes to fix anchor/layout issues
// Force dock reload when position/mode changes to fix anchor/layout issues
property bool _reloading: false
function handleReload() {
if (!autoHide && dockLoaded && !_reloading) {
_reloading = true;
// Brief unload/reload cycle to reset layout
Qt.callLater(() => {
dockLoaded = false;
Qt.callLater(() => {
dockLoaded = true;
_reloading = false;
});
});
}
}
onDockPositionChanged: handleReload()
onExclusiveChanged: handleReload()
Loader {
id: dockWindowLoader
active: Settings.data.dock.enabled && !isAttachedMode && (barIsReady || !hasBar) && modelData && (Settings.data.dock.monitors.length === 0 || Settings.data.dock.monitors.includes(modelData.name)) && dockLoaded && ToplevelManager && (dockApps.length > 0)
sourceComponent: PanelWindow {
id: dockWindow
screen: modelData
focusable: false
color: "transparent"
WlrLayershell.namespace: "noctalia-dock-" + (screen?.name || "unknown")
WlrLayershell.exclusionMode: exclusive ? ExclusionMode.Auto : ExclusionMode.Ignore
// Slide animation: content slides inside a fixed window, no margin animation.
// Only reserve extra space for sliding when auto-hide is enabled
property int slideDistance: autoHide ? ((isVertical ? dockContainerWrapper.contentWidth : dockContainerWrapper.contentHeight) + floatingMargin + 10) : 0
property real slideOffset: hidden ? slideDistance : 0
Behavior on slideOffset {
NumberAnimation {
duration: hidden ? hideAnimationDuration : showAnimationDuration
easing.type: hidden ? Easing.InCubic : Easing.OutCubic
}
}
// Signed slide: positive pushes content toward its edge (off-screen)
readonly property real slideX: dockPosition === "left" ? -slideOffset : dockPosition === "right" ? slideOffset : 0
readonly property real slideY: dockPosition === "top" ? -slideOffset : dockPosition === "bottom" ? slideOffset : 0
// Blur behind dock — offset by slide so it follows the content
BackgroundEffect.blurRegion: Settings.data.general.enableBlurBehind ? dockBlurRegion : null
Region {
id: dockBlurRegion
Region {
x: Math.round(dockContainerWrapper.x + dockContent.dockContainer.x + dockWindow.slideX)
y: Math.round(dockContainerWrapper.y + dockContent.dockContainer.y + dockWindow.slideY)
width: Math.round(dockContent.dockContainer.width)
height: Math.round(dockContent.dockContainer.height)
radius: Style.radiusL
}
}
// Window sized to fit content + slide distance so content can slide off-edge.
// When auto-hide is disabled, slideDistance is 0 so the window (and thus
// the exclusion zone) matches the dock content size.
implicitWidth: dockContainerWrapper.width + (isVertical ? slideDistance : 0)
implicitHeight: dockContainerWrapper.height + (!isVertical ? slideDistance : 0)
// Position based on dock setting
anchors.top: dockPosition === "top"
anchors.bottom: dockPosition === "bottom"
anchors.left: dockPosition === "left"
anchors.right: dockPosition === "right"
// Static margins — no animation, window stays put
margins.top: dockPosition === "top" ? (barAtSameEdge && !exclusive ? barHeight + (barFloating ? Settings.data.bar.marginVertical : 0) + floatingMargin : floatingMargin) : 0
margins.bottom: dockPosition === "bottom" ? (barAtSameEdge && !exclusive ? barHeight + (barFloating ? Settings.data.bar.marginVertical : 0) + floatingMargin : floatingMargin) : 0
margins.left: dockPosition === "left" ? (barAtSameEdge && !exclusive ? barHeight + (barFloating ? Settings.data.bar.marginHorizontal : 0) + floatingMargin : floatingMargin) : 0
margins.right: dockPosition === "right" ? (barAtSameEdge && !exclusive ? barHeight + (barFloating ? Settings.data.bar.marginHorizontal : 0) + floatingMargin : floatingMargin) : 0
// Container wrapper for animations
Item {
id: dockContainerWrapper
// Helper properties for orthogonal bar detection
readonly property string screenBarPosition: Settings.getBarPositionForScreen(modelData?.name)
readonly property bool barOnLeft: hasBar && screenBarPosition === "left" && !barFloating
readonly property bool barOnRight: hasBar && screenBarPosition === "right" && !barFloating
readonly property bool barOnTop: hasBar && screenBarPosition === "top" && !barFloating
readonly property bool barOnBottom: hasBar && screenBarPosition === "bottom" && !barFloating
// Calculate padding needed to shift center to match exclusive mode
readonly property int extraTop: (isVertical && !exclusive && barOnTop) ? barHeight : 0
readonly property int extraBottom: (isVertical && !exclusive && barOnBottom) ? barHeight : 0
readonly property int extraLeft: (!isVertical && !exclusive && barOnLeft) ? barHeight : 0
readonly property int extraRight: (!isVertical && !exclusive && barOnRight) ? barHeight : 0
// Expose content size for window sizing (before slide padding)
readonly property int contentWidth: dockContent.dockContainer.width + extraLeft + extraRight + 2
readonly property int contentHeight: dockContent.dockContainer.height + extraTop + extraBottom + 2
// Add +2 buffer for fractional scaling issues
width: contentWidth
height: contentHeight
anchors.horizontalCenter: isVertical ? undefined : parent.horizontalCenter
anchors.verticalCenter: isVertical ? parent.verticalCenter : undefined
anchors.top: dockPosition === "top" ? parent.top : undefined
anchors.bottom: dockPosition === "bottom" ? parent.bottom : undefined
anchors.left: dockPosition === "left" ? parent.left : undefined
anchors.right: dockPosition === "right" ? parent.right : undefined
// Slide content inside the fixed window
transform: Translate {
x: dockWindow.slideX
y: dockWindow.slideY
}
// Enable layer caching to reduce GPU usage from continuous animations
layer.enabled: true
DockContent {
id: dockContent
anchors.fill: parent
dockRoot: root
extraTop: dockContainerWrapper.extraTop
extraBottom: dockContainerWrapper.extraBottom
extraLeft: dockContainerWrapper.extraLeft
extraRight: dockContainerWrapper.extraRight
}
}
}
}
}
}
}
@@ -0,0 +1,895 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Commons
import qs.Services.Compositor
import qs.Services.System
import qs.Services.UI
import qs.Widgets
Item {
required property var dockRoot
required property int extraTop
required property int extraBottom
required property int extraLeft
required property int extraRight
property alias dockContainer: dockContainer
readonly property bool isAttachedMode: Settings.data.dock.dockType === "attached"
readonly property string tooltipDirection: dockRoot.dockPosition === "left" ? "right" : (dockRoot.dockPosition === "right" ? "left" : (dockRoot.dockPosition === "top" ? "bottom" : "top"))
Rectangle {
id: dockContainer
// For vertical dock, swap width and height logic
width: dockRoot.isVertical ? Math.round(dockRoot.iconSize * 1.5) : Math.min(dockLayout.implicitWidth + Style.marginXL, dockRoot.maxWidth)
height: dockRoot.isVertical ? Math.min(dockLayout.implicitHeight + Style.marginXL, dockRoot.maxHeight) : Math.round(dockRoot.iconSize * 1.5)
color: Qt.alpha(Color.mSurface, (isAttachedMode ? 0 : Color.adaptiveOpacity(Settings.data.dock.backgroundOpacity)))
// Anchor based on padding to achieve centering shift
anchors.horizontalCenter: extraLeft > 0 || extraRight > 0 ? undefined : parent.horizontalCenter
anchors.right: extraLeft > 0 ? parent.right : undefined
anchors.left: extraRight > 0 ? parent.left : undefined
anchors.verticalCenter: extraTop > 0 || extraBottom > 0 ? undefined : parent.verticalCenter
anchors.bottom: extraTop > 0 ? parent.bottom : undefined
anchors.top: extraBottom > 0 ? parent.top : undefined
radius: Style.radiusL
border.width: Style.borderS
border.color: Qt.alpha(Color.mOutline, (isAttachedMode ? 0 : Color.adaptiveOpacity(Settings.data.dock.backgroundOpacity)))
MouseArea {
id: dockMouseArea
anchors.fill: parent
hoverEnabled: true
onEntered: {
dockRoot.dockHovered = true;
if (dockRoot.autoHide) {
dockRoot.showTimer.stop();
dockRoot.hideTimer.stop();
dockRoot.unloadTimer.stop(); // Cancel unload if hovering
dockRoot.hidden = false; // Make sure dock is visible
}
}
onExited: {
dockRoot.dockHovered = false;
if (dockRoot.autoHide && !dockRoot.anyAppHovered && !dockRoot.peekHovered && !dockRoot.menuHovered && dockRoot.dragSourceIndex === -1) {
dockRoot.hideTimer.restart();
}
}
onClicked: {
// Close any open context menu when clicking on the dock background
dockRoot.closeAllContextMenus();
}
}
Flickable {
id: dock
// Use parent dimensions more directly to avoid clipping
width: dockRoot.isVertical ? parent.width : Math.min(dockLayout.implicitWidth, parent.width - Style.marginXL)
height: !dockRoot.isVertical ? parent.height : Math.min(dockLayout.implicitHeight, parent.height - Style.marginXL)
contentWidth: dockLayout.implicitWidth
contentHeight: dockLayout.implicitHeight
anchors.centerIn: parent
clip: true
flickableDirection: dockRoot.isVertical ? Flickable.VerticalFlick : Flickable.HorizontalFlick
// Keep interactive dependent on overflow
interactive: dockRoot.isVertical ? contentHeight > height : contentWidth > width
// Centering margins
contentX: dockRoot.isVertical && contentWidth < width ? (contentWidth - width) / 2 : 0
contentY: !dockRoot.isVertical && contentHeight < height ? (contentHeight - height) / 2 : 0
WheelHandler {
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
onWheel: event => {
var delta = (event.angleDelta.y !== 0) ? event.angleDelta.y : event.angleDelta.x;
if (dockRoot.isVertical) {
dock.contentY = Math.max(-dock.topMargin, Math.min(dock.contentHeight - dock.height + dock.bottomMargin, dock.contentY - delta));
} else {
// For horizontal dock, we want to scroll contentX with BOTH x and y wheels
var hDelta = (event.angleDelta.x !== 0) ? event.angleDelta.x : event.angleDelta.y;
dock.contentX = Math.max(-dock.leftMargin, Math.min(dock.contentWidth - dock.width + dock.rightMargin, dock.contentX - hDelta));
}
event.accepted = true;
}
}
ScrollBar.horizontal: ScrollBar {
visible: !dockRoot.isVertical && dock.interactive
policy: ScrollBar.AsNeeded
}
ScrollBar.vertical: ScrollBar {
visible: dockRoot.isVertical && dock.interactive
policy: ScrollBar.AsNeeded
}
function getAppIcon(appData): string {
if (!appData || !appData.appId)
return "";
return ThemeIcons.iconForAppId(appData.appId?.toLowerCase());
}
function getValidToplevels(appData) {
if (!appData || !ToplevelManager || !ToplevelManager.toplevels)
return [];
const source = appData.toplevels && appData.toplevels.length > 0 ? appData.toplevels : (appData.toplevel ? [appData.toplevel] : []);
const allToplevels = ToplevelManager.toplevels.values || [];
return source.filter(toplevel => toplevel && allToplevels.includes(toplevel));
}
function getPrimaryToplevel(appData) {
const toplevels = getValidToplevels(appData);
if (toplevels.length === 0)
return null;
if (ToplevelManager && ToplevelManager.activeToplevel && toplevels.includes(ToplevelManager.activeToplevel))
return ToplevelManager.activeToplevel;
return toplevels[0];
}
function launchAppById(appId) {
if (!appId)
return;
const app = ThemeIcons.findAppEntry(appId);
if (!app) {
Logger.w("Dock", `Could not find desktop entry for pinned app: ${appId}`);
return;
}
if (Settings.data.appLauncher.customLaunchPrefixEnabled && Settings.data.appLauncher.customLaunchPrefix) {
const prefix = Settings.data.appLauncher.customLaunchPrefix.split(" ");
if (app.runInTerminal) {
const terminal = Settings.data.appLauncher.terminalCommand.split(" ");
const command = prefix.concat(terminal.concat(app.command));
Quickshell.execDetached(command);
} else {
const command = prefix.concat(app.command);
Quickshell.execDetached(command);
}
} else {
if (app.runInTerminal) {
Logger.d("Dock", "Executing terminal app manually: " + app.name);
const terminal = Settings.data.appLauncher.terminalCommand.split(" ");
const command = terminal.concat(app.command);
CompositorService.spawn(command);
} else if (app.command && app.command.length > 0) {
CompositorService.spawn(app.command);
} else if (app.execute) {
app.execute();
} else {
Logger.w("Dock", `Could not launch: ${app.name}. No valid launch method.`);
}
}
}
// Use GridLayout for flexible horizontal/vertical arrangement
GridLayout {
id: dockLayout
columns: dockRoot.isVertical ? 1 : -1
rows: dockRoot.isVertical ? -1 : 1
rowSpacing: Style.marginS
columnSpacing: Style.marginS
// Ensure the layout takes its full implicit size
width: implicitWidth
height: implicitHeight
Component {
id: launcherButtonComponent
Item {
id: launcherButton
anchors.fill: parent
readonly property string screenName: dockRoot.modelData ? dockRoot.modelData.name : (dockRoot.screen ? dockRoot.screen.name : "")
readonly property var launcherWidgetSettings: {
const widgetsBySection = screenName ? Settings.getBarWidgetsForScreen(screenName) : Settings.data.bar.widgets;
if (!widgetsBySection)
return {};
const sections = ["left", "center", "right"];
for (let i = 0; i < sections.length; i++) {
const sectionWidgets = widgetsBySection[sections[i]] || [];
for (let j = 0; j < sectionWidgets.length; j++) {
const widget = sectionWidgets[j];
if (widget && widget.id === "Launcher")
return widget;
}
}
return {};
}
readonly property string launcherWidgetSection: {
const widgetsBySection = screenName ? Settings.getBarWidgetsForScreen(screenName) : Settings.data.bar.widgets;
if (!widgetsBySection)
return "";
const sections = ["left", "center", "right"];
for (let i = 0; i < sections.length; i++) {
const sectionWidgets = widgetsBySection[sections[i]] || [];
for (let j = 0; j < sectionWidgets.length; j++) {
const widget = sectionWidgets[j];
if (widget && widget.id === "Launcher")
return sections[i];
}
}
return "";
}
readonly property int launcherWidgetIndex: {
const widgetsBySection = screenName ? Settings.getBarWidgetsForScreen(screenName) : Settings.data.bar.widgets;
if (!widgetsBySection)
return -1;
const sections = ["left", "center", "right"];
for (let i = 0; i < sections.length; i++) {
const sectionWidgets = widgetsBySection[sections[i]] || [];
for (let j = 0; j < sectionWidgets.length; j++) {
const widget = sectionWidgets[j];
if (widget && widget.id === "Launcher")
return j;
}
}
return -1;
}
readonly property var launcherMetadata: BarWidgetRegistry.widgetMetadata["Launcher"]
readonly property string launcherIcon: {
if (Settings.data.dock.launcherIcon !== undefined && Settings.data.dock.launcherIcon !== "")
return Settings.data.dock.launcherIcon;
if (launcherWidgetSettings.icon !== undefined && launcherWidgetSettings.icon !== "")
return launcherWidgetSettings.icon;
return (launcherMetadata && launcherMetadata.icon) ? launcherMetadata.icon : "search";
}
readonly property string launcherIconColorKey: {
if (Settings.data.dock.launcherIconColor !== undefined)
return Settings.data.dock.launcherIconColor;
if (launcherWidgetSettings.iconColor !== undefined)
return launcherWidgetSettings.iconColor;
if (launcherMetadata && launcherMetadata.iconColor !== undefined)
return launcherMetadata.iconColor;
return "none";
}
readonly property bool launcherUseDistroLogo: {
if (Settings.data.dock.launcherUseDistroLogo !== undefined)
return Settings.data.dock.launcherUseDistroLogo;
if (launcherWidgetSettings.useDistroLogo !== undefined)
return launcherWidgetSettings.useDistroLogo;
if (launcherMetadata && launcherMetadata.useDistroLogo !== undefined)
return launcherMetadata.useDistroLogo;
return false;
}
Item {
id: launcherIconContainer
width: dockRoot.iconSize
height: dockRoot.iconSize
anchors.centerIn: parent
scale: launcherMouseArea.containsMouse ? 1.15 : 1.0
Behavior on scale {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutBack
easing.overshoot: 1.2
}
}
NIcon {
anchors.centerIn: parent
icon: launcherButton.launcherIcon
pointSize: dockRoot.iconSize * 0.7
color: Color.resolveColorKey(launcherButton.launcherIconColorKey)
visible: !launcherButton.launcherUseDistroLogo
}
IconImage {
anchors.centerIn: parent
width: dockRoot.iconSize * 0.8
height: width
source: launcherButton.launcherUseDistroLogo ? HostService.osLogo : ""
visible: source !== ""
smooth: true
asynchronous: true
layer.enabled: visible
layer.effect: ShaderEffect {
property color targetColor: Color.resolveColorKey(launcherButton.launcherIconColorKey)
property real colorizeMode: 2.0
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/appicon_colorize.frag.qsb")
}
}
}
MouseArea {
id: launcherMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
onEntered: {
dockRoot.anyAppHovered = true;
TooltipService.show(launcherButton, I18n.tr("actions.open-launcher"), tooltipDirection);
if (dockRoot.autoHide) {
dockRoot.showTimer.stop();
dockRoot.hideTimer.stop();
dockRoot.unloadTimer.stop();
dockRoot.hidden = false;
}
}
onExited: {
dockRoot.anyAppHovered = false;
TooltipService.hide();
if (dockRoot.autoHide && !dockRoot.dockHovered && !dockRoot.peekHovered && !dockRoot.menuHovered && dockRoot.dragSourceIndex === -1) {
dockRoot.hideTimer.restart();
}
}
onClicked: mouse => {
const targetScreen = dockRoot.modelData || dockRoot.screen || null;
if (!targetScreen) {
return;
}
if (mouse.button === Qt.RightButton) {
if (dockRoot.currentContextMenu === launcherContextMenu && launcherContextMenu.visible) {
dockRoot.closeAllContextMenus();
return;
}
dockRoot.closeAllContextMenus();
TooltipService.hideImmediately();
launcherContextMenu.show(launcherButton, null, targetScreen);
return;
}
if (mouse.button === Qt.LeftButton || mouse.button === Qt.MiddleButton) {
dockRoot.closeAllContextMenus();
PanelService.toggleLauncher(targetScreen);
}
}
}
DockMenu {
id: launcherContextMenu
dockPosition: dockRoot.dockPosition
menuMode: "launcher"
launcherWidgetSection: launcherButton.launcherWidgetSection
launcherWidgetIndex: launcherButton.launcherWidgetIndex
launcherWidgetSettings: launcherButton.launcherWidgetSettings
onHoveredChanged: {
if (dockRoot.currentContextMenu === launcherContextMenu && launcherContextMenu.visible) {
dockRoot.menuHovered = hovered;
} else {
dockRoot.menuHovered = false;
}
}
Connections {
target: launcherContextMenu
function onRequestClose() {
dockRoot.currentContextMenu = null;
dockRoot.hideTimer.stop();
launcherContextMenu.hide();
dockRoot.menuHovered = false;
dockRoot.anyAppHovered = false;
}
}
onVisibleChanged: {
if (visible) {
dockRoot.currentContextMenu = launcherContextMenu;
} else if (dockRoot.currentContextMenu === launcherContextMenu) {
dockRoot.currentContextMenu = null;
dockRoot.hideTimer.stop();
dockRoot.menuHovered = false;
if (dockRoot.autoHide && !dockRoot.dockHovered && !dockRoot.anyAppHovered && !dockRoot.peekHovered && !dockRoot.menuHovered) {
dockRoot.hideTimer.restart();
}
}
}
}
}
}
Loader {
id: launcherButtonStart
active: Settings.data.dock.showLauncherIcon && Settings.data.dock.launcherPosition === "start"
visible: active
sourceComponent: launcherButtonComponent
readonly property real indicatorMargin: Math.max(3, Math.round(dockRoot.iconSize * 0.18))
Layout.preferredWidth: active ? (dockRoot.isVertical ? dockRoot.iconSize + indicatorMargin * 2 : dockRoot.iconSize) : 0
Layout.preferredHeight: active ? (dockRoot.isVertical ? dockRoot.iconSize : dockRoot.iconSize + indicatorMargin * 2) : 0
Layout.minimumWidth: active ? Layout.preferredWidth : 0
Layout.minimumHeight: active ? Layout.preferredHeight : 0
Layout.maximumWidth: active ? Layout.preferredWidth : 0
Layout.maximumHeight: active ? Layout.preferredHeight : 0
Layout.alignment: Qt.AlignCenter
}
Repeater {
model: dockRoot.dockApps
delegate: Item {
id: appButton
readonly property real indicatorMargin: Math.max(3, Math.round(dockRoot.iconSize * 0.18))
Layout.preferredWidth: dockRoot.isVertical ? dockRoot.iconSize + indicatorMargin * 2 : dockRoot.iconSize
Layout.preferredHeight: dockRoot.isVertical ? dockRoot.iconSize : dockRoot.iconSize + indicatorMargin * 2
Layout.alignment: Qt.AlignCenter
property var toplevels: dock.getValidToplevels(modelData)
property bool isActive: ToplevelManager && ToplevelManager.activeToplevel && toplevels.includes(ToplevelManager.activeToplevel)
property bool hovered: appMouseArea.containsMouse
property string appId: modelData ? modelData.appId : ""
property int groupedCount: toplevels.length
property int focusedWindowIndex: {
if (!ToplevelManager || !ToplevelManager.activeToplevel)
return -1;
return toplevels.indexOf(ToplevelManager.activeToplevel);
}
property string groupedIndicatorText: focusedWindowIndex >= 0 ? (focusedWindowIndex + 1) + "/" + groupedCount : groupedCount.toString()
property string appTitle: {
if (!modelData)
return "";
const primaryToplevel = dock.getPrimaryToplevel(modelData);
if (primaryToplevel) {
const toplevelTitle = primaryToplevel.title || "";
// If title is "Loading..." or empty, use desktop entry name
if (!toplevelTitle || toplevelTitle === "Loading..." || toplevelTitle.trim() === "") {
return dockRoot.getAppNameFromDesktopEntry(modelData.appId) || modelData.appId;
}
return toplevelTitle;
}
// For pinned apps that aren't running, use the stored title
return modelData.title || modelData.appId || "";
}
property bool isRunning: toplevels.length > 0
readonly property bool baseIndicatorVisible: Settings.data.dock.inactiveIndicators ? isRunning : isActive
// Grouped indicators should be visible whenever grouped windows are running, even if none is focused.
readonly property bool showGroupedIndicator: Settings.data.dock.groupApps && groupedCount > 1 && isRunning
// Store index for drag-and-drop
property int modelIndex: index
objectName: "dockAppButton"
DropArea {
anchors.fill: parent
keys: ["dock-app"]
onEntered: function (drag) {
if (drag.source && drag.source.objectName === "dockAppButton") {
dockRoot.dragTargetIndex = appButton.modelIndex;
}
}
onExited: function () {
if (dockRoot.dragTargetIndex === appButton.modelIndex) {
dockRoot.dragTargetIndex = -1;
}
}
onDropped: function (drop) {
dockRoot.dragSourceIndex = -1;
dockRoot.dragTargetIndex = -1;
if (drop.source && drop.source.objectName === "dockAppButton" && drop.source !== appButton) {
dockRoot.reorderApps(drop.source.modelIndex, appButton.modelIndex);
}
}
}
// Listen for the toplevel being closed
Connections {
target: modelData?.toplevel
function onClosed() {
Qt.callLater(dockRoot.updateDockApps);
}
}
// Draggable container for the icon
Item {
id: iconContainer
width: dockRoot.iconSize
height: dockRoot.iconSize
// When dragging, remove anchors so MouseArea can position it
anchors.centerIn: dragging ? undefined : parent
property bool dragging: appMouseArea.drag.active
onDraggingChanged: {
if (dragging) {
dockRoot.dragSourceIndex = index;
} else {
// Reset if not handled by drop (e.g. dropped outside)
Qt.callLater(() => {
if (!appMouseArea.drag.active && dockRoot.dragSourceIndex === index) {
dockRoot.dragSourceIndex = -1;
dockRoot.dragTargetIndex = -1;
}
});
}
}
Drag.active: dragging
Drag.source: appButton
Drag.hotSpot.x: width / 2
Drag.hotSpot.y: height / 2
Drag.keys: ["dock-app"]
z: (dockRoot.dragSourceIndex === index) ? 1000 : ((dragging ? 1000 : 0))
scale: dragging ? 1.1 : (appButton.hovered ? 1.15 : 1.0)
Behavior on scale {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutBack
easing.overshoot: 1.2
}
}
// Visual shifting logic
readonly property bool isDragged: dockRoot.dragSourceIndex === index
property real shiftOffset: 0
Binding on shiftOffset {
value: {
if (dockRoot.dragSourceIndex !== -1 && dockRoot.dragTargetIndex !== -1 && !iconContainer.isDragged) {
if (dockRoot.dragSourceIndex < dockRoot.dragTargetIndex) {
// Dragging Forward: Items between source and target shift Backward
if (index > dockRoot.dragSourceIndex && index <= dockRoot.dragTargetIndex) {
return -1 * (dockRoot.isVertical ? dockRoot.iconSize + Style.marginS : dockRoot.iconSize + Style.marginS);
}
} else if (dockRoot.dragSourceIndex > dockRoot.dragTargetIndex) {
// Dragging Backward: Items between target and source shift Forward
if (index >= dockRoot.dragTargetIndex && index < dockRoot.dragSourceIndex) {
return (dockRoot.isVertical ? dockRoot.iconSize + Style.marginS : dockRoot.iconSize + Style.marginS);
}
}
}
return 0;
}
}
transform: Translate {
x: !dockRoot.isVertical ? iconContainer.shiftOffset : 0
y: dockRoot.isVertical ? iconContainer.shiftOffset : 0
Behavior on x {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutQuad
}
}
Behavior on y {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutQuad
}
}
}
IconImage {
id: appIcon
anchors.fill: parent
source: {
dockRoot.iconRevision; // Force re-evaluation when revision changes
return dock.getAppIcon(modelData);
}
visible: source.toString() !== ""
smooth: true
asynchronous: true
// Dim pinned apps that aren't running
opacity: appButton.isRunning ? 1.0 : Settings.data.dock.deadOpacity
// Apply dock-specific colorization shader only to non-focused apps
layer.enabled: !appButton.isActive && Settings.data.dock.colorizeIcons
layer.effect: ShaderEffect {
property color targetColor: Settings.data.colorSchemes.darkMode ? Color.mOnSurface : Color.mSurfaceVariant
property real colorizeMode: 0.0 // Dock mode (grayscale)
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/appicon_colorize.frag.qsb")
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutQuad
}
}
}
// Fall back if no icon
NIcon {
anchors.centerIn: parent
visible: !appIcon.visible
icon: "question-mark"
pointSize: dockRoot.iconSize * 0.7
color: appButton.isActive ? Color.mPrimary : Color.mOnSurfaceVariant
opacity: appButton.isRunning ? 1.0 : 0.6
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutQuad
}
}
}
}
// Context menu popup
DockMenu {
id: contextMenu
dockPosition: dockRoot.dockPosition // Pass dock position for menu placement
onHoveredChanged: {
// Only update menuHovered if this menu is current and visible
if (dockRoot.currentContextMenu === contextMenu && contextMenu.visible) {
dockRoot.menuHovered = hovered;
} else {
dockRoot.menuHovered = false;
}
}
Connections {
target: contextMenu
function onRequestClose() {
// Clear current menu immediately to prevent hover updates
dockRoot.currentContextMenu = null;
dockRoot.hideTimer.stop();
contextMenu.hide();
dockRoot.menuHovered = false;
dockRoot.anyAppHovered = false;
}
}
onAppClosed: dockRoot.updateDockApps // Force immediate dock update when app is closed
onVisibleChanged: {
if (visible) {
dockRoot.currentContextMenu = contextMenu;
} else if (dockRoot.currentContextMenu === contextMenu) {
dockRoot.currentContextMenu = null;
dockRoot.hideTimer.stop();
dockRoot.menuHovered = false;
// Restart hide timer after menu closes
if (dockRoot.autoHide && !dockRoot.dockHovered && !dockRoot.anyAppHovered && !dockRoot.peekHovered && !dockRoot.menuHovered) {
dockRoot.hideTimer.restart();
}
}
}
}
MouseArea {
id: appMouseArea
objectName: "appMouseArea"
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
// Only allow left-click dragging via axis control
drag.target: iconContainer
drag.axis: (pressedButtons & Qt.LeftButton) ? (dockRoot.isVertical ? Drag.YAxis : Drag.XAxis) : Drag.None
onPressed: {
var p1 = appButton.mapFromItem(dockContainer, 0, 0);
var p2 = appButton.mapFromItem(dockContainer, dockContainer.width, dockContainer.height);
drag.minimumX = p1.x;
drag.maximumX = p2.x - iconContainer.width;
drag.minimumY = p1.y;
drag.maximumY = p2.y - iconContainer.height;
}
onReleased: {
if (iconContainer.Drag.active) {
iconContainer.Drag.drop();
}
}
onEntered: {
dockRoot.anyAppHovered = true;
const appName = appButton.appTitle || appButton.appId || "Unknown";
const tooltipText = appName.length > 40 ? appName.substring(0, 37) + "..." : appName;
if (!contextMenu.visible) {
TooltipService.show(appButton, tooltipText, tooltipDirection);
}
if (dockRoot.autoHide) {
dockRoot.showTimer.stop();
dockRoot.hideTimer.stop();
dockRoot.unloadTimer.stop(); // Cancel unload if hovering app
dockRoot.hidden = false; // Make sure dock is visible
}
}
onExited: {
dockRoot.anyAppHovered = false;
TooltipService.hide();
// Clear menuHovered if no current menu or menu not visible
if (!dockRoot.currentContextMenu || !dockRoot.currentContextMenu.visible) {
dockRoot.menuHovered = false;
}
if (dockRoot.autoHide && !dockRoot.dockHovered && !dockRoot.peekHovered && !dockRoot.menuHovered && dockRoot.dragSourceIndex === -1) {
dockRoot.hideTimer.restart();
}
}
onClicked: mouse => {
if (mouse.button === Qt.RightButton) {
const targetScreen = dockRoot.modelData || dockRoot.screen || null;
// If right-clicking on the same app with an open context menu, close it
if (dockRoot.currentContextMenu === contextMenu && contextMenu.visible) {
dockRoot.closeAllContextMenus();
return;
}
// Close any other existing context menu first
dockRoot.closeAllContextMenus();
// Hide tooltip when showing context menu
TooltipService.hideImmediately();
contextMenu.show(appButton, modelData, targetScreen);
return;
}
// Close any existing context menu for non-right-click actions
dockRoot.closeAllContextMenus();
const runningToplevels = dock.getValidToplevels(modelData);
const primaryToplevel = dock.getPrimaryToplevel(modelData);
if (mouse.button === Qt.MiddleButton) {
if (primaryToplevel && primaryToplevel.close) {
primaryToplevel.close();
Qt.callLater(dockRoot.updateDockApps);
}
} else if (mouse.button === Qt.LeftButton) {
if (runningToplevels.length === 0) {
dock.launchAppById(modelData?.appId);
return;
}
if (!Settings.data.dock.groupApps || runningToplevels.length <= 1) {
if (primaryToplevel && primaryToplevel.activate) {
primaryToplevel.activate();
}
return;
}
const clickAction = Settings.data.dock.groupClickAction || "cycle";
if (clickAction === "list") {
const targetScreen = dockRoot.modelData || dockRoot.screen || null;
TooltipService.hideImmediately();
// Left-click list should always open the grouped window list view.
contextMenu.show(appButton, modelData, targetScreen, "list");
} else {
const appKey = modelData?.appId || "";
const state = dockRoot.groupCycleIndices || {};
const nextIndex = (state[appKey] || 0) % runningToplevels.length;
const nextToplevel = runningToplevels[nextIndex];
if (nextToplevel && nextToplevel.activate) {
nextToplevel.activate();
}
state[appKey] = (nextIndex + 1) % runningToplevels.length;
dockRoot.groupCycleIndices = Object.assign({}, state);
}
}
}
}
// Active indicator - positioned at the edge of the delegate area
Rectangle {
visible: baseIndicatorVisible && !showGroupedIndicator
width: dockRoot.isVertical ? indicatorMargin * 0.6 : dockRoot.iconSize * 0.2
height: dockRoot.isVertical ? dockRoot.iconSize * 0.2 : indicatorMargin * 0.6
color: Color.mPrimary
radius: Style.radiusXS
// Anchor to the edge facing the screen center
anchors.bottom: !dockRoot.isVertical && dockRoot.dockPosition === "bottom" ? parent.bottom : undefined
anchors.top: !dockRoot.isVertical && dockRoot.dockPosition === "top" ? parent.top : undefined
anchors.left: dockRoot.isVertical && dockRoot.dockPosition === "left" ? parent.left : undefined
anchors.right: dockRoot.isVertical && dockRoot.dockPosition === "right" ? parent.right : undefined
anchors.horizontalCenter: dockRoot.isVertical ? undefined : parent.horizontalCenter
anchors.verticalCenter: dockRoot.isVertical ? parent.verticalCenter : undefined
// Offset slightly from the edge
anchors.bottomMargin: !dockRoot.isVertical && dockRoot.dockPosition === "bottom" ? 2 : 0
anchors.topMargin: !dockRoot.isVertical && dockRoot.dockPosition === "top" ? 2 : 0
anchors.leftMargin: dockRoot.isVertical && dockRoot.dockPosition === "left" ? 2 : 0
anchors.rightMargin: dockRoot.isVertical && dockRoot.dockPosition === "right" ? 2 : 0
}
Loader {
id: groupedIndicatorLoader
active: showGroupedIndicator
anchors.bottom: !dockRoot.isVertical && dockRoot.dockPosition === "bottom" ? parent.bottom : undefined
anchors.top: !dockRoot.isVertical && dockRoot.dockPosition === "top" ? parent.top : undefined
anchors.left: dockRoot.isVertical && dockRoot.dockPosition === "left" ? parent.left : undefined
anchors.right: dockRoot.isVertical && dockRoot.dockPosition === "right" ? parent.right : undefined
anchors.horizontalCenter: dockRoot.isVertical ? undefined : parent.horizontalCenter
anchors.verticalCenter: dockRoot.isVertical ? parent.verticalCenter : undefined
anchors.bottomMargin: !dockRoot.isVertical && dockRoot.dockPosition === "bottom" ? 1 : 0
anchors.topMargin: !dockRoot.isVertical && dockRoot.dockPosition === "top" ? 1 : 0
anchors.leftMargin: dockRoot.isVertical && dockRoot.dockPosition === "left" ? 1 : 0
anchors.rightMargin: dockRoot.isVertical && dockRoot.dockPosition === "right" ? 1 : 0
sourceComponent: Settings.data.dock.groupIndicatorStyle === "dots" ? groupDotsIndicatorComponent : groupNumberIndicatorComponent
}
Component {
id: groupNumberIndicatorComponent
Rectangle {
radius: Style.radiusS
color: Qt.alpha(Color.mSurface, 0.9)
border.color: Qt.alpha(Color.mOutline, 0.7)
border.width: Style.borderS
width: Math.max(14, numberLabel.implicitWidth + Style.marginXS)
height: Math.max(10, numberLabel.implicitHeight + 2)
NText {
id: numberLabel
anchors.centerIn: parent
text: appButton.groupedIndicatorText
pointSize: Style.fontSizeXS
color: appButton.focusedWindowIndex >= 0 ? Color.mPrimary : Color.mOnSurfaceVariant
}
}
}
Component {
id: groupDotsIndicatorComponent
Item {
readonly property int maxVisibleDots: 5
readonly property int totalCount: Math.max(0, appButton.groupedCount)
readonly property int focusedIndex: appButton.focusedWindowIndex >= 0 ? appButton.focusedWindowIndex : 0
readonly property int visibleCount: Math.min(totalCount, maxVisibleDots)
readonly property int dotSize: Math.max(2, Math.round(dockRoot.iconSize * 0.1))
readonly property int dotSpacing: Math.max(1, Math.round(dotSize * 0.7))
readonly property int pitch: dotSize + dotSpacing
readonly property int windowStart: {
if (totalCount <= maxVisibleDots)
return 0;
const centeredStart = focusedIndex - Math.floor(maxVisibleDots / 2);
const maxStart = totalCount - maxVisibleDots;
return Math.max(0, Math.min(maxStart, centeredStart));
}
readonly property bool hasHiddenLeft: windowStart > 0
readonly property bool hasHiddenRight: (windowStart + visibleCount) < totalCount
width: dockRoot.isVertical ? dotSize : (visibleCount * dotSize + Math.max(0, visibleCount - 1) * dotSpacing)
height: dockRoot.isVertical ? (visibleCount * dotSize + Math.max(0, visibleCount - 1) * dotSpacing) : dotSize
Repeater {
model: parent.visibleCount
delegate: Rectangle {
readonly property int absoluteIndex: parent.windowStart + index
readonly property bool isFocusedDot: appButton.focusedWindowIndex >= 0 && absoluteIndex === appButton.focusedWindowIndex
readonly property bool isOverflowHint: (index === 0 && parent.hasHiddenLeft) || (index === parent.visibleCount - 1 && parent.hasHiddenRight)
width: isOverflowHint && !isFocusedDot ? Math.max(2, Math.round(parent.dotSize * 0.72)) : parent.dotSize
height: width
radius: width / 2
x: dockRoot.isVertical ? Math.round((parent.dotSize - width) / 2) : (index * parent.pitch + Math.round((parent.dotSize - width) / 2))
y: dockRoot.isVertical ? (index * parent.pitch + Math.round((parent.dotSize - width) / 2)) : Math.round((parent.dotSize - width) / 2)
color: isFocusedDot ? Color.mPrimary : Qt.alpha(Color.mOutline, 0.9)
opacity: isOverflowHint && !isFocusedDot ? 0.55 : 1.0
}
}
}
}
}
}
Loader {
id: launcherButtonEnd
active: Settings.data.dock.showLauncherIcon && Settings.data.dock.launcherPosition === "end"
visible: active
sourceComponent: launcherButtonComponent
readonly property real indicatorMargin: Math.max(3, Math.round(dockRoot.iconSize * 0.18))
Layout.preferredWidth: active ? (dockRoot.isVertical ? dockRoot.iconSize + indicatorMargin * 2 : dockRoot.iconSize) : 0
Layout.preferredHeight: active ? (dockRoot.isVertical ? dockRoot.iconSize : dockRoot.iconSize + indicatorMargin * 2) : 0
Layout.minimumWidth: active ? Layout.preferredWidth : 0
Layout.minimumHeight: active ? Layout.preferredHeight : 0
Layout.maximumWidth: active ? Layout.preferredWidth : 0
Layout.maximumHeight: active ? Layout.preferredHeight : 0
Layout.alignment: Qt.AlignCenter
}
}
}
}
}
@@ -0,0 +1,804 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Commons
import qs.Modules.Panels.Settings
import qs.Services.UI
import qs.Widgets
PopupWindow {
id: root
property var toplevel: null
property var appData: null
property Item anchorItem: null
property ShellScreen targetScreen: null
property string menuMode: "app" // "app" or "launcher"
property string launcherWidgetSection: ""
property int launcherWidgetIndex: -1
property var launcherWidgetSettings: ({})
property bool hovered: menuHoverHandler.hovered
property var onAppClosed: null // Callback function for when an app is closed
property bool canAutoClose: false
// Track which menu item is hovered
property int hoveredItem: -1 // -1: none, otherwise the index of the item in `items`
property var items: []
signal requestClose
property real menuContentWidth: 160
property real menuMinWidth: 120
property real menuMaxWidth: 360
property real menuMaxHeight: Math.max(180, Math.min(420, Math.round((targetScreen ? targetScreen.height : 600) * 0.3)))
property int separatorCompactHeight: Style.borderS + Style.margin2S
property string forcedGroupMenuMode: ""
readonly property int separatorIndex: {
for (let i = 0; i < root.items.length; i++) {
if (root.items[i] && root.items[i].separator === true)
return i;
}
return -1;
}
readonly property bool splitExtendedLayout: separatorIndex >= 0
readonly property var scrollItems: splitExtendedLayout ? root.items.slice(0, separatorIndex) : root.items
readonly property var fixedItems: splitExtendedLayout ? root.items.slice(separatorIndex + 1) : []
readonly property real menuInnerHeight: Math.max(0, implicitHeight - Style.margin2M)
readonly property real fixedActionsHeight: listHeight(fixedItems)
readonly property real separatorBlockHeight: splitExtendedLayout ? separatorCompactHeight : 0
readonly property real scrollAreaHeight: splitExtendedLayout ? Math.max(0, menuInnerHeight - fixedActionsHeight - separatorBlockHeight) : menuInnerHeight
readonly property bool listOverflowing: menuFlick && menuFlick.contentHeight > menuFlick.height
readonly property real menuBodyHeight: {
if (splitExtendedLayout) {
return listHeight(scrollItems) + separatorBlockHeight + fixedActionsHeight;
}
return listHeight(root.items);
}
implicitWidth: menuContentWidth + Style.margin2M
implicitHeight: Math.min(menuBodyHeight + Style.margin2M, menuMaxHeight)
color: "transparent"
visible: false
// Hidden text element for measuring text width
NText {
id: textMeasure
visible: false
pointSize: Style.fontSizeS
family: "Sans Serif" // Match your NText font if different
wrapMode: Text.NoWrap
elide: Text.ElideNone
}
// Calculate the maximum width needed for all menu items
function calculateMenuWidth() {
let maxWidth = 0; // Start with 0, we'll apply minimum later
if (root.items && root.items.length > 0) {
for (let i = 0; i < root.items.length; i++) {
const item = root.items[i];
if (item && item.text) {
// Calculate width: margins + icon (if present) + spacing + text width
let itemWidth = Style.margin2S; // left and right margins
if (item.icon && item.icon !== "") {
itemWidth += Style.fontSizeL + Style.marginS; // icon + spacing
}
// Measure actual text width
textMeasure.text = item.text;
const textWidth = textMeasure.contentWidth;
itemWidth += textWidth;
if (itemWidth > maxWidth) {
maxWidth = itemWidth;
}
}
}
}
// Keep menu readable without allowing extremely wide labels.
menuContentWidth = Math.max(menuMinWidth, Math.min(menuMaxWidth, Math.ceil(maxWidth)));
}
function getCurrentAppId() {
return appData?.appId || toplevel?.appId || "";
}
function getValidToplevels() {
if (!ToplevelManager || !ToplevelManager.toplevels)
return [];
const source = appData?.toplevels && appData.toplevels.length > 0 ? appData.toplevels : (toplevel ? [toplevel] : []);
const allToplevels = ToplevelManager.toplevels.values || [];
return source.filter(window => window && allToplevels.includes(window));
}
function getPrimaryToplevel() {
const windows = getValidToplevels();
if (windows.length === 0)
return null;
if (ToplevelManager && ToplevelManager.activeToplevel && windows.includes(ToplevelManager.activeToplevel))
return ToplevelManager.activeToplevel;
return windows[0];
}
function isItemActionable(index) {
if (index < 0 || index >= root.items.length)
return false;
const item = root.items[index];
return item && typeof item.action === "function";
}
function rowHeightForItem(item) {
return item && item.separator === true ? 16 : 32;
}
function listHeight(items) {
let total = 0;
if (!items)
return total;
for (let i = 0; i < items.length; i++) {
total += rowHeightForItem(items[i]);
}
return total;
}
function initItems() {
if (menuMode === "launcher") {
root.items = [
{
"icon": "adjustments",
"text": I18n.tr("actions.dock-settings"),
"action": function () {
handleDockSettings();
}
},
{
"icon": "adjustments",
"text": I18n.tr("actions.launcher-settings"),
"action": function () {
handleLauncherSettings();
}
}
];
calculateMenuWidth();
return;
}
const windows = getValidToplevels();
const primaryToplevel = getPrimaryToplevel();
const appId = getCurrentAppId();
const isRunning = windows.length > 0;
const isPinned = isAppPinned(appId);
const grouped = Settings.data.dock.groupApps && windows.length > 1;
const rawGroupMenuMode = forcedGroupMenuMode || Settings.data.dock.groupContextMenuMode || "extended";
const menuModeForGroup = grouped ? ((rawGroupMenuMode === "list" || rawGroupMenuMode === "extended") ? rawGroupMenuMode : "extended") : "single";
var next = [];
if (!grouped || menuModeForGroup === "single") {
if (isRunning) {
next.push({
"icon": "eye",
"text": I18n.tr("common.focus"),
"action": function () {
handleFocus(primaryToplevel);
}
});
}
next.push({
"icon": !isPinned ? "pin" : "unpin",
"text": !isPinned ? I18n.tr("common.pin") : I18n.tr("common.unpin"),
"action": function () {
handlePin(appId);
}
});
if (isRunning) {
next.push({
"icon": "close",
"text": I18n.tr("common.close"),
"action": function () {
handleClose(primaryToplevel);
}
});
}
} else {
windows.forEach((window, index) => {
const windowTitle = (window.title && window.title.trim() !== "") ? window.title : (appId || ("Window " + (index + 1)));
next.push({
"icon": window === ToplevelManager?.activeToplevel ? "circle-filled" : "square-rounded",
"text": windowTitle,
"action": function () {
handleFocus(window);
}
});
});
if (menuModeForGroup === "extended") {
next.push({
"separator": true
});
next.push({
"icon": "eye",
"text": I18n.tr("common.focus"),
"action": function () {
handleFocus(primaryToplevel);
}
});
next.push({
"icon": !isPinned ? "pin" : "unpin",
"text": !isPinned ? I18n.tr("common.pin") : I18n.tr("common.unpin"),
"action": function () {
handlePin(appId);
}
});
next.push({
"icon": "close",
"text": I18n.tr("common.close") + " All",
"action": function () {
handleCloseAll(windows);
}
});
}
}
// Keep grouped list mode as a clean window switcher.
const canAddDesktopActions = !grouped || menuModeForGroup === "extended";
// Create a menu entry for each app-specific action defined in its .desktop file
if (canAddDesktopActions && typeof DesktopEntries !== 'undefined' && DesktopEntries.byId && appId) {
const entry = (DesktopEntries.heuristicLookup) ? DesktopEntries.heuristicLookup(appId) : DesktopEntries.byId(appId);
if (entry != null) {
entry.actions.forEach(function (action) {
next.push({
"icon": "chevron-right",
"text": action.name,
"action": function () {
if (action.command && action.command.length > 0) {
Quickshell.execDetached(action.command);
} else if (action.execute) {
action.execute();
}
if (Settings.data.dock.dockType === "attached") {
const panel = PanelService.getPanel("staticDockPanel", root.screen, false);
if (panel)
panel.close();
}
}
});
});
}
}
root.items = next;
// Force width recalculation when items change
calculateMenuWidth();
}
// Helper function to normalize app IDs for case-insensitive matching
function normalizeAppId(appId) {
if (!appId || typeof appId !== 'string')
return "";
return appId.toLowerCase().trim();
}
// Helper function to get desktop entry ID from an app ID
function getDesktopEntryId(appId) {
if (!appId)
return appId;
// Try to find the desktop entry using heuristic lookup
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) {
try {
const entry = DesktopEntries.heuristicLookup(appId);
if (entry && entry.id) {
return entry.id;
}
} catch (e)
// Fall through to return original appId
{}
}
// Try direct lookup
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.byId) {
try {
const entry = DesktopEntries.byId(appId);
if (entry && entry.id) {
return entry.id;
}
} catch (e)
// Fall through to return original appId
{}
}
// Return original appId if we can't find a desktop entry
return appId;
}
// Helper functions for pin/unpin functionality
function isAppPinned(appId) {
if (!appId)
return false;
const pinnedApps = Settings.data.dock.pinnedApps || [];
const normalizedId = normalizeAppId(appId);
return pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedId);
}
function toggleAppPin(appId) {
if (!appId)
return;
// Get the desktop entry ID for consistent pinning
const desktopEntryId = getDesktopEntryId(appId);
const normalizedId = normalizeAppId(desktopEntryId);
let pinnedApps = (Settings.data.dock.pinnedApps || []).slice(); // Create a copy
// Find existing pinned app with case-insensitive matching
const existingIndex = pinnedApps.findIndex(pinnedId => normalizeAppId(pinnedId) === normalizedId);
const isPinned = existingIndex >= 0;
if (isPinned) {
// Unpin: remove from array
pinnedApps.splice(existingIndex, 1);
} else {
// Pin: add desktop entry ID to array
pinnedApps.push(desktopEntryId);
}
// Update the settings
Settings.data.dock.pinnedApps = pinnedApps;
}
// Dock position for context menu placement
property string dockPosition: "bottom"
anchor.item: anchorItem
// Position menu on opposite side of dock with comfortable spacing
anchor.rect.x: {
if (!anchorItem)
return 0;
switch (dockPosition) {
case "left":
return anchorItem.width + Style.marginL; // Open to right of dock
case "right":
return -implicitWidth - Style.marginL; // Open to left of dock
default:
return (anchorItem.width - implicitWidth) / 2; // Center horizontally
}
}
anchor.rect.y: {
if (!anchorItem)
return 0;
switch (dockPosition) {
case "top":
return anchorItem.height + Style.marginL; // Open below dock
case "bottom":
return -implicitHeight - Style.marginL; // Open above dock (default)
case "left":
case "right":
return (anchorItem.height - implicitHeight) / 2; // Center vertically
default:
return -implicitHeight - Style.marginL;
}
}
function show(item, toplevelData, screen, groupModeOverride) {
if (!item) {
return;
}
// First hide completely
visible = false;
// Then set up new data
anchorItem = item;
if (toplevelData && typeof toplevelData === "object" && (toplevelData.appId !== undefined || toplevelData.toplevels !== undefined)) {
appData = toplevelData;
toplevel = toplevelData.toplevel || null;
} else {
appData = toplevelData ? {
"appId": toplevelData.appId,
"toplevel": toplevelData,
"toplevels": toplevelData ? [toplevelData] : []
} : null;
toplevel = toplevelData;
}
targetScreen = screen || null;
forcedGroupMenuMode = groupModeOverride || "";
initItems();
visible = true;
canAutoClose = false;
gracePeriodTimer.restart();
}
// Helper function to determine which menu item is under the mouse
function getHoveredItem(mouseY) {
const startY = Style.marginM;
const localY = mouseY - startY;
if (localY < 0)
return -1;
function findIndexInList(items, relativeY, baseIndex) {
let offset = 0;
for (let i = 0; i < items.length; i++) {
const h = rowHeightForItem(items[i]);
if (relativeY >= offset && relativeY < offset + h)
return baseIndex + i;
offset += h;
}
return -1;
}
if (splitExtendedLayout) {
if (localY < scrollAreaHeight) {
return findIndexInList(scrollItems, localY + (menuFlick ? menuFlick.contentY : 0), 0);
}
if (localY < scrollAreaHeight + separatorBlockHeight) {
return -1;
}
return findIndexInList(fixedItems, localY - scrollAreaHeight - separatorBlockHeight, separatorIndex + 1);
} else {
return findIndexInList(scrollItems, localY + (menuFlick ? menuFlick.contentY : 0), 0);
}
}
function fixedItemGlobalIndex(localIndex) {
if (!splitExtendedLayout)
return localIndex;
return separatorIndex + 1 + localIndex;
}
function isScrollableHovered(mouseY) {
const localY = mouseY - Style.marginM;
return localY >= 0 && localY < scrollAreaHeight;
}
function onWheelScroll(deltaY) {
if (!menuFlick || menuFlick.contentHeight <= menuFlick.height)
return;
const nextY = menuFlick.contentY - deltaY;
menuFlick.contentY = Math.max(0, Math.min(nextY, menuFlick.contentHeight - menuFlick.height));
}
function resetMenuState() {
root.items.length = 0;
root.appData = null;
root.toplevel = null;
root.forcedGroupMenuMode = "";
menuContentWidth = menuMinWidth;
hoveredItem = -1;
if (menuFlick)
menuFlick.contentY = 0;
}
function hide() {
visible = false;
resetMenuState();
}
function hideWithoutReset() {
visible = false;
}
function closeAndReset() {
hide();
root.requestClose();
}
function handleFocus(targetToplevel) {
if (targetToplevel?.activate) {
targetToplevel.activate();
}
closeAndReset();
}
function handlePin(appId) {
if (appId) {
root.toggleAppPin(appId);
}
Qt.callLater(() => closeAndReset());
}
function handleClose(targetToplevel) {
const isValidToplevel = targetToplevel && ToplevelManager && ToplevelManager.toplevels.values.includes(targetToplevel);
if (isValidToplevel && targetToplevel.close) {
targetToplevel.close();
if (root.onAppClosed && typeof root.onAppClosed === "function") {
Qt.callLater(root.onAppClosed);
}
}
closeAndReset();
}
function handleCloseAll(windows) {
windows.forEach(window => {
if (window && ToplevelManager && ToplevelManager.toplevels.values.includes(window) && window.close) {
window.close();
}
});
if (root.onAppClosed && typeof root.onAppClosed === "function") {
Qt.callLater(root.onAppClosed);
}
closeAndReset();
}
function handleLauncherSettings() {
if (targetScreen) {
var panel = PanelService.getPanel("settingsPanel", targetScreen);
panel.requestedTab = SettingsPanel.Tab.Launcher;
panel.toggle();
}
closeAndReset();
}
function handleDockSettings() {
if (targetScreen) {
var panel = PanelService.getPanel("settingsPanel", targetScreen);
panel.requestedTab = SettingsPanel.Tab.Dock;
panel.toggle();
}
closeAndReset();
}
function handleLauncherWidgetSettings() {
if (targetScreen && launcherWidgetSection && launcherWidgetIndex >= 0) {
BarService.openWidgetSettings(targetScreen, launcherWidgetSection, launcherWidgetIndex, "Launcher", launcherWidgetSettings || {});
}
closeAndReset();
}
// Short delay to ignore spurious events
Timer {
id: gracePeriodTimer
interval: 1500
repeat: false
onTriggered: {
root.canAutoClose = true;
if (!menuHoverHandler.hovered) {
closeTimer.start();
}
}
}
Timer {
id: closeTimer
interval: 500
repeat: false
running: false
onTriggered: {
root.hideWithoutReset();
}
}
Rectangle {
anchors.fill: parent
anchors.margins: border.width
color: Color.mSurfaceVariant
radius: Style.radiusS
border.color: Color.mOutline
border.width: Style.borderS
HoverHandler {
id: menuHoverHandler
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
onHoveredChanged: {
if (hovered) {
closeTimer.stop();
} else {
root.hoveredItem = -1;
if (root.canAutoClose) {
closeTimer.start();
}
}
}
}
WheelHandler {
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
onWheel: event => {
if (!root.isScrollableHovered(event.y))
return;
const delta = event.pixelDelta.y !== 0 ? event.pixelDelta.y : event.angleDelta.y / 2;
root.onWheelScroll(delta);
event.accepted = true;
}
}
Flickable {
id: menuFlick
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: Style.marginM
anchors.rightMargin: Style.marginM
anchors.topMargin: Style.marginM
height: root.scrollAreaHeight
clip: true
contentWidth: width
contentHeight: scrollColumn.height
flickableDirection: Flickable.VerticalFlick
boundsBehavior: Flickable.StopAtBounds
interactive: contentHeight > height
ScrollBar.vertical: ScrollBar {
id: menuScrollBar
policy: ScrollBar.AsNeeded
visible: root.listOverflowing
interactive: true
hoverEnabled: true
}
Column {
id: scrollColumn
width: menuFlick.width
spacing: 0
Repeater {
model: root.scrollItems
Rectangle {
readonly property bool isSeparator: modelData && modelData.separator === true
width: scrollColumn.width
height: root.rowHeightForItem(modelData)
color: (!isSeparator && root.hoveredItem === index) ? Color.mHover : "transparent"
radius: Style.radiusXS
Row {
id: rowLayout
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.marginS
anchors.rightMargin: Style.marginS
spacing: Style.marginS
visible: !isSeparator
NIcon {
icon: modelData.icon
pointSize: Style.fontSizeL
color: root.hoveredItem === index ? Color.mOnHover : Color.mOnSurfaceVariant
visible: icon !== ""
anchors.verticalCenter: parent.verticalCenter
}
NText {
text: modelData.text
pointSize: Style.fontSizeS
color: root.hoveredItem === index ? Color.mOnHover : Color.mOnSurfaceVariant
anchors.verticalCenter: parent.verticalCenter
width: rowLayout.width - ((modelData.icon && modelData.icon !== "") ? (Style.fontSizeL + Style.marginS) : 0)
elide: Text.ElideRight
}
}
MouseArea {
anchors.fill: parent
enabled: !parent.isSeparator && root.isItemActionable(index)
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton
onEntered: {
root.hoveredItem = index;
}
onExited: {
if (root.hoveredItem === index) {
root.hoveredItem = -1;
}
}
onClicked: {
if (root.isItemActionable(index)) {
root.items[index].action.call();
}
}
}
}
}
}
}
Rectangle {
id: separator
visible: root.splitExtendedLayout
anchors.left: parent.left
anchors.right: parent.right
anchors.top: menuFlick.bottom
anchors.leftMargin: Style.marginS
anchors.rightMargin: Style.marginS
anchors.topMargin: Style.marginS
height: Style.borderS
color: Qt.alpha(Color.mOutline, 0.7)
radius: Style.radiusXS
}
Column {
id: fixedColumn
visible: root.splitExtendedLayout && root.fixedItems.length > 0
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: Style.marginM
anchors.rightMargin: Style.marginM
anchors.topMargin: Style.marginS
anchors.bottomMargin: Style.marginM
anchors.top: separator.bottom
spacing: 0
Repeater {
model: root.fixedItems
Rectangle {
id: fixedItemRect
readonly property int globalIndex: root.fixedItemGlobalIndex(index)
width: fixedColumn.width
height: root.rowHeightForItem(modelData)
color: root.hoveredItem === globalIndex ? Color.mHover : "transparent"
radius: Style.radiusXS
Row {
id: fixedRowLayout
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.marginS
anchors.rightMargin: Style.marginS
spacing: Style.marginS
NIcon {
icon: modelData.icon
pointSize: Style.fontSizeL
color: root.hoveredItem === fixedItemRect.globalIndex ? Color.mOnHover : Color.mOnSurfaceVariant
visible: icon !== ""
anchors.verticalCenter: parent.verticalCenter
}
NText {
text: modelData.text
pointSize: Style.fontSizeS
color: root.hoveredItem === fixedItemRect.globalIndex ? Color.mOnHover : Color.mOnSurfaceVariant
anchors.verticalCenter: parent.verticalCenter
width: fixedRowLayout.width - ((modelData.icon && modelData.icon !== "") ? (Style.fontSizeL + Style.marginS) : 0)
elide: Text.ElideRight
}
}
MouseArea {
anchors.fill: parent
enabled: root.isItemActionable(parent.globalIndex)
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton
onEntered: {
root.hoveredItem = parent.globalIndex;
}
onExited: {
if (root.hoveredItem === parent.globalIndex) {
root.hoveredItem = -1;
}
}
onClicked: {
if (root.isItemActionable(parent.globalIndex)) {
root.items[parent.globalIndex].action.call();
}
}
}
}
}
}
}
}