VoidShell/services/sway/workspaces.ts

808 lines
22 KiB
TypeScript

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];
}
}