1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-27 15:02:50 -05:00
Files
DankMaterialShell/quickshell/Modules/Dock/DockApps.qml
2026-01-26 15:25:45 -05:00

806 lines
36 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Hyprland
import qs.Common
import qs.Services
Item {
id: root
property var contextMenu: null
property bool requestDockShow: false
property int pinnedAppCount: 0
property bool groupByApp: false
property bool isVertical: false
property var dockScreen: null
property real iconSize: 40
property int draggedIndex: -1
property int dropTargetIndex: -1
property bool suppressShiftAnimation: false
property int maxVisibleApps: SettingsData.dockMaxVisibleApps
property int maxVisibleRunningApps: SettingsData.dockMaxVisibleRunningApps
property bool overflowExpanded: false
property int overflowItemCount: 0
property bool draggingPinned: false
property real maxAvailableLength: 0
// Calculate if scrolling is needed (use childrenRect for accurate measurement)
readonly property bool canScroll: {
if (!root.isVertical) {
return layoutFlow.childrenRect.width > availableScreenWidth;
} else {
return layoutFlow.childrenRect.height > availableScreenHeight;
}
}
// Available space calculations - 10% padding for nice margins when scrolling
readonly property real availableScreenWidth: {
if (!root.isVertical && maxAvailableLength > 0) {
return maxAvailableLength * 0.9; // 10% padding on sides
}
if (dockScreen && dockScreen.geometry && dockScreen.geometry.width) {
return dockScreen.geometry.width - 200;
}
return 1720;
}
readonly property real availableScreenHeight: {
if (root.isVertical && maxAvailableLength > 0) {
return maxAvailableLength * 0.9; // 10% padding
}
if (dockScreen && dockScreen.geometry && dockScreen.geometry.height) {
return dockScreen.geometry.height - 100;
}
return 980;
}
clip: false
implicitWidth: isVertical ? appLayout.height : appLayout.width
implicitHeight: isVertical ? appLayout.width : appLayout.height
function dockIndexToPinnedIndex(dockIndex) {
if (!SettingsData.dockLauncherEnabled) {
return dockIndex;
}
const launcherPos = SessionData.dockLauncherPosition;
if (dockIndex < launcherPos) {
return dockIndex;
} else {
return dockIndex - 1;
}
}
function movePinnedApp(fromDockIndex, toDockIndex) {
console.warn("movePinnedApp: dock", fromDockIndex, "->", toDockIndex);
const fromPinnedIndex = dockIndexToPinnedIndex(fromDockIndex);
const toPinnedIndex = dockIndexToPinnedIndex(toDockIndex);
console.warn(" Converted to pinned indices:", fromPinnedIndex, "->", toPinnedIndex);
if (fromPinnedIndex === toPinnedIndex) {
console.warn(" Same pinned index, skipping");
return;
}
const currentPinned = [...(SessionData.pinnedApps || [])];
console.warn(" Current pinned count:", currentPinned.length);
if (fromPinnedIndex < 0 || fromPinnedIndex >= currentPinned.length || toPinnedIndex < 0 || toPinnedIndex >= currentPinned.length) {
console.warn(" Invalid pinned indices! from:", fromPinnedIndex, "to:", toPinnedIndex, "length:", currentPinned.length);
return;
}
const movedApp = currentPinned.splice(fromPinnedIndex, 1)[0];
console.warn(" Moving app:", movedApp);
currentPinned.splice(toPinnedIndex, 0, movedApp);
SessionData.setPinnedApps(currentPinned);
console.warn(" Move complete");
}
function movePinnedAppByPinnedIndex(fromPinnedIndex, toPinnedIndex) {
console.warn("movePinnedAppByPinnedIndex:", fromPinnedIndex, "->", toPinnedIndex);
if (fromPinnedIndex === toPinnedIndex) {
console.warn(" Same index, skipping");
return;
}
const currentPinned = [...(SessionData.pinnedApps || [])];
console.warn(" Current pinned count:", currentPinned.length);
if (fromPinnedIndex < 0 || fromPinnedIndex >= currentPinned.length || toPinnedIndex < 0 || toPinnedIndex >= currentPinned.length) {
console.warn(" Invalid indices! from:", fromPinnedIndex, "to:", toPinnedIndex, "length:", currentPinned.length);
return;
}
const movedApp = currentPinned.splice(fromPinnedIndex, 1)[0];
console.warn(" Moving app:", movedApp);
currentPinned.splice(toPinnedIndex, 0, movedApp);
SessionData.setPinnedApps(currentPinned);
console.warn(" Move complete");
}
function pinnedIndexForDockIndex(dockIndex) {
const items = repeater.dockItems || [];
let pinnedIndex = 0;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type === "separator" || item.type === "overflow-toggle" || item.type === "launcher") {
continue;
}
if (!(item.type === "pinned" || item.type === "grouped") || !item.isPinned) {
continue;
}
if (i === dockIndex) {
return pinnedIndex;
}
pinnedIndex++;
}
return -1;
}
Item {
id: appLayout
width: layoutFlickable.width
height: layoutFlickable.height
// Clip when scrolling to prevent apps from overlapping dock bounds
clip: root.canScroll
// Anchoring for proper dock positioning
anchors.horizontalCenter: root.isVertical ? undefined : parent.horizontalCenter
anchors.verticalCenter: root.isVertical ? parent.verticalCenter : undefined
anchors.left: root.isVertical && SettingsData.dockPosition === SettingsData.Position.Left ? parent.left : undefined
anchors.right: root.isVertical && SettingsData.dockPosition === SettingsData.Position.Right ? parent.right : undefined
anchors.top: root.isVertical ? undefined : parent.top
Flickable {
id: layoutFlickable
width: {
if (!root.isVertical) {
const contentWidth = layoutFlow.childrenRect.width;
return Math.min(contentWidth, root.availableScreenWidth);
}
return layoutFlow.childrenRect.width;
}
height: {
if (root.isVertical) {
const contentHeight = layoutFlow.childrenRect.height;
return Math.min(contentHeight, root.availableScreenHeight);
}
return layoutFlow.childrenRect.height;
}
contentWidth: layoutFlow.childrenRect.width
contentHeight: layoutFlow.childrenRect.height
// Don't clip - let indicators extend beyond. Parent appLayout clips for scrolling.
clip: false
flickableDirection: root.isVertical ? Flickable.VerticalFlick : Flickable.HorizontalFlick
boundsBehavior: Flickable.StopAtBounds
// Smooth scrolling
maximumFlickVelocity: 2500
flickDeceleration: 1500
Flow {
id: layoutFlow
flow: root.isVertical ? Flow.TopToBottom : Flow.LeftToRight
spacing: Math.min(8, Math.max(4, root.iconSize * 0.08))
Repeater {
id: repeater
property var dockItems: []
model: ScriptModel {
values: repeater.dockItems
objectProp: "uniqueKey"
}
Component.onCompleted: updateModel()
function isOnScreen(toplevel, screenName) {
if (!toplevel.screens)
return false;
for (let i = 0; i < toplevel.screens.length; i++) {
if (toplevel.screens[i]?.name === screenName)
return true;
}
return false;
}
function getCoreAppData(appId) {
if (typeof AppSearchService === "undefined")
return null;
const coreApps = AppSearchService.coreApps || [];
for (let i = 0; i < coreApps.length; i++) {
const app = coreApps[i];
if (app.builtInPluginId === appId) {
return app;
}
}
return null;
}
function getCoreAppDataByTitle(windowTitle) {
if (typeof AppSearchService === "undefined" || !windowTitle)
return null;
const coreApps = AppSearchService.coreApps || [];
for (let i = 0; i < coreApps.length; i++) {
const app = coreApps[i];
if (app.name === windowTitle) {
return app;
}
}
return null;
}
function insertLauncher(targetArray) {
if (!SettingsData.dockLauncherEnabled)
return;
const launcherItem = {
uniqueKey: "launcher_button",
type: "launcher",
appId: "__LAUNCHER__",
toplevel: null,
isPinned: true,
isRunning: false
};
const pos = Math.max(0, Math.min(SessionData.dockLauncherPosition, targetArray.length));
targetArray.splice(pos, 0, launcherItem);
}
function updateModel() {
const items = [];
const pinnedApps = [...(SessionData.pinnedApps || [])];
const allToplevels = CompositorService.sortedToplevels;
const sortedToplevels = (SettingsData.dockIsolateDisplays && root.dockScreen) ? allToplevels.filter(t => isOnScreen(t, root.dockScreen.name)) : allToplevels;
const runningAppIds = new Set();
const windowItems = [];
if (!root.groupByApp) {
sortedToplevels.forEach((toplevel, index) => {
let uniqueKey = "window_" + index;
if (CompositorService.isHyprland && Hyprland.toplevels) {
const hyprlandToplevels = Array.from(Hyprland.toplevels.values);
for (let i = 0; i < hyprlandToplevels.length; i++) {
if (hyprlandToplevels[i].wayland === toplevel) {
uniqueKey = "window_" + hyprlandToplevels[i].address;
break;
}
}
}
const rawAppId = toplevel.appId || "unknown";
const moddedAppId = Paths.moddedAppId(rawAppId);
let coreAppData = null;
let isCoreApp = false;
if (rawAppId === "org.quickshell") {
coreAppData = getCoreAppDataByTitle(toplevel.title);
if (coreAppData) {
isCoreApp = true;
}
}
const finalAppId = isCoreApp ? coreAppData.builtInPluginId : moddedAppId;
windowItems.push({
uniqueKey: uniqueKey,
type: "window",
appId: finalAppId,
toplevel: toplevel,
isPinned: false,
isRunning: true,
isCoreApp: isCoreApp,
coreAppData: coreAppData
});
runningAppIds.add(finalAppId);
});
}
if (root.groupByApp) {
const appGroups = new Map();
pinnedApps.forEach(rawAppId => {
const appId = Paths.moddedAppId(rawAppId);
const coreAppData = getCoreAppData(appId);
appGroups.set(appId, {
appId: appId,
isPinned: true,
windows: [],
isCoreApp: coreAppData !== null,
coreAppData: coreAppData
});
});
sortedToplevels.forEach((toplevel, index) => {
const rawAppId = toplevel.appId || "unknown";
let appId = Paths.moddedAppId(rawAppId);
let coreAppData = null;
if (rawAppId === "org.quickshell") {
coreAppData = getCoreAppDataByTitle(toplevel.title);
if (coreAppData) {
appId = coreAppData.builtInPluginId;
}
}
if (!appGroups.has(appId)) {
appGroups.set(appId, {
appId: appId,
isPinned: false,
windows: [],
isCoreApp: coreAppData !== null,
coreAppData: coreAppData
});
}
appGroups.get(appId).windows.push({
toplevel: toplevel,
index: index
});
});
const pinnedGroups = [];
const unpinnedGroups = [];
Array.from(appGroups.entries()).forEach(([appId, group]) => {
const firstWindow = group.windows.length > 0 ? group.windows[0] : null;
const item = {
uniqueKey: "grouped_" + appId,
type: "grouped",
appId: appId,
toplevel: firstWindow ? firstWindow.toplevel : null,
isPinned: group.isPinned,
isRunning: group.windows.length > 0,
windowCount: group.windows.length,
allWindows: group.windows,
isCoreApp: group.isCoreApp || false,
coreAppData: group.coreAppData || null
};
if (group.isPinned) {
pinnedGroups.push(item);
} else {
unpinnedGroups.push(item);
}
});
pinnedGroups.forEach(item => items.push(item));
insertLauncher(items);
if (pinnedGroups.length > 0 && unpinnedGroups.length > 0) {
items.push({
uniqueKey: "separator_grouped",
type: "separator",
appId: "__SEPARATOR__",
toplevel: null,
isPinned: false,
isRunning: false
});
}
unpinnedGroups.forEach(item => items.push(item));
root.pinnedAppCount = pinnedGroups.length + (SettingsData.dockLauncherEnabled ? 1 : 0);
} else {
const remainingWindowItems = windowItems.slice();
pinnedApps.forEach(rawAppId => {
const appId = Paths.moddedAppId(rawAppId);
const coreAppData = getCoreAppData(appId);
const matchIndex = remainingWindowItems.findIndex(item => item.appId === appId);
if (matchIndex !== -1) {
const windowItem = remainingWindowItems.splice(matchIndex, 1)[0];
windowItem.isPinned = true;
if (!windowItem.isCoreApp && coreAppData) {
windowItem.isCoreApp = true;
windowItem.coreAppData = coreAppData;
}
items.push(windowItem);
} else {
items.push({
uniqueKey: "pinned_" + appId,
type: "pinned",
appId: appId,
toplevel: null,
isPinned: true,
isRunning: runningAppIds.has(appId),
isCoreApp: coreAppData !== null,
coreAppData: coreAppData
});
}
});
root.pinnedAppCount = pinnedApps.length + (SettingsData.dockLauncherEnabled ? 1 : 0);
insertLauncher(items);
if (pinnedApps.length > 0 && remainingWindowItems.length > 0) {
items.push({
uniqueKey: "separator_ungrouped",
type: "separator",
appId: "__SEPARATOR__",
toplevel: null,
isPinned: false,
isRunning: false
});
}
remainingWindowItems.forEach(item => items.push(item));
}
// Overflow logic
const countableItems = items.filter(item => (item.type === "pinned" || item.type === "grouped" || item.type === "window") && item.isPinned && item.appId !== "__LAUNCHER__");
const hideRunningItems = root.maxVisibleRunningApps === 0;
let runningItems = [];
if (!hideRunningItems) {
if (root.groupByApp) {
runningItems = items.filter(item => item.type === "grouped" && item.isRunning && !item.isPinned);
} else {
runningItems = items.filter(item => item.type === "window" && item.isRunning && !item.isPinned);
}
}
let uniqueRunningItems = runningItems;
let duplicateRunningItems = [];
if (!root.groupByApp && runningItems.length > 0) {
const pinnedAppIds = new Set(items.filter(item => item.isPinned).map(item => item.appId));
const seenRunningIds = new Set();
uniqueRunningItems = [];
duplicateRunningItems = [];
for (let i = 0; i < runningItems.length; i++) {
const item = runningItems[i];
if (pinnedAppIds.has(item.appId) || seenRunningIds.has(item.appId)) {
duplicateRunningItems.push(item);
continue;
}
seenRunningIds.add(item.appId);
uniqueRunningItems.push(item);
}
}
const pinnedOverflowNeeded = root.maxVisibleApps > 0 && countableItems.length > root.maxVisibleApps;
const runningOverflowNeeded = !hideRunningItems && root.maxVisibleRunningApps > 0 && uniqueRunningItems.length > root.maxVisibleRunningApps;
const overflowNeeded = pinnedOverflowNeeded || runningOverflowNeeded;
if (overflowNeeded) {
const visibleCountable = pinnedOverflowNeeded ? countableItems.slice(0, root.maxVisibleApps) : countableItems.slice(0, countableItems.length);
const overflowCountable = pinnedOverflowNeeded ? countableItems.slice(root.maxVisibleApps) : [];
const visibleRunning = !hideRunningItems ? uniqueRunningItems.slice(0, root.maxVisibleRunningApps) : [];
const overflowRunning = !hideRunningItems ? uniqueRunningItems.slice(root.maxVisibleRunningApps) : [];
const combinedOverflowRunning = duplicateRunningItems.concat(overflowRunning);
const finalItems = [];
// Add items in order, preserving launcher position
let visibleIndex = 0;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type === "launcher") {
finalItems.push(item);
} else if (visibleIndex < visibleCountable.length && item.uniqueKey === visibleCountable[visibleIndex].uniqueKey) {
finalItems.push(item);
visibleIndex++;
}
}
const totalOverflowCount = overflowCountable.length + combinedOverflowRunning.length;
const hasSeparator = items.some(item => item.type === "separator");
const shouldShowSeparator = hasSeparator && (visibleRunning.length > 0 || totalOverflowCount > 0);
if (shouldShowSeparator) {
finalItems.push({
uniqueKey: "separator",
type: "separator",
appId: "__SEPARATOR__",
toplevel: null,
isPinned: false,
isRunning: false
});
}
for (let i = 0; i < visibleRunning.length; i++) {
finalItems.push(visibleRunning[i]);
}
if (totalOverflowCount > 0) {
finalItems.push({
uniqueKey: "overflow_toggle",
type: "overflow-toggle",
appId: "__OVERFLOW_TOGGLE__",
toplevel: null,
isPinned: false,
isRunning: false,
overflowCount: totalOverflowCount
});
}
for (let i = 0; i < overflowCountable.length; i++) {
const item = overflowCountable[i];
const overflowItem = {
uniqueKey: item.uniqueKey,
type: item.type,
appId: item.appId,
toplevel: item.toplevel,
isPinned: item.isPinned,
isRunning: item.isRunning,
windowCount: item.windowCount,
allWindows: item.allWindows,
isCoreApp: item.isCoreApp,
coreAppData: item.coreAppData,
isInOverflow: true
};
finalItems.push(overflowItem);
}
for (let i = 0; i < combinedOverflowRunning.length; i++) {
const item = combinedOverflowRunning[i];
const overflowItem = {
uniqueKey: item.uniqueKey,
type: item.type,
appId: item.appId,
toplevel: item.toplevel,
isPinned: false,
isRunning: true,
windowCount: item.windowCount,
allWindows: item.allWindows,
isCoreApp: item.isCoreApp,
coreAppData: item.coreAppData,
isInOverflow: true
};
finalItems.push(overflowItem);
}
root.overflowItemCount = totalOverflowCount;
root.pinnedAppCount = countableItems.length;
dockItems = finalItems;
} else {
root.overflowItemCount = 0;
root.pinnedAppCount = countableItems.length;
if (hideRunningItems) {
const filteredItems = items.filter(item => !((item.type === "window" || item.type === "grouped") && item.isRunning && !item.isPinned) && item.type !== "separator");
dockItems = filteredItems;
} else {
dockItems = items;
}
}
}
delegate: Item {
id: delegateItem
property var dockButton: itemData.type === "launcher" ? launcherButton : button
property var itemData: modelData
readonly property bool isOverflowToggle: itemData.type === "overflow-toggle"
readonly property bool isInOverflow: itemData.isInOverflow === true
clip: false
z: (itemData.type === "launcher" ? launcherButton.dragging : button.dragging) ? 100 : 0
// Overflow items: hidden when collapsed, visible when expanded
visible: !isInOverflow || root.overflowExpanded
// Overflow items collapse to 0 size when hidden
width: (isInOverflow && !root.overflowExpanded) ? 0 :
(itemData.type === "separator" ? (root.isVertical ? root.iconSize : 8) :
(root.isVertical ? root.iconSize : root.iconSize * 1.2))
height: (isInOverflow && !root.overflowExpanded) ? 0 :
(itemData.type === "separator" ? (root.isVertical ? 8 : root.iconSize) :
(root.isVertical ? root.iconSize * 1.2 : root.iconSize))
opacity: (isInOverflow && !root.overflowExpanded) ? 0 : 1
scale: (isInOverflow && !root.overflowExpanded) ? 0.8 : 1
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Easing.OutCubic
}
}
Behavior on scale {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Easing.OutCubic
}
}
// No Behavior on width/height - they must change immediately
// so Flow layout properly excludes hidden overflow items
// Visual smoothness comes from opacity and scale animations
property real shiftOffset: {
if (root.draggedIndex < 0 || !itemData.isPinned || itemData.type === "separator")
return 0;
const myIdx = root.draggingPinned ? root.pinnedIndexForDockIndex(model.index) : model.index;
if (myIdx < 0)
return 0;
if (myIdx === root.draggedIndex)
return 0;
const dragIdx = root.draggedIndex;
const dropIdx = root.dropTargetIndex;
const shiftAmount = root.iconSize * 1.2 + layoutFlow.spacing;
if (dropIdx < 0)
return 0;
if (dragIdx < dropIdx && myIdx > dragIdx && myIdx <= dropIdx)
return -shiftAmount;
if (dragIdx > dropIdx && myIdx >= dropIdx && myIdx < dragIdx)
return shiftAmount;
return 0;
}
transform: Translate {
x: root.isVertical ? 0 : delegateItem.shiftOffset
y: root.isVertical ? delegateItem.shiftOffset : 0
Behavior on x {
enabled: !root.suppressShiftAnimation
NumberAnimation {
duration: 150
easing.type: Easing.OutCubic
}
}
Behavior on y {
enabled: !root.suppressShiftAnimation
NumberAnimation {
duration: 150
easing.type: Easing.OutCubic
}
}
}
Rectangle {
visible: itemData.type === "separator"
width: root.isVertical ? root.iconSize * 0.5 : 2
height: root.isVertical ? 2 : root.iconSize * 0.5
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3)
radius: 1
anchors.centerIn: parent
}
DockOverflowButton {
id: overflowButton
visible: isOverflowToggle
anchors.centerIn: parent
width: delegateItem.width
height: delegateItem.height
actualIconSize: root.iconSize
overflowCount: itemData.overflowCount || 0
overflowExpanded: root.overflowExpanded
isVertical: root.isVertical
onClicked: root.overflowExpanded = !root.overflowExpanded
}
DockLauncherButton {
id: launcherButton
visible: itemData.type === "launcher"
anchors.centerIn: parent
width: delegateItem.width
height: delegateItem.height
actualIconSize: root.iconSize
dockApps: root
index: model.index
}
DockAppButton {
id: button
visible: !isOverflowToggle && itemData.type !== "separator" && itemData.type !== "launcher"
anchors.centerIn: parent
width: delegateItem.width
height: delegateItem.height
actualIconSize: root.iconSize
appData: itemData
contextMenu: root.contextMenu
dockApps: root
index: model.index
parentDockScreen: root.dockScreen
showWindowTitle: itemData?.type === "window" || itemData?.type === "grouped"
windowTitle: {
const title = itemData?.toplevel?.title || "(Unnamed)";
return title.length > 50 ? title.substring(0, 47) + "..." : title;
}
}
}
}
}
}
}
Connections {
target: CompositorService
function onToplevelsChanged() {
repeater.updateModel();
}
}
Connections {
target: SessionData
function onPinnedAppsChanged() {
root.suppressShiftAnimation = true;
root.draggedIndex = -1;
root.dropTargetIndex = -1;
root.draggingPinned = false;
repeater.updateModel();
Qt.callLater(() => {
root.suppressShiftAnimation = false;
});
}
}
onGroupByAppChanged: repeater.updateModel()
// Don't rebuild model on overflow toggle - delegates react to overflowExpanded via bindings
// Model structure doesn't change, only delegate visibility changes
Connections {
target: SettingsData
function onDockIsolateDisplaysChanged() {
repeater.updateModel();
}
function onDockLauncherEnabledChanged() {
root.suppressShiftAnimation = true;
root.draggedIndex = -1;
root.dropTargetIndex = -1;
repeater.updateModel();
Qt.callLater(() => {
root.suppressShiftAnimation = false;
});
}
}
Connections {
target: SessionData
function onDockLauncherPositionChanged() {
root.suppressShiftAnimation = true;
root.draggedIndex = -1;
root.dropTargetIndex = -1;
repeater.updateModel();
Qt.callLater(() => {
root.suppressShiftAnimation = false;
});
}
}
Connections {
target: SettingsData
function onDockMaxVisibleAppsChanged() {
repeater.updateModel();
}
}
Connections {
target: SettingsData
function onDockMaxVisibleRunningAppsChanged() {
repeater.updateModel();
}
}
}