Started work on generic compositor API
This commit is contained in:
parent
23b63fa58d
commit
c023391702
76
DerivedConnectable.ts
Normal file
76
DerivedConnectable.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@ -1,3 +1,11 @@
|
||||
<!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">
|
||||
<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
320
package-lock.json
generated
@ -10,7 +10,8 @@
|
||||
"license": "All right reserved",
|
||||
"dependencies": {
|
||||
"@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": {
|
||||
"eslint": "^9.14.0",
|
||||
@ -76,13 +77,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz",
|
||||
"integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==",
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz",
|
||||
"integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/object-schema": "^2.1.4",
|
||||
"@eslint/object-schema": "^2.1.6",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^3.1.2"
|
||||
},
|
||||
@ -91,19 +92,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/core": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz",
|
||||
"integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==",
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.11.0.tgz",
|
||||
"integrity": "sha512-DWUB2pksgNEb6Bz2fggIy1wh6fGgZP4Xyy/Mt0QZPiloKKXerbqq9D3SBQTlCRYOrcRPu4vuz+CGjwdfqxnoWA==",
|
||||
"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/@eslint/eslintrc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz",
|
||||
"integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz",
|
||||
"integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -125,9 +129,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.14.0.tgz",
|
||||
"integrity": "sha512-pFoEtFWCPyDOl+C6Ift+wC7Ro89otjigCf5vcuWqWgqNSQbRrpjSvdeE6ofLz4dHmyxD5f7gIdGT4+p36L6Twg==",
|
||||
"version": "9.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.20.0.tgz",
|
||||
"integrity": "sha512-iZA07H9io9Wn836aVTytRaNqh00Sad+EamwOVJT12GTLw1VGMFV/4JaME+JjLtr9fiGaoWgYnS54wrfWsSs4oQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -135,9 +139,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/object-schema": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz",
|
||||
"integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==",
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
|
||||
"integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
@ -145,151 +149,165 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.2.tgz",
|
||||
"integrity": "sha512-CXtq5nR4Su+2I47WPOlWud98Y5Lv8Kyxp2ukhgFx/eW6Blm18VXJO5WuQylPugRo8nbluoi6GvvxBLqHcvqUUw==",
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.5.tgz",
|
||||
"integrity": "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^0.10.0",
|
||||
"levn": "^0.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"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": {
|
||||
"version": "1.0.0-4.0.0-beta.16",
|
||||
"resolved": "https://registry.npmjs.org/@girs/cairo-1.0/-/cairo-1.0-1.0.0-4.0.0-beta.16.tgz",
|
||||
"integrity": "sha512-nIrOvL73lhCHB/4n4ro7Ud4XaR0DrUg+MvCN/B42L2eCS8YIZ0MHZ+Fma0shifDQyv8uoopHfE5I7X/v7VOYFg==",
|
||||
"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.19.tgz",
|
||||
"integrity": "sha512-nBI5oveqH0N7czBC95ofJ4Un5KKqK6guANE6O0OxkwuF6B3YxVqdNmF8O8i2tk3GIxBzwxkC0bi0Wb6X4tLR8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@girs/gjs": "^4.0.0-beta.16",
|
||||
"@girs/glib-2.0": "^2.82.0-4.0.0-beta.16",
|
||||
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16"
|
||||
"@girs/gjs": "^4.0.0-beta.19",
|
||||
"@girs/glib-2.0": "^2.82.2-4.0.0-beta.19",
|
||||
"@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19"
|
||||
}
|
||||
},
|
||||
"node_modules/@girs/freetype2-2.0": {
|
||||
"version": "2.0.0-4.0.0-beta.16",
|
||||
"resolved": "https://registry.npmjs.org/@girs/freetype2-2.0/-/freetype2-2.0-2.0.0-4.0.0-beta.16.tgz",
|
||||
"integrity": "sha512-djReh9OOKW5vG8t94ie4mQmsUWS4qgLWLMBIFg/KKcCrGHYgwkcSafLnnn/5HyRgdz6qxoFm6N3C+AJEcYYz6A==",
|
||||
"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.19.tgz",
|
||||
"integrity": "sha512-xU3uVUXMY0MeLc2U4QgpWBQ6VSBtMKaqKRcRT7TrOM1etkLzGhh70SWt16xjqA6vrpF+wZ9TtndQZy2ky0gpwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@girs/gjs": "^4.0.0-beta.16",
|
||||
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16"
|
||||
"@girs/gjs": "^4.0.0-beta.19",
|
||||
"@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19"
|
||||
}
|
||||
},
|
||||
"node_modules/@girs/gdk-3.0": {
|
||||
"version": "3.24.43-4.0.0-beta.16",
|
||||
"resolved": "https://registry.npmjs.org/@girs/gdk-3.0/-/gdk-3.0-3.24.43-4.0.0-beta.16.tgz",
|
||||
"integrity": "sha512-AKFbDjRrrNcKlbv57tFfcgCLIimn2mYJypqK7HzK5JIYvm9dogBnCTC9BpIMyFF/RcClCOxWgn7WnaAVzOSRrw==",
|
||||
"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.19.tgz",
|
||||
"integrity": "sha512-qEMU2BnXySVEq0kttQzzjFFh5kki7mm4YutJmzU5PpSj53GavCIALmjvbJZ1QDhLaCpLjZ2DZ8sm/erImRkSvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@girs/cairo-1.0": "^1.0.0-4.0.0-beta.16",
|
||||
"@girs/freetype2-2.0": "^2.0.0-4.0.0-beta.16",
|
||||
"@girs/gdkpixbuf-2.0": "^2.0.0-4.0.0-beta.16",
|
||||
"@girs/gio-2.0": "^2.82.0-4.0.0-beta.16",
|
||||
"@girs/gjs": "^4.0.0-beta.16",
|
||||
"@girs/glib-2.0": "^2.82.0-4.0.0-beta.16",
|
||||
"@girs/gmodule-2.0": "^2.0.0-4.0.0-beta.16",
|
||||
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16",
|
||||
"@girs/harfbuzz-0.0": "^9.0.0-4.0.0-beta.16",
|
||||
"@girs/pango-1.0": "^1.54.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.19",
|
||||
"@girs/gdkpixbuf-2.0": "^2.0.0-4.0.0-beta.19",
|
||||
"@girs/gio-2.0": "^2.82.2-4.0.0-beta.19",
|
||||
"@girs/gjs": "^4.0.0-beta.19",
|
||||
"@girs/glib-2.0": "^2.82.2-4.0.0-beta.19",
|
||||
"@girs/gmodule-2.0": "^2.0.0-4.0.0-beta.19",
|
||||
"@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19",
|
||||
"@girs/harfbuzz-0.0": "^9.0.0-4.0.0-beta.19",
|
||||
"@girs/pango-1.0": "^1.54.0-4.0.0-beta.19"
|
||||
}
|
||||
},
|
||||
"node_modules/@girs/gdkpixbuf-2.0": {
|
||||
"version": "2.0.0-4.0.0-beta.16",
|
||||
"resolved": "https://registry.npmjs.org/@girs/gdkpixbuf-2.0/-/gdkpixbuf-2.0-2.0.0-4.0.0-beta.16.tgz",
|
||||
"integrity": "sha512-d4v7fGCuegpf5sscnMsOCjeuQgJjUJ4+Gozr+wFL30Are944V1FYWrdWP1GgIaNnBeWXY43D58YFQhsfFi+bWg==",
|
||||
"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.19.tgz",
|
||||
"integrity": "sha512-h8+LofOkyChEhduPGWYI4XDi5bCb9h8aRhYU4p6CTWEAiWXE2a7EtPjdLKXkQADAL/xt3eQoF2t6ecFdYIoiEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@girs/gio-2.0": "^2.82.0-4.0.0-beta.16",
|
||||
"@girs/gjs": "^4.0.0-beta.16",
|
||||
"@girs/glib-2.0": "^2.82.0-4.0.0-beta.16",
|
||||
"@girs/gmodule-2.0": "^2.0.0-4.0.0-beta.16",
|
||||
"@girs/gobject-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.19",
|
||||
"@girs/glib-2.0": "^2.82.2-4.0.0-beta.19",
|
||||
"@girs/gmodule-2.0": "^2.0.0-4.0.0-beta.19",
|
||||
"@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19"
|
||||
}
|
||||
},
|
||||
"node_modules/@girs/gio-2.0": {
|
||||
"version": "2.82.0-4.0.0-beta.16",
|
||||
"resolved": "https://registry.npmjs.org/@girs/gio-2.0/-/gio-2.0-2.82.0-4.0.0-beta.16.tgz",
|
||||
"integrity": "sha512-5JW6qgyzh3OW7dEihP+h2ZtXWw7kDXi/ra63ZscnYqUIuoebqaSB66HFzd3AMyDYYQ7j8IGZdJCog+6kKwnk8Q==",
|
||||
"version": "2.82.2-4.0.0-beta.19",
|
||||
"resolved": "https://registry.npmjs.org/@girs/gio-2.0/-/gio-2.0-2.82.2-4.0.0-beta.19.tgz",
|
||||
"integrity": "sha512-OM2E/mdNX3Z+X7mB8ZVFch4WbUqX1Y6hADiG9+LuGWsAOjPAVvDDUk5N4+34+BTPEfFHLGOdo8E7dI235jylCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@girs/gjs": "^4.0.0-beta.16",
|
||||
"@girs/glib-2.0": "^2.82.0-4.0.0-beta.16",
|
||||
"@girs/gmodule-2.0": "^2.0.0-4.0.0-beta.16",
|
||||
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16"
|
||||
"@girs/gjs": "^4.0.0-beta.19",
|
||||
"@girs/glib-2.0": "^2.82.2-4.0.0-beta.19",
|
||||
"@girs/gmodule-2.0": "^2.0.0-4.0.0-beta.19",
|
||||
"@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19"
|
||||
}
|
||||
},
|
||||
"node_modules/@girs/gjs": {
|
||||
"version": "4.0.0-beta.16",
|
||||
"resolved": "https://registry.npmjs.org/@girs/gjs/-/gjs-4.0.0-beta.16.tgz",
|
||||
"integrity": "sha512-UjWj6GXXCqJ+z8DdzQ2XYxl1IqzgcIOIs6BTcEsXQfaZx9704GNpd9PRcqOKj7hVe2VGqO5VdIbk80u9Bo+lUQ==",
|
||||
"version": "4.0.0-beta.19",
|
||||
"resolved": "https://registry.npmjs.org/@girs/gjs/-/gjs-4.0.0-beta.19.tgz",
|
||||
"integrity": "sha512-tl+BjmMqqZH9xZcJZ2AwWI0sotGDc7qVPppAJNeFZFCgUDOFVWThZfkpZffZcQGNphn7wgU8HOvgZyrtVhrltw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@girs/cairo-1.0": "^1.0.0-4.0.0-beta.16",
|
||||
"@girs/gio-2.0": "^2.82.0-4.0.0-beta.16",
|
||||
"@girs/glib-2.0": "^2.82.0-4.0.0-beta.16",
|
||||
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16"
|
||||
"@girs/cairo-1.0": "^1.0.0-4.0.0-beta.19",
|
||||
"@girs/gio-2.0": "^2.82.2-4.0.0-beta.19",
|
||||
"@girs/glib-2.0": "^2.82.2-4.0.0-beta.19",
|
||||
"@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19"
|
||||
}
|
||||
},
|
||||
"node_modules/@girs/glib-2.0": {
|
||||
"version": "2.82.0-4.0.0-beta.16",
|
||||
"resolved": "https://registry.npmjs.org/@girs/glib-2.0/-/glib-2.0-2.82.0-4.0.0-beta.16.tgz",
|
||||
"integrity": "sha512-YZjsythyPhClrq0uEGq8rBJ8ER0iBPf3alr1kkSOt28tDG4tn0Rv2kudHEKsFenlFjzhYVOMrJSjVvsxtcqRUQ==",
|
||||
"version": "2.82.2-4.0.0-beta.19",
|
||||
"resolved": "https://registry.npmjs.org/@girs/glib-2.0/-/glib-2.0-2.82.2-4.0.0-beta.19.tgz",
|
||||
"integrity": "sha512-mRUhcp7O65KQQkyrgiQpUzl1rY+TH4X+A98Kk67g3VjuA7Ei/lV4lcuZnW04HnXAGvQ3Qfkq877NmcDXRgb02g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@girs/gjs": "^4.0.0-beta.16",
|
||||
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16"
|
||||
"@girs/gjs": "^4.0.0-beta.19",
|
||||
"@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19"
|
||||
}
|
||||
},
|
||||
"node_modules/@girs/gmodule-2.0": {
|
||||
"version": "2.0.0-4.0.0-beta.16",
|
||||
"resolved": "https://registry.npmjs.org/@girs/gmodule-2.0/-/gmodule-2.0-2.0.0-4.0.0-beta.16.tgz",
|
||||
"integrity": "sha512-jhl7j0JOtttJnOFQ4OypPjGP5DL8AlEiSsd8YKpXI3CEC7tUFZXHq+/mymFeZ6m4GFLEdC4j0efl9roPTSEYnA==",
|
||||
"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.19.tgz",
|
||||
"integrity": "sha512-whTYGLL63Hw1Tn9ZicpJRAMtkYiZwB3lrcln4ETFxyO1ckGTTZ5s17cWcrQjij27veVvjWcb5rmc93L5djeBpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@girs/gjs": "^4.0.0-beta.16",
|
||||
"@girs/glib-2.0": "^2.82.0-4.0.0-beta.16",
|
||||
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16"
|
||||
"@girs/gjs": "^4.0.0-beta.19",
|
||||
"@girs/glib-2.0": "^2.82.2-4.0.0-beta.19",
|
||||
"@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19"
|
||||
}
|
||||
},
|
||||
"node_modules/@girs/gobject-2.0": {
|
||||
"version": "2.82.0-4.0.0-beta.16",
|
||||
"resolved": "https://registry.npmjs.org/@girs/gobject-2.0/-/gobject-2.0-2.82.0-4.0.0-beta.16.tgz",
|
||||
"integrity": "sha512-ziBHUMSVuoHlzhZrMvuJDSZPOR4HzRW+nk35aI3QW9TpHtIYTk6vWvY0EG0+ylzJlltT9C/kzoVvspyMulBZ5g==",
|
||||
"version": "2.82.2-4.0.0-beta.19",
|
||||
"resolved": "https://registry.npmjs.org/@girs/gobject-2.0/-/gobject-2.0-2.82.2-4.0.0-beta.19.tgz",
|
||||
"integrity": "sha512-E1fCaIZvZ7K035Waa/vYOTqLKI47OSUX3wpMrD+DBlyfDLDCFvyXwaIrFsiKUVFJIxj1xxq7H2yCZvkYqkfklA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@girs/gjs": "^4.0.0-beta.16",
|
||||
"@girs/glib-2.0": "^2.82.0-4.0.0-beta.16"
|
||||
"@girs/gjs": "^4.0.0-beta.19",
|
||||
"@girs/glib-2.0": "^2.82.2-4.0.0-beta.19"
|
||||
}
|
||||
},
|
||||
"node_modules/@girs/harfbuzz-0.0": {
|
||||
"version": "9.0.0-4.0.0-beta.16",
|
||||
"resolved": "https://registry.npmjs.org/@girs/harfbuzz-0.0/-/harfbuzz-0.0-9.0.0-4.0.0-beta.16.tgz",
|
||||
"integrity": "sha512-ONBRGCdBj668eComG25kOZVDU6HqRZWHIA42YYlB0JxF6Dgtu52sLiDoGXh00gkpATqnFbHfkDJhiL9EowGgIQ==",
|
||||
"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.19.tgz",
|
||||
"integrity": "sha512-m+rGrFJs6OUdz/WdG/lvXgJ6eSMW5vSPmtZDHoipDnY5LDMDfF0/tEc12RqPqJ7uF59czcYtpSa2gR4ZtrKHUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@girs/freetype2-2.0": "^2.0.0-4.0.0-beta.16",
|
||||
"@girs/gjs": "^4.0.0-beta.16",
|
||||
"@girs/glib-2.0": "^2.82.0-4.0.0-beta.16",
|
||||
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16"
|
||||
"@girs/freetype2-2.0": "^2.0.0-4.0.0-beta.19",
|
||||
"@girs/gjs": "^4.0.0-beta.19",
|
||||
"@girs/glib-2.0": "^2.82.2-4.0.0-beta.19",
|
||||
"@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19"
|
||||
}
|
||||
},
|
||||
"node_modules/@girs/pango-1.0": {
|
||||
"version": "1.54.0-4.0.0-beta.16",
|
||||
"resolved": "https://registry.npmjs.org/@girs/pango-1.0/-/pango-1.0-1.54.0-4.0.0-beta.16.tgz",
|
||||
"integrity": "sha512-EY1/70Q1eZ8B3DlkB+QKgK4BLRM2B0sl8tZiHxy99H72Yl+DW2yqyXrG5mx5TxVfphd2hB5x3+vREej15XSgRQ==",
|
||||
"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.19.tgz",
|
||||
"integrity": "sha512-O/tJNlRAdmuJFrw8pOMKZrYldgSLgAN9dOJrtQXSh4+6vFH7LQ9SnCKv+6hmJ6yJUnfzEtyOgWAhdmee/Hz3tQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@girs/cairo-1.0": "^1.0.0-4.0.0-beta.16",
|
||||
"@girs/freetype2-2.0": "^2.0.0-4.0.0-beta.16",
|
||||
"@girs/gio-2.0": "^2.82.0-4.0.0-beta.16",
|
||||
"@girs/gjs": "^4.0.0-beta.16",
|
||||
"@girs/glib-2.0": "^2.82.0-4.0.0-beta.16",
|
||||
"@girs/gmodule-2.0": "^2.0.0-4.0.0-beta.16",
|
||||
"@girs/gobject-2.0": "^2.82.0-4.0.0-beta.16",
|
||||
"@girs/harfbuzz-0.0": "^9.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.19",
|
||||
"@girs/gio-2.0": "^2.82.2-4.0.0-beta.19",
|
||||
"@girs/gjs": "^4.0.0-beta.19",
|
||||
"@girs/glib-2.0": "^2.82.2-4.0.0-beta.19",
|
||||
"@girs/gmodule-2.0": "^2.0.0-4.0.0-beta.19",
|
||||
"@girs/gobject-2.0": "^2.82.2-4.0.0-beta.19",
|
||||
"@girs/harfbuzz-0.0": "^9.0.0-4.0.0-beta.19"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
@ -345,9 +363,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/retry": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.0.tgz",
|
||||
"integrity": "sha512-xnRgu9DxZbkWak/te3fcytNyp8MTbuiZIaueg2rgEvBuN55n04nwLYLU9TX/VVlusc9L2ZNXi99nUFNkHXtr5g==",
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz",
|
||||
"integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
@ -541,9 +559,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -556,9 +574,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -581,9 +599,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz",
|
||||
"integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz",
|
||||
"integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@ -601,27 +619,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "9.14.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.14.0.tgz",
|
||||
"integrity": "sha512-c2FHsVBr87lnUtjP4Yhvk4yEhKrQavGafRA/Se1ouse8PfbfC/Qh9Mxa00yWsZRlqeUB9raXip0aiiUZkgnr9g==",
|
||||
"version": "9.20.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.20.1.tgz",
|
||||
"integrity": "sha512-m1mM33o6dBUjxl2qb6wv6nGNwCAsns1eKtaQ4l/NPHeTvhiUPbtdfMyktxN4B3fgHIgsYh1VT3V9txblpQHq+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
"@eslint/config-array": "^0.18.0",
|
||||
"@eslint/core": "^0.7.0",
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@eslint/js": "9.14.0",
|
||||
"@eslint/plugin-kit": "^0.2.0",
|
||||
"@eslint/config-array": "^0.19.0",
|
||||
"@eslint/core": "^0.11.0",
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "9.20.0",
|
||||
"@eslint/plugin-kit": "^0.2.5",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.0",
|
||||
"@humanwhocodes/retry": "^0.4.1",
|
||||
"@types/estree": "^1.0.6",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"ajv": "^6.12.4",
|
||||
"chalk": "^4.0.0",
|
||||
"cross-spawn": "^7.0.2",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"debug": "^4.3.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"eslint-scope": "^8.2.0",
|
||||
@ -640,8 +658,7 @@
|
||||
"lodash.merge": "^4.6.2",
|
||||
"minimatch": "^3.1.2",
|
||||
"natural-compare": "^1.4.0",
|
||||
"optionator": "^0.9.3",
|
||||
"text-table": "^0.2.0"
|
||||
"optionator": "^0.9.3"
|
||||
},
|
||||
"bin": {
|
||||
"eslint": "bin/eslint.js"
|
||||
@ -662,9 +679,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-jsdoc": {
|
||||
"version": "50.4.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.4.3.tgz",
|
||||
"integrity": "sha512-uWtwFxGRv6B8sU63HZM5dAGDhgsatb+LONwmILZJhdRALLOkCX2HFZhdL/Kw2ls8SQMAVEfK+LmnEfxInRN8HA==",
|
||||
"version": "50.6.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.6.3.tgz",
|
||||
"integrity": "sha512-NxbJyt1M5zffPcYZ8Nb53/8nnbIScmiLAMdoe0/FAszwb7lcSiX3iYBTsuF7RV84dZZJC8r3NghomrUXsmWvxQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@ -847,9 +864,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
|
||||
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz",
|
||||
"integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@ -900,9 +917,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||
"integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -1202,9 +1219,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.6.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
|
||||
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
|
||||
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@ -1263,9 +1280,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/spdx-license-ids": {
|
||||
"version": "3.0.20",
|
||||
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.20.tgz",
|
||||
"integrity": "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==",
|
||||
"version": "3.0.21",
|
||||
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz",
|
||||
"integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
@ -1312,13 +1329,6 @@
|
||||
"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": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@ -1340,9 +1350,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
||||
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
||||
"version": "5.7.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
||||
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@ -1389,6 +1399,18 @@
|
||||
"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": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
120
services/compositor/connection.ts
Normal file
120
services/compositor/connection.ts
Normal 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;
|
||||
}
|
||||
459
services/compositor/connections/sway.ts
Normal file
459
services/compositor/connections/sway.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
403
services/compositor/connections/sway_types.ts
Normal file
403
services/compositor/connections/sway_types.ts
Normal 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;
|
||||
}
|
||||
18
services/compositor/errors.ts
Normal file
18
services/compositor/errors.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
260
services/compositor/types.ts
Normal file
260
services/compositor/types.ts
Normal 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
1
services/sway/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./workspaces";
|
||||
118
services/sway/scoring.ts
Normal file
118
services/sway/scoring.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
@ -278,6 +278,7 @@ export interface TreeNode {
|
||||
inhibit_idle?: boolean;
|
||||
window?: number;
|
||||
window_properties?: XWaylandWindowProperties;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
export enum InputType {
|
||||
|
||||
@ -7,8 +7,10 @@ import Gio from "gi://Gio";
|
||||
import GLib from "gi://GLib";
|
||||
import { Sway } from "./ipc";
|
||||
import { InvalidContextError } from "./exceptions";
|
||||
import { dereferenceSymbolicLink, getDbusXml } from "@/utils";
|
||||
import { Connection, DBusObject } from "@services/dbus";
|
||||
import { delay, dereferenceSymbolicLink } from "@/utils";
|
||||
import { DBusObject } from "@services/dbus";
|
||||
import { computeContextScore, computeGroupScore } from "./scoring";
|
||||
import { Mutex } from "@/synchronization";
|
||||
|
||||
@register({
|
||||
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.
|
||||
*/
|
||||
@ -68,6 +85,7 @@ export class Workspace extends DBusObject {
|
||||
}
|
||||
this.#active = value;
|
||||
this.notify("active");
|
||||
this.notify("indicator-class-name");
|
||||
this.dbusObj?.emit_property_changed(
|
||||
"active",
|
||||
GLib.Variant.new_boolean(value),
|
||||
@ -91,6 +109,7 @@ export class Workspace extends DBusObject {
|
||||
}
|
||||
this.#focused = value;
|
||||
this.notify("focused");
|
||||
this.notify("indicator-class-name");
|
||||
this.dbusObj?.emit_property_changed(
|
||||
"focused",
|
||||
GLib.Variant.new_boolean(value),
|
||||
@ -108,6 +127,7 @@ export class Workspace extends DBusObject {
|
||||
}
|
||||
this.#visible = value;
|
||||
this.notify("visible");
|
||||
this.notify("indicator-class-name");
|
||||
this.dbusObj?.emit_property_changed(
|
||||
"visible",
|
||||
GLib.Variant.new_boolean(value),
|
||||
@ -183,7 +203,14 @@ export class Workspace extends DBusObject {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@ -273,7 +300,7 @@ export class Group extends DBusObject {
|
||||
) {
|
||||
super(
|
||||
"dev.ezri.voidshell.workspaces.Group",
|
||||
`/dev/ezri/VoidShell/workspaces/context/${context.name}/group/${name}`,
|
||||
`${context.objectPath}/group/${name}`,
|
||||
);
|
||||
this.#context = context;
|
||||
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.
|
||||
*/
|
||||
score(output: types.OutputResponse): [number, number] {
|
||||
if (
|
||||
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;
|
||||
score(output: types.OutputResponse): number {
|
||||
return computeGroupScore(this.outputDefinition, output);
|
||||
}
|
||||
|
||||
async activate(output: types.OutputResponse) {
|
||||
// Ensure that this output is compatible with the group.
|
||||
const [score, _] = this.score(output);
|
||||
const score = this.score(output);
|
||||
if (score === 0) {
|
||||
console.error(`Output ${output.name} is not compatible with this group`);
|
||||
throw new Error(
|
||||
`Output ${output.name} is not compatible with this group.`,
|
||||
);
|
||||
@ -352,12 +347,11 @@ export class Group extends DBusObject {
|
||||
|
||||
// Configure monitor
|
||||
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.
|
||||
await Sway.instance.command(`output ${output.name} enable`);
|
||||
|
||||
// Store monitor name.
|
||||
this.#outputName = output.name;
|
||||
|
||||
@ -384,6 +378,29 @@ export class Group extends DBusObject {
|
||||
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() {
|
||||
if (this.#outputName === null) {
|
||||
throw new Error("Group is not activated.");
|
||||
@ -413,6 +430,20 @@ export class Group extends DBusObject {
|
||||
// Remove ourselves from every workspace
|
||||
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({
|
||||
@ -430,7 +461,7 @@ export class Context extends DBusObject {
|
||||
) {
|
||||
super(
|
||||
"dev.ezri.voidshell.workspaces.Context",
|
||||
`/dev/ezri/VoidShell/workspaces/context/${name}`,
|
||||
`/dev/ezri/VoidShell/workspaces/context/${name.replace("-", "_")}`,
|
||||
);
|
||||
definition.outputs.forEach((output) => {
|
||||
if (output.group === undefined || !definition.groups[output.group]) {
|
||||
@ -453,11 +484,45 @@ export class Context extends DBusObject {
|
||||
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)
|
||||
get 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 {
|
||||
return Array.from(this.#groups.values()).find(
|
||||
(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.
|
||||
* @returns A map of group names to outputs and scores.
|
||||
*/
|
||||
#matchGroupsWithOutputs(outputSet: types.OutputResponse[]) {
|
||||
// First get preferences
|
||||
const scores = Array.from(this.#groups.entries()).map(([name, group]) => {
|
||||
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;
|
||||
private matchGroupsWithOutputs(outputSet: types.OutputResponse[]) {
|
||||
const [_, match] = computeContextScore(this.definition, outputSet);
|
||||
return match;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the score of this context based on the given Sway output set.
|
||||
*/
|
||||
score(outputSet: types.OutputResponse[], includePriority = true) {
|
||||
const match = this.#matchGroupsWithOutputs(outputSet);
|
||||
if (match === null) {
|
||||
return 0;
|
||||
const [score] = computeContextScore(this.definition, outputSet);
|
||||
if (includePriority && score !== 0) {
|
||||
return score + (this.definition.priority ?? 0);
|
||||
}
|
||||
return Array.from(match.values()).reduce(
|
||||
(acc, [_, score]) => acc + score,
|
||||
includePriority ? (this.definition.priority ?? 0) : 0,
|
||||
);
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate this context.
|
||||
*/
|
||||
async activate() {
|
||||
console.log("Context activation requested");
|
||||
const outputs = await Sway.instance.getOutputs();
|
||||
const match = this.#matchGroupsWithOutputs(outputs);
|
||||
if (match === null) {
|
||||
const match = this.matchGroupsWithOutputs(outputs);
|
||||
if (match === undefined) {
|
||||
throw new InvalidContextError(
|
||||
this,
|
||||
"Could not activate context: context is incompatible with current outputs.",
|
||||
@ -542,15 +565,15 @@ export class Context extends DBusObject {
|
||||
}
|
||||
// Construct a set of output names that this context will use.
|
||||
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) {
|
||||
await Tree.instance.currentContext.deactivate(usedOutputs);
|
||||
}
|
||||
// Activate groups.
|
||||
await Promise.all(
|
||||
Array.from(match.entries()).map(async ([name, [output, _]]) => {
|
||||
await Array.from(match.entries()).amap(async ([name, output]) => {
|
||||
const group = this.#groups.get(name);
|
||||
console.log(`Activating group ${name} on output ${output.name}`);
|
||||
if (group === undefined) {
|
||||
throw new InvalidContextError(
|
||||
this,
|
||||
@ -558,22 +581,57 @@ export class Context extends DBusObject {
|
||||
);
|
||||
}
|
||||
await group.activate(output);
|
||||
}),
|
||||
);
|
||||
});
|
||||
// Notify listeners.
|
||||
this.#active = true;
|
||||
this.notify("active");
|
||||
this.active = true;
|
||||
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.
|
||||
* @param outputsToLeave A set of outputs that should not be disabled, selected by output name
|
||||
*/
|
||||
async deactivate(outputsToLeave: Set<string>) {
|
||||
// First, notify for deactivation and move ourselves to the lowest priority in the event list
|
||||
this.#active = false;
|
||||
Tree.instance.emit("deactivating");
|
||||
this.active = false;
|
||||
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.
|
||||
@ -625,7 +683,7 @@ export class Context extends DBusObject {
|
||||
@register({
|
||||
GTypeName: "SwayWorkspaceTree",
|
||||
})
|
||||
export class Tree extends GObject.Object {
|
||||
export class Tree extends DBusObject {
|
||||
#workspaces: Map<string, Workspace> = 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,
|
||||
// wait to be connected and then do a full tree sync.
|
||||
if (Sway.instance.isConnected) {
|
||||
this.sync();
|
||||
await this.sync();
|
||||
this.onOutputEvent();
|
||||
} else {
|
||||
const connId = Sway.instance.connect("connected", () => {
|
||||
const connId = Sway.instance.connect("connected", async () => {
|
||||
Sway.instance.disconnect(connId);
|
||||
this.sync();
|
||||
await this.sync();
|
||||
this.onOutputEvent();
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -737,9 +797,68 @@ export class Tree extends GObject.Object {
|
||||
.catch((reason) => {
|
||||
console.error("Error dereferencing symlink:", reason);
|
||||
});
|
||||
Sway.instance.connect("workspace", (_, event: types.WorkspaceEvent) => {
|
||||
this.sync();
|
||||
Sway.instance.connect("workspace", (_, event) => {
|
||||
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)
|
||||
@ -804,4 +923,24 @@ export class Tree extends GObject.Object {
|
||||
): [workspace: Workspace, group: Group] | [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("_", "-"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
// This module contains async synchronization primitives
|
||||
//
|
||||
// Synchronization primitives are still needed in async code because any "await" or ".then()" call will
|
||||
// return to the event loop, potentially allowing another coroutine to
|
||||
|
||||
export class MutexAcquireFailure extends Error {}
|
||||
|
||||
/**
|
||||
* Basic synchronization primitive that only allows a single coroutine to access a resource
|
||||
@ -56,4 +55,15 @@ export class Mutex {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
21
utils.ts
21
utils.ts
@ -1,6 +1,7 @@
|
||||
import Gio from "gi://Gio";
|
||||
import GLib from "gi://GLib";
|
||||
import { readFileAsync } from "astal/file";
|
||||
import GObject from "astal/gobject";
|
||||
|
||||
export async function readFromStreamRaw(
|
||||
stream: Gio.InputStream,
|
||||
@ -130,9 +131,23 @@ declare global {
|
||||
* @returns The rotated array.
|
||||
*/
|
||||
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[] {
|
||||
const array = this;
|
||||
const length = array.length;
|
||||
@ -142,3 +157,9 @@ Array.prototype.rotate = function <T>(this: T[], n: number): T[] {
|
||||
n = ((n % length) + length) % length;
|
||||
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
46
widget/sway/Workspace.tsx
Normal 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) => {});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user