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 = 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 { 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 = 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(); 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) { // 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 = new Map(); #contexts: Map = 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(); 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]; } }