diff --git a/Config/Config.qml b/Config/Config.qml index 0f7d41d..e3b4776 100644 --- a/Config/Config.qml +++ b/Config/Config.qml @@ -33,6 +33,10 @@ Singleton { recentSaveCooldown.restart(); } + function saveNoToast(): void { + saveTimer.restart(); + } + function serializeAppearance(): var { return { rounding: { diff --git a/Helpers/TaskbarApps.qml b/Helpers/TaskbarApps.qml index 4e19d3f..6bed8ee 100644 --- a/Helpers/TaskbarApps.qml +++ b/Helpers/TaskbarApps.qml @@ -9,64 +9,82 @@ Singleton { id: root property list apps: { - var map = new Map(); + const pinnedApps = uniq((Config.dock.pinnedApps ?? []).map(normalizeId)); + const openMap = buildOpenMap(); + const openIds = [...openMap.keys()]; + const sessionOrder = uniq(root.unpinnedOrder.map(normalizeId)); - // Pinned apps - const pinnedApps = Config.dock.pinnedApps ?? []; - for (const appId of pinnedApps) { - if (!map.has(appId.toLowerCase())) - map.set(appId.toLowerCase(), ({ - pinned: true, - toplevels: [] - })); - } + const orderedUnpinned = sessionOrder.filter(id => openIds.includes(id) && !pinnedApps.includes(id)).concat(openIds.filter(id => !pinnedApps.includes(id) && !sessionOrder.includes(id))); - // Separator - if (pinnedApps.length > 0) { - map.set("SEPARATOR", { + return [].concat(pinnedApps.map(appId => appEntryComp.createObject(null, { + appId, + pinned: true, + toplevels: openMap.get(appId) ?? [] + }))).concat(pinnedApps.length > 0 ? [appEntryComp.createObject(null, { + appId: root.separatorId, pinned: false, toplevels: [] - }); - } + })] : []).concat(orderedUnpinned.map(appId => appEntryComp.createObject(null, { + appId, + pinned: false, + toplevels: openMap.get(appId) ?? [] + }))); + } + readonly property string separatorId: "__dock_separator__" + property var unpinnedOrder: [] - // Ignored apps - const ignoredRegexStrings = Config.dock.ignoredAppRegexes ?? []; - const ignoredRegexes = ignoredRegexStrings.map(pattern => new RegExp(pattern, "i")); - // Open windows - for (const toplevel of ToplevelManager.toplevels.values) { + function buildOpenMap() { + const ignoredRegexes = (Config.dock.ignoredAppRegexes ?? []).map(pattern => new RegExp(pattern, "i")); + + return ToplevelManager.toplevels.values.reduce((map, toplevel) => { if (ignoredRegexes.some(re => re.test(toplevel.appId))) - continue; - if (!map.has(toplevel.appId.toLowerCase())) - map.set(toplevel.appId.toLowerCase(), ({ - pinned: false, - toplevels: [] - })); - map.get(toplevel.appId.toLowerCase()).toplevels.push(toplevel); - } + return map; - var values = []; + const appId = normalizeId(toplevel.appId); + if (!appId) + return map; - for (const [key, value] of map) { - values.push(appEntryComp.createObject(null, { - appId: key, - toplevels: value.toplevels, - pinned: value.pinned - })); - } + map.set(appId, (map.get(appId) ?? []).concat([toplevel])); + return map; + }, new Map()); + } - return values; + function commitVisualOrder(ids) { + const orderedIds = uniq(ids.map(normalizeId)); + const separatorIndex = orderedIds.indexOf(root.separatorId); + + const pinnedApps = (separatorIndex === -1 ? [] : orderedIds.slice(0, separatorIndex)).filter(id => id !== root.separatorId); + + const visibleUnpinned = orderedIds.slice(separatorIndex === -1 ? 0 : separatorIndex + 1).filter(id => id !== root.separatorId); + + Config.dock.pinnedApps = pinnedApps; + root.unpinnedOrder = visibleUnpinned.concat(root.unpinnedOrder.map(normalizeId).filter(id => !pinnedApps.includes(id) && !visibleUnpinned.includes(id))); + Config.saveNoToast(); } function isPinned(appId) { - return Config.dock.pinnedApps.indexOf(appId) !== -1; + return uniq((Config.dock.pinnedApps ?? []).map(normalizeId)).includes(normalizeId(appId)); + } + + function normalizeId(appId) { + if (appId === root.separatorId) + return root.separatorId; + + return String(appId ?? "").toLowerCase(); } function togglePin(appId) { - if (root.isPinned(appId)) { - Config.dock.pinnedApps = Config.dock.pinnedApps.filter(id => id !== appId); - } else { - Config.dock.pinnedApps = Config.dock.pinnedApps.concat([appId]); - } + const id = normalizeId(appId); + const pinnedApps = uniq((Config.dock.pinnedApps ?? []).map(normalizeId)); + const pinned = pinnedApps.includes(id); + + Config.dock.pinnedApps = pinned ? pinnedApps.filter(x => x !== id) : pinnedApps.concat([id]); + + root.unpinnedOrder = pinned ? [id].concat(root.unpinnedOrder.map(normalizeId).filter(x => x !== id)) : root.unpinnedOrder.map(normalizeId).filter(x => x !== id); + } + + function uniq(ids) { + return (ids ?? []).filter((id, i, arr) => id && arr.indexOf(id) === i); } Component { @@ -77,8 +95,6 @@ Singleton { } component TaskbarAppEntry: QtObject { - id: wrapper - required property string appId required property bool pinned required property list toplevels diff --git a/Modules/DesktopIcons/BackgroundContextMenu.qml b/Modules/DesktopIcons/BackgroundContextMenu.qml index 9d95d54..a9eefa8 100644 --- a/Modules/DesktopIcons/BackgroundContextMenu.qml +++ b/Modules/DesktopIcons/BackgroundContextMenu.qml @@ -96,7 +96,8 @@ Item { anchors.fill: parent onClicked: { - Quickshell.execDetached(["qs", "-p", "/usr/share/sleex/settings.qml"]) + const visibilities = Visibilities.getForActive(); + visibilities.settings = true; root.close() } } diff --git a/Modules/Dock/Content.qml b/Modules/Dock/Content.qml index 2b28f55..2a4bba4 100644 --- a/Modules/Dock/Content.qml +++ b/Modules/Dock/Content.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick +import QtQml.Models import qs.Modules.Dock.Parts import qs.Components import qs.Helpers @@ -10,37 +11,198 @@ import qs.Config Item { id: root + readonly property int dockContentWidth: TaskbarApps.apps.reduce((sum, app, i) => sum + (app.appId === TaskbarApps.separatorId ? 1 : Config.dock.height) + (i > 0 ? dockRow.spacing : 0), 0) + property bool dragActive: false + property real dragHeight: Config.dock.height + property real dragStartX: 0 + property real dragStartY: 0 + property real dragWidth: Config.dock.height + property real dragX: 0 + property real dragY: 0 + property string draggedAppId: "" + property var draggedModelData: null readonly property int padding: Appearance.padding.small required property var panels readonly property int rounding: Appearance.rounding.large required property PersistentProperties visibilities + property var visualIds: [] + + function beginVisualDrag(appId, modelData, item) { + const pos = item.mapToItem(root, 0, 0); + + root.visualIds = TaskbarApps.apps.map(app => app.appId); + root.draggedAppId = appId; + root.draggedModelData = modelData; + root.dragWidth = item.width; + root.dragHeight = item.height; + root.dragStartX = pos.x; + root.dragStartY = pos.y; + root.dragX = pos.x; + root.dragY = pos.y; + root.dragActive = true; + } + + function endVisualDrag() { + const ids = root.visualIds.slice(); + + root.dragActive = false; + root.draggedAppId = ""; + root.draggedModelData = null; + root.visualIds = []; + + TaskbarApps.commitVisualOrder(ids); + } + + function moveArrayItem(list, from, to) { + const next = list.slice(); + const [item] = next.splice(from, 1); + next.splice(to, 0, item); + return next; + } + + function previewVisualMove(from, hovered, before) { + let to = hovered + (before ? 0 : 1); + + if (to > from) + to -= 1; + + to = Math.max(0, Math.min(visualModel.items.count - 1, to)); + + if (from === to) + return; + + visualModel.items.move(from, to); + root.visualIds = moveArrayItem(root.visualIds, from, to); + } implicitHeight: Config.dock.height + root.padding * 2 - implicitWidth: dockRow.implicitWidth + root.padding * 2 + implicitWidth: root.dockContentWidth + root.padding * 2 - CustomListView { - id: dockRow + Component { + id: dockDelegate - anchors.centerIn: parent - implicitHeight: Config.dock.height - implicitWidth: contentWidth - orientation: ListView.Horizontal - spacing: Appearance.padding.smaller + DropArea { + id: slot - delegate: DockAppButton { + readonly property string appId: modelData.appId + readonly property bool isSeparator: appId === TaskbarApps.separatorId required property var modelData - appListRoot: root - appToplevel: modelData - visibilities: root.visibilities - } - Behavior on implicitWidth { - Anim { + function previewReorder(drag) { + const source = drag.source; + if (!source || !source.appId || source.appId === appId) + return; + + const from = source.visualIndex; + const hovered = slot.DelegateModel.itemsIndex; + + if (from < 0 || hovered < 0) + return; + + root.previewVisualMove(from, hovered, drag.x < width / 2); + } + + height: Config.dock.height + width: isSeparator ? 1 : Config.dock.height + + onEntered: drag => previewReorder(drag) + onPositionChanged: drag => previewReorder(drag) + + DockAppButton { + id: button + + anchors.centerIn: parent + appListRoot: root + appToplevel: modelData + visibilities: root.visibilities + visible: root.draggedAppId !== slot.appId + } + + DragHandler { + id: dragHandler + + enabled: !slot.isSeparator + grabPermissions: PointerHandler.CanTakeOverFromAnything + target: null + xAxis.enabled: true + yAxis.enabled: false + + onActiveChanged: { + if (active) { + root.beginVisualDrag(slot.appId, slot.modelData, button); + } else if (root.draggedAppId === slot.appId) { + dragProxy.Drag.drop(); + root.endVisualDrag(); + } + } + onActiveTranslationChanged: { + if (!active || root.draggedAppId !== slot.appId) + return; + + root.dragX = root.dragStartX + activeTranslation.x; + root.dragY = root.dragStartY + activeTranslation.y; + } } } + } + + DelegateModel { + id: visualModel + + delegate: dockDelegate + model: ScriptModel { objectProp: "appId" values: TaskbarApps.apps } } + + CustomListView { + id: dockRow + + anchors.centerIn: parent + boundsBehavior: Flickable.StopAtBounds + implicitHeight: Config.dock.height + implicitWidth: root.dockContentWidth + interactive: !root.dragActive + model: visualModel + orientation: ListView.Horizontal + spacing: Appearance.padding.smaller + + Behavior on implicitWidth { + Anim { + } + } + moveDisplaced: Transition { + Anim { + properties: "x,y" + } + } + } + + Item { + id: dragProxy + + property string appId: root.draggedAppId + property int visualIndex: root.visualIds.indexOf(root.draggedAppId) + + Drag.active: root.dragActive + Drag.hotSpot.x: width / 2 + Drag.hotSpot.y: height / 2 + Drag.source: dragProxy + height: root.dragHeight + visible: root.dragActive && !!root.draggedModelData + width: root.dragWidth + x: root.dragX + y: root.dragY + z: 9999 + + DockAppButton { + anchors.fill: parent + appListRoot: root + appToplevel: root.draggedModelData + enabled: false + visibilities: root.visibilities + } + } } diff --git a/Modules/Dock/Parts/DockAppButton.qml b/Modules/Dock/Parts/DockAppButton.qml index 98b1972..c7e79b9 100644 --- a/Modules/Dock/Parts/DockAppButton.qml +++ b/Modules/Dock/Parts/DockAppButton.qml @@ -9,14 +9,14 @@ import qs.Config CustomRect { id: root - property bool appIsActive: appToplevel.toplevels.find(t => (t.activated == true)) !== undefined + property bool appIsActive: appToplevel?.toplevels.find(t => (t.activated == true)) !== undefined property var appListRoot property var appToplevel property real countDotHeight: 4 property real countDotWidth: 10 - property var desktopEntry: DesktopEntries.heuristicLookup(appToplevel.appId) + property var desktopEntry: DesktopEntries.heuristicLookup(appToplevel?.appId) property real iconSize: implicitHeight - 20 - readonly property bool isSeparator: appToplevel.appId === "SEPARATOR" + readonly property bool isSeparator: appToplevel?.appId === "__dock_separator__" property int lastFocused: -1 required property PersistentProperties visibilities @@ -34,7 +34,7 @@ CustomRect { Layout.alignment: Qt.AlignHCenter implicitSize: root.iconSize - source: Quickshell.iconPath(AppSearch.guessIcon(appToplevel.appId), "image-missing") + source: Quickshell.iconPath(AppSearch.guessIcon(appToplevel?.appId), "image-missing") } RowLayout { @@ -42,14 +42,14 @@ CustomRect { spacing: 3 Repeater { - model: Math.min(appToplevel.toplevels.length, 3) + model: Math.min(appToplevel?.toplevels.length, 3) delegate: Rectangle { required property int index color: appIsActive ? DynamicColors.palette.m3primary : DynamicColors.tPalette.m3primary implicitHeight: root.countDotHeight - implicitWidth: (appToplevel.toplevels.length <= 3) ? root.countDotWidth : root.countDotHeight // Circles when too many + implicitWidth: (appToplevel?.toplevels.length <= 3) ? root.countDotWidth : root.countDotHeight // Circles when too many radius: Appearance.rounding.full } } @@ -59,20 +59,20 @@ CustomRect { StateLayer { onClicked: { - if (appToplevel.toplevels.length === 0) { + if (appToplevel?.toplevels.length === 0) { root.desktopEntry?.execute(); root.visibilities.dock = false; return; } - lastFocused = (lastFocused + 1) % appToplevel.toplevels.length; - appToplevel.toplevels[lastFocused].activate(); + lastFocused = (lastFocused + 1) % appToplevel?.toplevels.length; + appToplevel?.toplevels[lastFocused].activate(); root.visibilities.dock = false; } } Connections { function onApplicationsChanged() { - root.desktopEntry = DesktopEntries.heuristicLookup(appToplevel.appId); + root.desktopEntry = DesktopEntries.heuristicLookup(appToplevel?.appId); } target: DesktopEntries