214 lines
7.9 KiB
QML
214 lines
7.9 KiB
QML
pragma Singleton
|
|
|
|
import qs.modules.common
|
|
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Wayland
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
function isPinned(appId) {
|
|
return Config.options.dock.pinnedApps.indexOf(appId) !== -1;
|
|
}
|
|
|
|
function togglePin(appId) {
|
|
if (root.isPinned(appId)) {
|
|
Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.filter(id => id !== appId)
|
|
} else {
|
|
Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.concat([appId])
|
|
}
|
|
}
|
|
|
|
// --- Fan-out stacks ------------------------------------------------
|
|
// Backing store: Config.options.dock.stacks, a list<string> where each
|
|
// entry is "stackId|appId,appId,appId". stackId is a display label and
|
|
// the key; members are appIds. Same string-list-in-JsonAdapter shape as
|
|
// pinnedApps (nested-object arrays don't survive the adapter).
|
|
|
|
function parseStack(entry) {
|
|
const bar = entry.indexOf("|");
|
|
if (bar === -1) return { id: entry, members: [] };
|
|
const id = entry.slice(0, bar);
|
|
const rest = entry.slice(bar + 1).trim();
|
|
const members = rest.length ? rest.split(",").map(s => s.trim()).filter(s => s.length) : [];
|
|
return { id: id, members: members };
|
|
}
|
|
|
|
function stacksList() {
|
|
return (Config.options?.dock.stacks ?? []).map(root.parseStack);
|
|
}
|
|
|
|
// appId -> the stackId that contains it, or "" if none.
|
|
function stackContaining(appId) {
|
|
const low = appId.toLowerCase();
|
|
for (const s of root.stacksList()) {
|
|
if (s.members.some(m => m.toLowerCase() === low)) return s.id;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function encodeStack(id, members) {
|
|
return id + "|" + members.join(",");
|
|
}
|
|
|
|
// Rewrite the whole stacks list from a parsed [{id, members}] array,
|
|
// dropping any that end up empty.
|
|
function writeStacks(parsed) {
|
|
Config.options.dock.stacks = parsed
|
|
.filter(s => s.members.length > 0)
|
|
.map(s => root.encodeStack(s.id, s.members));
|
|
}
|
|
|
|
function addToStack(stackId, appId) {
|
|
const parsed = root.stacksList();
|
|
const existing = parsed.find(s => s.id === stackId);
|
|
if (existing) {
|
|
if (!existing.members.some(m => m.toLowerCase() === appId.toLowerCase()))
|
|
existing.members = existing.members.concat([appId]);
|
|
} else {
|
|
parsed.push({ id: stackId, members: [appId] });
|
|
}
|
|
root.writeStacks(parsed);
|
|
}
|
|
|
|
function removeFromStack(stackId, appId) {
|
|
const parsed = root.stacksList();
|
|
const existing = parsed.find(s => s.id === stackId);
|
|
if (!existing) return;
|
|
existing.members = existing.members.filter(m => m.toLowerCase() !== appId.toLowerCase());
|
|
root.writeStacks(parsed);
|
|
}
|
|
|
|
// --- Drag interactions (Tier 1) ------------------------------------
|
|
// Auto-name a fresh stack when two loose icons are combined. "Stack N"
|
|
// where N is one past the current count; rename waits for the menu.
|
|
function nextStackName() {
|
|
return "Stack " + ((Config.options?.dock.stacks?.length ?? 0) + 1);
|
|
}
|
|
|
|
// Drag `draggedAppId` onto `targetAppId` -> combine. If target is
|
|
// already a stack (stackId non-empty), add into it; else make a new
|
|
// stack containing target then dragged (target stays on top = first).
|
|
function combineIntoStack(targetAppId, draggedAppId, targetStackId) {
|
|
if (!draggedAppId || draggedAppId.toLowerCase() === targetAppId.toLowerCase()) return;
|
|
// If the dragged app is currently in some stack, pull it out first.
|
|
const from = root.stackContaining(draggedAppId);
|
|
if (from) root.removeFromStack(from, draggedAppId);
|
|
|
|
if (targetStackId) {
|
|
root.addToStack(targetStackId, draggedAppId);
|
|
} else {
|
|
const id = root.nextStackName();
|
|
const parsed = root.stacksList();
|
|
parsed.push({ id: id, members: [targetAppId, draggedAppId] });
|
|
// targetAppId was a loose pinned app; drop it from standalone
|
|
// pinned so it lives only in the stack now.
|
|
root.writeStacks(parsed);
|
|
}
|
|
}
|
|
|
|
// Reorder a member within its stack by delta (-1 up/left, +1 down/right).
|
|
// Member 0 is the collapsed/top icon.
|
|
function reorderStackMember(stackId, appId, delta) {
|
|
const parsed = root.stacksList();
|
|
const s = parsed.find(x => x.id === stackId);
|
|
if (!s) return;
|
|
const i = s.members.findIndex(m => m.toLowerCase() === appId.toLowerCase());
|
|
const j = i + delta;
|
|
if (i < 0 || j < 0 || j >= s.members.length) return;
|
|
const m = s.members.slice();
|
|
[m[i], m[j]] = [m[j], m[i]];
|
|
s.members = m;
|
|
root.writeStacks(parsed);
|
|
}
|
|
|
|
// Replace a stack's member order wholesale (arc-reorder commit).
|
|
function setStackOrder(stackId, members) {
|
|
const parsed = root.stacksList();
|
|
const s = parsed.find(x => x.id === stackId);
|
|
if (!s) return;
|
|
s.members = members;
|
|
root.writeStacks(parsed);
|
|
}
|
|
|
|
// Pull a member out of its stack back to a standalone pinned app.
|
|
function unstackMember(stackId, appId) {
|
|
root.removeFromStack(stackId, appId);
|
|
if (!root.isPinned(appId)) root.togglePin(appId);
|
|
}
|
|
|
|
property list<var> apps: {
|
|
var map = new Map();
|
|
|
|
// Fan-out stacks come first, in their configured order. Their
|
|
// member appIds are suppressed as standalone pinned entries below
|
|
// so an app lives in one place: its stack.
|
|
const stacks = root.stacksList();
|
|
const stackedMembers = new Set();
|
|
for (const s of stacks) {
|
|
for (const m of s.members) stackedMembers.add(m.toLowerCase());
|
|
map.set("STACK:" + s.id, {
|
|
pinned: true, toplevels: [], isStack: true, members: s.members
|
|
});
|
|
}
|
|
|
|
// Pinned apps (skip any already living in a stack)
|
|
const pinnedApps = Config.options?.dock.pinnedApps ?? [];
|
|
for (const appId of pinnedApps) {
|
|
if (stackedMembers.has(appId.toLowerCase())) continue;
|
|
if (!map.has(appId.toLowerCase())) map.set(appId.toLowerCase(), ({
|
|
pinned: true,
|
|
toplevels: []
|
|
}));
|
|
}
|
|
|
|
// Separator
|
|
if (map.size > 0) {
|
|
map.set("SEPARATOR", { pinned: false, toplevels: [] });
|
|
}
|
|
|
|
// Ignored apps
|
|
const ignoredRegexStrings = Config.options?.dock.ignoredAppRegexes ?? [];
|
|
const ignoredRegexes = ignoredRegexStrings.map(pattern => new RegExp(pattern, "i"));
|
|
// Open windows
|
|
for (const toplevel of ToplevelManager.toplevels.values) {
|
|
if (ignoredRegexes.some(re => re.test(toplevel.appId))) continue;
|
|
// A running app that belongs to a stack keeps its running dot on
|
|
// the stack, not as a separate icon.
|
|
if (stackedMembers.has(toplevel.appId.toLowerCase())) continue;
|
|
if (!map.has(toplevel.appId.toLowerCase())) map.set(toplevel.appId.toLowerCase(), ({
|
|
pinned: false,
|
|
toplevels: []
|
|
}));
|
|
map.get(toplevel.appId.toLowerCase()).toplevels.push(toplevel);
|
|
}
|
|
|
|
var values = [];
|
|
|
|
for (const [key, value] of map) {
|
|
values.push(appEntryComp.createObject(null, {
|
|
appId: value.isStack ? key.slice(6) : key,
|
|
toplevels: value.toplevels,
|
|
pinned: value.pinned,
|
|
isStack: value.isStack ?? false,
|
|
members: value.members ?? []
|
|
}));
|
|
}
|
|
|
|
return values;
|
|
}
|
|
|
|
component TaskbarAppEntry: QtObject {
|
|
id: wrapper
|
|
required property string appId
|
|
required property list<var> toplevels
|
|
required property bool pinned
|
|
property bool isStack: false
|
|
property list<var> members: []
|
|
}
|
|
Component {
|
|
id: appEntryComp
|
|
TaskbarAppEntry {}
|
|
}
|
|
}
|