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 { delay, dereferenceSymbolicLink } from "@/utils"; import { DBusObject } from "@services/dbus"; import { computeContextScore, computeGroupScore } from "./scoring"; import { Mutex } from "@/synchronization"; @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}`, ); } @property(String) get indicatorClassName() { const result = ["indicator-circle", "sway--ws"]; if (this.active) { result.push("sway--active"); } if (this.visible) { result.push("sway--visible"); } if (this.focused) { result.push("sway--focused"); } return result.join(" "); } /** * Create an ephemeral workspace from a sync operation that is not defined in the system. */ 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.notify("indicator-class-name"); 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.notify("indicator-class-name"); 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.notify("indicator-class-name"); 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 | string, retainFocus: boolean = false) { if (typeof to === "string") { const group = Tree.instance.findGroupByObjectPath(to); if (group === undefined) { throw new Error("Group not found"); } to = group; } if (this.#currentGroup === to) { return; } 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", `${context.objectPath}/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 { return computeGroupScore(this.outputDefinition, output); } async activate(output: types.OutputResponse) { // Ensure that this output is compatible with the group. const score = this.score(output); if (score === 0) { console.error(`Output ${output.name} is not compatible with this group`); throw new Error( `Output ${output.name} is not compatible with this group.`, ); } // Configure monitor await Sway.instance.command( `output ${output.name} pos ${this.outputDefinition.position[0]} ${this.outputDefinition.position[1]} mode ${this.outputDefinition.mode}`, ); // Enable monitor. await Sway.instance.command(`output ${output.name} enable`); // Store monitor name. this.#outputName = output.name; // 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 dummyActivate(output: types.OutputResponse) { const score = this.score(output); if (score === 0) { console.error( `Output ${output.name} is not compatible with this group. Dummy activation, ignoring...`, ); } this.#outputName = output.name; const monitor = Gdk.Display.get_default()?.get_monitor_at_point( this.outputDefinition.position[0], this.outputDefinition.position[1], ) ?? null; this.#monitor = monitor; if (monitor !== null) { this.activated(monitor); } this.notify("monitor"); this.#workspaces.forEach((workspace) => workspace.groups.add(this)); } async focus() { if (this.#outputName === null) { throw new Error("Group is not activated."); } 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)); } async focusNextWorkspace() { const index = (this.#workspaces.findIndex((ws) => ws === this.focusedWorkspace) + 1) % this.#workspaces.length; return this.#workspaces[index].focus(); } async focusPreviousWorkspace() { const index = (this.#workspaces.findIndex((ws) => ws === this.focusedWorkspace) - 1) % this.#workspaces.length; return this.#workspaces[index].focus(); } } @register({ 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.replace("-", "_")}`, ); 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; } get groups(): Omit, "set" | "delete" | "clear"> { return this.#groups; } private set focusedGroup(value) { if (this.#focusedGroup !== value) { this.#focusedGroup = value; this.notify("focused-group"); this.dbusObj?.emit_property_changed( "focusedGroupPath", GLib.Variant.new_string(value?.objectPath ?? "/"), ); } } @property(String) get focusedGroupPath() { return this.#focusedGroup?.objectPath ?? "/"; } @property(Boolean) get active() { return this.#active; } private set active(value: boolean) { if (value !== this.#active) { this.#active = value; this.notify("active"); this.dbusObj?.emit_property_changed( "active", GLib.Variant.new_boolean(value), ); if (!value) { Tree.instance.deactivating(); } } } getGroupOnOutput(output: string): Group | undefined { return Array.from(this.#groups.values()).find( (group) => group.outputName === output, ); } /** * 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. */ private matchGroupsWithOutputs(outputSet: types.OutputResponse[]) { const [_, match] = computeContextScore(this.definition, outputSet); return match; } /** * Compute the score of this context based on the given Sway output set. */ score(outputSet: types.OutputResponse[], includePriority = true) { const [score] = computeContextScore(this.definition, outputSet); if (includePriority && score !== 0) { return score + (this.definition.priority ?? 0); } return score; } /** * Activate this context. */ async activate() { console.log("Context activation requested"); const outputs = await Sway.instance.getOutputs(); const match = this.matchGroupsWithOutputs(outputs); if (match === undefined) { 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 Array.from(match.entries()).amap(async ([name, output]) => { const group = this.#groups.get(name); console.log(`Activating group ${name} on output ${output.name}`); if (group === undefined) { throw new InvalidContextError( this, `Could not activate context: group ${name} is not defined.`, ); } await group.activate(output); }); // Notify listeners. this.active = true; Tree.instance.notify("current-context"); } /** * Mark the context as activated, without changing outputs */ async dummyActivate() { console.log( "Context dummy activation requested. This will not produce any changes to the current output configuration!", ); const outputs = await Sway.instance.getOutputs(); const match = this.matchGroupsWithOutputs(outputs); if (match === undefined) { throw new InvalidContextError( this, "Could not activate context: context is incompatible with current outputs.", ); } await Tree.instance.currentContext?.dummyDeactivate(); await Array.from(match.entries()).amap(async ([name, output]) => { const group = this.#groups.get(name); console.log(`Dummy-activating group ${name} on output ${output.name}`); if (group === undefined) { throw new InvalidContextError( this, `Could not activate context: group ${name} is not defined.`, ); } await group.dummyActivate(output); }), (this.active = true); Tree.instance.notify("current-context"); } async dummyDeactivate() { this.active = false; await new Promise((resolve) => setTimeout(resolve, 1)); Array.from(this.#groups.values()).forEach((group) => group.deactivate()); } /** * Deactivate this context. * @param outputsToLeave A set of outputs that should not be disabled, selected by output name */ async deactivate(outputsToLeave: Set) { // First, notify for deactivation and move ourselves to the lowest priority in the event list this.active = false; 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 DBusObject { #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) { await this.sync(); this.onOutputEvent(); } else { const connId = Sway.instance.connect("connected", async () => { Sway.instance.disconnect(connId); await this.sync(); this.onOutputEvent(); }); } } /** * 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) => { this.onWorkspaceEvent(event); }); Sway.instance.connect("output", (_, event) => this.onOutputEvent(event)); } private onWorkspaceEvent(event: types.WorkspaceEvent) { switch (event.change) { case types.WorkspaceEventChange.RELOAD: break; default: this.sync(); } } private outputDelayLock = new Mutex(); /** * Callback for when we receive output events. * * @param event - The event to process. Currently, this event is always the same ({ change: "unspecified" }), so it is unused. */ private async onOutputEvent(_?: types.OutputEvent) { try { // Wait for half a second, since a single hardware change can trigger a flurry of output events await this.outputDelayLock.immediate(delay.bind(this, 500)); } catch { return; } const outputs = await Sway.instance.getOutputs(); // Score each context const scores = Array.from(this.#contexts.entries()).reduce( (acc, [name, context]) => { acc[name] = context.score(outputs); return acc; }, {} as Record, ); console.log(scores); // Find highest score const [highName] = Object.entries(scores).sort( ([_, a], [__, b]) => b - a, )[0]; if (highName === undefined) { console.warn( "No configured contexts are compatible with the current display arrangement!", ); return; } const newContext = this.#contexts.get(highName); if (newContext === undefined) { console.error( "High score context does not exist in memory. This should not happen.", ); return; } if (newContext === this.currentContext) { console.log("Context is already active, ignoring..."); } console.log("Activating context", highName); await newContext.dummyActivate(); } @property(Context) 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]; } findGroupByObjectPath(path: string): Group | undefined { const match = path.match( new RegExp( "/dev/ezri/VoidShell/workspaces/context/([\\w_]+)/group/([\\w_]+)", ), ); if (match === null) { console.error(`Group object path ${path} invalid`); return undefined; } const context = this.#contexts.get(match[1]) ?? this.#contexts.get(match[1].replace("_", "-")); if (context === undefined) return undefined; return ( context.groups.get(match[2]) ?? context.groups.get(match[2].replace("_", "-")) ); } }