From c023391702f9a5088a67aec1b0d99be87c4608c9 Mon Sep 17 00:00:00 2001 From: Ezri Brimhall Date: Sat, 15 Feb 2025 15:32:27 -0700 Subject: [PATCH] Started work on generic compositor API --- DerivedConnectable.ts | 76 +++ .../dev.ezri.voidshell.workspaces.Context.xml | 10 +- package-lock.json | 320 ++++++------ package.json | 3 +- services/compositor/connection.ts | 120 +++++ services/compositor/connections/sway.ts | 459 ++++++++++++++++++ services/compositor/connections/sway_types.ts | 403 +++++++++++++++ services/compositor/errors.ts | 18 + services/compositor/types.ts | 260 ++++++++++ services/sway/index.ts | 1 + services/sway/scoring.ts | 118 +++++ services/sway/types.ts | 1 + services/sway/workspaces.ts | 375 +++++++++----- synchronization.ts | 16 +- utils.ts | 21 + widget/sway/Workspace.tsx | 46 ++ 16 files changed, 1975 insertions(+), 272 deletions(-) create mode 100644 DerivedConnectable.ts create mode 100644 services/compositor/connection.ts create mode 100644 services/compositor/connections/sway.ts create mode 100644 services/compositor/connections/sway_types.ts create mode 100644 services/compositor/errors.ts create mode 100644 services/compositor/types.ts create mode 100644 services/sway/index.ts create mode 100644 services/sway/scoring.ts create mode 100644 widget/sway/Workspace.tsx diff --git a/DerivedConnectable.ts b/DerivedConnectable.ts new file mode 100644 index 0000000..3b33af5 --- /dev/null +++ b/DerivedConnectable.ts @@ -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 +{ + 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, + key: KeyType, +): Subscribable[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; + }); +} diff --git a/dbus/dev.ezri.voidshell.workspaces.Context.xml b/dbus/dev.ezri.voidshell.workspaces.Context.xml index 9fb5240..d89e685 100644 --- a/dbus/dev.ezri.voidshell.workspaces.Context.xml +++ b/dbus/dev.ezri.voidshell.workspaces.Context.xml @@ -1,3 +1,11 @@ + + + + + + + + diff --git a/package-lock.json b/package-lock.json index fb121ae..4de4f0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c69d011..eab7a0a 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/services/compositor/connection.ts b/services/compositor/connection.ts new file mode 100644 index 0000000..c407d19 --- /dev/null +++ b/services/compositor/connection.ts @@ -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 { + 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 { + throw new NotImplementedError(); + } + + /** + * Focus an output + * @param outputName The output to focus + */ + async focusOutput(outputName: string): Promise { + 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 { + 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 { + throw new NotImplementedError(); + } + + /** + * Gets a list of all connected outputs + * @returns a list of VSOutputAdapter objects representing the connected outputs + */ + async getOutputs(): Promise { + throw new NotImplementedError(); + } + + /** + * Disable an output + * @param outputName The name of the output to disable + */ + async disableOutput(outputName: string): Promise { + 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; +} diff --git a/services/compositor/connections/sway.ts b/services/compositor/connections/sway.ts new file mode 100644 index 0000000..510bbbc --- /dev/null +++ b/services/compositor/connections/sway.ts @@ -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 +{ + public readonly success: false = false; + public readonly error: string; + public readonly parse_error: boolean; + + constructor( + response: Required, + public readonly command?: string, + ) { + super(`Command ${command ?? ""} 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, 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 { + 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 { + return JSON.parse( + (await this.#sendRaw( + this.#commandSocket, + SwayMessage.RUN_COMMAND, + command, + )) as string, + ); + } + + /** + * Get the current sway workspaces + */ + public async getWorkspaces(): Promise { + return JSON.parse( + (await this.#sendRaw( + this.#commandSocket, + SwayMessage.GET_WORKSPACES, + )) as string, + ); + } + + /** + * Get the current sway outputs + */ + public async getOutputs(): Promise { + return JSON.parse( + (await this.#sendRaw( + this.#commandSocket, + SwayMessage.GET_OUTPUTS, + )) as string, + ); + } + + /** + * Get the current sway tree + */ + public async getTree(): Promise { + 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 { + console.log(`Connecting to sway socket ${address}`); + const client = new Gio.SocketClient(); + return new Promise((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 { + 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 { + 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; + } + } +} diff --git a/services/compositor/connections/sway_types.ts b/services/compositor/connections/sway_types.ts new file mode 100644 index 0000000..bdf6756 --- /dev/null +++ b/services/compositor/connections/sway_types.ts @@ -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; +} diff --git a/services/compositor/errors.ts b/services/compositor/errors.ts new file mode 100644 index 0000000..a4e0911 --- /dev/null +++ b/services/compositor/errors.ts @@ -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; + } +} diff --git a/services/compositor/types.ts b/services/compositor/types.ts new file mode 100644 index 0000000..38f4f2f --- /dev/null +++ b/services/compositor/types.ts @@ -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 = { new (...args: any[]): T }; + +@register({ + GTypeName: "VSWrapper", +}) +export class VSWrapper extends GObject.Object { + constructor(obj: WrappedInterface) { + super(); + Object.assign(this, obj); + } + + /** + * Wraps the given object. + */ + public static wrap< + WrappedInterface extends Object, + Wrapper extends VSWrapper, + >(this: Constructor, 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 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 + 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 + 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 + 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 + 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; +}; diff --git a/services/sway/index.ts b/services/sway/index.ts new file mode 100644 index 0000000..5b84fc9 --- /dev/null +++ b/services/sway/index.ts @@ -0,0 +1 @@ +export * from "./workspaces"; diff --git a/services/sway/scoring.ts b/services/sway/scoring.ts new file mode 100644 index 0000000..a10611a --- /dev/null +++ b/services/sway/scoring.ts @@ -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 | undefined] { + let score = 0; + const map = new Map(); + const outputSet = new Set(outputs); + const usedGroups = new Set(); + + 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]; + } +} diff --git a/services/sway/types.ts b/services/sway/types.ts index 9258815..bdf6756 100644 --- a/services/sway/types.ts +++ b/services/sway/types.ts @@ -278,6 +278,7 @@ export interface TreeNode { inhibit_idle?: boolean; window?: number; window_properties?: XWaylandWindowProperties; + output?: string; } export enum InputType { diff --git a/services/sway/workspaces.ts b/services/sway/workspaces.ts index 4699ed1..b515955 100644 --- a/services/sway/workspaces.ts +++ b/services/sway/workspaces.ts @@ -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, "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(); - 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,38 +565,73 @@ 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, _]]) => { - const group = this.#groups.get(name); - if (group === undefined) { - throw new InvalidContextError( - this, - `Could not activate context: group ${name} is not defined.`, - ); - } - await group.activate(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, + `Could not activate context: group ${name} is not defined.`, + ); + } + 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) { // 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 = new Map(); #contexts: Map = 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, + ); + 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("_", "-")) + ); + } } diff --git a/synchronization.ts b/synchronization.ts index 3ffe88f..c64b13c 100644 --- a/synchronization.ts +++ b/synchronization.ts @@ -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(func: () => T): Promise { + if (this.locked) { + throw new MutexAcquireFailure("Mutex was already locked"); + } + return this.with(func); + } } diff --git a/utils.ts b/utils.ts index 6b4aa68..7069385 100644 --- a/utils.ts +++ b/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( + callbackfn: (value: T, index: number, array: T[]) => Promise, + ): Promise; } } +Array.prototype.amap = function ( + this: unknown[], + callbackfn: (value: unknown, index: number, array: unknown[]) => Promise, +): Promise { + return Promise.all(this.map(callbackfn)); +}; + Array.prototype.rotate = function (this: T[], n: number): T[] { const array = this; const length = array.length; @@ -142,3 +157,9 @@ Array.prototype.rotate = function (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((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/widget/sway/Workspace.tsx b/widget/sway/Workspace.tsx new file mode 100644 index 0000000..8936896 --- /dev/null +++ b/widget/sway/Workspace.tsx @@ -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 ( + + ); +} + +export function SwayActiveWorkspace(group: Group) { + const workspace = bind(group, "focusedWorkspace"); + const name = Variable.derive([workspace], (ws) => {}); +}