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