VoidShell/widget/services/sway/workspaces.ts
2024-10-31 15:39:18 -06:00

344 lines
8.5 KiB
TypeScript

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