fedora
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user