808 lines
22 KiB
TypeScript
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];
|
|
}
|
|
}
|