First update in a while lol
This commit is contained in:
parent
cc75318666
commit
23b63fa58d
4
.eslintrc
Normal file
4
.eslintrc
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"plugins": ["gjs"],
|
||||||
|
"extends": ["plugin:gjs/application"]
|
||||||
|
}
|
||||||
4
ambient.d.ts
vendored
Normal file
4
ambient.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import "@girs/gjs";
|
||||||
|
import "@girs/gdk-3.0";
|
||||||
|
import "@girs/gjs/dom";
|
||||||
|
import "@girs/gtk-3.0";
|
||||||
15
app.ts
15
app.ts
@ -1,11 +1,16 @@
|
|||||||
import { App } from "astal/gtk3"
|
import { App } from "astal/gtk3";
|
||||||
import style from "./style.css"
|
import style from "./style.css";
|
||||||
import Bar from "./widget/Bar"
|
import Bar from "./widget/Bar";
|
||||||
|
import "@/globals";
|
||||||
|
import { Sway } from "@services/sway/ipc";
|
||||||
|
import { Tree } from "@services/sway/workspaces";
|
||||||
|
|
||||||
|
new Tree("/home/ezri/.config/sway/workspaces.json");
|
||||||
|
|
||||||
App.start({
|
App.start({
|
||||||
css: style,
|
css: style,
|
||||||
main() {
|
main() {
|
||||||
Bar(0)
|
Bar(0);
|
||||||
// Bar(1) // initialize other monitors
|
// Bar(1) // initialize other monitors
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
0
dbus/dbus.ts
Normal file
0
dbus/dbus.ts
Normal file
3
dbus/dev.ezri.voidshell.workspaces.Context.xml
Normal file
3
dbus/dev.ezri.voidshell.workspaces.Context.xml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<!DOCTYPE
|
||||||
|
interface PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
|
||||||
|
"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
|
||||||
12
dbus/dev.ezri.voidshell.workspaces.Group.xml
Normal file
12
dbus/dev.ezri.voidshell.workspaces.Group.xml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE
|
||||||
|
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.Group">
|
||||||
|
<property name="name" type="s" access="read" />
|
||||||
|
<property name="outputName" type="s" access="read" />
|
||||||
|
<property name="focused" type="b" access="read" />
|
||||||
|
<property name="focusedWorkspacePath" type="o" access="read" />
|
||||||
|
<property name="workspacePaths" type="ao" access="read" />
|
||||||
|
</interface>
|
||||||
|
</node>
|
||||||
16
dbus/dev.ezri.voidshell.workspaces.Workspace.xml
Normal file
16
dbus/dev.ezri.voidshell.workspaces.Workspace.xml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE
|
||||||
|
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.Workspace">
|
||||||
|
<property name="active" type="b" access="read" />
|
||||||
|
<property name="focused" type="b" access="read" />
|
||||||
|
<property name="visible" type="b" access="read" />
|
||||||
|
<property name="urgent" type="b" access="read" />
|
||||||
|
<property name="name" type="s" access="read" />
|
||||||
|
<method name="focus" />
|
||||||
|
<method name="relocate">
|
||||||
|
<arg name="to" type="o" direction="in"/>
|
||||||
|
</method>
|
||||||
|
</interface>
|
||||||
|
</node>
|
||||||
1406
package-lock.json
generated
Normal file
1406
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "VoidShell",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "Custom GTK3 desktop shell meant for Sway sessions",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://git.ezri.dev/ezri/VoidShell.git"
|
||||||
|
},
|
||||||
|
"author": "Ezri Brimhall <ezri@ezri.dev>",
|
||||||
|
"license": "All right reserved",
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "^9.14.0",
|
||||||
|
"eslint-plugin-jsdoc": "^50.4.3",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@girs/gdk-3.0": "^3.24.43-4.0.0-beta.16",
|
||||||
|
"@girs/gjs": "^4.0.0-beta.16"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ export class System {
|
|||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.map((line) => /^PRETTY_HOSTNAME="?([^"]*)"?$/.exec(line))
|
.map((line) => /^PRETTY_HOSTNAME="?([^"]*)"?$/.exec(line))
|
||||||
.filter((line) => line)
|
.filter((line) => line !== null)
|
||||||
.map((line) => line[1]);
|
.map((line) => line[1]);
|
||||||
if (lines.length > 0) {
|
if (lines.length > 0) {
|
||||||
this.#name.set(lines[0]);
|
this.#name.set(lines[0]);
|
||||||
@ -38,7 +38,7 @@ export class System {
|
|||||||
return System._instance;
|
return System._instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
private #name: Variable<string> = Variable("");
|
#name: Variable<string> = Variable("");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The "pretty" hostname of this computer, as a bound value.
|
* The "pretty" hostname of this computer, as a bound value.
|
||||||
42
services/dbus.ts
Normal file
42
services/dbus.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import Gio from "gi://Gio";
|
||||||
|
import GObject, { register } from "astal/gobject";
|
||||||
|
import { getDbusXml } from "@/utils";
|
||||||
|
|
||||||
|
let dbusConnectionResolve: ((connection: any) => void) | null = null;
|
||||||
|
export const Connection: Promise<any> = new Promise<any>(
|
||||||
|
(resolve) => (dbusConnectionResolve = resolve),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ownerId = Gio.bus_own_name(
|
||||||
|
Gio.BusType.SESSION,
|
||||||
|
"dev.ezri.VoidShell",
|
||||||
|
Gio.BusNameOwnerFlags.NONE,
|
||||||
|
(connection) => {
|
||||||
|
dbusConnectionResolve?.(connection);
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
|
||||||
|
@register()
|
||||||
|
export class DBusObject extends GObject.Object {
|
||||||
|
protected dbusObj: Gio.DBusExportedObject | null = null;
|
||||||
|
#objectPath: string;
|
||||||
|
|
||||||
|
constructor(iface: string, objectPath: string) {
|
||||||
|
super();
|
||||||
|
this.#objectPath = objectPath;
|
||||||
|
getDbusXml(iface).then(async (xml) => {
|
||||||
|
try {
|
||||||
|
this.dbusObj = Gio.DBusExportedObject.wrapJSObject(xml, this);
|
||||||
|
this.dbusObj.export(await Connection, objectPath);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error exporting to D-Bus: ${e}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get objectPath(): string {
|
||||||
|
return this.#objectPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -163,7 +163,8 @@ export interface Context {
|
|||||||
* As such, this value can be used to give a context a slight advantage in the case of a tie, boost a more general
|
* As such, this value can be used to give a context a slight advantage in the case of a tie, boost a more general
|
||||||
* context over a more specific one, or remove it from consideration entirely.
|
* context over a more specific one, or remove it from consideration entirely.
|
||||||
*
|
*
|
||||||
* This value is not considered when requesting a context by name.
|
* This value is not considered when requesting a context by name, allowing for contexts which cannot be automatically
|
||||||
|
* selected to be manually selected.
|
||||||
*/
|
*/
|
||||||
priority?: number;
|
priority?: number;
|
||||||
}
|
}
|
||||||
10
services/sway/exceptions.ts
Normal file
10
services/sway/exceptions.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import type { Context } from "./workspaces";
|
||||||
|
|
||||||
|
export class InvalidContextError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly context: Context,
|
||||||
|
reason: string,
|
||||||
|
) {
|
||||||
|
super(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
349
services/sway/ipc.ts
Normal file
349
services/sway/ipc.ts
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
import GObject, { register, property, signal } from "astal/gobject";
|
||||||
|
import {
|
||||||
|
WorkspaceResponse,
|
||||||
|
OutputResponse,
|
||||||
|
SwayMessage,
|
||||||
|
SwayEvent,
|
||||||
|
WorkspaceEvent,
|
||||||
|
OutputEvent,
|
||||||
|
TreeNode,
|
||||||
|
CommandResponse,
|
||||||
|
} from "./types";
|
||||||
|
import Gio from "gi://Gio";
|
||||||
|
import GLib from "gi://GLib";
|
||||||
|
import * as utils from "@/utils";
|
||||||
|
import { Mutex } from "@/synchronization";
|
||||||
|
|
||||||
|
@register({
|
||||||
|
GTypeName: "Sway",
|
||||||
|
})
|
||||||
|
export class Sway extends GObject.Object {
|
||||||
|
#commandSocket: Gio.SocketConnection | null = null;
|
||||||
|
#subscriptionSocket: Gio.SocketConnection | null = null;
|
||||||
|
|
||||||
|
#mode: string = "";
|
||||||
|
|
||||||
|
@signal()
|
||||||
|
/**
|
||||||
|
* Emitted when the connection to sway is established
|
||||||
|
*/
|
||||||
|
declare connected: () => void;
|
||||||
|
|
||||||
|
@signal()
|
||||||
|
/**
|
||||||
|
* Emitted when the connection to sway is lost
|
||||||
|
*/
|
||||||
|
declare disconnected: () => void;
|
||||||
|
|
||||||
|
// TODO: figure out how to give these signals a proper type. For now, Object is /fine/.
|
||||||
|
|
||||||
|
@signal(Object)
|
||||||
|
/**
|
||||||
|
* Emitted when we receive a subscription event
|
||||||
|
*/
|
||||||
|
declare subscribed: (events: SwayEvent[]) => void;
|
||||||
|
|
||||||
|
@signal(Object)
|
||||||
|
/**
|
||||||
|
* Emitted when we receive a workspace event
|
||||||
|
*/
|
||||||
|
declare workspace: (event: WorkspaceEvent) => void;
|
||||||
|
|
||||||
|
@signal(Object)
|
||||||
|
/**
|
||||||
|
* Emitted when we receive an output event
|
||||||
|
*/
|
||||||
|
declare output: (event: OutputEvent) => void;
|
||||||
|
|
||||||
|
@property(String)
|
||||||
|
/**
|
||||||
|
* The current binding mode of sway
|
||||||
|
*/
|
||||||
|
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: Sway;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default instance of Sway
|
||||||
|
*/
|
||||||
|
static get instance() {
|
||||||
|
if (!Sway._instance) {
|
||||||
|
Sway._instance = new Sway();
|
||||||
|
}
|
||||||
|
return Sway._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 = Sway.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 < Sway.MAGIC.length) {
|
||||||
|
const buffer = await utils.readFromStreamRaw(stream, 1);
|
||||||
|
const byte = buffer[0];
|
||||||
|
if (byte !== Sway.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(Sway.HEADER_SIZE + payload.length);
|
||||||
|
const magicView = new Uint8Array(buffer, 0, Sway.MAGIC.length);
|
||||||
|
const lengthView = new DataView(buffer, Sway.MAGIC.length, 4);
|
||||||
|
const typeView = new DataView(buffer, Sway.MAGIC.length + 4, 4);
|
||||||
|
const payloadView = new Uint8Array(buffer, Sway.HEADER_SIZE);
|
||||||
|
|
||||||
|
magicView.set(Sway.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
services/sway/service.ts
Normal file
26
services/sway/service.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import * as Astal from "astal";
|
||||||
|
import GObject, { register, property, signal } from "astal/gobject";
|
||||||
|
import { Sway } from "./ipc";
|
||||||
|
import * as types from "./types";
|
||||||
|
|
||||||
|
@register({
|
||||||
|
GTypeName: "SwayService",
|
||||||
|
})
|
||||||
|
export class SwayService extends GObject.Object {
|
||||||
|
static _instance: SwayService;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
// Initialize the IPC and register event listeners
|
||||||
|
Sway.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static get instance(): SwayService {
|
||||||
|
if (!SwayService._instance) {
|
||||||
|
SwayService._instance = new SwayService();
|
||||||
|
}
|
||||||
|
return SwayService._instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
#onWorkspaceEvent(event: types.WorkspaceEvent) {}
|
||||||
|
}
|
||||||
@ -252,7 +252,7 @@ export interface XWaylandWindowProperties {
|
|||||||
*/
|
*/
|
||||||
export interface TreeNode {
|
export interface TreeNode {
|
||||||
id: number;
|
id: number;
|
||||||
name: number;
|
name: string;
|
||||||
type: NodeType;
|
type: NodeType;
|
||||||
border: NodeBorder;
|
border: NodeBorder;
|
||||||
current_border_width: number;
|
current_border_width: number;
|
||||||
@ -351,8 +351,8 @@ export enum WorkspaceEventChange {
|
|||||||
|
|
||||||
export interface WorkspaceEvent {
|
export interface WorkspaceEvent {
|
||||||
change: WorkspaceEventChange;
|
change: WorkspaceEventChange;
|
||||||
current: WorkspaceResponse;
|
current: TreeNode;
|
||||||
old: WorkspaceResponse | null;
|
old: TreeNode | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OutputEvent {
|
export interface OutputEvent {
|
||||||
807
services/sway/workspaces.ts
Normal file
807
services/sway/workspaces.ts
Normal file
@ -0,0 +1,807 @@
|
|||||||
|
import GObject, { register, property, signal } from "astal/gobject";
|
||||||
|
import { readFileAsync, monitorFile } from "astal/file";
|
||||||
|
import * as types from "./types";
|
||||||
|
import * as defs from "./definitions";
|
||||||
|
import Gdk from "gi://Gdk";
|
||||||
|
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";
|
||||||
|
|
||||||
|
@register({
|
||||||
|
GTypeName: "SwayWorkspace",
|
||||||
|
})
|
||||||
|
export class Workspace extends DBusObject {
|
||||||
|
#active: boolean = false;
|
||||||
|
#focused: boolean = false;
|
||||||
|
#visible: boolean = false;
|
||||||
|
#urgent: boolean = false;
|
||||||
|
|
||||||
|
groups: Set<Group> = new Set();
|
||||||
|
#currentGroup: Group | null = null;
|
||||||
|
#currentOutput: string | null = null;
|
||||||
|
|
||||||
|
constructor(public readonly definition: defs.Workspace) {
|
||||||
|
super(
|
||||||
|
"dev.ezri.voidshell.workspaces.Workspace",
|
||||||
|
`/dev/ezri/VoidShell/workspaces/workspace/${definition.index}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an ephemeral workspace from a sync operation that is not defined in the system.
|
||||||
|
*/
|
||||||
|
public static fromSync(update: types.WorkspaceResponse) {
|
||||||
|
const [name, index] = (() => {
|
||||||
|
if (parseInt(update.name).toString() === update.name) {
|
||||||
|
return [`ephemeral: ${update.name}`, parseInt(update.name)];
|
||||||
|
} else {
|
||||||
|
return [update.name, update.num];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const definition: defs.Workspace = {
|
||||||
|
index,
|
||||||
|
name,
|
||||||
|
exec: "console",
|
||||||
|
program_name: "console",
|
||||||
|
};
|
||||||
|
const workspace = new this(definition);
|
||||||
|
workspace.sync(update);
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(String)
|
||||||
|
get objectPath() {
|
||||||
|
return `/dev/ezri/VoidShell/workspaces/workspace/${this.definition.index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Boolean)
|
||||||
|
get active() {
|
||||||
|
return this.#active;
|
||||||
|
}
|
||||||
|
|
||||||
|
set active(value: boolean) {
|
||||||
|
if (this.#active === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#active = value;
|
||||||
|
this.notify("active");
|
||||||
|
this.dbusObj?.emit_property_changed(
|
||||||
|
"active",
|
||||||
|
GLib.Variant.new_boolean(value),
|
||||||
|
);
|
||||||
|
if (!value) {
|
||||||
|
// Whenever a workspace is deactivated, all other flags should be unset.
|
||||||
|
this.focused = false;
|
||||||
|
this.visible = false;
|
||||||
|
this.urgent = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Boolean)
|
||||||
|
get focused() {
|
||||||
|
return this.#focused;
|
||||||
|
}
|
||||||
|
|
||||||
|
set focused(value: boolean) {
|
||||||
|
if (this.#focused === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#focused = value;
|
||||||
|
this.notify("focused");
|
||||||
|
this.dbusObj?.emit_property_changed(
|
||||||
|
"focused",
|
||||||
|
GLib.Variant.new_boolean(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Boolean)
|
||||||
|
get visible() {
|
||||||
|
return this.#visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
set visible(value: boolean) {
|
||||||
|
if (this.#visible === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#visible = value;
|
||||||
|
this.notify("visible");
|
||||||
|
this.dbusObj?.emit_property_changed(
|
||||||
|
"visible",
|
||||||
|
GLib.Variant.new_boolean(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Boolean)
|
||||||
|
get urgent() {
|
||||||
|
return this.#urgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
set urgent(value: boolean) {
|
||||||
|
if (this.#urgent === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#urgent = value;
|
||||||
|
this.notify("urgent");
|
||||||
|
this.dbusObj?.emit_property_changed(
|
||||||
|
"urgent",
|
||||||
|
GLib.Variant.new_boolean(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(String)
|
||||||
|
get name() {
|
||||||
|
return this.definition.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Group)
|
||||||
|
/**
|
||||||
|
* The group that this workspace is currently assigned to.
|
||||||
|
*
|
||||||
|
* While a workspace can be in multiple groups, including multiple groups
|
||||||
|
* in the same context, it can only be assigned to one group at a time. If
|
||||||
|
* activated in a different group in the same context, it will be reassigned
|
||||||
|
* to that group.
|
||||||
|
*
|
||||||
|
* An inactive workspace will not have a current group, even if it only belongs
|
||||||
|
* to a single group in the active context.
|
||||||
|
*/
|
||||||
|
get currentGroup() {
|
||||||
|
return this.#currentGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
set currentGroup(group: Group | null) {
|
||||||
|
if (group !== this.#currentGroup) {
|
||||||
|
this.#currentGroup = group;
|
||||||
|
this.notify("current-group");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(String)
|
||||||
|
get currentOutput() {
|
||||||
|
return this.#currentGroup?.outputName ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
set currentOutput(output) {
|
||||||
|
if (output !== this.#currentOutput) {
|
||||||
|
this.#currentOutput = output;
|
||||||
|
this.notify("current-output");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate() {
|
||||||
|
console.log(`Deactivating workspace ${this.definition.index}`);
|
||||||
|
this.active = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
sync(update: types.WorkspaceResponse) {
|
||||||
|
this.active = true;
|
||||||
|
this.visible = update.visible;
|
||||||
|
this.focused = update.focused;
|
||||||
|
this.urgent = update.urgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async relocate(to: Group, retainFocus: boolean = false) {
|
||||||
|
if (this.#currentGroup === to) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Sway.instance.command(
|
||||||
|
`workspace ${this.definition.index}; move workspace to output ${to.outputName}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async focus(to?: Group) {
|
||||||
|
if (this.#currentGroup === to || to === undefined) {
|
||||||
|
await Sway.instance.command(`workspace ${this.definition.index}`);
|
||||||
|
} else {
|
||||||
|
await this.relocate(to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveContainerTo() {
|
||||||
|
await Sway.instance.command(
|
||||||
|
`move container to workspace ${this.definition.index}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@register({
|
||||||
|
GTypeName: "SwayWorkspaceGroup",
|
||||||
|
})
|
||||||
|
export class Group extends DBusObject {
|
||||||
|
#workspaces: Workspace[] = [];
|
||||||
|
#context: Context;
|
||||||
|
#outputName: string | null = null;
|
||||||
|
|
||||||
|
#monitor: Gdk.Monitor | null = null;
|
||||||
|
|
||||||
|
@property(Context)
|
||||||
|
get context() {
|
||||||
|
return this.#context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Workspace)
|
||||||
|
get focusedWorkspace() {
|
||||||
|
return this.#workspaces.find((workspace) => workspace.visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Workspace)
|
||||||
|
get focused() {
|
||||||
|
return this.focusedWorkspace?.focused ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Gdk.Monitor)
|
||||||
|
get monitor() {
|
||||||
|
return this.#monitor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(String)
|
||||||
|
get outputName() {
|
||||||
|
return this.#outputName ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Object)
|
||||||
|
get workspacePaths() {
|
||||||
|
return this.#workspaces.map((workspace) => workspace.objectPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(String)
|
||||||
|
get focusedWorkspacePath() {
|
||||||
|
return this.focusedWorkspace?.objectPath ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
@signal()
|
||||||
|
declare gainedFocus: () => void;
|
||||||
|
|
||||||
|
@signal()
|
||||||
|
declare lostFocus: () => void;
|
||||||
|
|
||||||
|
@signal(Gdk.Monitor)
|
||||||
|
declare activated: (monitor: Gdk.Monitor) => void;
|
||||||
|
|
||||||
|
@signal()
|
||||||
|
declare deactivated: () => void;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly definition: defs.Group,
|
||||||
|
public readonly name: string,
|
||||||
|
public readonly outputDefinition: defs.Output,
|
||||||
|
context: Context,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
"dev.ezri.voidshell.workspaces.Group",
|
||||||
|
`/dev/ezri/VoidShell/workspaces/context/${context.name}/group/${name}`,
|
||||||
|
);
|
||||||
|
this.#context = context;
|
||||||
|
this.#workspaces = definition.workspaces
|
||||||
|
.map((index) => Tree.instance.workspaces.get(index.toString()))
|
||||||
|
.filter((workspace) => workspace !== undefined);
|
||||||
|
|
||||||
|
this.#workspaces.forEach((workspace) => {
|
||||||
|
workspace.connect(
|
||||||
|
"notify::visible",
|
||||||
|
// Bind the function, just to be safe.
|
||||||
|
(() => {
|
||||||
|
if (workspace.visible) {
|
||||||
|
this.notify("focused-workspace");
|
||||||
|
}
|
||||||
|
}).bind(this),
|
||||||
|
);
|
||||||
|
workspace.connect(
|
||||||
|
"notify::focused",
|
||||||
|
(() => {
|
||||||
|
if (workspace === this.focusedWorkspace) {
|
||||||
|
this.notify("focused");
|
||||||
|
}
|
||||||
|
}).bind(this),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async activate(output: types.OutputResponse) {
|
||||||
|
// Ensure that this output is compatible with the group.
|
||||||
|
const [score, _] = this.score(output);
|
||||||
|
if (score === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Output ${output.name} is not compatible with this group.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure monitor
|
||||||
|
await Sway.instance.command(
|
||||||
|
`output ${output.name} pos ${this.outputDefinition.position[0]} ${this.outputDefinition.position[1]} ${this.outputDefinition.mode}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enable monitor.
|
||||||
|
await Sway.instance.command(`output ${output.name} enable`);
|
||||||
|
|
||||||
|
// Store monitor name.
|
||||||
|
this.#outputName = output.name;
|
||||||
|
|
||||||
|
// Retrieve GDK monitor.
|
||||||
|
const monitor =
|
||||||
|
Gdk.Display.get_default()?.get_monitor_at_point(
|
||||||
|
this.outputDefinition.position[0],
|
||||||
|
this.outputDefinition.position[1],
|
||||||
|
) ?? null;
|
||||||
|
|
||||||
|
// Notify listeners.
|
||||||
|
this.#monitor = monitor;
|
||||||
|
if (monitor !== null) {
|
||||||
|
this.activated(monitor);
|
||||||
|
}
|
||||||
|
this.notify("monitor");
|
||||||
|
|
||||||
|
// If the output's focused workspace is not in this group, focus the first workspace.
|
||||||
|
if (!this.#workspaces.some((workspace) => workspace.visible)) {
|
||||||
|
await this.#workspaces[0].focus(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add ourselves to each workspace
|
||||||
|
this.#workspaces.forEach((workspace) => workspace.groups.add(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
async focus() {
|
||||||
|
if (this.#outputName === null) {
|
||||||
|
throw new Error("Group is not activated.");
|
||||||
|
}
|
||||||
|
await Sway.instance.command(`focus output ${this.#outputName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkspace(index: number): Workspace | undefined {
|
||||||
|
if (index >= this.#workspaces.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return this.#workspaces[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
async focusWorkspace(index: number): Promise<boolean> {
|
||||||
|
const workspace = this.getWorkspace(index);
|
||||||
|
if (workspace === undefined) return false;
|
||||||
|
try {
|
||||||
|
await workspace.focus(this);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate() {
|
||||||
|
// Remove ourselves from every workspace
|
||||||
|
this.#workspaces.forEach((workspace) => workspace.groups.delete(this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@register({
|
||||||
|
GTypeName: "SwayWorkspaceContext",
|
||||||
|
})
|
||||||
|
export class Context extends DBusObject {
|
||||||
|
#groups: Map<string, Group> = new Map();
|
||||||
|
#focusedGroup: Group | null = null;
|
||||||
|
#groupOrder: string[] = [];
|
||||||
|
#active: boolean = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly definition: defs.Context,
|
||||||
|
public readonly name: string,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
"dev.ezri.voidshell.workspaces.Context",
|
||||||
|
`/dev/ezri/VoidShell/workspaces/context/${name}`,
|
||||||
|
);
|
||||||
|
definition.outputs.forEach((output) => {
|
||||||
|
if (output.group === undefined || !definition.groups[output.group]) {
|
||||||
|
throw new Error(`Group ${output.group} not found in context`);
|
||||||
|
}
|
||||||
|
const group = new Group(
|
||||||
|
definition.groups[output.group],
|
||||||
|
output.group,
|
||||||
|
output,
|
||||||
|
this,
|
||||||
|
);
|
||||||
|
this.#groups.set(output.group, group);
|
||||||
|
});
|
||||||
|
// Group order is defined by the order in which the groups are defined in the context, not the order in which they are defined in the outputs.
|
||||||
|
this.#groupOrder = Object.keys(definition.groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Group)
|
||||||
|
get focusedGroup() {
|
||||||
|
return this.#focusedGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Boolean)
|
||||||
|
get active() {
|
||||||
|
return this.#active;
|
||||||
|
}
|
||||||
|
|
||||||
|
getGroupOnOutput(output: string): Group | undefined {
|
||||||
|
return Array.from(this.#groups.values()).find(
|
||||||
|
(group) => group.outputName === output,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function implements the output matching algorithm.
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
return Array.from(match.values()).reduce(
|
||||||
|
(acc, [_, score]) => acc + score,
|
||||||
|
includePriority ? (this.definition.priority ?? 0) : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate this context.
|
||||||
|
*/
|
||||||
|
async activate() {
|
||||||
|
const outputs = await Sway.instance.getOutputs();
|
||||||
|
const match = this.#matchGroupsWithOutputs(outputs);
|
||||||
|
if (match === null) {
|
||||||
|
throw new InvalidContextError(
|
||||||
|
this,
|
||||||
|
"Could not activate context: context is incompatible with current outputs.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Construct a set of output names that this context will use.
|
||||||
|
const usedOutputs = new Set(
|
||||||
|
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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Notify listeners.
|
||||||
|
this.#active = true;
|
||||||
|
this.notify("active");
|
||||||
|
Tree.instance.notify("current-context");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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");
|
||||||
|
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.
|
||||||
|
const command = Array.from(this.#groups.values())
|
||||||
|
.map((group) => {
|
||||||
|
group.deactivate();
|
||||||
|
return group;
|
||||||
|
})
|
||||||
|
.filter((group) => !outputsToLeave.has(group.outputName ?? ""))
|
||||||
|
.map((group) => `output ${group.outputName} disable`)
|
||||||
|
.join("; ");
|
||||||
|
await Sway.instance.command(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkspace(
|
||||||
|
index: number,
|
||||||
|
): [workspace: Workspace, group: Group] | [null, null] {
|
||||||
|
const currentGroupIndex = Math.max(
|
||||||
|
this.#groupOrder.indexOf(this.focusedGroup?.name ?? ""),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const groupName = this.#groupOrder
|
||||||
|
.rotate(currentGroupIndex)
|
||||||
|
.find((name) => {
|
||||||
|
const group = this.#groups.get(name);
|
||||||
|
if (group === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const workspace = group.getWorkspace(index);
|
||||||
|
if (workspace === this.focusedGroup?.focusedWorkspace) {
|
||||||
|
// If the workspace is the focused workspace of the focused group, don't return it; we want to search other outputs.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// If the workspace at this index exists on this output, return it. Otherwise, return the accumulator.
|
||||||
|
return !!workspace;
|
||||||
|
});
|
||||||
|
if (groupName === undefined) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
const group = this.#groups.get(groupName) ?? null;
|
||||||
|
const workspace = group?.getWorkspace(index) ?? null;
|
||||||
|
if (workspace === null || group === null) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
return [workspace, group];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@register({
|
||||||
|
GTypeName: "SwayWorkspaceTree",
|
||||||
|
})
|
||||||
|
export class Tree extends GObject.Object {
|
||||||
|
#workspaces: Map<string, Workspace> = new Map();
|
||||||
|
#contexts: Map<string, Context> = new Map();
|
||||||
|
|
||||||
|
static _instance: Tree | null = null;
|
||||||
|
|
||||||
|
public get workspaces() {
|
||||||
|
return this.#workspaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #parseFile(path: string) {
|
||||||
|
console.log(`reading file ${path}`);
|
||||||
|
const contents = await readFileAsync(path);
|
||||||
|
const data = JSON.parse(contents) as defs.Config;
|
||||||
|
this.#workspaces = new Map();
|
||||||
|
this.#contexts = new Map();
|
||||||
|
|
||||||
|
// Create workspaces
|
||||||
|
data.workspaces.forEach((workspace) => {
|
||||||
|
this.#workspaces.set(
|
||||||
|
workspace.index.toString(),
|
||||||
|
new Workspace(workspace),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create contexts
|
||||||
|
Object.entries(data.contexts).forEach(([name, context]) => {
|
||||||
|
this.#contexts.set(name, new Context(context, name));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify for tree refresh
|
||||||
|
this.emit("tree-refresh");
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
} else {
|
||||||
|
const connId = Sway.instance.connect("connected", () => {
|
||||||
|
Sway.instance.disconnect(connId);
|
||||||
|
this.sync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronize the state of VoidShell's workspace tree with Sway
|
||||||
|
*/
|
||||||
|
async sync() {
|
||||||
|
const touched = new Set<Workspace>();
|
||||||
|
try {
|
||||||
|
const workspaces = await Sway.instance.getWorkspaces();
|
||||||
|
workspaces.forEach((workspace) => {
|
||||||
|
const ws = this.#workspaces.get(workspace.name);
|
||||||
|
if (ws === undefined) {
|
||||||
|
this.#workspaces.set(workspace.name, Workspace.fromSync(workspace));
|
||||||
|
} else {
|
||||||
|
ws.sync(workspace);
|
||||||
|
touched.add(ws);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.#workspaces.forEach((workspace) => {
|
||||||
|
if (!touched.has(workspace)) {
|
||||||
|
workspace.active = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to sync", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static get instance() {
|
||||||
|
if (!this._instance) {
|
||||||
|
throw new Error("Tree instance not initialized");
|
||||||
|
}
|
||||||
|
return Tree._instance as Tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(filepath: string) {
|
||||||
|
super();
|
||||||
|
console.log(`Parsing file ${filepath}`);
|
||||||
|
if (Tree._instance !== null) {
|
||||||
|
console.error("Tree instance already initialized...");
|
||||||
|
throw new Error("Tree instance already initialized");
|
||||||
|
}
|
||||||
|
Tree._instance = this;
|
||||||
|
console.log("Dereferencing symlink");
|
||||||
|
dereferenceSymbolicLink(filepath)
|
||||||
|
.then(async (resolvedPath) => {
|
||||||
|
console.log(`Resolved ${filepath} to ${resolvedPath}`);
|
||||||
|
try {
|
||||||
|
await this.#parseFile(resolvedPath);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error parsing config file:", e);
|
||||||
|
}
|
||||||
|
monitorFile(filepath, async (_, event) => {
|
||||||
|
if (event === Gio.FileMonitorEvent.CHANGES_DONE_HINT) {
|
||||||
|
const oldWorkspaces = this.#workspaces;
|
||||||
|
const oldContexts = this.#contexts;
|
||||||
|
try {
|
||||||
|
await this.#parseFile(filepath);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error parsing config file:", e);
|
||||||
|
this.#workspaces = oldWorkspaces;
|
||||||
|
this.#contexts = oldContexts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
console.error("Error dereferencing symlink:", reason);
|
||||||
|
});
|
||||||
|
Sway.instance.connect("workspace", (_, event: types.WorkspaceEvent) => {
|
||||||
|
this.sync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Context)
|
||||||
|
get currentContext(): Context | null {
|
||||||
|
return (
|
||||||
|
Array.from(this.#contexts.values()).find((context) => context.active) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(String)
|
||||||
|
get currentContextName(): string {
|
||||||
|
return (
|
||||||
|
Array.from(this.#contexts.entries()).find(
|
||||||
|
([, context]) => context.active,
|
||||||
|
)?.[0] ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@signal()
|
||||||
|
/**
|
||||||
|
* Emitted when a context begins deactivating.
|
||||||
|
*/
|
||||||
|
declare deactivating: () => void;
|
||||||
|
|
||||||
|
@signal()
|
||||||
|
/**
|
||||||
|
* Emitted after a full tree refresh which invalidates all context, group, and workspace objects.
|
||||||
|
*/
|
||||||
|
declare treeRefresh: () => void;
|
||||||
|
|
||||||
|
async findNewContext() {
|
||||||
|
const outputs = await Sway.instance.getOutputs();
|
||||||
|
const topScore = Array.from(this.#contexts.values())
|
||||||
|
.map((context) => [context, context.score(outputs)] as const)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.find(([_, score]) => score > 0)?.[0];
|
||||||
|
if (topScore === undefined) {
|
||||||
|
// No available context
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await topScore.activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestContext(name: string) {
|
||||||
|
const context = this.#contexts.get(name);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error(`Context ${name} not found`);
|
||||||
|
}
|
||||||
|
// Get current outputs
|
||||||
|
const outputs = await Sway.instance.getOutputs();
|
||||||
|
// Score the context
|
||||||
|
const score = context.score(outputs, false);
|
||||||
|
if (score > 0) {
|
||||||
|
await context.activate();
|
||||||
|
}
|
||||||
|
throw new Error(`Context ${name} is incompatible with current outputs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkspace(
|
||||||
|
groupIndex: number,
|
||||||
|
): [workspace: Workspace, group: Group] | [null, null] {
|
||||||
|
return this.currentContext?.getWorkspace(groupIndex) ?? [null, null];
|
||||||
|
}
|
||||||
|
}
|
||||||
59
synchronization.ts
Normal file
59
synchronization.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic synchronization primitive that only allows a single coroutine to access a resource
|
||||||
|
* at a time.
|
||||||
|
*/
|
||||||
|
export class Mutex {
|
||||||
|
private locking = Promise.resolve();
|
||||||
|
private isLocked = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current state of the mutex.
|
||||||
|
* Unlike multithreaded synchronization primitives, this can be queried safely without having to acquire
|
||||||
|
* the mutex, as asyncio multitasking is cooperative, meaning you don't need to worry about another task
|
||||||
|
* interrupting your function unless your function returns to the event loop via an 'await' statement.
|
||||||
|
*/
|
||||||
|
get locked() {
|
||||||
|
return this.isLocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquire the mutex. This returns a function to release the mutex, which must be called
|
||||||
|
* or the application will deadlock.
|
||||||
|
*
|
||||||
|
* It is preferred to use the with() method instead of calling acquire() directly, which provides
|
||||||
|
* safeguards to ensure that the lock will always be released, however some logic and code flows
|
||||||
|
* will require the flexibility that calling acquire() directly gives.
|
||||||
|
*/
|
||||||
|
async acquire() {
|
||||||
|
this.isLocked = true;
|
||||||
|
let unlockNext: () => void;
|
||||||
|
const willLock = new Promise<void>((resolve) => (unlockNext = resolve));
|
||||||
|
willLock.then(() => (this.isLocked = false));
|
||||||
|
const willUnlock = this.locking.then(() => unlockNext);
|
||||||
|
this.locking = this.locking.then(() => willLock);
|
||||||
|
return willUnlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes the given function with the mutex acquired, and releases the mutex when the function exits.
|
||||||
|
*
|
||||||
|
* This is the preferred way to utilize a Mutex, as it ensures that it will not be left in a locked state.
|
||||||
|
*/
|
||||||
|
async with<T>(func: () => T): Promise<T> {
|
||||||
|
const release = await this.acquire();
|
||||||
|
try {
|
||||||
|
let result = func();
|
||||||
|
while (result instanceof Promise) {
|
||||||
|
result = await result;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,13 +9,13 @@
|
|||||||
// "allowJs": true,
|
// "allowJs": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "/usr/share/astal/gjs/gtk3",
|
"jsxImportSource": "/usr/share/astal/gjs/gtk3",
|
||||||
|
"include": ["ambient.d.ts"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"astal": [
|
"astal": ["/usr/share/astal/gjs"],
|
||||||
"/usr/share/astal/gjs"
|
"astal/*": ["/usr/share/astal/gjs/*"],
|
||||||
],
|
"@/*": ["./*"],
|
||||||
"astal/*": [
|
"@widgets/*": ["./widget/*"],
|
||||||
"/usr/share/astal/gjs/*"
|
"@services/*": ["./services/*"]
|
||||||
]
|
}
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
144
utils.ts
Normal file
144
utils.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import Gio from "gi://Gio";
|
||||||
|
import GLib from "gi://GLib";
|
||||||
|
import { readFileAsync } from "astal/file";
|
||||||
|
|
||||||
|
export async function readFromStreamRaw(
|
||||||
|
stream: Gio.InputStream,
|
||||||
|
bytes: number,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
return new Promise<Uint8Array>((resolve, reject) => {
|
||||||
|
stream.read_bytes_async(
|
||||||
|
bytes,
|
||||||
|
GLib.PRIORITY_DEFAULT,
|
||||||
|
null,
|
||||||
|
(stream, result) => {
|
||||||
|
try {
|
||||||
|
const data = stream!.read_bytes_finish(result);
|
||||||
|
const buffer = data.get_data();
|
||||||
|
if (buffer === null) {
|
||||||
|
reject(new Error("Failed to read from stream"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(buffer);
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readFromStream(
|
||||||
|
stream: Gio.InputStream,
|
||||||
|
bytes: number,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const chunkCount = Math.ceil(bytes / 4096);
|
||||||
|
const buffer = await Array.from({ length: chunkCount }, (_, i) => i).reduce(
|
||||||
|
async (acc, i) => {
|
||||||
|
const buffer = await acc;
|
||||||
|
const chunkSize = Math.min(4096, bytes - i * 4096);
|
||||||
|
const chunk = await readFromStreamRaw(stream, chunkSize);
|
||||||
|
buffer.set(chunk, i * 4096);
|
||||||
|
return buffer;
|
||||||
|
},
|
||||||
|
Promise.resolve(new Uint8Array(bytes)),
|
||||||
|
);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeToStream(
|
||||||
|
stream: Gio.OutputStream,
|
||||||
|
data: ArrayBuffer | Uint8Array,
|
||||||
|
): Promise<void> {
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
data = new Uint8Array(data);
|
||||||
|
}
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
stream.write_all_async(
|
||||||
|
data as Uint8Array,
|
||||||
|
GLib.PRIORITY_DEFAULT,
|
||||||
|
null,
|
||||||
|
(stream, result) => {
|
||||||
|
try {
|
||||||
|
stream?.write_all_finish(result) ??
|
||||||
|
reject(new Error("Failed to write to stream"));
|
||||||
|
resolve();
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dereferenceSymbolicLink(
|
||||||
|
filename: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
console.log(`Dereferencing symbolic link for ${filename}`);
|
||||||
|
const file = Gio.File.new_for_path(filename);
|
||||||
|
file.query_info_async(
|
||||||
|
"standard::is-symlink,standard::symlink-target",
|
||||||
|
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
|
||||||
|
GLib.PRIORITY_DEFAULT,
|
||||||
|
null,
|
||||||
|
(file, result) => {
|
||||||
|
try {
|
||||||
|
const info = file!.query_info_finish(result);
|
||||||
|
if (info.get_is_symlink()) {
|
||||||
|
console.log("File is a symlink");
|
||||||
|
const target = info.get_symlink_target() as string;
|
||||||
|
console.log(target);
|
||||||
|
if (target.startsWith("./") || target.startsWith("../")) {
|
||||||
|
resolve(file!.resolve_relative_path(target).get_path()!);
|
||||||
|
} else if (target.startsWith("/")) {
|
||||||
|
resolve(target);
|
||||||
|
} else {
|
||||||
|
resolve(file!.get_parent()!.get_child(target).get_path()!);
|
||||||
|
}
|
||||||
|
resolve(file!.resolve_relative_path(target).get_path()!);
|
||||||
|
} else {
|
||||||
|
resolve(filename);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbusXml = new Map<string, string>();
|
||||||
|
|
||||||
|
export async function getDbusXml(ifaceName: string) {
|
||||||
|
if (dbusXml.has(ifaceName)) {
|
||||||
|
return dbusXml.get(ifaceName) as string;
|
||||||
|
}
|
||||||
|
const contents = await readFileAsync(`dbus/${ifaceName}.xml`);
|
||||||
|
dbusXml.set(ifaceName, contents);
|
||||||
|
return contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// Add new "rotate" method to Array
|
||||||
|
interface Array<T> {
|
||||||
|
/**
|
||||||
|
* Rotates the array by `n` positions.
|
||||||
|
*
|
||||||
|
* @param n The number of positions to rotate the array by.
|
||||||
|
* @returns The rotated array.
|
||||||
|
*/
|
||||||
|
rotate(n: number): T[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.prototype.rotate = function <T>(this: T[], n: number): T[] {
|
||||||
|
const array = this;
|
||||||
|
const length = array.length;
|
||||||
|
if (length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
n = ((n % length) + length) % length;
|
||||||
|
return array.slice(n).concat(array.slice(0, n));
|
||||||
|
};
|
||||||
@ -1,10 +1,13 @@
|
|||||||
import { App, Astal, Gtk } from "astal/gtk3";
|
import { App, Astal, Gtk } from "astal/gtk3";
|
||||||
import { Variable } from "astal";
|
import { bind, Variable } from "astal";
|
||||||
import { SystemName } from "./system/Name";
|
import { SystemName } from "./system/Name";
|
||||||
|
import { Tree } from "@/services/sway/workspaces";
|
||||||
|
|
||||||
const time = Variable("").poll(1000, "date");
|
const time = Variable("").poll(1000, "date");
|
||||||
|
|
||||||
export default function Bar(monitor: number) {
|
export default function Bar(monitor: number) {
|
||||||
|
const tree = Tree.instance;
|
||||||
|
const currentContext = bind(tree, "currentContextName");
|
||||||
return (
|
return (
|
||||||
<window
|
<window
|
||||||
className="Bar"
|
className="Bar"
|
||||||
@ -18,9 +21,7 @@ export default function Bar(monitor: number) {
|
|||||||
application={App}
|
application={App}
|
||||||
>
|
>
|
||||||
<centerbox>
|
<centerbox>
|
||||||
<button onClicked="echo hello" halign={Gtk.Align.CENTER}>
|
<label label={currentContext} />
|
||||||
Welcome to AGS!
|
|
||||||
</button>
|
|
||||||
<box>
|
<box>
|
||||||
<SystemName />
|
<SystemName />
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@ -1,312 +0,0 @@
|
|||||||
import GObject, { register, property, signal } from "astal/gobject";
|
|
||||||
import { App } from "astal";
|
|
||||||
import {
|
|
||||||
WorkspaceResponse,
|
|
||||||
OutputResponse,
|
|
||||||
SwayMessage,
|
|
||||||
SwayEvent,
|
|
||||||
ModeEvent,
|
|
||||||
WorkspaceEvent,
|
|
||||||
OutputEvent,
|
|
||||||
} from "./types";
|
|
||||||
import Gdk from "gi://Gdk";
|
|
||||||
import Gio from "gi://Gio";
|
|
||||||
import GLib from "gi://GLib";
|
|
||||||
import * as utils from "../../utils";
|
|
||||||
|
|
||||||
@register({
|
|
||||||
GTypeName: "Sway",
|
|
||||||
})
|
|
||||||
/**
|
|
||||||
* Sway connection singleton that implements the sway IPC protocol at a low level.
|
|
||||||
*/
|
|
||||||
export class Sway extends GObject.Object {
|
|
||||||
private #commandSocket: Gio.SocketConnection | null = null;
|
|
||||||
private #subscriptionSocket: Gio.SocketConnection | null = null;
|
|
||||||
|
|
||||||
private #mode: string = "";
|
|
||||||
|
|
||||||
@signal()
|
|
||||||
/**
|
|
||||||
* Emitted when the connection to sway is established
|
|
||||||
*/
|
|
||||||
declare connected: () => void;
|
|
||||||
|
|
||||||
@signal()
|
|
||||||
/**
|
|
||||||
* Emitted when the connection to sway is lost
|
|
||||||
*/
|
|
||||||
declare disconnected: () => void;
|
|
||||||
|
|
||||||
@signal(Array)
|
|
||||||
/**
|
|
||||||
* Emitted when we receive a subscription event
|
|
||||||
*/
|
|
||||||
declare subscribed: (events: SwayEvent[]) => void;
|
|
||||||
|
|
||||||
@signal(Object)
|
|
||||||
/**
|
|
||||||
* Emitted when we receive a workspace event
|
|
||||||
*/
|
|
||||||
declare workspace: (event: WorkspaceEvent) => void;
|
|
||||||
|
|
||||||
@signal(Object)
|
|
||||||
/**
|
|
||||||
* Emitted when we receive an output event
|
|
||||||
*/
|
|
||||||
declare output: (event: OutputEvent) => void;
|
|
||||||
|
|
||||||
@property(String)
|
|
||||||
/**
|
|
||||||
* The current binding mode of sway
|
|
||||||
*/
|
|
||||||
get mode(): string {
|
|
||||||
return this.#mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run a command on sway
|
|
||||||
*/
|
|
||||||
public async command(command: string): Promise<CommandResponse[]> {
|
|
||||||
const result = JSON.parse(
|
|
||||||
await this.#sendRaw(this.#commandSocket, SwayMessage.RUN_COMMAND, command)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current sway workspaces
|
|
||||||
*/
|
|
||||||
public async getWorkspaces(): Promise<WorkspaceResponse[]> {
|
|
||||||
const result = JSON.parse(
|
|
||||||
await this.#sendRaw(this.#commandSocket, SwayMessage.GET_WORKSPACES)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current sway outputs
|
|
||||||
*/
|
|
||||||
public async getOutputs(): Promise<OutputResponse[]> {
|
|
||||||
const result = JSON.parse(
|
|
||||||
await this.#sendRaw(this.#commandSocket, SwayMessage.GET_OUTPUTS)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current sway tree
|
|
||||||
*/
|
|
||||||
public async getTree(): Promise<TreeNode> {
|
|
||||||
const result = JSON.parse(
|
|
||||||
await this.#sendRaw(this.#commandSocket, SwayMessage.GET_TREE)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private constructor() {
|
|
||||||
super();
|
|
||||||
this.#connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static #instance: Sway;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the default instance of Sway
|
|
||||||
*/
|
|
||||||
static get_default() {
|
|
||||||
if (!Sway.#instance) {
|
|
||||||
Sway.#instance = new Sway();
|
|
||||||
}
|
|
||||||
return Sway.#instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async #createConnection(
|
|
||||||
address: string
|
|
||||||
): Promise<Gio.SocketConnection> {
|
|
||||||
const client = new Gio.SocketClient();
|
|
||||||
return new Promise<Gio.SocketConnection>((resolve, reject) => {
|
|
||||||
client.connect_to_uri_async(
|
|
||||||
`unix:${address}`,
|
|
||||||
0,
|
|
||||||
null,
|
|
||||||
(client, result) => {
|
|
||||||
try {
|
|
||||||
const connection = client.connect_to_uri_finish(result);
|
|
||||||
resolve(connection);
|
|
||||||
} catch (e) {
|
|
||||||
reject(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async #getMode(): Promise<string> {
|
|
||||||
const result = JSON.parse(
|
|
||||||
await this.#sendRaw(this.#commandSocket, SwayMessage.GET_BINDING_STATE)
|
|
||||||
);
|
|
||||||
return result.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async #connect() {
|
|
||||||
const address = GLib.getenv("SWAYSOCK");
|
|
||||||
if (!address) {
|
|
||||||
throw new Error("SWAYSOCK not set");
|
|
||||||
}
|
|
||||||
const socket = new Gio.SocketClient();
|
|
||||||
// Create two connections, one for commands and one for subscriptions. This is so we
|
|
||||||
// can easily distinguish between responses to commands and subscription events.
|
|
||||||
const command = await this.#createConnection(address);
|
|
||||||
const subscription = await this.#createConnection(address);
|
|
||||||
|
|
||||||
this.#commandSocket = command;
|
|
||||||
this.#subscriptionSocket = subscription;
|
|
||||||
this.connected();
|
|
||||||
|
|
||||||
// Get the current mode
|
|
||||||
this.#mode = await this.#getMode();
|
|
||||||
this.notify("mode");
|
|
||||||
|
|
||||||
this.#startSubscriberListen();
|
|
||||||
}
|
|
||||||
|
|
||||||
static readonly MAGIC = "i3-ipc";
|
|
||||||
static readonly HEADER_SIZE = Sway.MAGIC.length + 8;
|
|
||||||
static readonly CHUNK_SIZE = 4096;
|
|
||||||
|
|
||||||
private #startSubscriberListen() {
|
|
||||||
if (!this.#subscriptionSocket) {
|
|
||||||
throw new Error("Not connected");
|
|
||||||
}
|
|
||||||
this.#subscribe(
|
|
||||||
SwayEvent.OUTPUT,
|
|
||||||
SwayEvent.WORKSPACE,
|
|
||||||
SwayEvent.MODE,
|
|
||||||
SwayEvent.SHUTDOWN
|
|
||||||
);
|
|
||||||
const inputStream = this.#subscriptionSocket.input_stream;
|
|
||||||
const reader = new Gio.DataInputStream({ base_stream: inputStream });
|
|
||||||
const read = async () => {
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
const [event, payload] = await this.#readRaw(
|
|
||||||
this.#subscriptionSocket
|
|
||||||
);
|
|
||||||
switch (event) {
|
|
||||||
case SwayEvent.OUTPUT:
|
|
||||||
const event = JSON.parse(payload) as OutputResponse;
|
|
||||||
this.output(event);
|
|
||||||
break;
|
|
||||||
case SwayEvent.WORKSPACE:
|
|
||||||
const event = JSON.parse(payload) as WorkspaceResponse;
|
|
||||||
this.workspace(event);
|
|
||||||
break;
|
|
||||||
case SwayEvent.MODE:
|
|
||||||
const event = JSON.parse(payload) as ModeResponse;
|
|
||||||
this.mode(event);
|
|
||||||
break;
|
|
||||||
case SwayEvent.SHUTDOWN:
|
|
||||||
this.disconnected();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.warn("Unhandled event", event);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to read event", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
read();
|
|
||||||
}
|
|
||||||
|
|
||||||
private 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;
|
|
||||||
const magic = new Uint8Array(Sway.MAGIC.length);
|
|
||||||
while (idx < Sway.MAGIC.length) {
|
|
||||||
const buffer = await utils.readFromStreamRaw(stream, 1);
|
|
||||||
const byte = buffer[0];
|
|
||||||
if (byte !== Sway.MAGIC.charCodeAt(idx)) {
|
|
||||||
// Reset if we don't match the magic string
|
|
||||||
idx = 0;
|
|
||||||
} else {
|
|
||||||
// Otherwise, keep going
|
|
||||||
idx++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const header = new DataView(await readFromStream(stream, 8).buffer);
|
|
||||||
const length = header.getUint32(0, true);
|
|
||||||
const type = header.getUint32(4, true);
|
|
||||||
return { length, type };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async #readRaw(
|
|
||||||
socket: Gio.SocketConnection
|
|
||||||
): Promise<[SwayMessage | SwayEvent, string]> {
|
|
||||||
const inputStream = socket.input_stream;
|
|
||||||
|
|
||||||
const { length, type } = await this.#readHeader(inputStream);
|
|
||||||
const payloadBuf = utils.readFromStream(inputStream, length);
|
|
||||||
const payload = new TextDecoder().decode(payloadBuf);
|
|
||||||
|
|
||||||
return [type as SwayMessage | SwayEvent, payload];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async #sendRaw(
|
|
||||||
socket: Gio.SocketConnection | null,
|
|
||||||
type: SwayMessage,
|
|
||||||
payload: string = ""
|
|
||||||
): Promise<string> {
|
|
||||||
if (!socket || !socket.is_connected()) {
|
|
||||||
throw new Error("Not connected");
|
|
||||||
}
|
|
||||||
if (socket === this.#commandSocket && type === SwayMessage.SUBSCRIBE) {
|
|
||||||
throw new Error("Cannot subscribe on command socket");
|
|
||||||
}
|
|
||||||
// Construct the message
|
|
||||||
const buffer = new ArrayBuffer(Sway.HEADER_SIZE + payload.length);
|
|
||||||
const magicView = new Uint8Array(buffer, 0, Sway.MAGIC.length);
|
|
||||||
const lengthView = new DataView(buffer, Sway.MAGIC.length, 4);
|
|
||||||
const typeView = new DataView(buffer, Sway.MAGIC.length + 4, 4);
|
|
||||||
const payloadView = new Uint8Array(buffer, Sway.HEADER_SIZE);
|
|
||||||
|
|
||||||
magicView.set(Sway.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 = this.#commandSocket.output_stream;
|
|
||||||
|
|
||||||
// Send the message
|
|
||||||
await utils.writeToStream(outputStream, buffer);
|
|
||||||
|
|
||||||
// Read the response
|
|
||||||
const [resultType, result] = await this.#readRaw(socket);
|
|
||||||
if (resultType !== type) {
|
|
||||||
throw new Error(`Unexpected response type: ${resultType}`);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async #subscribe(...events: SwayEvent[]) {
|
|
||||||
if (!this.#subscriptionSocket) {
|
|
||||||
throw new Error("Not connected");
|
|
||||||
}
|
|
||||||
const eventNames = events.map((e) => SwayEvent[e].toLowerCase());
|
|
||||||
const payload = JSON.stringify(eventNames);
|
|
||||||
const result = JSON.parse(
|
|
||||||
await this.#sendRaw(
|
|
||||||
this.#subscriptionSocket,
|
|
||||||
SwayMessage.SUBSCRIBE,
|
|
||||||
payload
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (result.success) {
|
|
||||||
this.subscribed(events);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Failed to subscribe: ${result}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
import { App } from "astal";
|
|
||||||
import GObject, { register, property, signal } from "astal/gobject";
|
|
||||||
import { Sway } from "./ipc";
|
|
||||||
import * as types from "./types";
|
|
||||||
|
|
||||||
@register({
|
|
||||||
GTypeName: "SwayService",
|
|
||||||
})
|
|
||||||
export class SwayService extends GObject.Object {
|
|
||||||
private static #instance: SwayService;
|
|
||||||
|
|
||||||
private constructor() {
|
|
||||||
// Initialize the IPC and register event listeners
|
|
||||||
Sway.get_default();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get_default(): SwayService {
|
|
||||||
if (!SwayService.#instance) {
|
|
||||||
SwayService.#instance = new SwayService();
|
|
||||||
}
|
|
||||||
return SwayService.#instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
private #onWorkspaceEvent(event: types.WorkspaceEvent) {}
|
|
||||||
}
|
|
||||||
@ -1,343 +0,0 @@
|
|||||||
import GObject, { register, property, signal } from "astal/gobject";
|
|
||||||
import { App } from "astal";
|
|
||||||
import { readFileAsync, monitorFile } from "astal/file";
|
|
||||||
import * as types from "./types";
|
|
||||||
import * as defs from "./definitions";
|
|
||||||
import Gdk from "gi://Gdk";
|
|
||||||
import Gio from "gi://Gio";
|
|
||||||
import GLib from "gi://GLib";
|
|
||||||
import { Sway } from "./ipc";
|
|
||||||
|
|
||||||
@register({
|
|
||||||
GTypeName: "SwayWorkspace",
|
|
||||||
})
|
|
||||||
export class Workspace extends GObject.Object {
|
|
||||||
private #active: boolean = false;
|
|
||||||
private #focused: boolean = false;
|
|
||||||
private #visible: boolean = false;
|
|
||||||
private #urgent: boolean = false;
|
|
||||||
|
|
||||||
private #groups: Group[] = [];
|
|
||||||
private #currentGroup: Group | null = null;
|
|
||||||
private #currentOutput: string | null = null;
|
|
||||||
|
|
||||||
constructor(public readonly definition: defs.Workspace) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
@property(Boolean)
|
|
||||||
get active() {
|
|
||||||
return this.#active;
|
|
||||||
}
|
|
||||||
|
|
||||||
@property(Boolean)
|
|
||||||
get focused() {
|
|
||||||
return this.#focused;
|
|
||||||
}
|
|
||||||
|
|
||||||
@property(Boolean)
|
|
||||||
get visible() {
|
|
||||||
return this.#visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
@property(Boolean)
|
|
||||||
get urgent() {
|
|
||||||
return this.#urgent;
|
|
||||||
}
|
|
||||||
|
|
||||||
@property({ $gtype: "SwayWorkspaceGroup" })
|
|
||||||
/**
|
|
||||||
* The group that this workspace is currently assigned to.
|
|
||||||
*
|
|
||||||
* While a workspace can be in multiple groups, including multiple groups
|
|
||||||
* in the same context, it can only be assigned to one group at a time. If
|
|
||||||
* activated in a different group in the same context, it will be reassigned
|
|
||||||
* to that group.
|
|
||||||
*
|
|
||||||
* An inactive workspace will not have a current group, even if it only belongs
|
|
||||||
* to a single group in the active context.
|
|
||||||
*/
|
|
||||||
get currentGroup() {
|
|
||||||
return this.#currentGroup;
|
|
||||||
}
|
|
||||||
|
|
||||||
@property(String)
|
|
||||||
get currentOutput() {
|
|
||||||
return this.#currentGroup?.outputName ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleEvent(event: types.WorkspaceEvent) {
|
|
||||||
if (
|
|
||||||
event.change === types.WorkspaceEventChange.EMPTY &&
|
|
||||||
event.current?.name == this.definition.index.toString()
|
|
||||||
) {
|
|
||||||
// If the workspace was emptied, deactivate it.
|
|
||||||
this.#active = false;
|
|
||||||
this.#focused = false;
|
|
||||||
this.#visible = false;
|
|
||||||
this.#urgent = false;
|
|
||||||
this.#currentGroup = null;
|
|
||||||
this.#currentOutput = null;
|
|
||||||
this.notify("active");
|
|
||||||
this.notify("focused");
|
|
||||||
this.notify("visible");
|
|
||||||
this.notify("urgent");
|
|
||||||
this.notify("current-group");
|
|
||||||
this.notify("current-output");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const update =
|
|
||||||
event.current?.name === this.definition.index.toString()
|
|
||||||
? event.current
|
|
||||||
: event.old;
|
|
||||||
if (update !== undefined) {
|
|
||||||
this.parseUpdate(update);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
parseUpdate(update: types.WorkspaceResponse) {
|
|
||||||
if (!this.#active) {
|
|
||||||
this.#active = true;
|
|
||||||
this.notify("active");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.#focused !== update.focused) {
|
|
||||||
this.#focused = update.focused;
|
|
||||||
this.notify("focused");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.#visible !== update.visible) {
|
|
||||||
this.#visible = update.visible;
|
|
||||||
this.notify("visible");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.#urgent !== update.urgent) {
|
|
||||||
this.#urgent = update.urgent;
|
|
||||||
this.notify("urgent");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.#currentOutput !== update.output) {
|
|
||||||
this.#currentGroup =
|
|
||||||
this.#groups.find((group) => group.outputName === update.output) ??
|
|
||||||
// Default to null, rather than undefined.
|
|
||||||
null;
|
|
||||||
this.notify("current-group");
|
|
||||||
this.notify("current-output");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async relocate(to: Group) {
|
|
||||||
if (this.#currentGroup === to) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.#active) {
|
|
||||||
// If the workspace is active, we need to move it to the new group.
|
|
||||||
await Sway.command(
|
|
||||||
`workspace ${this.#index}; move workspace to output ${to.outputName}`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// If the workspace is inactive, we can simply assign it to the new group.
|
|
||||||
await Sway.command(`workspace ${this.#index} output ${to.outputName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@register({
|
|
||||||
GTypeName: "SwayWorkspaceGroup",
|
|
||||||
})
|
|
||||||
export class Group extends GObject.Object {
|
|
||||||
private #workspaces: Workspace[] = [];
|
|
||||||
private #context: Context;
|
|
||||||
private #outputName: string | null = null;
|
|
||||||
|
|
||||||
private #monitor: Gdk.Monitor | null = null;
|
|
||||||
|
|
||||||
@property({ $gtype: "SwayWorkspaceContext" })
|
|
||||||
get context() {
|
|
||||||
return this.#context;
|
|
||||||
}
|
|
||||||
|
|
||||||
@property(Workspace)
|
|
||||||
get focusedWorkspace() {
|
|
||||||
return this.#workspaces.find((workspace) => workspace.visible);
|
|
||||||
}
|
|
||||||
|
|
||||||
@property(Workspace)
|
|
||||||
get focused() {
|
|
||||||
return this.focusedWorkspace?.focused ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@property(Gdk.Monitor)
|
|
||||||
get monitor() {
|
|
||||||
return this.#monitor;
|
|
||||||
}
|
|
||||||
|
|
||||||
@signal()
|
|
||||||
declare focused: () => void;
|
|
||||||
|
|
||||||
@signal()
|
|
||||||
declare unfocused: () => void;
|
|
||||||
|
|
||||||
@signal(Gdk.Monitor)
|
|
||||||
declare activated: (monitor: Gdk.Monitor) => void;
|
|
||||||
|
|
||||||
@signal()
|
|
||||||
declare deactivated: () => void;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
public readonly definition: defs.Group,
|
|
||||||
public readonly outputDefinition: defs.Output,
|
|
||||||
context: Context
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
this.#context = context;
|
|
||||||
this.#workspaces = definition.workspaces.map((index) =>
|
|
||||||
Tree.instance.workspaces.get(index.toString())
|
|
||||||
);
|
|
||||||
|
|
||||||
this.#workspaces.forEach((workspace) => {
|
|
||||||
workspace.connect(
|
|
||||||
"notify::visible",
|
|
||||||
// Bind the function, just to be safe.
|
|
||||||
(() => {
|
|
||||||
if (workspace.visible) {
|
|
||||||
this.notify("focused-workspace");
|
|
||||||
}
|
|
||||||
}).bind(this)
|
|
||||||
);
|
|
||||||
workspace.connect(
|
|
||||||
"notify::focused",
|
|
||||||
(() => {
|
|
||||||
if (workspace === this.focusedWorkspace) {
|
|
||||||
this.notify("focused");
|
|
||||||
}
|
|
||||||
}).bind(this)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
);
|
|
||||||
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)
|
|
||||||
.map(([output]) => output);
|
|
||||||
return scores;
|
|
||||||
}
|
|
||||||
|
|
||||||
async activate(output: types.OutputResponse) {
|
|
||||||
// Ensure that this output is compatible with the group.
|
|
||||||
const [score, _] = this.score(output);
|
|
||||||
if (score === 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Output ${output.name} is not compatible with this group.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure monitor
|
|
||||||
await Sway.get_default().command(
|
|
||||||
`output ${output.name} pos ${this.outputDefinition.x} ${this.outputDefinition.y} ${this.outputDefinition.mode}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Enable monitor.
|
|
||||||
await Sway.get_default().command(`output ${output.name} enable`);
|
|
||||||
|
|
||||||
// Store monitor name.
|
|
||||||
this.#outputName = output.name;
|
|
||||||
|
|
||||||
// Retrieve GDK monitor.
|
|
||||||
const monitor = Gdk.Display.get_default().get_monitor_at_point(
|
|
||||||
this.outputDefinition.x,
|
|
||||||
this.outputDefinition.y
|
|
||||||
);
|
|
||||||
|
|
||||||
// Notify listeners.
|
|
||||||
this.#monitor = monitor;
|
|
||||||
this.activated(monitor);
|
|
||||||
this.notify("monitor");
|
|
||||||
}
|
|
||||||
|
|
||||||
async focus() {
|
|
||||||
if (this.#outputName === null) {
|
|
||||||
throw new Error("Group is not activated.");
|
|
||||||
}
|
|
||||||
await Sway.get_default().command(`focus output ${this.#outputName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@register({
|
|
||||||
GTypeName: "SwayWorkspaceContext",
|
|
||||||
})
|
|
||||||
class Context extends GObject.Object {
|
|
||||||
private #groups: Map<string, Group> = new Map();
|
|
||||||
private #focusedGroup: Group | null = null;
|
|
||||||
|
|
||||||
constructor(public readonly definition: defs.Context) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
@property(Group)
|
|
||||||
get focusedGroup() {
|
|
||||||
return this.#focusedGroup;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@register({
|
|
||||||
GTypeName: "SwayWorkspaceTree",
|
|
||||||
})
|
|
||||||
class Tree extends GObject.Object {
|
|
||||||
private #workspaces: Map<string, Workspace> = new Map();
|
|
||||||
private #contexts: Map<string, Context> = new Map();
|
|
||||||
|
|
||||||
private static #instance: Tree | null = null;
|
|
||||||
|
|
||||||
public get workspaces() {
|
|
||||||
return this.#workspaces;
|
|
||||||
}
|
|
||||||
|
|
||||||
private constructor() {}
|
|
||||||
|
|
||||||
private async #parseFile(path: string) {
|
|
||||||
const contents = await readFileAsync(path);
|
|
||||||
const data = JSON.parse(contents) as defs.Config;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get instance() {
|
|
||||||
if (!Tree.#instance) {
|
|
||||||
Tree.#instance = new Tree();
|
|
||||||
}
|
|
||||||
return Tree.#instance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { System } from "../services/System";
|
import { System } from "@services/System";
|
||||||
|
|
||||||
export function SystemName() {
|
export function SystemName() {
|
||||||
const system = System.get_default();
|
const system = System.get_default();
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
import Gio from "gi://Gio";
|
|
||||||
import GLib from "gi://GLib";
|
|
||||||
|
|
||||||
export async function readFromStreamRaw(
|
|
||||||
stream: Gio.InputStream,
|
|
||||||
bytes: number
|
|
||||||
): Promise<Uint8Array> {
|
|
||||||
return new Promise<Uint8Array>((resolve, reject) => {
|
|
||||||
const buffer = new Uint8Array(bytes);
|
|
||||||
stream.read_bytes_async(
|
|
||||||
bytes,
|
|
||||||
GLib.PRIORITY_DEFAULT,
|
|
||||||
null,
|
|
||||||
(stream, result) => {
|
|
||||||
try {
|
|
||||||
const data = stream.read_bytes_finish(result);
|
|
||||||
data.get_data(buffer);
|
|
||||||
resolve(buffer);
|
|
||||||
} catch (e) {
|
|
||||||
reject(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readFromStream(
|
|
||||||
stream: Gio.InputStream,
|
|
||||||
bytes: number
|
|
||||||
): Promise<Uint8Array> {
|
|
||||||
return new Promise<Uint8Array>((resolve, reject) => {
|
|
||||||
const chunkCount = Math.ceil(bytes / 4096);
|
|
||||||
const buffer = await Array.from({ length: chunkCount }, (_, i) => i).reduce(
|
|
||||||
(acc, i) => {
|
|
||||||
const buffer = await acc;
|
|
||||||
const chunkSize = Math.min(4096, bytes - i * 4096);
|
|
||||||
const chunk = await readFromStreamRaw(stream, chunkSize);
|
|
||||||
buffer.set(chunk, i * 4096);
|
|
||||||
return buffer;
|
|
||||||
},
|
|
||||||
Promise.resolve(new Uint8Array(bytes))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function writeToStream(
|
|
||||||
stream: Gio.OutputStream,
|
|
||||||
data: ArrayBuffer | Uint8Array
|
|
||||||
): Promise<void> {
|
|
||||||
if (data instanceof ArrayBuffer) {
|
|
||||||
data = new Uint8Array(data);
|
|
||||||
}
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
stream.write_all_async(
|
|
||||||
data,
|
|
||||||
GLib.PRIORITY_DEFAULT,
|
|
||||||
null,
|
|
||||||
(stream, result) => {
|
|
||||||
try {
|
|
||||||
stream.write_all_finish(result);
|
|
||||||
resolve();
|
|
||||||
} catch (e) {
|
|
||||||
reject(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user