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";
|
||||
23
app.ts
23
app.ts
@ -1,11 +1,16 @@
|
||||
import { App } from "astal/gtk3"
|
||||
import style from "./style.css"
|
||||
import Bar from "./widget/Bar"
|
||||
import { App } from "astal/gtk3";
|
||||
import style from "./style.css";
|
||||
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({
|
||||
css: style,
|
||||
main() {
|
||||
Bar(0)
|
||||
// Bar(1) // initialize other monitors
|
||||
},
|
||||
})
|
||||
css: style,
|
||||
main() {
|
||||
Bar(0);
|
||||
// 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")
|
||||
.map((line) => line.trim())
|
||||
.map((line) => /^PRETTY_HOSTNAME="?([^"]*)"?$/.exec(line))
|
||||
.filter((line) => line)
|
||||
.filter((line) => line !== null)
|
||||
.map((line) => line[1]);
|
||||
if (lines.length > 0) {
|
||||
this.#name.set(lines[0]);
|
||||
@ -38,7 +38,7 @@ export class System {
|
||||
return System._instance;
|
||||
}
|
||||
|
||||
private #name: Variable<string> = Variable("");
|
||||
#name: Variable<string> = Variable("");
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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;
|
||||
}
|
||||
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 {
|
||||
id: number;
|
||||
name: number;
|
||||
name: string;
|
||||
type: NodeType;
|
||||
border: NodeBorder;
|
||||
current_border_width: number;
|
||||
@ -351,8 +351,8 @@ export enum WorkspaceEventChange {
|
||||
|
||||
export interface WorkspaceEvent {
|
||||
change: WorkspaceEventChange;
|
||||
current: WorkspaceResponse;
|
||||
old: WorkspaceResponse | null;
|
||||
current: TreeNode;
|
||||
old: TreeNode | null;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,21 +1,21 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"strict": true,
|
||||
"target": "ES2023",
|
||||
"moduleResolution": "Bundler",
|
||||
// "checkJs": true,
|
||||
// "allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "/usr/share/astal/gjs/gtk3",
|
||||
"paths": {
|
||||
"astal": [
|
||||
"/usr/share/astal/gjs"
|
||||
],
|
||||
"astal/*": [
|
||||
"/usr/share/astal/gjs/*"
|
||||
]
|
||||
},
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"strict": true,
|
||||
"target": "ES2023",
|
||||
"moduleResolution": "Bundler",
|
||||
// "checkJs": true,
|
||||
// "allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "/usr/share/astal/gjs/gtk3",
|
||||
"include": ["ambient.d.ts"],
|
||||
"paths": {
|
||||
"astal": ["/usr/share/astal/gjs"],
|
||||
"astal/*": ["/usr/share/astal/gjs/*"],
|
||||
"@/*": ["./*"],
|
||||
"@widgets/*": ["./widget/*"],
|
||||
"@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 { Variable } from "astal";
|
||||
import { bind, Variable } from "astal";
|
||||
import { SystemName } from "./system/Name";
|
||||
import { Tree } from "@/services/sway/workspaces";
|
||||
|
||||
const time = Variable("").poll(1000, "date");
|
||||
|
||||
export default function Bar(monitor: number) {
|
||||
const tree = Tree.instance;
|
||||
const currentContext = bind(tree, "currentContextName");
|
||||
return (
|
||||
<window
|
||||
className="Bar"
|
||||
@ -18,9 +21,7 @@ export default function Bar(monitor: number) {
|
||||
application={App}
|
||||
>
|
||||
<centerbox>
|
||||
<button onClicked="echo hello" halign={Gtk.Align.CENTER}>
|
||||
Welcome to AGS!
|
||||
</button>
|
||||
<label label={currentContext} />
|
||||
<box>
|
||||
<SystemName />
|
||||
</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() {
|
||||
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