Started work on generic compositor API

This commit is contained in:
Ezri Brimhall 2025-02-15 15:32:27 -07:00
parent 23b63fa58d
commit c023391702
Signed by: ezri
GPG Key ID: 058A78E5680C6F24
16 changed files with 1975 additions and 272 deletions

76
DerivedConnectable.ts Normal file
View File

@ -0,0 +1,76 @@
import { type Subscribable, type Connectable, kebabify } from "astal/binding";
export class Derived<
ResultType,
ObjectType extends Connectable,
PropKeys extends keyof ObjectType & string,
> implements Subscribable<ResultType>
{
private upstreamSubscriptions: number[];
private downstreamSubscriptions: ((value: ResultType) => void)[] = [];
private properties: PropKeys[];
// @ts-expect-error - value _is_ definitively defined in the constructor, thorugh the call to refresh()
private value: ResultType;
constructor(
private object: ObjectType,
private transform: (...args: any) => ResultType,
...properties: PropKeys[]
) {
this.upstreamSubscriptions = properties.map((prop) =>
object.connect(kebabify(prop), () => this.refresh()),
);
this.properties = properties;
this.refresh(true);
}
private refresh(initial = false) {
this.value = this.transform(
this.properties.map((prop) => this.object[prop]),
);
if (!initial) {
this.downstreamSubscriptions.forEach((callback) => callback(this.value));
}
}
subscribe(callback: (value: ResultType) => void): () => void {
this.downstreamSubscriptions.push(callback);
return () => {
const index = this.downstreamSubscriptions.findIndex(
(func) => func === callback,
);
this.downstreamSubscriptions.splice(index, 1);
};
}
get(): ResultType {
return this.value;
}
drop(): void {
this.upstreamSubscriptions.forEach((sub) => this.object.disconnect(sub));
}
}
export function nestedBind<
ObjectType extends Connectable | undefined,
KeyType extends keyof ObjectType & string,
>(
bound: Subscribable<ObjectType>,
key: KeyType,
): Subscribable<Required<ObjectType>[KeyType] | undefined> {
let subscription: number | undefined;
let lastObject: ObjectType = bound.get();
let value: ObjectType[KeyType] | undefined = bound.get()?.[key];
const subscriptions: ((value: ObjectType[KeyType]) => void)[] = [];
const unsubscribeUpstream = bound.subscribe((obj) => {
if (subscription !== undefined) {
lastObject?.disconnect(subscription);
}
subscription = obj?.connect(`notify:${key}`, () => {
subscriptions.forEach((sub) => sub(obj![key]));
});
lastObject = obj;
});
}

View File

@ -1,3 +1,11 @@
<!DOCTYPE <!DOCTYPE
interface PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd"> "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
<node>
<interface name="dev.ezri.voidshell.workspaces.Context">
<property name="focusedGroupPath" type="o" access="read" />
<property name="groupPaths" type="ao" access="read" />
<property name="active" type="b" access="read" />
<method name="activate" />
</interface>
</node>

320
package-lock.json generated
View File

@ -10,7 +10,8 @@
"license": "All right reserved", "license": "All right reserved",
"dependencies": { "dependencies": {
"@girs/gdk-3.0": "^3.24.43-4.0.0-beta.16", "@girs/gdk-3.0": "^3.24.43-4.0.0-beta.16",
"@girs/gjs": "^4.0.0-beta.16" "@girs/gjs": "^4.0.0-beta.16",
"yaml": "^2.7.0"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^9.14.0", "eslint": "^9.14.0",
@ -76,13 +77,13 @@
} }
}, },
"node_modules/@eslint/config-array": { "node_modules/@eslint/config-array": {
"version": "0.18.0", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz",
"integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@eslint/object-schema": "^2.1.4", "@eslint/object-schema": "^2.1.6",
"debug": "^4.3.1", "debug": "^4.3.1",
"minimatch": "^3.1.2" "minimatch": "^3.1.2"
}, },
@ -91,19 +92,22 @@
} }
}, },
"node_modules/@eslint/core": { "node_modules/@eslint/core": {
"version": "0.7.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.11.0.tgz",
"integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==", "integrity": "sha512-DWUB2pksgNEb6Bz2fggIy1wh6fGgZP4Xyy/Mt0QZPiloKKXerbqq9D3SBQTlCRYOrcRPu4vuz+CGjwdfqxnoWA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@eslint/eslintrc": { "node_modules/@eslint/eslintrc": {
"version": "3.1.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz",
"integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -125,9 +129,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.14.0", "version": "9.20.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.14.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.20.0.tgz",
"integrity": "sha512-pFoEtFWCPyDOl+C6Ift+wC7Ro89otjigCf5vcuWqWgqNSQbRrpjSvdeE6ofLz4dHmyxD5f7gIdGT4+p36L6Twg==", "integrity": "sha512-iZA07H9io9Wn836aVTytRaNqh00Sad+EamwOVJT12GTLw1VGMFV/4JaME+JjLtr9fiGaoWgYnS54wrfWsSs4oQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -135,9 +139,9 @@
} }
}, },
"node_modules/@eslint/object-schema": { "node_modules/@eslint/object-schema": {
"version": "2.1.4", "version": "2.1.6",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
"integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@ -145,151 +149,165 @@
} }
}, },
"node_modules/@eslint/plugin-kit": { "node_modules/@eslint/plugin-kit": {
"version": "0.2.2", "version": "0.2.5",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.2.tgz", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.5.tgz",
"integrity": "sha512-CXtq5nR4Su+2I47WPOlWud98Y5Lv8Kyxp2ukhgFx/eW6Blm18VXJO5WuQylPugRo8nbluoi6GvvxBLqHcvqUUw==", "integrity": "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@eslint/core": "^0.10.0",
"levn": "^0.4.1" "levn": "^0.4.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz",
"integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@girs/cairo-1.0": { "node_modules/@girs/cairo-1.0": {
"version": "1.0.0-4.0.0-beta.16", "version": "1.0.0-4.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@girs/cairo-1.0/-/cairo-1.0-1.0.0-4.0.0-beta.16.tgz", "resolved": "https://registry.npmjs.org/@girs/cairo-1.0/-/cairo-1.0-1.0.0-4.0.0-beta.19.tgz",
"integrity": "sha512-nIrOvL73lhCHB/4n4ro7Ud4XaR0DrUg+MvCN/B42L2eCS8YIZ0MHZ+Fma0shifDQyv8uoopHfE5I7X/v7VOYFg==", "integrity": "sha512-nBI5oveqH0N7czBC95ofJ4Un5KKqK6guANE6O0OxkwuF6B3YxVqdNmF8O8i2tk3GIxBzwxkC0bi0Wb6X4tLR8g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@girs/gjs": "^4.0.0-beta.16", "@girs/gjs": "^4.0.0-beta.19",
"@girs/glib-2.0": "^2.82.0-4.0.0-beta.16", "@girs/glib-2.0": "^2.82.2-4.0.0-beta.19",
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16" "@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19"
} }
}, },
"node_modules/@girs/freetype2-2.0": { "node_modules/@girs/freetype2-2.0": {
"version": "2.0.0-4.0.0-beta.16", "version": "2.0.0-4.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@girs/freetype2-2.0/-/freetype2-2.0-2.0.0-4.0.0-beta.16.tgz", "resolved": "https://registry.npmjs.org/@girs/freetype2-2.0/-/freetype2-2.0-2.0.0-4.0.0-beta.19.tgz",
"integrity": "sha512-djReh9OOKW5vG8t94ie4mQmsUWS4qgLWLMBIFg/KKcCrGHYgwkcSafLnnn/5HyRgdz6qxoFm6N3C+AJEcYYz6A==", "integrity": "sha512-xU3uVUXMY0MeLc2U4QgpWBQ6VSBtMKaqKRcRT7TrOM1etkLzGhh70SWt16xjqA6vrpF+wZ9TtndQZy2ky0gpwA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@girs/gjs": "^4.0.0-beta.16", "@girs/gjs": "^4.0.0-beta.19",
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16" "@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19"
} }
}, },
"node_modules/@girs/gdk-3.0": { "node_modules/@girs/gdk-3.0": {
"version": "3.24.43-4.0.0-beta.16", "version": "3.24.43-4.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@girs/gdk-3.0/-/gdk-3.0-3.24.43-4.0.0-beta.16.tgz", "resolved": "https://registry.npmjs.org/@girs/gdk-3.0/-/gdk-3.0-3.24.43-4.0.0-beta.19.tgz",
"integrity": "sha512-AKFbDjRrrNcKlbv57tFfcgCLIimn2mYJypqK7HzK5JIYvm9dogBnCTC9BpIMyFF/RcClCOxWgn7WnaAVzOSRrw==", "integrity": "sha512-qEMU2BnXySVEq0kttQzzjFFh5kki7mm4YutJmzU5PpSj53GavCIALmjvbJZ1QDhLaCpLjZ2DZ8sm/erImRkSvg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@girs/cairo-1.0": "^1.0.0-4.0.0-beta.16", "@girs/cairo-1.0": "^1.0.0-4.0.0-beta.19",
"@girs/freetype2-2.0": "^2.0.0-4.0.0-beta.16", "@girs/freetype2-2.0": "^2.0.0-4.0.0-beta.19",
"@girs/gdkpixbuf-2.0": "^2.0.0-4.0.0-beta.16", "@girs/gdkpixbuf-2.0": "^2.0.0-4.0.0-beta.19",
"@girs/gio-2.0": "^2.82.0-4.0.0-beta.16", "@girs/gio-2.0": "^2.82.2-4.0.0-beta.19",
"@girs/gjs": "^4.0.0-beta.16", "@girs/gjs": "^4.0.0-beta.19",
"@girs/glib-2.0": "^2.82.0-4.0.0-beta.16", "@girs/glib-2.0": "^2.82.2-4.0.0-beta.19",
"@girs/gmodule-2.0": "^2.0.0-4.0.0-beta.16", "@girs/gmodule-2.0": "^2.0.0-4.0.0-beta.19",
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16", "@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19",
"@girs/harfbuzz-0.0": "^9.0.0-4.0.0-beta.16", "@girs/harfbuzz-0.0": "^9.0.0-4.0.0-beta.19",
"@girs/pango-1.0": "^1.54.0-4.0.0-beta.16" "@girs/pango-1.0": "^1.54.0-4.0.0-beta.19"
} }
}, },
"node_modules/@girs/gdkpixbuf-2.0": { "node_modules/@girs/gdkpixbuf-2.0": {
"version": "2.0.0-4.0.0-beta.16", "version": "2.0.0-4.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@girs/gdkpixbuf-2.0/-/gdkpixbuf-2.0-2.0.0-4.0.0-beta.16.tgz", "resolved": "https://registry.npmjs.org/@girs/gdkpixbuf-2.0/-/gdkpixbuf-2.0-2.0.0-4.0.0-beta.19.tgz",
"integrity": "sha512-d4v7fGCuegpf5sscnMsOCjeuQgJjUJ4+Gozr+wFL30Are944V1FYWrdWP1GgIaNnBeWXY43D58YFQhsfFi+bWg==", "integrity": "sha512-h8+LofOkyChEhduPGWYI4XDi5bCb9h8aRhYU4p6CTWEAiWXE2a7EtPjdLKXkQADAL/xt3eQoF2t6ecFdYIoiEg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@girs/gio-2.0": "^2.82.0-4.0.0-beta.16", "@girs/gio-2.0": "^2.82.2-4.0.0-beta.19",
"@girs/gjs": "^4.0.0-beta.16", "@girs/gjs": "^4.0.0-beta.19",
"@girs/glib-2.0": "^2.82.0-4.0.0-beta.16", "@girs/glib-2.0": "^2.82.2-4.0.0-beta.19",
"@girs/gmodule-2.0": "^2.0.0-4.0.0-beta.16", "@girs/gmodule-2.0": "^2.0.0-4.0.0-beta.19",
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16" "@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19"
} }
}, },
"node_modules/@girs/gio-2.0": { "node_modules/@girs/gio-2.0": {
"version": "2.82.0-4.0.0-beta.16", "version": "2.82.2-4.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@girs/gio-2.0/-/gio-2.0-2.82.0-4.0.0-beta.16.tgz", "resolved": "https://registry.npmjs.org/@girs/gio-2.0/-/gio-2.0-2.82.2-4.0.0-beta.19.tgz",
"integrity": "sha512-5JW6qgyzh3OW7dEihP+h2ZtXWw7kDXi/ra63ZscnYqUIuoebqaSB66HFzd3AMyDYYQ7j8IGZdJCog+6kKwnk8Q==", "integrity": "sha512-OM2E/mdNX3Z+X7mB8ZVFch4WbUqX1Y6hADiG9+LuGWsAOjPAVvDDUk5N4+34+BTPEfFHLGOdo8E7dI235jylCQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@girs/gjs": "^4.0.0-beta.16", "@girs/gjs": "^4.0.0-beta.19",
"@girs/glib-2.0": "^2.82.0-4.0.0-beta.16", "@girs/glib-2.0": "^2.82.2-4.0.0-beta.19",
"@girs/gmodule-2.0": "^2.0.0-4.0.0-beta.16", "@girs/gmodule-2.0": "^2.0.0-4.0.0-beta.19",
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16" "@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19"
} }
}, },
"node_modules/@girs/gjs": { "node_modules/@girs/gjs": {
"version": "4.0.0-beta.16", "version": "4.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@girs/gjs/-/gjs-4.0.0-beta.16.tgz", "resolved": "https://registry.npmjs.org/@girs/gjs/-/gjs-4.0.0-beta.19.tgz",
"integrity": "sha512-UjWj6GXXCqJ+z8DdzQ2XYxl1IqzgcIOIs6BTcEsXQfaZx9704GNpd9PRcqOKj7hVe2VGqO5VdIbk80u9Bo+lUQ==", "integrity": "sha512-tl+BjmMqqZH9xZcJZ2AwWI0sotGDc7qVPppAJNeFZFCgUDOFVWThZfkpZffZcQGNphn7wgU8HOvgZyrtVhrltw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@girs/cairo-1.0": "^1.0.0-4.0.0-beta.16", "@girs/cairo-1.0": "^1.0.0-4.0.0-beta.19",
"@girs/gio-2.0": "^2.82.0-4.0.0-beta.16", "@girs/gio-2.0": "^2.82.2-4.0.0-beta.19",
"@girs/glib-2.0": "^2.82.0-4.0.0-beta.16", "@girs/glib-2.0": "^2.82.2-4.0.0-beta.19",
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16" "@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19"
} }
}, },
"node_modules/@girs/glib-2.0": { "node_modules/@girs/glib-2.0": {
"version": "2.82.0-4.0.0-beta.16", "version": "2.82.2-4.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@girs/glib-2.0/-/glib-2.0-2.82.0-4.0.0-beta.16.tgz", "resolved": "https://registry.npmjs.org/@girs/glib-2.0/-/glib-2.0-2.82.2-4.0.0-beta.19.tgz",
"integrity": "sha512-YZjsythyPhClrq0uEGq8rBJ8ER0iBPf3alr1kkSOt28tDG4tn0Rv2kudHEKsFenlFjzhYVOMrJSjVvsxtcqRUQ==", "integrity": "sha512-mRUhcp7O65KQQkyrgiQpUzl1rY+TH4X+A98Kk67g3VjuA7Ei/lV4lcuZnW04HnXAGvQ3Qfkq877NmcDXRgb02g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@girs/gjs": "^4.0.0-beta.16", "@girs/gjs": "^4.0.0-beta.19",
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16" "@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19"
} }
}, },
"node_modules/@girs/gmodule-2.0": { "node_modules/@girs/gmodule-2.0": {
"version": "2.0.0-4.0.0-beta.16", "version": "2.0.0-4.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@girs/gmodule-2.0/-/gmodule-2.0-2.0.0-4.0.0-beta.16.tgz", "resolved": "https://registry.npmjs.org/@girs/gmodule-2.0/-/gmodule-2.0-2.0.0-4.0.0-beta.19.tgz",
"integrity": "sha512-jhl7j0JOtttJnOFQ4OypPjGP5DL8AlEiSsd8YKpXI3CEC7tUFZXHq+/mymFeZ6m4GFLEdC4j0efl9roPTSEYnA==", "integrity": "sha512-whTYGLL63Hw1Tn9ZicpJRAMtkYiZwB3lrcln4ETFxyO1ckGTTZ5s17cWcrQjij27veVvjWcb5rmc93L5djeBpg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@girs/gjs": "^4.0.0-beta.16", "@girs/gjs": "^4.0.0-beta.19",
"@girs/glib-2.0": "^2.82.0-4.0.0-beta.16", "@girs/glib-2.0": "^2.82.2-4.0.0-beta.19",
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16" "@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19"
} }
}, },
"node_modules/@girs/gobject-2.0": { "node_modules/@girs/gobject-2.0": {
"version": "2.82.0-4.0.0-beta.16", "version": "2.82.2-4.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@girs/gobject-2.0/-/gobject-2.0-2.82.0-4.0.0-beta.16.tgz", "resolved": "https://registry.npmjs.org/@girs/gobject-2.0/-/gobject-2.0-2.82.2-4.0.0-beta.19.tgz",
"integrity": "sha512-ziBHUMSVuoHlzhZrMvuJDSZPOR4HzRW+nk35aI3QW9TpHtIYTk6vWvY0EG0+ylzJlltT9C/kzoVvspyMulBZ5g==", "integrity": "sha512-E1fCaIZvZ7K035Waa/vYOTqLKI47OSUX3wpMrD+DBlyfDLDCFvyXwaIrFsiKUVFJIxj1xxq7H2yCZvkYqkfklA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@girs/gjs": "^4.0.0-beta.16", "@girs/gjs": "^4.0.0-beta.19",
"@girs/glib-2.0": "^2.82.0-4.0.0-beta.16" "@girs/glib-2.0": "^2.82.2-4.0.0-beta.19"
} }
}, },
"node_modules/@girs/harfbuzz-0.0": { "node_modules/@girs/harfbuzz-0.0": {
"version": "9.0.0-4.0.0-beta.16", "version": "9.0.0-4.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@girs/harfbuzz-0.0/-/harfbuzz-0.0-9.0.0-4.0.0-beta.16.tgz", "resolved": "https://registry.npmjs.org/@girs/harfbuzz-0.0/-/harfbuzz-0.0-9.0.0-4.0.0-beta.19.tgz",
"integrity": "sha512-ONBRGCdBj668eComG25kOZVDU6HqRZWHIA42YYlB0JxF6Dgtu52sLiDoGXh00gkpATqnFbHfkDJhiL9EowGgIQ==", "integrity": "sha512-m+rGrFJs6OUdz/WdG/lvXgJ6eSMW5vSPmtZDHoipDnY5LDMDfF0/tEc12RqPqJ7uF59czcYtpSa2gR4ZtrKHUA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@girs/freetype2-2.0": "^2.0.0-4.0.0-beta.16", "@girs/freetype2-2.0": "^2.0.0-4.0.0-beta.19",
"@girs/gjs": "^4.0.0-beta.16", "@girs/gjs": "^4.0.0-beta.19",
"@girs/glib-2.0": "^2.82.0-4.0.0-beta.16", "@girs/glib-2.0": "^2.82.2-4.0.0-beta.19",
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16" "@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19"
} }
}, },
"node_modules/@girs/pango-1.0": { "node_modules/@girs/pango-1.0": {
"version": "1.54.0-4.0.0-beta.16", "version": "1.54.0-4.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@girs/pango-1.0/-/pango-1.0-1.54.0-4.0.0-beta.16.tgz", "resolved": "https://registry.npmjs.org/@girs/pango-1.0/-/pango-1.0-1.54.0-4.0.0-beta.19.tgz",
"integrity": "sha512-EY1/70Q1eZ8B3DlkB+QKgK4BLRM2B0sl8tZiHxy99H72Yl+DW2yqyXrG5mx5TxVfphd2hB5x3+vREej15XSgRQ==", "integrity": "sha512-O/tJNlRAdmuJFrw8pOMKZrYldgSLgAN9dOJrtQXSh4+6vFH7LQ9SnCKv+6hmJ6yJUnfzEtyOgWAhdmee/Hz3tQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@girs/cairo-1.0": "^1.0.0-4.0.0-beta.16", "@girs/cairo-1.0": "^1.0.0-4.0.0-beta.19",
"@girs/freetype2-2.0": "^2.0.0-4.0.0-beta.16", "@girs/freetype2-2.0": "^2.0.0-4.0.0-beta.19",
"@girs/gio-2.0": "^2.82.0-4.0.0-beta.16", "@girs/gio-2.0": "^2.82.2-4.0.0-beta.19",
"@girs/gjs": "^4.0.0-beta.16", "@girs/gjs": "^4.0.0-beta.19",
"@girs/glib-2.0": "^2.82.0-4.0.0-beta.16", "@girs/glib-2.0": "^2.82.2-4.0.0-beta.19",
"@girs/gmodule-2.0": "^2.0.0-4.0.0-beta.16", "@girs/gmodule-2.0": "^2.0.0-4.0.0-beta.19",
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16", "@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19",
"@girs/harfbuzz-0.0": "^9.0.0-4.0.0-beta.16" "@girs/harfbuzz-0.0": "^9.0.0-4.0.0-beta.19"
} }
}, },
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
@ -345,9 +363,9 @@
} }
}, },
"node_modules/@humanwhocodes/retry": { "node_modules/@humanwhocodes/retry": {
"version": "0.4.0", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz",
"integrity": "sha512-xnRgu9DxZbkWak/te3fcytNyp8MTbuiZIaueg2rgEvBuN55n04nwLYLU9TX/VVlusc9L2ZNXi99nUFNkHXtr5g==", "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@ -541,9 +559,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.3", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -556,9 +574,9 @@
} }
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.7", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -581,9 +599,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "1.5.4", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz",
"integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -601,27 +619,27 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.14.0", "version": "9.20.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.14.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.20.1.tgz",
"integrity": "sha512-c2FHsVBr87lnUtjP4Yhvk4yEhKrQavGafRA/Se1ouse8PfbfC/Qh9Mxa00yWsZRlqeUB9raXip0aiiUZkgnr9g==", "integrity": "sha512-m1mM33o6dBUjxl2qb6wv6nGNwCAsns1eKtaQ4l/NPHeTvhiUPbtdfMyktxN4B3fgHIgsYh1VT3V9txblpQHq+g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.18.0", "@eslint/config-array": "^0.19.0",
"@eslint/core": "^0.7.0", "@eslint/core": "^0.11.0",
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "9.14.0", "@eslint/js": "9.20.0",
"@eslint/plugin-kit": "^0.2.0", "@eslint/plugin-kit": "^0.2.5",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.0", "@humanwhocodes/retry": "^0.4.1",
"@types/estree": "^1.0.6", "@types/estree": "^1.0.6",
"@types/json-schema": "^7.0.15", "@types/json-schema": "^7.0.15",
"ajv": "^6.12.4", "ajv": "^6.12.4",
"chalk": "^4.0.0", "chalk": "^4.0.0",
"cross-spawn": "^7.0.2", "cross-spawn": "^7.0.6",
"debug": "^4.3.2", "debug": "^4.3.2",
"escape-string-regexp": "^4.0.0", "escape-string-regexp": "^4.0.0",
"eslint-scope": "^8.2.0", "eslint-scope": "^8.2.0",
@ -640,8 +658,7 @@
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"minimatch": "^3.1.2", "minimatch": "^3.1.2",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"optionator": "^0.9.3", "optionator": "^0.9.3"
"text-table": "^0.2.0"
}, },
"bin": { "bin": {
"eslint": "bin/eslint.js" "eslint": "bin/eslint.js"
@ -662,9 +679,9 @@
} }
}, },
"node_modules/eslint-plugin-jsdoc": { "node_modules/eslint-plugin-jsdoc": {
"version": "50.4.3", "version": "50.6.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.4.3.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.6.3.tgz",
"integrity": "sha512-uWtwFxGRv6B8sU63HZM5dAGDhgsatb+LONwmILZJhdRALLOkCX2HFZhdL/Kw2ls8SQMAVEfK+LmnEfxInRN8HA==", "integrity": "sha512-NxbJyt1M5zffPcYZ8Nb53/8nnbIScmiLAMdoe0/FAszwb7lcSiX3iYBTsuF7RV84dZZJC8r3NghomrUXsmWvxQ==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
@ -847,9 +864,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.3.1", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz",
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@ -900,9 +917,9 @@
} }
}, },
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.0", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1202,9 +1219,9 @@
} }
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.6.3", "version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
@ -1263,9 +1280,9 @@
} }
}, },
"node_modules/spdx-license-ids": { "node_modules/spdx-license-ids": {
"version": "3.0.20", "version": "3.0.21",
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.20.tgz", "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz",
"integrity": "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==", "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==",
"dev": true, "dev": true,
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
@ -1312,13 +1329,6 @@
"url": "https://opencollective.com/unts" "url": "https://opencollective.com/unts"
} }
}, },
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true,
"license": "MIT"
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@ -1340,9 +1350,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.6.3", "version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@ -1389,6 +1399,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/yaml": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz",
"integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -17,6 +17,7 @@
}, },
"dependencies": { "dependencies": {
"@girs/gdk-3.0": "^3.24.43-4.0.0-beta.16", "@girs/gdk-3.0": "^3.24.43-4.0.0-beta.16",
"@girs/gjs": "^4.0.0-beta.16" "@girs/gjs": "^4.0.0-beta.16",
"yaml": "^2.7.0"
} }
} }

View File

@ -0,0 +1,120 @@
import GObject, { register, property, signal } from "astal/gobject";
import { ConnectionInstantiationError, NotImplementedError } from "./errors";
import { OutputConfig, VSOutputAdapter, VSWorkspaceAdapter } from "./types";
/**
* Mid-level abstract compositor connection providing raw data about the compositor to the rest of VoidShell.
* Wraps the low-level compositor-specific IPC APIs with a common interface that other services can build on top of.
*
* This should generally only be utilized directly by the high-level compositor-related services (workspaces,
* contexts, etc.). Other services should generally interact with the compositor via these high-level services.
*/
@register({
GTypeName: "AbstractCompositorConnection",
})
export class CompositorConnection extends GObject.Object {
constructor() {
if (CompositorConnection._instance !== undefined) {
throw new ConnectionInstantiationError(
"Cannot create a CompositorConnection where one already exists",
);
}
super();
if (Object.getPrototypeOf(this) === CompositorConnection.prototype) {
throw new ConnectionInstantiationError(
"Cannnot instantiate abstract CompositorConnection",
);
}
CompositorConnection._instance = this;
}
static _instance: CompositorConnection | undefined;
static get instance() {
if (this._instance === undefined) {
throw new Error(
"Must instantiate a CompositorConnection before attempting to retrieve instance",
);
}
return this._instance;
}
/**
* Focus a workspace by ID
* @param workspaceId The workspace to focus
*/
async focusWorkspace(workspaceId: string): Promise<void> {
throw new NotImplementedError();
}
/**
* Move a workspace to a different output
* @param workspaceId The workspace to move
* @param outputName The output to move the workspace to
*/
async moveWorkspace(workspaceId: string, outputName: string): Promise<void> {
throw new NotImplementedError();
}
/**
* Focus an output
* @param outputName The output to focus
*/
async focusOutput(outputName: string): Promise<void> {
throw new NotImplementedError();
}
/**
* Move the focused container to the given workspace
* @param workspaceId The workspace to move the container to
*/
async moveContainer(workspaceId: string): Promise<void> {
throw new NotImplementedError();
}
/**
* Configure an output
* @param outputName The name of the output to configure
* @param config The configuration object for the output
*/
async configureOutput(
outputName: string,
config: OutputConfig,
): Promise<void> {
throw new NotImplementedError();
}
/**
* Gets a list of all connected outputs
* @returns a list of VSOutputAdapter objects representing the connected outputs
*/
async getOutputs(): Promise<VSOutputAdapter[]> {
throw new NotImplementedError();
}
/**
* Disable an output
* @param outputName The name of the output to disable
*/
async disableOutput(outputName: string): Promise<void> {
throw new NotImplementedError();
}
/**
* Emitted when a workspace's data changes
*/
@signal(VSWorkspaceAdapter)
declare workspaceChange: (event: VSWorkspaceAdapter) => void;
/**
* Emitted when an output's data changes
* For example, it is enabled or disabled, or its mode changes
*/
@signal(VSOutputAdapter)
declare outputChange: (event: VSOutputAdapter) => void;
/**
* Emitted when the binding mode changes
*/
@signal(String)
declare modeChange: (event: string) => void;
}

View File

@ -0,0 +1,459 @@
import GObject, { register, property, signal } from "astal/gobject";
import {
WorkspaceResponse,
OutputResponse,
SwayMessage,
SwayEvent,
WorkspaceEvent,
OutputEvent,
TreeNode,
CommandResponse,
} from "./sway_types";
import Gio from "gi://Gio";
import GLib from "gi://GLib";
import * as utils from "@/utils";
import { Mutex } from "@/synchronization";
import { CompositorConnection } from "../connection";
import { ActionError } from "../errors";
import { OutputConfig, VSOutputAdapter, VSRect } from "../types";
class SwayCommandError
extends ActionError
implements Required<CommandResponse>
{
public readonly success: false = false;
public readonly error: string;
public readonly parse_error: boolean;
constructor(
response: Required<CommandResponse>,
public readonly command?: string,
) {
super(`Command ${command ?? "<unknown>"} failed: ${response.error}`);
this.error = response.error;
this.parse_error = response.parse_error;
}
/**
* Run the given commands and assert that they were successful, raising a SwayCommandError if they were not.
* When multiple commands are given, the first error is what will be raised.
*/
public static async assertCommands(...commands: string[]) {
const ipc = SwayIPC.instance;
const results = await ipc.command(commands.join("; "));
results.forEach((response, idx) => {
if (!response.success) {
throw new this(response as Required<CommandResponse>, commands[idx]);
}
});
}
}
/**
* Sway implementation of the CompositorConnection API
*/
@register({
GTypeName: "SwayCompositorConnection",
})
export class SwayConnection extends CompositorConnection {
private ipc: SwayIPC;
constructor() {
super();
this.ipc = SwayIPC.instance;
}
async focusWorkspace(workspaceId: string) {
return SwayCommandError.assertCommands(`workspace ${workspaceId}`);
}
async moveWorkspace(workspaceId: string, outputName: string) {
return SwayCommandError.assertCommands(
`workspace ${workspaceId}`,
`move workspace to output ${outputName}`,
);
}
async focusOutput(outputName: string) {
return SwayCommandError.assertCommands(`focus output ${outputName}`);
}
async moveContainer(workspaceId: string) {
return SwayCommandError.assertCommands(
`move container to workspace ${workspaceId}`,
);
}
async configureOutput(
outputName: string,
{ modeline, scale, position, ...extra_args }: OutputConfig,
) {
const scale_setting = scale ? `scale ${scale}` : "";
const extra_settings = Object.entries(extra_args).reduce(
(settings, [name, arg]) => `${settings} ${name} ${arg}`,
"",
);
return SwayCommandError.assertCommands(
`output ${outputName} mode ${modeline} position ${position.join(" ")} ${scale_setting} ${extra_settings} enable`,
);
}
async disableOutput(outputName: string) {
return SwayCommandError.assertCommands(`output ${outputName} disable`);
}
async getOutputs(): Promise<VSOutputAdapter[]> {
const result = await this.ipc.getOutputs();
return result.map(
(swayOutput) =>
new VSOutputAdapter({
name: swayOutput.name,
active: swayOutput.active,
rect: new VSRect(swayOutput.rect),
scale: swayOutput.scale,
transform: swayOutput.transform,
current_workspace: swayOutput.current_workspace,
make: swayOutput.make,
model: swayOutput.model,
serial: swayOutput.serial,
}),
);
}
}
/**
* Low-level Sway IPC API
*/
@register({
GTypeName: "SwayIPC",
})
class SwayIPC extends GObject.Object {
#commandSocket: Gio.SocketConnection | null = null;
#subscriptionSocket: Gio.SocketConnection | null = null;
#mode: string = "";
/**
* Emitted when the connection to sway is established
*/
@signal()
declare connected: () => void;
/**
* Emitted when the connection to sway is lost
*/
@signal()
declare disconnected: () => void;
// TODO: figure out how to give these signals a proper type. For now, Object is /fine/.
/**
* Emitted when we receive a subscription event
*/
@signal(Object)
declare subscribed: (events: SwayEvent[]) => void;
/**
* Emitted when we receive a workspace event
*/
@signal(Object)
declare workspace: (event: WorkspaceEvent) => void;
/**
* Emitted when we receive an output event
*/
@signal(Object)
declare output: (event: OutputEvent) => void;
/**
* The current binding mode of sway
*/
@property(String)
get mode(): string {
return this.#mode;
}
@property(Boolean)
get isConnected(): boolean {
return this.#connected;
}
#connected = false;
/**
* Run a command on sway
*/
public async command(command: string): Promise<CommandResponse[]> {
return JSON.parse(
(await this.#sendRaw(
this.#commandSocket,
SwayMessage.RUN_COMMAND,
command,
)) as string,
);
}
/**
* Get the current sway workspaces
*/
public async getWorkspaces(): Promise<WorkspaceResponse[]> {
return JSON.parse(
(await this.#sendRaw(
this.#commandSocket,
SwayMessage.GET_WORKSPACES,
)) as string,
);
}
/**
* Get the current sway outputs
*/
public async getOutputs(): Promise<OutputResponse[]> {
return JSON.parse(
(await this.#sendRaw(
this.#commandSocket,
SwayMessage.GET_OUTPUTS,
)) as string,
);
}
/**
* Get the current sway tree
*/
public async getTree(): Promise<TreeNode> {
return JSON.parse(
(await this.#sendRaw(
this.#commandSocket,
SwayMessage.GET_TREE,
)) as string,
);
}
constructor() {
super();
this.#connect();
}
static _instance: SwayIPC;
/**
* Get the default instance of Sway
*/
static get instance() {
if (!SwayIPC._instance) {
SwayIPC._instance = new SwayIPC();
}
return SwayIPC._instance;
}
async #createConnection(address: string): Promise<Gio.SocketConnection> {
console.log(`Connecting to sway socket ${address}`);
const client = new Gio.SocketClient();
return new Promise<Gio.SocketConnection>((resolve, reject) => {
const socket_addr = new Gio.UnixSocketAddress({ path: address });
client.connect_async(socket_addr, null, (client, result) => {
try {
// Type assertion is safe, if it fails we reject the promise in the catch block
const connection = client!.connect_finish(result);
resolve(connection);
} catch (e) {
console.error(`Failed to connect to socket.`, e);
reject(e);
}
});
});
}
async #getMode(): Promise<string> {
const result = JSON.parse(
(await this.#sendRaw(
this.#commandSocket,
SwayMessage.GET_BINDING_STATE,
)) as string,
);
return result.name;
}
async #connect() {
console.log("Connecting to sway");
const address = GLib.getenv("SWAYSOCK");
if (!address) {
console.error("SWAYSOCK not set");
throw new Error("SWAYSOCK not set");
}
this.#commandSocket = await this.#createConnection(address);
this.#subscriptionSocket = await this.#createConnection(address);
console.log("Connected to sway");
// Start listening for subscriptions
this.#startSubscriberListen();
// Get the current mode
this.#mode = await this.#getMode();
this.notify("mode");
this.connected();
this.#connected = true;
this.notify("is-connected");
}
static readonly MAGIC = "i3-ipc";
static readonly HEADER_SIZE = SwayIPC.MAGIC.length + 8;
static readonly CHUNK_SIZE = 4096;
async #startSubscriberListen() {
if (!this.#subscriptionSocket) {
console.error("Not connected");
throw new Error("Not connected");
}
await this.#subscribe(
SwayEvent.OUTPUT,
SwayEvent.WORKSPACE,
SwayEvent.MODE,
SwayEvent.SHUTDOWN,
);
const read = async () => {
while (true) {
try {
const [event, payload] = await this.#readRaw(
this.#subscriptionSocket!,
);
switch (event) {
case SwayEvent.OUTPUT:
this.output(JSON.parse(payload) as OutputEvent);
break;
case SwayEvent.WORKSPACE:
this.workspace(JSON.parse(payload) as WorkspaceEvent);
break;
case SwayEvent.MODE:
this.#mode = JSON.parse(payload).change;
this.notify("mode");
break;
case SwayEvent.SHUTDOWN:
this.disconnected();
break;
default:
console.warn("Unhandled event", event);
break;
}
} catch (e) {
console.error("Failed to read event", e);
}
}
};
read();
}
async #readHeader(
stream: Gio.InputStream,
): Promise<{ length: number; type: SwayMessage | SwayEvent }> {
// We read one byte at a time until we have constructed a full magic string, then we read the rest of the header.
// This allows us to handle partial reads and corrupted data gracefully.
let idx = 0;
while (idx < SwayIPC.MAGIC.length) {
const buffer = await utils.readFromStreamRaw(stream, 1);
const byte = buffer[0];
if (byte !== SwayIPC.MAGIC.charCodeAt(idx)) {
// Reset if we don't match the magic string
idx = 0;
} else {
// Otherwise, keep going
idx++;
}
}
const header = new DataView((await utils.readFromStream(stream, 8)).buffer);
const length = header.getUint32(0, true);
const type = header.getUint32(4, true);
return { length, type };
}
async #readRaw(
socket: Gio.SocketConnection,
): Promise<[SwayMessage | SwayEvent, string]> {
const inputStream = socket.input_stream;
const { length, type } = await this.#readHeader(inputStream);
const payloadBuf = await utils.readFromStream(inputStream, length);
const payload = new TextDecoder().decode(payloadBuf);
return [type as SwayMessage | SwayEvent, payload];
}
private messageMutex = new Mutex();
async #sendRaw(
socket: Gio.SocketConnection | null,
type: SwayMessage,
payload: string = "",
waitForResponse: boolean = true,
): Promise<string | null> {
return this.messageMutex.with(async () => {
if (!socket || !socket.is_connected()) {
console.error("Not connected");
throw new Error("Not connected");
}
if (socket === this.#commandSocket) {
if (type === SwayMessage.SUBSCRIBE) {
console.error("Cannot subscribe on command socket");
throw new Error("Cannot subscribe on command socket");
}
}
// Construct the message
const buffer = new ArrayBuffer(SwayIPC.HEADER_SIZE + payload.length);
const magicView = new Uint8Array(buffer, 0, SwayIPC.MAGIC.length);
const lengthView = new DataView(buffer, SwayIPC.MAGIC.length, 4);
const typeView = new DataView(buffer, SwayIPC.MAGIC.length + 4, 4);
const payloadView = new Uint8Array(buffer, SwayIPC.HEADER_SIZE);
magicView.set(SwayIPC.MAGIC.split("").map((c) => c.charCodeAt(0)));
lengthView.setUint32(0, payload.length, true);
typeView.setUint32(0, type, true);
payloadView.set(payload.split("").map((c) => c.charCodeAt(0)));
const outputStream = socket.output_stream;
// Send the message
try {
await utils.writeToStream(outputStream, buffer);
} catch (e) {
console.error("Failed to write to stream...", e);
throw e;
}
if (!waitForResponse) {
return null;
}
// Read the response
const [resultType, result] = await this.#readRaw(socket);
if (resultType !== type) {
throw new Error(`Unexpected response type: ${resultType}`);
}
return result;
});
}
async #subscribe(...events: SwayEvent[]) {
if (!this.#subscriptionSocket) {
console.error("Not connected");
throw new Error("Not connected");
}
const eventNames = events.map((e) => SwayEvent[e].toLowerCase());
const payload = JSON.stringify(eventNames);
console.log("Subscribing to events:", payload);
try {
await this.#sendRaw(
this.#subscriptionSocket,
SwayMessage.SUBSCRIBE,
payload,
);
console.log(`Subscribed to events: ${payload}`);
this.subscribed(events);
} catch (e) {
console.error(`Failed to subscribe:`, e);
throw e;
}
}
}

View File

@ -0,0 +1,403 @@
import Gio from "gi://Gio";
/**
* Sway IPC message types
*
* @see man:sway-ipc(7)
*/
export enum SwayMessage {
/**
* Runs the payload as sway commands
*/
RUN_COMMAND = 0,
/**
* Get the list of current workspaces
*/
GET_WORKSPACES = 1,
SUBSCRIBE = 2,
GET_OUTPUTS = 3,
GET_TREE = 4,
GET_MARKS = 5,
GET_BAR_CONFIG = 6,
GET_VERSION = 7,
GET_BINDING_MODES = 8,
GET_CONFIG = 9,
SEND_TICK = 10,
SYNC = 11,
GET_BINDING_STATE = 12,
GET_INPUTS = 100,
GET_SEATS = 101,
}
export enum SwayEvent {
WORKSPACE = 0x80000000,
OUTPUT = 0x80000001,
MODE = 0x80000002,
WINDOW = 0x80000003,
BARCONFIG_UPDATE = 0x80000004,
BINDING = 0x80000005,
SHUTDOWN = 0x80000006,
TICK = 0x80000007,
BAR_STATE_UPDATE = 0x80000014,
INPUT = 0x80000015,
}
export enum SubpixelHinting {
NONE = "none",
RGB = "rgb",
BGR = "bgr",
VRGB = "vrgb",
VBGR = "vbgr",
}
export type OutputTransform =
| "normal"
| "90"
| "180"
| "270"
| "flipped"
| "flipped-90"
| "flipped-180"
| "flipped-270";
/**
* A rectangle with a top-left corner at (x, y) and a width and height. Defines
* a region of the screen.
*/
export interface Rect {
x: number;
y: number;
width: number;
height: number;
}
/**
* The response to a command.
*/
export interface CommandResponse {
/**
* Whether the command was successful
*/
success: boolean;
/**
* The error message if the command was not successful.
* Undefined if the command was successful.
*/
error?: string;
/**
* Whether the command was not successful due to a parse error.
* Undefined if the command was successful.
*/
parse_error?: boolean;
}
/**
* A workspace as returned by GET_WORKSPACES
*/
export interface WorkspaceResponse {
/**
* The name of the workspace
*/
name: string;
/**
* The number of the workspace
*/
num: number;
/**
* Whether the workspace is focused
*/
focused: boolean;
/**
* Whether the workspace is visible
*/
visible: boolean;
/**
* Whether the workspace is urgent
*/
urgent: boolean;
/**
* The output the workspace is on
*/
output: string;
/**
* The rect of the workspace
*/
rect: Rect;
}
/**
* A sway output's modes as returned by GET_OUTPUTS
*/
export interface OutputMode {
/**
* The width of the mode in pixels
*/
width: number;
/**
* The height of the mode in pixels
*/
height: number;
/**
* The refresh rate of the mode in millihertz
*/
refresh: number;
}
/**
* A sway output as returned by GET_OUTPUTS
*/
export interface OutputResponse {
/**
* The name of the output.
*
* This is based on the hardware location of the output, e.g. "HDMI-A-1".
*/
name: string;
/**
* The make of the output, e.g. "Dell".
*/
make: string;
/**
* The model of the output, e.g. "U2412M".
*/
model: string;
/**
* The serial number of the output.
*/
serial: string;
/**
* Whether this output is active/enabled.
*/
active: boolean;
/**
* Whether this output is on/off.
*/
power: boolean;
/**
* The scale of the output. Will be -1 if the output is disabled.
*/
scale: number;
/**
* The subpixel hinting of the output.
*/
subpixel_hinting: SubpixelHinting;
/**
* The current transform of the output.
*/
transform: OutputTransform;
/**
* The name of the workspace currently visible on the output.
*/
current_workspace: string;
/**
* The bounds of the output.
*/
rect: Rect;
/**
* The modes supported by the output.
*/
modes: OutputMode[];
/**
* The current mode of the output.
*/
current_mode: OutputMode;
}
export enum NodeType {
ROOT = "root",
OUTPUT = "output",
WORKSPACE = "workspace",
CONTAINER = "con",
FLOATING_CONTAINER = "floating_con",
}
export enum NodeBorder {
NONE = "none",
PIXEL = "pixel",
NORMAL = "normal",
CSD = "csd",
}
export enum NodeLayout {
SPLITH = "splith",
SPLITV = "splitv",
STACKED = "stacked",
TABBED = "tabbed",
OUTPUT = "output",
}
export enum NodeOrientation {
HORIZONTAL = "horizontal",
VERTICAL = "vertical",
NONE = "none",
}
export enum NodeFullscreenMode {
NONE = 0,
WORKSPACE = 1,
GLOBAL = 2,
}
export interface XWaylandWindowProperties {
class: string;
instance: string;
title: string;
window_role: string;
window_type: string;
transient_for: number;
}
/**
* A sway node as returned by GET_TREE
*/
export interface TreeNode {
id: number;
name: string;
type: NodeType;
border: NodeBorder;
current_border_width: number;
layout: NodeLayout;
orientation: NodeOrientation;
percent: number | null;
rect: Rect;
window_rect: Rect;
deco_rect: Rect;
geometry: Rect;
urgent?: boolean;
sticky: boolean;
marks: string[];
focused: boolean;
focus: number[];
nodes: TreeNode[];
representation?: string;
fullscreen_mode?: NodeFullscreenMode;
app_id?: string | null;
pid?: number;
visible?: boolean;
shell?: "xdg_shell" | "xwayland";
inhibit_idle?: boolean;
window?: number;
window_properties?: XWaylandWindowProperties;
output?: string;
}
export enum InputType {
KEYBOARD = "keyboard",
POINTER = "pointer",
TOUCH = "touch",
TABLET_TOOL = "tablet_tool",
TABLET_PAD = "tablet_pad",
SWITCH = "switch",
}
export enum LibInputAccelProfile {
NONE = "none",
FLAT = "flat",
ADAPTIVE = "adaptive",
}
export enum LibInputBool {
TRUE = "enabled",
FALSE = "disabled",
}
/**
* The configuration of a libinput device as returned by GET_INPUTS
*/
export interface LibInputDevice {
send_events: "enabled" | "disabled" | "disabled_on_external_mouse";
tap: LibInputBool;
tap_button_map: "lmr" | "lrm";
tap_drag: LibInputBool;
tap_drag_lock: LibInputBool;
accel_speed: number;
accel_profile: LibInputAccelProfile;
natural_scroll: LibInputBool;
left_handed: LibInputBool;
click_method: "none" | "button_areas" | "clickfinger";
middle_emulation: LibInputBool;
scroll_method: "none" | "two_finger" | "edge" | "on_button_down";
scroll_button: number;
scroll_button_lock: LibInputBool;
dwt: LibInputBool;
dwtp: LibInputBool;
calibration_matrix: [number, number, number, number, number, number];
}
/**
* A sway input device as returned by GET_INPUTS
*/
export interface InputResponse {
identifier: string;
name: string;
vendor: number;
product: number;
type: InputType;
xkb_active_layout_name?: string;
xkb_layout_names?: string[];
xkb_active_layout_index?: number;
scroll_factor?: number;
libinput?: LibInputDevice;
}
export enum WorkspaceEventChange {
INIT = "init",
EMPTY = "empty",
FOCUS = "focus",
MOVE = "move",
RENAME = "rename",
URGENT = "urgent",
RELOAD = "reload",
}
export interface WorkspaceEvent {
change: WorkspaceEventChange;
current: TreeNode;
old: TreeNode | null;
}
export interface OutputEvent {
/**
* The change that triggered the event. Currently, this is always "unspecified".
* @todo: Update with new values when sway adds them
*/
change: "unspecified";
}
export interface ModeEvent {
/**
* The new binding mode.
*/
change: string;
pango_markup: boolean;
}
export enum WindowEventChange {
NEW = "new",
CLOSE = "close",
FOCUS = "focus",
TITLE = "title",
FULLSCREEN_MODE = "fullscreen_mode",
MOVE = "move",
FLOATING = "floating",
URGENT = "urgent",
MARK = "mark",
}
export interface WindowEvent {
change: WindowEventChange;
container: TreeNode;
}
export enum InputEventChange {
ADDED = "added",
REMOVED = "removed",
XKB_KEYMAP = "xkb_keymap",
XKB_LAYOUT = "xkb_layout",
LIBINPUT_CONFIG = "libinput_config",
}
export interface InputEvent {
change: InputEventChange;
input: InputResponse;
}

View File

@ -0,0 +1,18 @@
export class ConnectionInstantiationError extends Error {}
export class NotImplementedError extends Error {
name: string = "NotImplementedError";
constructor() {
super();
this.message = `function ${this.stack?.split("\n")?.[1].split("@")?.[0]} not implemented!`;
}
}
/**
* Indicates that the compositor was unable to perform the action requested.
*
* Individual compositor adapters should subclass this to provide a more specific error.
*/
export class ActionError extends Error {
get name(): string {
return this.constructor.name;
}
}

View File

@ -0,0 +1,260 @@
import GObject, { property, register } from "astal/gobject";
export type OutputTransform =
| "normal"
| "90"
| "180"
| "270"
| "flipped"
| "flipped-90"
| "flipped-180"
| "flipped-270";
/**
* A type of event most commonly used for outputs and workspaces
*/
export const enum EventType {
/**
* The event indicates that the associated object has changed
*/
CHANGE,
/**
* The event indicates that the associated object has been created
*/
CREATE,
/**
* the event indicates that the associated object has been destroyed
*/
DESTROY,
}
type Constructor<T> = { new (...args: any[]): T };
@register({
GTypeName: "VSWrapper",
})
export class VSWrapper<WrappedInterface extends Object> extends GObject.Object {
constructor(obj: WrappedInterface) {
super();
Object.assign(this, obj);
}
/**
* Wraps the given object.
*/
public static wrap<
WrappedInterface extends Object,
Wrapper extends VSWrapper<WrappedInterface>,
>(this: Constructor<Wrapper>, obj: WrappedInterface): Wrapper {
if (obj instanceof this) {
return obj;
}
return new this(obj);
}
}
/**
* A rectangle with a top-left corner at (x, y) and a width and height. Defines
* a region of the screen.
*/
export interface Rect {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
@register({
GTypeName: "VSRect",
})
export class VSRect extends VSWrapper<Rect> implements Rect {
@property(Number)
declare readonly x: number;
@property(Number)
declare readonly y: number;
@property(Number)
declare readonly width: number;
@property(Number)
declare readonly height: number;
}
/**
* Common interface for compositor backends that represents the results of a workspace query.
* Contains only information that VoidShell needs to know.
*/
export interface WorkspaceAdapter {
/**
* The name of the workspace as known to the compositor, referred to in VoidShell as the workspace ID
*/
readonly id: string;
readonly output: string;
readonly focused: boolean;
readonly visible: boolean;
readonly urgent: boolean;
}
@register({
GTypeName: "VSWorkspaceAdapter",
})
export class VSWorkspaceAdapter
extends VSWrapper<WorkspaceAdapter>
implements WorkspaceAdapter
{
@property(String)
declare readonly id: string;
@property(String)
declare readonly output: string;
@property(Boolean)
declare readonly focused: boolean;
@property(Boolean)
declare readonly visible: boolean;
@property(Boolean)
declare readonly urgent: boolean;
}
/**
* Common interface for compositor backends that represents the results of an output query.
* Contains only information that VoidShell needs to know.
*/
export interface OutputAdapter {
readonly name: string;
readonly active: boolean;
readonly rect: Rect;
readonly scale: number;
readonly transform: OutputTransform;
readonly focused: boolean;
readonly current_workspace: string;
readonly make: string;
readonly model: string;
readonly serial: string;
}
@register({
GTypeName: "VSOutputAdapter",
})
export class VSOutputAdapter
extends VSWrapper<OutputAdapter>
implements OutputAdapter
{
@property(String)
declare readonly name: string;
@property(Boolean)
declare readonly active: boolean;
@property(VSRect)
declare readonly rect: VSRect;
@property(Number)
declare readonly scale: number;
@property(String)
declare readonly transform: OutputTransform;
@property(Boolean)
declare readonly focused: boolean;
@property(String)
declare readonly current_workspace: string;
@property(String)
declare readonly make: string;
@property(String)
declare readonly model: string;
@property(String)
declare readonly serial: string;
constructor(adapter: OutputAdapter) {
super(adapter);
this.rect = VSRect.wrap(adapter.rect);
}
}
/**
* A VoidShell workspace event
*/
export interface WorkspaceEvent {
/**
* The type of the event
*/
type: EventType;
/**
* Which workspace it references.
*/
workspace: string;
/**
* The workspace adapter, or null if this is a destroy event.
*/
adapter: WorkspaceAdapter | null;
}
/**
* GObject wrapping of a VoidShell workspace event
*/
@register({
GTypeName: "VSWorkspaceEvent",
})
export class VSWorkspaceEvent
extends VSWrapper<WorkspaceEvent>
implements WorkspaceEvent
{
@property(String)
declare readonly type: EventType;
@property(String)
declare readonly workspace: string;
@property(VSWorkspaceAdapter)
declare readonly adapter: VSWorkspaceAdapter | null;
constructor(event: WorkspaceEvent) {
super(event);
this.adapter = event.adapter
? VSWorkspaceAdapter.wrap(event.adapter)
: null;
}
}
/**
* A VoidShell output event
*/
export interface OutputEvent {
/**
* The type of the event
*/
type: EventType;
/**
* Which output it references.
*/
output: string;
/**
* The output adapter, or null if this is a destroy event.
*/
adapter: OutputAdapter | null;
}
/**
* GObject wrapping of a VoidShell output event
*/
@register({
GTypeName: "VSOutputEvent",
})
export class VSOutputEvent
extends VSWrapper<OutputEvent>
implements OutputEvent
{
@property(String)
declare readonly type: EventType;
@property(String)
declare readonly output: string;
@property(VSOutputAdapter)
declare readonly adapter: VSOutputAdapter | null;
constructor(event: OutputEvent) {
super(event);
this.adapter = event.adapter ? VSOutputAdapter.wrap(event.adapter) : null;
}
}
export type OutputConfig = {
modeline: string;
scale?: number;
position: [x: number, y: number];
} & {
/**
* Extra options added to the configuration line. Support for these is compositor-dependent, and currently
* they will only be honored under Sway.
*/
[extra_option: string]: string | number | boolean;
};

1
services/sway/index.ts Normal file
View File

@ -0,0 +1 @@
export * from "./workspaces";

118
services/sway/scoring.ts Normal file
View File

@ -0,0 +1,118 @@
import { OutputResponse } from "./types";
import { Context, Output } from "./definitions";
export function getPerfectMatch(
config: Output,
outputs: OutputResponse[],
): OutputResponse | null {
return (
outputs.find(
({ make, model, serial }) =>
make === config.make &&
model === config.model &&
serial === config.serial,
) ?? null
);
}
export function getNamePreferences(
outputs: OutputResponse[],
config: Output,
): (OutputResponse | null)[] {
return Array.from(
{ length: config.names?.length ?? 0 },
(_, idx) =>
outputs.find(({ name }) => config.names?.[idx] === name) ?? null,
);
}
export enum OutputScore {
ID_MATCH = 3,
NAME_MATCH = 2,
WILDCARD_MATCH = 1,
NO_MATCH = 0,
}
export function computeGroupScore(
config: Output,
output: OutputResponse | OutputResponse[],
) {
if (getPerfectMatch(config, [output].flat())) {
return OutputScore.ID_MATCH;
}
const preferences = getNamePreferences([output].flat(), config);
if (preferences.some((preference) => preference !== null)) {
return OutputScore.NAME_MATCH;
}
if (config.names?.includes("*")) {
return OutputScore.WILDCARD_MATCH;
}
return OutputScore.NO_MATCH;
}
export function computeContextScore(
config: Context,
outputs: OutputResponse[],
): [score: number, outputMap: Map<string, OutputResponse> | undefined] {
let score = 0;
const map = new Map<string, OutputResponse>();
const outputSet = new Set(outputs);
const usedGroups = new Set<Output>();
Object.values(config.outputs).forEach((output) => {
const match = getPerfectMatch(output, Array.from(outputSet));
if (match === null || output.group === undefined) {
return;
}
console.log(
`ID_MATCH for group "${output.group}" on output "${match.name}"`,
);
score += OutputScore.ID_MATCH;
outputSet.delete(match);
usedGroups.add(output);
map.set(output.group, match);
});
let groups = config.outputs.filter((group) => !usedGroups.has(group));
if (groups.length === 0) return [score, map] as const;
outputs = Array.from(outputSet);
const preferences = groups.map(getNamePreferences.bind(null, outputs));
const maxPreferences = Math.max(...preferences.map(({ length }) => length));
Array.from({ length: maxPreferences }, (_, idx) =>
preferences.forEach((preference, jdx) => {
if (
idx < preference.length &&
preference[idx] !== null &&
outputSet.has(preference[idx]) &&
groups[jdx].group !== undefined
) {
console.log(
`NAME_MATCH for group "${groups[jdx].group}" on output "${preference[idx].name}"`,
);
score += OutputScore.NAME_MATCH;
outputSet.delete(preference[idx]);
usedGroups.add(groups[jdx]);
map.set(groups[jdx].group, preference[idx]);
}
}),
);
groups = groups.filter((group) => !usedGroups.has(group));
if (groups.length > 1) {
return [0, undefined];
} else if (groups.length === 0) {
return [score, map];
} else if (groups[0].group !== undefined && groups[0].names?.includes("*")) {
map.set(groups[0].group, Array.from(outputSet)[0]);
console.log(
`WILDCARD_MATCH for group "${groups[0].group}" on output "${map.get(groups[0].group)}"`,
);
return [score + OutputScore.WILDCARD_MATCH, map];
} else {
return [0, undefined];
}
}

View File

@ -278,6 +278,7 @@ export interface TreeNode {
inhibit_idle?: boolean; inhibit_idle?: boolean;
window?: number; window?: number;
window_properties?: XWaylandWindowProperties; window_properties?: XWaylandWindowProperties;
output?: string;
} }
export enum InputType { export enum InputType {

View File

@ -7,8 +7,10 @@ import Gio from "gi://Gio";
import GLib from "gi://GLib"; import GLib from "gi://GLib";
import { Sway } from "./ipc"; import { Sway } from "./ipc";
import { InvalidContextError } from "./exceptions"; import { InvalidContextError } from "./exceptions";
import { dereferenceSymbolicLink, getDbusXml } from "@/utils"; import { delay, dereferenceSymbolicLink } from "@/utils";
import { Connection, DBusObject } from "@services/dbus"; import { DBusObject } from "@services/dbus";
import { computeContextScore, computeGroupScore } from "./scoring";
import { Mutex } from "@/synchronization";
@register({ @register({
GTypeName: "SwayWorkspace", GTypeName: "SwayWorkspace",
@ -30,6 +32,21 @@ export class Workspace extends DBusObject {
); );
} }
@property(String)
get indicatorClassName() {
const result = ["indicator-circle", "sway--ws"];
if (this.active) {
result.push("sway--active");
}
if (this.visible) {
result.push("sway--visible");
}
if (this.focused) {
result.push("sway--focused");
}
return result.join(" ");
}
/** /**
* Create an ephemeral workspace from a sync operation that is not defined in the system. * Create an ephemeral workspace from a sync operation that is not defined in the system.
*/ */
@ -68,6 +85,7 @@ export class Workspace extends DBusObject {
} }
this.#active = value; this.#active = value;
this.notify("active"); this.notify("active");
this.notify("indicator-class-name");
this.dbusObj?.emit_property_changed( this.dbusObj?.emit_property_changed(
"active", "active",
GLib.Variant.new_boolean(value), GLib.Variant.new_boolean(value),
@ -91,6 +109,7 @@ export class Workspace extends DBusObject {
} }
this.#focused = value; this.#focused = value;
this.notify("focused"); this.notify("focused");
this.notify("indicator-class-name");
this.dbusObj?.emit_property_changed( this.dbusObj?.emit_property_changed(
"focused", "focused",
GLib.Variant.new_boolean(value), GLib.Variant.new_boolean(value),
@ -108,6 +127,7 @@ export class Workspace extends DBusObject {
} }
this.#visible = value; this.#visible = value;
this.notify("visible"); this.notify("visible");
this.notify("indicator-class-name");
this.dbusObj?.emit_property_changed( this.dbusObj?.emit_property_changed(
"visible", "visible",
GLib.Variant.new_boolean(value), GLib.Variant.new_boolean(value),
@ -183,7 +203,14 @@ export class Workspace extends DBusObject {
this.urgent = update.urgent; this.urgent = update.urgent;
} }
async relocate(to: Group, retainFocus: boolean = false) { async relocate(to: Group | string, retainFocus: boolean = false) {
if (typeof to === "string") {
const group = Tree.instance.findGroupByObjectPath(to);
if (group === undefined) {
throw new Error("Group not found");
}
to = group;
}
if (this.#currentGroup === to) { if (this.#currentGroup === to) {
return; return;
} }
@ -273,7 +300,7 @@ export class Group extends DBusObject {
) { ) {
super( super(
"dev.ezri.voidshell.workspaces.Group", "dev.ezri.voidshell.workspaces.Group",
`/dev/ezri/VoidShell/workspaces/context/${context.name}/group/${name}`, `${context.objectPath}/group/${name}`,
); );
this.#context = context; this.#context = context;
this.#workspaces = definition.workspaces this.#workspaces = definition.workspaces
@ -304,47 +331,15 @@ export class Group extends DBusObject {
/** /**
* Compute the score of this group based on the given Sway output. * Compute the score of this group based on the given Sway output.
*/ */
score(output: types.OutputResponse): [number, number] { score(output: types.OutputResponse): number {
if ( return computeGroupScore(this.outputDefinition, output);
this.outputDefinition.make === output.make &&
this.outputDefinition.model === output.model &&
this.outputDefinition.serial === output.serial
) {
// Perfect match scores 3.
return [3, 0];
}
const nameMatch =
this.outputDefinition.names?.findIndex((name) => name === output.name) ??
-1;
if (nameMatch !== -1) {
// Name match scores 2.
return [2, nameMatch];
}
if (this.outputDefinition.names?.includes("*")) {
// Wildcard match scores 1.
return [1, 0];
}
return [0, 0];
}
getOutputPreferences(outputs: types.OutputResponse[]) {
// Highest preference goes to a perfect match
const scores = outputs
.map((output) => [output, ...this.score(output)] as const)
.sort((a, b) => {
if (a[1] === b[1]) {
return a[2] - b[2];
}
return b[1] - a[1];
})
.filter(([_, score]) => score > 0);
return scores;
} }
async activate(output: types.OutputResponse) { async activate(output: types.OutputResponse) {
// Ensure that this output is compatible with the group. // Ensure that this output is compatible with the group.
const [score, _] = this.score(output); const score = this.score(output);
if (score === 0) { if (score === 0) {
console.error(`Output ${output.name} is not compatible with this group`);
throw new Error( throw new Error(
`Output ${output.name} is not compatible with this group.`, `Output ${output.name} is not compatible with this group.`,
); );
@ -352,12 +347,11 @@ export class Group extends DBusObject {
// Configure monitor // Configure monitor
await Sway.instance.command( await Sway.instance.command(
`output ${output.name} pos ${this.outputDefinition.position[0]} ${this.outputDefinition.position[1]} ${this.outputDefinition.mode}`, `output ${output.name} pos ${this.outputDefinition.position[0]} ${this.outputDefinition.position[1]} mode ${this.outputDefinition.mode}`,
); );
// Enable monitor. // Enable monitor.
await Sway.instance.command(`output ${output.name} enable`); await Sway.instance.command(`output ${output.name} enable`);
// Store monitor name. // Store monitor name.
this.#outputName = output.name; this.#outputName = output.name;
@ -384,6 +378,29 @@ export class Group extends DBusObject {
this.#workspaces.forEach((workspace) => workspace.groups.add(this)); this.#workspaces.forEach((workspace) => workspace.groups.add(this));
} }
async dummyActivate(output: types.OutputResponse) {
const score = this.score(output);
if (score === 0) {
console.error(
`Output ${output.name} is not compatible with this group. Dummy activation, ignoring...`,
);
}
this.#outputName = output.name;
const monitor =
Gdk.Display.get_default()?.get_monitor_at_point(
this.outputDefinition.position[0],
this.outputDefinition.position[1],
) ?? null;
this.#monitor = monitor;
if (monitor !== null) {
this.activated(monitor);
}
this.notify("monitor");
this.#workspaces.forEach((workspace) => workspace.groups.add(this));
}
async focus() { async focus() {
if (this.#outputName === null) { if (this.#outputName === null) {
throw new Error("Group is not activated."); throw new Error("Group is not activated.");
@ -413,6 +430,20 @@ export class Group extends DBusObject {
// Remove ourselves from every workspace // Remove ourselves from every workspace
this.#workspaces.forEach((workspace) => workspace.groups.delete(this)); this.#workspaces.forEach((workspace) => workspace.groups.delete(this));
} }
async focusNextWorkspace() {
const index =
(this.#workspaces.findIndex((ws) => ws === this.focusedWorkspace) + 1) %
this.#workspaces.length;
return this.#workspaces[index].focus();
}
async focusPreviousWorkspace() {
const index =
(this.#workspaces.findIndex((ws) => ws === this.focusedWorkspace) - 1) %
this.#workspaces.length;
return this.#workspaces[index].focus();
}
} }
@register({ @register({
@ -430,7 +461,7 @@ export class Context extends DBusObject {
) { ) {
super( super(
"dev.ezri.voidshell.workspaces.Context", "dev.ezri.voidshell.workspaces.Context",
`/dev/ezri/VoidShell/workspaces/context/${name}`, `/dev/ezri/VoidShell/workspaces/context/${name.replace("-", "_")}`,
); );
definition.outputs.forEach((output) => { definition.outputs.forEach((output) => {
if (output.group === undefined || !definition.groups[output.group]) { if (output.group === undefined || !definition.groups[output.group]) {
@ -453,11 +484,45 @@ export class Context extends DBusObject {
return this.#focusedGroup; return this.#focusedGroup;
} }
get groups(): Omit<Map<string, Group>, "set" | "delete" | "clear"> {
return this.#groups;
}
private set focusedGroup(value) {
if (this.#focusedGroup !== value) {
this.#focusedGroup = value;
this.notify("focused-group");
this.dbusObj?.emit_property_changed(
"focusedGroupPath",
GLib.Variant.new_string(value?.objectPath ?? "/"),
);
}
}
@property(String)
get focusedGroupPath() {
return this.#focusedGroup?.objectPath ?? "/";
}
@property(Boolean) @property(Boolean)
get active() { get active() {
return this.#active; return this.#active;
} }
private set active(value: boolean) {
if (value !== this.#active) {
this.#active = value;
this.notify("active");
this.dbusObj?.emit_property_changed(
"active",
GLib.Variant.new_boolean(value),
);
if (!value) {
Tree.instance.deactivating();
}
}
}
getGroupOnOutput(output: string): Group | undefined { getGroupOnOutput(output: string): Group | undefined {
return Array.from(this.#groups.values()).find( return Array.from(this.#groups.values()).find(
(group) => group.outputName === output, (group) => group.outputName === output,
@ -469,72 +534,30 @@ export class Context extends DBusObject {
* @param outputSet The set of outputs to match against. Usually the currently connected outputs, as retrieved from Sway. * @param outputSet The set of outputs to match against. Usually the currently connected outputs, as retrieved from Sway.
* @returns A map of group names to outputs and scores. * @returns A map of group names to outputs and scores.
*/ */
#matchGroupsWithOutputs(outputSet: types.OutputResponse[]) { private matchGroupsWithOutputs(outputSet: types.OutputResponse[]) {
// First get preferences const [_, match] = computeContextScore(this.definition, outputSet);
const scores = Array.from(this.#groups.entries()).map(([name, group]) => { return match;
return [[name, group], group.getOutputPreferences(outputSet)] as const;
});
// Create set of available outputs
const availableOutputs = new Set(outputSet);
// Create set of used groups
const usedGroups = new Set<Group>();
let i = 0;
let score = 3;
const result = new Map<
string,
[output: types.OutputResponse, score: number]
>();
while (availableOutputs.size > 0 && score > 0) {
const [[name, group], preferences] = scores[i];
if (usedGroups.has(group)) {
i++;
if (i >= scores.length) {
i = 0;
score--;
}
continue;
}
const [output, groupScore] = preferences.find(
([output, s]) => s === score && availableOutputs.has(output),
) ?? [undefined];
if (output !== undefined) {
availableOutputs.delete(output);
usedGroups.add(group);
result.set(name, [output, groupScore]);
if (score === 1) {
// Only one output can be matched with a wildcard
break;
}
}
}
// Verify that all groups have been matched
if (usedGroups.size !== this.#groups.size) {
return null;
}
return result;
} }
/** /**
* Compute the score of this context based on the given Sway output set. * Compute the score of this context based on the given Sway output set.
*/ */
score(outputSet: types.OutputResponse[], includePriority = true) { score(outputSet: types.OutputResponse[], includePriority = true) {
const match = this.#matchGroupsWithOutputs(outputSet); const [score] = computeContextScore(this.definition, outputSet);
if (match === null) { if (includePriority && score !== 0) {
return 0; return score + (this.definition.priority ?? 0);
} }
return Array.from(match.values()).reduce( return score;
(acc, [_, score]) => acc + score,
includePriority ? (this.definition.priority ?? 0) : 0,
);
} }
/** /**
* Activate this context. * Activate this context.
*/ */
async activate() { async activate() {
console.log("Context activation requested");
const outputs = await Sway.instance.getOutputs(); const outputs = await Sway.instance.getOutputs();
const match = this.#matchGroupsWithOutputs(outputs); const match = this.matchGroupsWithOutputs(outputs);
if (match === null) { if (match === undefined) {
throw new InvalidContextError( throw new InvalidContextError(
this, this,
"Could not activate context: context is incompatible with current outputs.", "Could not activate context: context is incompatible with current outputs.",
@ -542,38 +565,73 @@ export class Context extends DBusObject {
} }
// Construct a set of output names that this context will use. // Construct a set of output names that this context will use.
const usedOutputs = new Set( const usedOutputs = new Set(
Array.from(match.values()).map(([output]) => output.name), Array.from(match.values()).map((output) => output.name),
); );
if (Tree.instance.currentContext !== null) { if (Tree.instance.currentContext !== null) {
await Tree.instance.currentContext.deactivate(usedOutputs); await Tree.instance.currentContext.deactivate(usedOutputs);
} }
// Activate groups. // Activate groups.
await Promise.all( await Array.from(match.entries()).amap(async ([name, output]) => {
Array.from(match.entries()).map(async ([name, [output, _]]) => { const group = this.#groups.get(name);
const group = this.#groups.get(name); console.log(`Activating group ${name} on output ${output.name}`);
if (group === undefined) { if (group === undefined) {
throw new InvalidContextError( throw new InvalidContextError(
this, this,
`Could not activate context: group ${name} is not defined.`, `Could not activate context: group ${name} is not defined.`,
); );
} }
await group.activate(output); await group.activate(output);
}), });
);
// Notify listeners. // Notify listeners.
this.#active = true; this.active = true;
this.notify("active");
Tree.instance.notify("current-context"); Tree.instance.notify("current-context");
} }
/**
* Mark the context as activated, without changing outputs
*/
async dummyActivate() {
console.log(
"Context dummy activation requested. This will not produce any changes to the current output configuration!",
);
const outputs = await Sway.instance.getOutputs();
const match = this.matchGroupsWithOutputs(outputs);
if (match === undefined) {
throw new InvalidContextError(
this,
"Could not activate context: context is incompatible with current outputs.",
);
}
await Tree.instance.currentContext?.dummyDeactivate();
await Array.from(match.entries()).amap(async ([name, output]) => {
const group = this.#groups.get(name);
console.log(`Dummy-activating group ${name} on output ${output.name}`);
if (group === undefined) {
throw new InvalidContextError(
this,
`Could not activate context: group ${name} is not defined.`,
);
}
await group.dummyActivate(output);
}),
(this.active = true);
Tree.instance.notify("current-context");
}
async dummyDeactivate() {
this.active = false;
await new Promise((resolve) => setTimeout(resolve, 1));
Array.from(this.#groups.values()).forEach((group) => group.deactivate());
}
/** /**
* Deactivate this context. * Deactivate this context.
* @param outputsToLeave A set of outputs that should not be disabled, selected by output name * @param outputsToLeave A set of outputs that should not be disabled, selected by output name
*/ */
async deactivate(outputsToLeave: Set<string>) { async deactivate(outputsToLeave: Set<string>) {
// First, notify for deactivation and move ourselves to the lowest priority in the event list // First, notify for deactivation and move ourselves to the lowest priority in the event list
this.#active = false; this.active = false;
Tree.instance.emit("deactivating");
await Promise.resolve(); // This should allow the event loop to run and any event listeners to run before we continue await Promise.resolve(); // This should allow the event loop to run and any event listeners to run before we continue
// Then, deactivate the outputs and groups. // Then, deactivate the outputs and groups.
@ -625,7 +683,7 @@ export class Context extends DBusObject {
@register({ @register({
GTypeName: "SwayWorkspaceTree", GTypeName: "SwayWorkspaceTree",
}) })
export class Tree extends GObject.Object { export class Tree extends DBusObject {
#workspaces: Map<string, Workspace> = new Map(); #workspaces: Map<string, Workspace> = new Map();
#contexts: Map<string, Context> = new Map(); #contexts: Map<string, Context> = new Map();
@ -661,11 +719,13 @@ export class Tree extends GObject.Object {
// If we're connected to Sway, immediately do a full tree sync. Otherwise, // If we're connected to Sway, immediately do a full tree sync. Otherwise,
// wait to be connected and then do a full tree sync. // wait to be connected and then do a full tree sync.
if (Sway.instance.isConnected) { if (Sway.instance.isConnected) {
this.sync(); await this.sync();
this.onOutputEvent();
} else { } else {
const connId = Sway.instance.connect("connected", () => { const connId = Sway.instance.connect("connected", async () => {
Sway.instance.disconnect(connId); Sway.instance.disconnect(connId);
this.sync(); await this.sync();
this.onOutputEvent();
}); });
} }
} }
@ -737,9 +797,68 @@ export class Tree extends GObject.Object {
.catch((reason) => { .catch((reason) => {
console.error("Error dereferencing symlink:", reason); console.error("Error dereferencing symlink:", reason);
}); });
Sway.instance.connect("workspace", (_, event: types.WorkspaceEvent) => { Sway.instance.connect("workspace", (_, event) => {
this.sync(); this.onWorkspaceEvent(event);
}); });
Sway.instance.connect("output", (_, event) => this.onOutputEvent(event));
}
private onWorkspaceEvent(event: types.WorkspaceEvent) {
switch (event.change) {
case types.WorkspaceEventChange.RELOAD:
break;
default:
this.sync();
}
}
private outputDelayLock = new Mutex();
/**
* Callback for when we receive output events.
*
* @param event - The event to process. Currently, this event is always the same ({ change: "unspecified" }), so it is unused.
*/
private async onOutputEvent(_?: types.OutputEvent) {
try {
// Wait for half a second, since a single hardware change can trigger a flurry of output events
await this.outputDelayLock.immediate(delay.bind(this, 500));
} catch {
return;
}
const outputs = await Sway.instance.getOutputs();
// Score each context
const scores = Array.from(this.#contexts.entries()).reduce(
(acc, [name, context]) => {
acc[name] = context.score(outputs);
return acc;
},
{} as Record<string, number>,
);
console.log(scores);
// Find highest score
const [highName] = Object.entries(scores).sort(
([_, a], [__, b]) => b - a,
)[0];
if (highName === undefined) {
console.warn(
"No configured contexts are compatible with the current display arrangement!",
);
return;
}
const newContext = this.#contexts.get(highName);
if (newContext === undefined) {
console.error(
"High score context does not exist in memory. This should not happen.",
);
return;
}
if (newContext === this.currentContext) {
console.log("Context is already active, ignoring...");
}
console.log("Activating context", highName);
await newContext.dummyActivate();
} }
@property(Context) @property(Context)
@ -804,4 +923,24 @@ export class Tree extends GObject.Object {
): [workspace: Workspace, group: Group] | [null, null] { ): [workspace: Workspace, group: Group] | [null, null] {
return this.currentContext?.getWorkspace(groupIndex) ?? [null, null]; return this.currentContext?.getWorkspace(groupIndex) ?? [null, null];
} }
findGroupByObjectPath(path: string): Group | undefined {
const match = path.match(
new RegExp(
"/dev/ezri/VoidShell/workspaces/context/([\\w_]+)/group/([\\w_]+)",
),
);
if (match === null) {
console.error(`Group object path ${path} invalid`);
return undefined;
}
const context =
this.#contexts.get(match[1]) ??
this.#contexts.get(match[1].replace("_", "-"));
if (context === undefined) return undefined;
return (
context.groups.get(match[2]) ??
context.groups.get(match[2].replace("_", "-"))
);
}
} }

View File

@ -1,7 +1,6 @@
// This module contains async synchronization primitives // This module contains async synchronization primitives
//
// Synchronization primitives are still needed in async code because any "await" or ".then()" call will export class MutexAcquireFailure extends Error {}
// return to the event loop, potentially allowing another coroutine to
/** /**
* Basic synchronization primitive that only allows a single coroutine to access a resource * Basic synchronization primitive that only allows a single coroutine to access a resource
@ -56,4 +55,15 @@ export class Mutex {
release(); release();
} }
} }
/**
* Executes the given function with the mutex acquired, throwing a MutexAcquireFailure exception if the
* mutex was already acquired.
*/
async immediate<T>(func: () => T): Promise<T> {
if (this.locked) {
throw new MutexAcquireFailure("Mutex was already locked");
}
return this.with(func);
}
} }

View File

@ -1,6 +1,7 @@
import Gio from "gi://Gio"; import Gio from "gi://Gio";
import GLib from "gi://GLib"; import GLib from "gi://GLib";
import { readFileAsync } from "astal/file"; import { readFileAsync } from "astal/file";
import GObject from "astal/gobject";
export async function readFromStreamRaw( export async function readFromStreamRaw(
stream: Gio.InputStream, stream: Gio.InputStream,
@ -130,9 +131,23 @@ declare global {
* @returns The rotated array. * @returns The rotated array.
*/ */
rotate(n: number): T[]; rotate(n: number): T[];
/**
* Asynchronous version of the map() function.
*/
amap<U>(
callbackfn: (value: T, index: number, array: T[]) => Promise<U>,
): Promise<U[]>;
} }
} }
Array.prototype.amap = function <T>(
this: unknown[],
callbackfn: (value: unknown, index: number, array: unknown[]) => Promise<T>,
): Promise<T[]> {
return Promise.all(this.map(callbackfn));
};
Array.prototype.rotate = function <T>(this: T[], n: number): T[] { Array.prototype.rotate = function <T>(this: T[], n: number): T[] {
const array = this; const array = this;
const length = array.length; const length = array.length;
@ -142,3 +157,9 @@ Array.prototype.rotate = function <T>(this: T[], n: number): T[] {
n = ((n % length) + length) % length; n = ((n % length) + length) % length;
return array.slice(n).concat(array.slice(0, n)); return array.slice(n).concat(array.slice(0, n));
}; };
export async function delay(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}

46
widget/sway/Workspace.tsx Normal file
View File

@ -0,0 +1,46 @@
import { Derived } from "@/DerivedConnectable";
import { Context, Group, type Workspace } from "@services/sway";
import { bind, Variable } from "astal";
import { type Subscribable } from "astal/binding";
export function SwayWorkspace(ws: Workspace) {
const className = new Derived(
ws,
(active: boolean, visible: boolean, focused: boolean) => {
const result = ["indicator-circle", "sway--ws"];
if (active) {
result.push("sway--active");
}
if (visible) {
result.push("sway--visible");
}
if (focused) {
result.push("sway--focused");
}
return result.join(" ");
},
"active",
"visible",
"focused",
);
return (
<button onClick={ws.focus.bind(ws)} onDestroy={() => className.drop()}>
<circularprogress
className={bind(className)}
value={100}
start-at={0}
clockwise
width={16}
thickness={1}
>
<box className="fill" />
</circularprogress>
</button>
);
}
export function SwayActiveWorkspace(group: Group) {
const workspace = bind(group, "focusedWorkspace");
const name = Variable.derive([workspace], (ws) => {});
}