344 lines
8.5 KiB
TypeScript
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;
|
|
}
|
|
}
|