mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
616 lines
21 KiB
QML
616 lines
21 KiB
QML
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Wayland
|
|
import qs.Commons
|
|
import qs.Modules.Dock
|
|
import qs.Modules.MainScreen
|
|
|
|
SmartPanel {
|
|
id: root
|
|
panelBackgroundColor: Qt.alpha(Color.mSurface, Settings.data.dock.backgroundOpacity)
|
|
|
|
readonly property string dockPosition: Settings.data.dock.position
|
|
readonly property bool isVertical: dockPosition === "left" || dockPosition === "right"
|
|
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 bool isFramed: Settings.data.bar.barType === "framed" && hasBar
|
|
property bool isDockHovered: false
|
|
property bool panelHovered: false
|
|
readonly property int iconSize: Math.round(12 + 24 * (Settings.data.dock.size ?? 1))
|
|
readonly property int maxWidth: screen ? screen.width * 0.8 : 1000
|
|
readonly property int maxHeight: screen ? screen.height * 0.8 : 1000
|
|
readonly property bool autoHide: false
|
|
readonly property int hideDelay: 1000
|
|
readonly property int showDelay: 100
|
|
|
|
// Shared state with dock content
|
|
property bool dockHovered: false
|
|
property bool anyAppHovered: false
|
|
property bool menuHovered: false
|
|
property bool hidden: false
|
|
property bool peekHovered: false
|
|
|
|
// 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
|
|
|
|
// Revision counter to force icon re-evaluation
|
|
property int iconRevision: 0
|
|
|
|
property alias hideTimer: hideTimer
|
|
property alias showTimer: showTimer
|
|
|
|
onMenuHoveredChanged: {
|
|
if (!menuHovered && !panelHovered) {
|
|
hoverCloseTimer.restart();
|
|
}
|
|
}
|
|
|
|
onAnyAppHoveredChanged: {
|
|
if (anyAppHovered) {
|
|
hoverCloseTimer.stop();
|
|
} else if (!panelHovered && !menuHovered && !dockHovered && !isDockHovered) {
|
|
hoverCloseTimer.restart();
|
|
}
|
|
}
|
|
|
|
onClosed: {
|
|
hoverCloseTimer.stop();
|
|
isDockHovered = false;
|
|
}
|
|
|
|
onOpened: {
|
|
// Don't auto-start close timer here — the peek zone's onExited in Dock.qml
|
|
// starts hideTimer which checks panel.isDockHovered, giving the user time
|
|
// to move into the panel without it closing prematurely.
|
|
}
|
|
|
|
panelAnchorTop: dockPosition === "top"
|
|
panelAnchorBottom: dockPosition === "bottom"
|
|
panelAnchorLeft: dockPosition === "left"
|
|
panelAnchorRight: dockPosition === "right"
|
|
panelAnchorHorizontalCenter: !isVertical
|
|
panelAnchorVerticalCenter: isVertical
|
|
|
|
forceAttachToBar: hasBar
|
|
exclusiveKeyboard: false
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Function to close any open context menu
|
|
function closeAllContextMenus() {
|
|
if (currentContextMenu && currentContextMenu.visible) {
|
|
currentContextMenu.hide();
|
|
}
|
|
}
|
|
|
|
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.
|
|
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(screen)));
|
|
}
|
|
|
|
if (!appData.toplevel)
|
|
return [];
|
|
|
|
if (Settings.data.dock.onlySameOutput && appData.toplevel.screens && !appData.toplevel.screens.includes(screen))
|
|
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;
|
|
}
|
|
if (Settings.data.dock.onlySameOutput && toplevel.screens && !toplevel.screens.includes(screen)) {
|
|
return;
|
|
}
|
|
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;
|
|
}
|
|
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
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 when DesktopEntries becomes available
|
|
Connections {
|
|
target: DesktopEntries.applications
|
|
function onValuesChanged() {
|
|
root.iconRevision++;
|
|
root._desktopEntryIdCache = {};
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: hideTimer
|
|
interval: hideDelay
|
|
onTriggered: {}
|
|
}
|
|
|
|
Timer {
|
|
id: showTimer
|
|
interval: showDelay
|
|
onTriggered: {}
|
|
}
|
|
|
|
Timer {
|
|
id: hoverCloseTimer
|
|
interval: hideDelay
|
|
onTriggered: {
|
|
if (root.dockHovered || root.isDockHovered || root.anyAppHovered || root.menuHovered || (root.currentContextMenu && root.currentContextMenu.visible)) {
|
|
restart();
|
|
return;
|
|
}
|
|
root.isDockHovered = false;
|
|
root.close();
|
|
}
|
|
}
|
|
|
|
panelContent: Item {
|
|
id: panelContent
|
|
|
|
property bool allowAttach: true
|
|
property real frameThickness: isFramed && !barAtSameEdge && !Settings.data.dock.sitOnFrame ? Settings.data.bar.frameThickness : 0
|
|
property real contentPreferredWidth: Math.round(dockContainerWrapper.width) - (isVertical ? frameThickness : 0)
|
|
property real contentPreferredHeight: Math.round(dockContainerWrapper.height) - (!isVertical ? frameThickness : 0)
|
|
|
|
Item {
|
|
id: hoverArea
|
|
anchors.fill: dockContainerWrapper
|
|
anchors.margins: -frameThickness
|
|
|
|
// Detect hover over dock area including frame thickness
|
|
HoverHandler {
|
|
id: dockHoverArea
|
|
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
|
onHoveredChanged: {
|
|
root.panelHovered = hovered;
|
|
if (hovered) {
|
|
root.isDockHovered = true;
|
|
hoverCloseTimer.stop();
|
|
} else {
|
|
root.isDockHovered = false;
|
|
if (root.menuHovered || (root.currentContextMenu && root.currentContextMenu.visible)) {
|
|
hoverCloseTimer.stop();
|
|
} else {
|
|
hoverCloseTimer.restart();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Item {
|
|
id: dockContainerWrapper
|
|
readonly property real frameThickness: isFramed ? Settings.data.bar.frameThickness : 0
|
|
width: dockContent.dockContainer.width
|
|
height: dockContent.dockContainer.height
|
|
anchors.top: root.dockPosition === "bottom" ? parent.top : undefined
|
|
anchors.bottom: root.dockPosition === "top" ? parent.bottom : undefined
|
|
anchors.left: root.dockPosition === "right" ? parent.left : undefined
|
|
anchors.right: root.dockPosition === "left" ? parent.right : undefined
|
|
|
|
DockContent {
|
|
id: dockContent
|
|
anchors.fill: parent
|
|
dockRoot: root
|
|
extraTop: 0
|
|
extraBottom: 0
|
|
extraLeft: 0
|
|
extraRight: 0
|
|
}
|
|
}
|
|
}
|
|
}
|