diff --git a/app.ts b/app.ts
index 9ebd4ed..21bb966 100644
--- a/app.ts
+++ b/app.ts
@@ -2,10 +2,11 @@ import { App } from "astal/gtk3";
import style from "./style.css";
import Bar from "./widget/Bar";
import "@/globals";
-import { Sway } from "@services/sway/ipc";
-import { Tree } from "@services/sway/workspaces";
+import { CompositorConnection, SwayConnection } from "@services/compositor";
+import { OutputService } from "./services/outputs/service";
-new Tree("/home/ezri/.config/sway/workspaces.json");
+new SwayConnection();
+new OutputService();
App.start({
css: style,
diff --git a/dbus/dev.ezri.voidshell.Monitor.xml b/dbus/dev.ezri.voidshell.Monitor.xml
new file mode 100644
index 0000000..02b1642
--- /dev/null
+++ b/dbus/dev.ezri.voidshell.Monitor.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dbus/dev.ezri.voidshell.Output.xml b/dbus/dev.ezri.voidshell.Output.xml
new file mode 100644
index 0000000..2482161
--- /dev/null
+++ b/dbus/dev.ezri.voidshell.Output.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/services/compositor/connection.ts b/services/compositor/connection.ts
index c407d19..4758c89 100644
--- a/services/compositor/connection.ts
+++ b/services/compositor/connection.ts
@@ -1,6 +1,13 @@
import GObject, { register, property, signal } from "astal/gobject";
import { ConnectionInstantiationError, NotImplementedError } from "./errors";
-import { OutputConfig, VSOutputAdapter, VSWorkspaceAdapter } from "./types";
+import {
+ OutputAdapter,
+ OutputConfig,
+ VSOutputAdapter,
+ VSOutputEvent,
+ VSWorkspaceAdapter,
+ VSWorkspaceEvent,
+} from "./types";
/**
* Mid-level abstract compositor connection providing raw data about the compositor to the rest of VoidShell.
@@ -91,6 +98,14 @@ export class CompositorConnection extends GObject.Object {
throw new NotImplementedError();
}
+ /**
+ * Gets a list of all workspaces
+ * @returns a list of VSWorkspaceAdapter objects representing the workspaces presently known to the compositor
+ */
+ async getWorkspaces(): Promise {
+ throw new NotImplementedError();
+ }
+
/**
* Disable an output
* @param outputName The name of the output to disable
@@ -99,18 +114,28 @@ export class CompositorConnection extends GObject.Object {
throw new NotImplementedError();
}
+ /**
+ * Set an output property without fully reconfiguring the output
+ */
+ async setOutputProperty(
+ property: Prop,
+ value: Exclude,
+ ) {
+ throw new NotImplementedError();
+ }
+
/**
* Emitted when a workspace's data changes
*/
- @signal(VSWorkspaceAdapter)
- declare workspaceChange: (event: VSWorkspaceAdapter) => void;
+ @signal(VSWorkspaceEvent)
+ declare workspaceChange: (event: VSWorkspaceEvent) => void;
/**
* Emitted when an output's data changes
* For example, it is enabled or disabled, or its mode changes
*/
- @signal(VSOutputAdapter)
- declare outputChange: (event: VSOutputAdapter) => void;
+ @signal(VSOutputEvent)
+ declare outputChange: (event: VSOutputEvent) => void;
/**
* Emitted when the binding mode changes
diff --git a/services/compositor/connections/sway.ts b/services/compositor/connections/sway.ts
index 510bbbc..1e9f445 100644
--- a/services/compositor/connections/sway.ts
+++ b/services/compositor/connections/sway.ts
@@ -15,7 +15,14 @@ import * as utils from "@/utils";
import { Mutex } from "@/synchronization";
import { CompositorConnection } from "../connection";
import { ActionError } from "../errors";
-import { OutputConfig, VSOutputAdapter, VSRect } from "../types";
+import {
+ EventType,
+ OutputConfig,
+ VSOutputAdapter,
+ VSOutputEvent,
+ VSWorkspaceAdapter,
+ VSWorkspaceEvent,
+} from "../types";
class SwayCommandError
extends ActionError
@@ -58,9 +65,121 @@ class SwayCommandError
export class SwayConnection extends CompositorConnection {
private ipc: SwayIPC;
+ private lastOutputs = new Map();
+ private lastWorkspaces = new Map();
+
constructor() {
super();
this.ipc = SwayIPC.instance;
+ this.onWorkspaceEvent();
+ this.onOutputEvent();
+ this.ipc.connect("workspace", () => {
+ this.onWorkspaceEvent().catch(console.error);
+ });
+ this.ipc.connect("output", () => {
+ this.onOutputEvent().catch(console.error);
+ });
+ }
+
+ /**
+ * Call when we receive a workspace event from Sway
+ */
+ private async onWorkspaceEvent() {
+ const workspaces = await this.getWorkspaces();
+ // Emit new events for all new outputs. No need to check set, this is the first emission.
+ workspaces
+ .filter((workspace) => !this.lastWorkspaces.has(workspace.id))
+ .forEach((workspace) => {
+ this.workspaceChange(
+ new VSWorkspaceEvent({
+ type: EventType.CREATE,
+ workspace: workspace.id,
+ adapter: workspace,
+ }),
+ );
+ });
+ // Emit changed events for all outputs that have changed. No need to check set, as this and previous loops are guaranteed disjoint.
+ workspaces
+ .filter((workspace) => this.lastWorkspaces.has(workspace.id))
+ .filter(
+ (workspace) => !workspace.equals(this.lastWorkspaces.get(workspace.id)),
+ )
+ .forEach((workspace) => {
+ this.workspaceChange(
+ new VSWorkspaceEvent({
+ type: EventType.CHANGE,
+ workspace: workspace.id,
+ adapter: workspace,
+ }),
+ );
+ });
+ // Emit destroy events for all outputs that do not now exist.
+ const workspaceNames = new Set(workspaces.map((workspace) => workspace.id));
+ Array.from(this.lastWorkspaces.values())
+ .filter((workspace) => !workspaceNames.has(workspace.id))
+ .forEach((workspace) => {
+ this.workspaceChange(
+ new VSWorkspaceEvent({
+ type: EventType.DESTROY,
+ workspace: workspace.id,
+ adapter: null,
+ }),
+ );
+ });
+ this.lastWorkspaces = new Map();
+ workspaces.forEach((workspace) =>
+ this.lastWorkspaces.set(workspace.id, workspace),
+ );
+ }
+
+ /**
+ * Call when we receive an output event from Sway
+ *
+ * NOTE: Currently, Sway does not provide any useful information with this event, so we need to get all outputs
+ * ourselves and determine what changed.
+ */
+ private async onOutputEvent() {
+ const outputs = await this.getOutputs();
+ // Emit new events for all new outputs. No need to check set, this is the first emission.
+ outputs
+ .filter((output) => !this.lastOutputs.has(output.name))
+ .forEach((output) => {
+ this.outputChange(
+ new VSOutputEvent({
+ type: EventType.CREATE,
+ output: output.name,
+ adapter: output,
+ }),
+ );
+ });
+ // Emit changed events for all outputs that have changed. No need to check set, as this and previous loops are guaranteed disjoint.
+ outputs
+ .filter((output) => this.lastOutputs.has(output.name))
+ .filter((output) => !output.equals(this.lastOutputs.get(output.name)))
+ .forEach((output) => {
+ this.outputChange(
+ new VSOutputEvent({
+ type: EventType.CHANGE,
+ output: output.name,
+ adapter: output,
+ }),
+ );
+ });
+ // Emit destroy events for all outputs that do not now exist.
+ const outputNames = new Set(outputs.map((output) => output.name));
+ Array.from(this.lastOutputs.values())
+ .filter((output) => !outputNames.has(output.name))
+ .forEach((output) => {
+ this.outputChange(
+ new VSOutputEvent({
+ type: EventType.DESTROY,
+ output: output.name,
+ adapter: null,
+ }),
+ );
+ });
+ this.lastOutputs = new Map();
+ outputs.forEach((output) => this.lastOutputs.set(output.name, output));
}
async focusWorkspace(workspaceId: string) {
@@ -102,21 +221,89 @@ export class SwayConnection extends CompositorConnection {
return SwayCommandError.assertCommands(`output ${outputName} disable`);
}
+ async setOutputProperty(
+ output: string,
+ property: Prop,
+ value: Exclude,
+ ) {
+ switch (property) {
+ case "modeline":
+ return SwayCommandError.assertCommands(
+ `output ${output} mode ${value}`,
+ );
+ case "scale":
+ return SwayCommandError.assertCommands(
+ `output ${output} scale ${value}`,
+ );
+ case "position":
+ return SwayCommandError.assertCommands(
+ `output ${output} position ${(value as number[])[0]} ${(value as number[])[1]}`,
+ );
+ case "active":
+ if (value) {
+ return SwayCommandError.assertCommands(`output ${output} enable`);
+ } else {
+ return SwayCommandError.assertCommands(`output ${output} disable`);
+ }
+ case "powered":
+ return SwayCommandError.assertCommands(
+ `output ${output} power ${value ? "on" : "off"}`,
+ );
+ case "transform":
+ return SwayCommandError.assertCommands(
+ `output ${output} transform ${value}`,
+ );
+ case "colorProfile":
+ return SwayCommandError.assertCommands(
+ `output ${output} color_profile ${value}`,
+ );
+ }
+ }
+
+ private parseSwayOutput(output: OutputResponse): VSOutputAdapter {
+ return new VSOutputAdapter({
+ name: output.name,
+ active: output.active,
+ powered: output.power,
+ rect: output.rect,
+ scale: output.scale,
+ transform: output.transform,
+ current_workspace: output.current_workspace,
+ make: output.make,
+ model: output.model,
+ serial: output.serial,
+ availableModes: output.modes.map(({ picture_aspect_ratio, ...mode }) => ({
+ aspect_ratio: picture_aspect_ratio,
+ ...mode,
+ })),
+ mode: {
+ height: output.current_mode.height,
+ width: output.current_mode.width,
+ refresh: output.current_mode.refresh,
+ aspect_ratio: output.current_mode.picture_aspect_ratio,
+ },
+ });
+ }
+
+ private parseSwayWorkspace(workspace: WorkspaceResponse): VSWorkspaceAdapter {
+ return new VSWorkspaceAdapter({
+ id: workspace.name,
+ output: workspace.output,
+ focused: workspace.focused,
+ visible: workspace.visible,
+ urgent: workspace.urgent,
+ });
+ }
+
async getOutputs(): Promise {
- const result = await this.ipc.getOutputs();
- return result.map(
- (swayOutput) =>
- new VSOutputAdapter({
- name: swayOutput.name,
- active: swayOutput.active,
- rect: new VSRect(swayOutput.rect),
- scale: swayOutput.scale,
- transform: swayOutput.transform,
- current_workspace: swayOutput.current_workspace,
- make: swayOutput.make,
- model: swayOutput.model,
- serial: swayOutput.serial,
- }),
+ return (await this.ipc.getOutputs()).map((swayOutput) =>
+ this.parseSwayOutput(swayOutput),
+ );
+ }
+
+ async getWorkspaces(): Promise {
+ return (await this.ipc.getWorkspaces()).map((swayWorkspace) =>
+ this.parseSwayWorkspace(swayWorkspace),
);
}
}
@@ -179,11 +366,13 @@ class SwayIPC extends GObject.Object {
}
#connected = false;
+ #onConnected: Promise;
/**
* Run a command on sway
*/
public async command(command: string): Promise {
+ await this.#onConnected;
return JSON.parse(
(await this.#sendRaw(
this.#commandSocket,
@@ -197,6 +386,7 @@ class SwayIPC extends GObject.Object {
* Get the current sway workspaces
*/
public async getWorkspaces(): Promise {
+ await this.#onConnected;
return JSON.parse(
(await this.#sendRaw(
this.#commandSocket,
@@ -209,6 +399,7 @@ class SwayIPC extends GObject.Object {
* Get the current sway outputs
*/
public async getOutputs(): Promise {
+ await this.#onConnected;
return JSON.parse(
(await this.#sendRaw(
this.#commandSocket,
@@ -221,6 +412,7 @@ class SwayIPC extends GObject.Object {
* Get the current sway tree
*/
public async getTree(): Promise {
+ await this.#onConnected;
return JSON.parse(
(await this.#sendRaw(
this.#commandSocket,
@@ -231,6 +423,12 @@ class SwayIPC extends GObject.Object {
constructor() {
super();
+ this.#onConnected = new Promise((resolve) => {
+ const id = this.connect("connected", () => {
+ resolve();
+ this.disconnect(id);
+ });
+ });
this.#connect();
}
diff --git a/services/compositor/index.ts b/services/compositor/index.ts
new file mode 100644
index 0000000..8a72fc9
--- /dev/null
+++ b/services/compositor/index.ts
@@ -0,0 +1,2 @@
+export { SwayConnection } from "./connections/sway";
+export { CompositorConnection } from "./connection";
diff --git a/services/compositor/types.ts b/services/compositor/types.ts
index 38f4f2f..22f4e60 100644
--- a/services/compositor/types.ts
+++ b/services/compositor/types.ts
@@ -28,15 +28,151 @@ export const enum EventType {
DESTROY,
}
-type Constructor = { new (...args: any[]): T };
+export type Constructor = { new (...args: any[]): T };
+class WrappingError extends Error {
+ name = "WrappingError";
+}
+
+/**
+ * Generic wrapper for TypeScript interfaces that are used in GObject signals.
+ * While this can be used directly, it is recommended to subclass it for each
+ * interface to expose the properties as GObject properties.
+ */
@register({
GTypeName: "VSWrapper",
})
export class VSWrapper extends GObject.Object {
+ declare static readonly wrappers: {
+ [key: string]: typeof VSWrapper;
+ };
+
+ declare private keys: string[];
+
+ private getWrapperConstructor(
+ key: Key,
+ ): Constructor> {
+ if ("wrappers" in this.constructor) {
+ if (
+ typeof this.constructor.wrappers !== "object" ||
+ this.constructor.wrappers === null
+ ) {
+ throw new WrappingError(
+ `Trying to wrap ${key}: ${this.constructor.name}.wrappers is not an object!`,
+ );
+ }
+ if (!(key in this.constructor.wrappers)) {
+ throw new WrappingError(
+ `Trying to wrap ${key}: ${this.constructor.name}.wrappers.${key} does not exist!`,
+ );
+ }
+ // @ts-ignore yeah, no good way to type this properly :/
+ return this.constructor.wrappers[key];
+ }
+ throw new WrappingError(
+ `Trying to wrap ${key}: ${this.constructor.name}.wrappers does not exist!`,
+ );
+ }
+
constructor(obj: WrappedInterface) {
- super();
- Object.assign(this, obj);
+ const keys = (Object.keys(obj) as (keyof typeof obj)[]).filter(
+ (key) => typeof key === "string",
+ );
+ keys
+ .filter((key) => obj[key] === undefined)
+ .forEach((key) => (obj[key] = null as any));
+ const toWrap = new Set();
+ const toConstruct = Object.entries(obj).reduce(
+ (acc, [key, value]) => {
+ if (typeof key !== "string") return acc;
+ if (
+ ["string", "number", "boolean"].some(
+ (type) => typeof value === type,
+ ) ||
+ value === null ||
+ value instanceof GObject.Object
+ ) {
+ acc[key] = value;
+ } else if (value === undefined) {
+ acc[key] = null;
+ } else {
+ toWrap.add(key as string & keyof typeof obj);
+ }
+ return acc;
+ },
+ {} as Record>,
+ );
+ // @ts-ignore
+ super(toConstruct);
+ toWrap.forEach((key) => {
+ const constructor = this.getWrapperConstructor(key);
+ if (obj[key] instanceof Array) {
+ // @ts-ignore
+ this[key] = obj[key].map((val) => new constructor(val));
+ } else {
+ // @ts-ignore
+ this[key] = new constructor(obj[key]);
+ }
+ });
+ Object.defineProperty(this, "keys", {
+ value: keys,
+ writable: false,
+ enumerable: false,
+ configurable: false,
+ });
+ }
+
+ /**
+ * Reconstruct the native JS object that doesn't have any GObject add-ons
+ * This is necessary over the spread operator because GJS defines GObject
+ * properties as non-enumerable for some reason.
+ */
+ public simplify(): WrappedInterface {
+ return this.keys.reduce((acc, key) => {
+ const val = this[key as keyof this];
+ if (val instanceof VSWrapper) {
+ return {
+ ...acc,
+ [key]: val.simplify(),
+ };
+ }
+ return {
+ ...acc,
+ [key]: val,
+ };
+ }, {} as WrappedInterface);
+ }
+
+ /**
+ * Value-based deep equality check
+ * NOTE: this may be expensive depending on the data structure!
+ * NOTE: only deep-checks VSWrapper instances!
+ */
+ public equals(other: WrappedInterface | null | undefined): boolean {
+ if (other === null || other === undefined) {
+ return false;
+ }
+ return this.keys.every((key) => {
+ // @ts-ignore
+ const thisValue = this[key];
+ // @ts-ignore
+ const otherValue = other[key];
+ return this.checkItemEquality(thisValue, otherValue);
+ });
+ }
+
+ private checkItemEquality(thisValue: any, otherValue: any): boolean {
+ if (thisValue === otherValue) return true;
+ if (thisValue instanceof Array && otherValue instanceof Array) {
+ return thisValue.every((val, idx) =>
+ this.checkItemEquality(val, otherValue[idx]),
+ );
+ }
+ if (thisValue instanceof VSWrapper && typeof otherValue === "object") {
+ return thisValue.equals(otherValue);
+ }
+ console.log(`items differ: ${thisValue} !== ${otherValue}`);
+ return false;
}
/**
@@ -45,10 +181,14 @@ export class VSWrapper extends GObject.Object {
public static wrap<
WrappedInterface extends Object,
Wrapper extends VSWrapper,
- >(this: Constructor, obj: WrappedInterface): Wrapper {
+ >(
+ this: Constructor,
+ obj: WrappedInterface,
+ ): Wrapper & WrappedInterface {
if (obj instanceof this) {
return obj;
}
+ // @ts-ignore The values of WrappedInterface exist on the object, as guaranteed by the Object.assign() call.
return new this(obj);
}
}
@@ -112,6 +252,39 @@ export class VSWorkspaceAdapter
declare readonly urgent: boolean;
}
+/**
+ * Common interface for compositor backends that represents an output mode.
+ */
+export interface OutputMode {
+ /**
+ * The width of the output in pixels
+ */
+ readonly width: number;
+ /**
+ * The height of the output in pixels
+ */
+ readonly height: number;
+ /**
+ * The refresh rate of the mode in integer millihertz
+ */
+ readonly refresh: number;
+ readonly aspect_ratio: string;
+}
+
+@register({
+ GTypeName: "VSOutputMode",
+})
+export class VSOutputMode extends VSWrapper implements OutputMode {
+ @property(Number)
+ declare readonly width: number;
+ @property(Number)
+ declare readonly height: number;
+ @property(Number)
+ declare readonly refresh: number;
+ @property(String)
+ declare readonly aspect_ratio: string;
+}
+
/**
* Common interface for compositor backends that represents the results of an output query.
* Contains only information that VoidShell needs to know.
@@ -119,14 +292,16 @@ export class VSWorkspaceAdapter
export interface OutputAdapter {
readonly name: string;
readonly active: boolean;
+ readonly powered: boolean;
readonly rect: Rect;
readonly scale: number;
readonly transform: OutputTransform;
- readonly focused: boolean;
readonly current_workspace: string;
readonly make: string;
readonly model: string;
readonly serial: string;
+ readonly availableModes: OutputMode[];
+ readonly mode: OutputMode;
}
@register({
@@ -136,18 +311,24 @@ export class VSOutputAdapter
extends VSWrapper
implements OutputAdapter
{
+ static readonly wrappers = {
+ rect: VSRect,
+ availableModes: VSOutputMode,
+ mode: VSOutputMode,
+ };
+
@property(String)
declare readonly name: string;
@property(Boolean)
declare readonly active: boolean;
+ @property(Boolean)
+ declare readonly powered: boolean;
@property(VSRect)
declare readonly rect: VSRect;
@property(Number)
declare readonly scale: number;
@property(String)
declare readonly transform: OutputTransform;
- @property(Boolean)
- declare readonly focused: boolean;
@property(String)
declare readonly current_workspace: string;
@property(String)
@@ -156,11 +337,10 @@ export class VSOutputAdapter
declare readonly model: string;
@property(String)
declare readonly serial: string;
-
- constructor(adapter: OutputAdapter) {
- super(adapter);
- this.rect = VSRect.wrap(adapter.rect);
- }
+ @property(Object)
+ declare readonly availableModes: VSOutputMode[];
+ @property(VSOutputMode)
+ declare readonly mode: VSOutputMode;
}
/**
@@ -191,19 +371,16 @@ export class VSWorkspaceEvent
extends VSWrapper
implements WorkspaceEvent
{
- @property(String)
+ static readonly wrappers = {
+ adapter: VSWorkspaceAdapter,
+ };
+
+ @property(Number)
declare readonly type: EventType;
@property(String)
declare readonly workspace: string;
@property(VSWorkspaceAdapter)
declare readonly adapter: VSWorkspaceAdapter | null;
-
- constructor(event: WorkspaceEvent) {
- super(event);
- this.adapter = event.adapter
- ? VSWorkspaceAdapter.wrap(event.adapter)
- : null;
- }
}
/**
@@ -234,27 +411,24 @@ export class VSOutputEvent
extends VSWrapper
implements OutputEvent
{
- @property(String)
+ static readonly wrappers = {
+ adapter: VSOutputAdapter,
+ };
+
+ @property(Number)
declare readonly type: EventType;
@property(String)
declare readonly output: string;
@property(VSOutputAdapter)
declare readonly adapter: VSOutputAdapter | null;
-
- constructor(event: OutputEvent) {
- super(event);
- this.adapter = event.adapter ? VSOutputAdapter.wrap(event.adapter) : null;
- }
}
export type OutputConfig = {
- modeline: string;
+ modeline: `${number}x${number}${`@${number}Hz` | `@${string}.${string}Hz` | ""}`;
scale?: number;
position: [x: number, y: number];
-} & {
- /**
- * Extra options added to the configuration line. Support for these is compositor-dependent, and currently
- * they will only be honored under Sway.
- */
- [extra_option: string]: string | number | boolean;
+ active?: boolean;
+ colorProfile?: string;
+ powered?: boolean;
+ transform: OutputTransform;
};
diff --git a/services/dbus.ts b/services/dbus.ts
index 44bf9c5..1351fb8 100644
--- a/services/dbus.ts
+++ b/services/dbus.ts
@@ -1,6 +1,7 @@
import Gio from "gi://Gio";
import GObject, { register } from "astal/gobject";
import { getDbusXml } from "@/utils";
+import GLib from "gi://GLib";
let dbusConnectionResolve: ((connection: any) => void) | null = null;
export const Connection: Promise = new Promise(
@@ -28,7 +29,7 @@ export class DBusObject extends GObject.Object {
this.#objectPath = objectPath;
getDbusXml(iface).then(async (xml) => {
try {
- this.dbusObj = Gio.DBusExportedObject.wrapJSObject(xml, this);
+ this.dbusObj = Gio.DBusExportedObject.wrapJSObject(xml, this.proxify());
this.dbusObj.export(await Connection, objectPath);
} catch (e) {
console.error(`Error exporting to D-Bus: ${e}`);
@@ -36,6 +37,114 @@ export class DBusObject extends GObject.Object {
});
}
+ /**
+ * Creates a proxy of the dbus object that returns object paths of DBusObjects
+ * rather than the objects themselves.
+ * This proxy should be used with the DBus export, and should not be used in any other context.
+ *
+ * Due to the way this works, setting DBus object paths is NOT SUPPORTED! Use a method if you really need to do that.
+ */
+ private proxify() {
+ return new Proxy(this, {
+ get(target, property: keyof DBusObject) {
+ const marshall_func = `marshall_${String(property)}` as string &
+ keyof typeof target;
+ if (
+ marshall_func in target &&
+ typeof target[marshall_func] === "function"
+ ) {
+ const variant: GLib.Variant = target[marshall_func]();
+ return variant.deepUnpack();
+ }
+ if (!(property in target)) {
+ return undefined;
+ }
+ if (target[property] instanceof DBusObject) {
+ return target[property].objectPath;
+ }
+ if (target[property] instanceof Array) {
+ return target[property].map((prop) =>
+ prop instanceof DBusObject ? prop.objectPath : prop,
+ );
+ }
+ return target[property];
+ },
+ });
+ }
+
+ private marshallValue(
+ property: string & keyof this,
+ value: any,
+ ): GLib.Variant | null {
+ const marshall_func_name = `marshall_${property}` as string & keyof this;
+ if (
+ marshall_func_name in this &&
+ typeof this[marshall_func_name] === "function"
+ ) {
+ console.log(`Calling ${marshall_func_name} to marshall value`);
+ return this[marshall_func_name]();
+ }
+ console.log(
+ `Did not find ${marshall_func_name}, falling back to default marshalling`,
+ );
+ switch (typeof value) {
+ case "string":
+ return GLib.Variant.new_string(value);
+ case "number":
+ return GLib.Variant.new_double(value);
+ case "bigint":
+ return GLib.Variant.new_int64(Number(value));
+ case "boolean":
+ return GLib.Variant.new_boolean(value);
+ case "symbol":
+ return null;
+ case "object":
+ if (value instanceof Array) {
+ if (value.every((val) => val instanceof DBusObject)) {
+ return new GLib.Variant(
+ "ao",
+ value.map((val) => val.objectPath),
+ );
+ }
+ return new GLib.Variant("av", value);
+ } else {
+ return new GLib.Variant("a{sv}", value);
+ }
+ case "undefined":
+ return null;
+ case "function":
+ return null;
+ }
+ }
+
+ // private deepMarshallObject(obj: Object): GLib.Variant {
+ // if (obj instanceof DBusObject) {
+ // return GLib.Variant.new_object_path(obj.objectPath);
+ // }
+ // if (obj instanceof Array) {
+ // return new GLib.Variant(
+ // "av",
+ // obj.map(this.marshallValue.bind(this)).filter((val) => val !== null),
+ // );
+ // }
+ // const newObj = Object.entries(obj).reduce((obj, [key, value]) => {
+ // const toEmit = this.marshallValue(, value);
+ // if (toEmit !== null) {
+ // obj[key] = toEmit;
+ // }
+ // return obj;
+ // }, {} as any);
+ // return new GLib.Variant("a{sv}", newObj);
+ // }
+
+ protected emitDBusProperty(property: Prop) {
+ const toEmit = this.marshallValue(property, this[property]);
+ if (!toEmit) {
+ return;
+ }
+ this.dbusObj?.emit_property_changed(property, toEmit);
+ }
+
get objectPath(): string {
return this.#objectPath;
}
diff --git a/services/outputs/monitor.ts b/services/outputs/monitor.ts
new file mode 100644
index 0000000..adbc558
--- /dev/null
+++ b/services/outputs/monitor.ts
@@ -0,0 +1,318 @@
+import { property, register, signal } from "astal/gobject";
+import GLib from "gi://GLib";
+import { DBusObject } from "@services/dbus";
+import {
+ OutputMode,
+ OutputTransform,
+ Rect,
+ VSOutputAdapter,
+ VSOutputMode,
+ VSRect,
+ VSWrapper,
+} from "@services/compositor/types";
+import { CompositorConnection } from "@services/compositor";
+
+@register({
+ GTypeName: "VSMonitor",
+})
+export class VSMonitor extends DBusObject {
+ static _nextId: number = 1;
+
+ /**
+ * The simplified name of the monitor, such as HDMI-A-1 or DP-3
+ */
+ @property(String)
+ public readonly name: string;
+
+ /**
+ * The list of available modes reported by the monitor's EDID
+ */
+ @property(Object)
+ public readonly availableModes: VSOutputMode[];
+ /**
+ * The make of the monitor reported by EDID
+ */
+ @property(String)
+ public readonly make: string;
+ /**
+ * The model of the monitor reported by EDID
+ */
+ @property(String)
+ public readonly model: string;
+ /**
+ * The serial of the monitor reported by EDID
+ */
+ @property(String)
+ public readonly serial: string;
+
+ _mode: OutputMode;
+ _active: boolean;
+ _powered: boolean;
+ _rect: Rect;
+ _scale: number;
+ _transform: OutputTransform;
+
+ constructor(adapter: VSOutputAdapter) {
+ super(
+ "dev.ezri.voidshell.Monitor",
+ `/dev/ezri/VoidShell/Monitor/_${VSMonitor._nextId++}`,
+ );
+ this.name = adapter.name;
+ this.availableModes = adapter.availableModes;
+ this.make = adapter.make;
+ this.model = adapter.model;
+ this.serial = adapter.serial;
+ this._mode = adapter.mode.simplify();
+ this._active = adapter.active;
+ this._powered = adapter.powered;
+ this._rect = adapter.rect.simplify();
+ this._scale = adapter.scale;
+ this._transform = adapter.transform;
+
+ const subId = this.connect("destroyed", () => {
+ this.disconnect(subId);
+ this.dbusObj?.unexport();
+ });
+ }
+
+ public sync(adapter: VSOutputAdapter) {
+ // EDID data shouldn't ever change, so we'll ignore it.
+ const toEmit = new Set();
+ if (!adapter.mode.equals(this._mode)) {
+ toEmit.add("mode");
+ }
+ if (this._active !== adapter.active) {
+ toEmit.add("active");
+ }
+ if (this._powered !== adapter.powered) {
+ toEmit.add("powered");
+ }
+ if (!adapter.rect.equals(this._rect)) {
+ toEmit.add("rect");
+ if (adapter.rect.x !== this._rect.x || adapter.rect.y !== this._rect.y) {
+ toEmit.add("position");
+ }
+ }
+ if (this._scale !== adapter.scale) {
+ toEmit.add("scale");
+ }
+ if (this._transform !== adapter.transform) {
+ toEmit.add("transform");
+ }
+ this._mode = adapter.mode.simplify();
+ this._active = adapter.active;
+ this._powered = adapter.powered;
+ this._rect = adapter.rect.simplify();
+ this._scale = adapter.scale;
+ this._transform = adapter.transform;
+ toEmit.forEach((prop) => {
+ let value = this[prop];
+ if (value instanceof VSWrapper) {
+ value = value.simplify();
+ }
+ this.notify(prop);
+ this.emitDBusProperty(prop);
+ });
+ }
+
+ protected marshall_rect(): GLib.Variant {
+ return new GLib.Variant("(iiii)", [
+ this._rect.x,
+ this._rect.y,
+ this._rect.width,
+ this._rect.height,
+ ]);
+ }
+
+ protected marshall_mode(): GLib.Variant {
+ return new GLib.Variant("(iiis)", [
+ this._mode.width,
+ this._mode.height,
+ this._mode.refresh,
+ this._mode.aspect_ratio,
+ ]);
+ }
+
+ protected marshall_availableModes(): GLib.Variant {
+ return new GLib.Variant(
+ "a(iiis)",
+ this.availableModes.map((mode) => [
+ mode.width,
+ mode.height,
+ mode.refresh,
+ mode.aspect_ratio,
+ ]),
+ );
+ }
+
+ /**
+ * The current "mode" of the monitor, consisting of width, height, and refresh rate.
+ * Setting this will attempt to apply the change to the monitor asynchronously. Changes
+ * will not be immediately reflected.
+ */
+ @property(VSOutputMode)
+ public get mode(): VSOutputMode {
+ return VSOutputMode.wrap(this._mode);
+ }
+
+ public set mode(value: OutputMode | [number, number, number, string]) {
+ if (value instanceof Array) {
+ const [width, height, refresh, aspect_ratio] = value;
+ value = {
+ width,
+ height,
+ refresh,
+ aspect_ratio,
+ };
+ }
+ // Wrap it to get the features of VSWrapper
+ const wrapped = VSOutputMode.wrap(value);
+ if (wrapped.equals(this._mode)) {
+ // Already in this mode
+ return;
+ }
+ console.log("setting mode");
+ const refreshString = wrapped.refresh.toString();
+ const modeline =
+ `${wrapped.width}x${wrapped.height}@${refreshString.substring(0, refreshString.length - 3)}.${refreshString.substring(refreshString.length - 3)}Hz` as const;
+ console.log(this.availableModes.map((mode) => mode.simplify()));
+ // Check to ensure that at least one mode matches this
+ if (!this.availableModes.some((mode) => wrapped.equals(mode))) {
+ // Invalid mode, log and return
+ console.warn(`Invalid mode ${modeline} for output ${this.name}`);
+ return;
+ }
+ // Apply the mode
+ CompositorConnection.instance.setOutputProperty(
+ this.name,
+ "modeline",
+ modeline,
+ );
+ // Don't notify anyone yet, the stored value will be updated asynchronously when we get the output change event
+ }
+
+ /**
+ * Whether the monitor is active (can have windows, workspaces, etc. on it).
+ * Setting this will attempt to apply the change to the monitor asynchronously. Changes
+ * will not be immediately reflected.
+ */
+ @property(Boolean)
+ public get active() {
+ return this._active;
+ }
+
+ public set active(value) {
+ if (value === this._active) {
+ // Do nothing if nothing changed
+ return;
+ }
+ // Set the active state
+ CompositorConnection.instance.setOutputProperty(this.name, "active", value);
+ }
+
+ /**
+ * Whether the monitor is powered. An unpowered monitor can still be active, but will be placed into
+ * a power-saving mode with the panel turned off.
+ * Setting this will attempt to apply the change to the monitor asynchronously. Changes
+ * will not be immediately reflected.
+ */
+ @property(Boolean)
+ public get powered() {
+ return this._powered;
+ }
+
+ public set powered(value) {
+ if (value === this._powered) {
+ // Do nothing if nothing changed
+ return;
+ }
+ // Set the powered state
+ CompositorConnection.instance
+ .setOutputProperty(this.name, "powered", value)
+ .catch(console.error);
+ }
+
+ /**
+ * The position in the framebuffer the upper-left corner of the monitor is placed at.
+ * Setting this will attempt to apply the change to the monitor asynchronously. Changes
+ * will not be immediately reflected.
+ */
+ @property(Object)
+ public get position(): [x: number, y: number] {
+ return [this._rect.x, this._rect.y];
+ }
+
+ public set position(value) {
+ if (value[0] === this._rect.x && value[1] === this._rect.y) {
+ // Do nothing if nothing changed
+ return;
+ }
+ // Set the position
+ CompositorConnection.instance.setOutputProperty(
+ this.name,
+ "position",
+ value,
+ );
+ }
+
+ /**
+ * The scaling factor of the monitor, changing the effective size of the viewport into
+ * the framebuffer.
+ * Setting this will attempt to apply the change to the monitor asynchronously. Changes
+ * will not be immediately reflected.
+ */
+ @property(Number)
+ public get scale(): number {
+ return this._scale;
+ }
+
+ public set scale(value) {
+ if (value === this._scale) {
+ return;
+ }
+ CompositorConnection.instance.setOutputProperty(this.name, "scale", value);
+ }
+
+ /**
+ * The rectangle of the viewport into the framebuffer. The width and height here represent
+ * the _effective_ size of the monitor after scaling.
+ * This property is read-only, and is determined by a combination of mode, position, and scale.
+ */
+ @property(VSRect)
+ public get rect(): VSRect {
+ return VSRect.wrap(this._rect);
+ }
+
+ /**
+ * The transform string of the monitor, indicating if and how it is rotated and flipped
+ * Setting this will attempt to apply the change to the monitor asynchronously. Changes
+ * will not be immediately reflected.
+ */
+ @property(String)
+ public get transform(): OutputTransform {
+ return this._transform;
+ }
+
+ public set transform(value) {
+ if (this._transform === value) {
+ return;
+ }
+ CompositorConnection.instance.setOutputProperty(
+ this.name,
+ "transform",
+ value,
+ );
+ }
+
+ /**
+ * Fired when the monitor is "destroyed" (disconnected). Invalidates the object.
+ */
+ @signal(VSMonitor)
+ declare destroyed: () => void;
+
+ /**
+ * Fired when any element of the monitor's configuration changes.
+ */
+ @signal(VSMonitor)
+ declare changed: (monitor: this) => void;
+}
diff --git a/services/outputs/output.ts b/services/outputs/output.ts
new file mode 100644
index 0000000..5350f6f
--- /dev/null
+++ b/services/outputs/output.ts
@@ -0,0 +1 @@
+import GObject, { property, register } from "astal/gobject";
diff --git a/services/outputs/service.ts b/services/outputs/service.ts
new file mode 100644
index 0000000..7adc066
--- /dev/null
+++ b/services/outputs/service.ts
@@ -0,0 +1,67 @@
+import GObject, { register, property, signal } from "astal/gobject";
+import { VSMonitor } from "./monitor";
+import { VSOutput } from "./output";
+import { CompositorConnection } from "@services/compositor";
+import { EventType, VSOutputEvent } from "@services/compositor/types";
+
+@register({
+ GTypeName: "OutputService",
+})
+export class OutputService extends GObject.Object {
+ #monitors: VSMonitor[] = [];
+ #outputs: VSOutput[] = [];
+
+ public get monitors(): readonly VSMonitor[] {
+ return this.#monitors;
+ }
+
+ public get outputs(): readonly VSOutput[] {
+ return this.#outputs;
+ }
+
+ @signal(VSMonitor)
+ declare monitorConnected: (monitor: VSMonitor) => void;
+
+ constructor() {
+ super();
+ CompositorConnection.instance.connect(
+ "output-change",
+ (_, event: VSOutputEvent) => {
+ console.log("processing output change event");
+ switch (event.type) {
+ case EventType.CREATE:
+ if (
+ this.#monitors.find(
+ (monitor) => monitor.name === event.output,
+ ) === null
+ ) {
+ this.#monitors.push(new VSMonitor(event.adapter!));
+ break;
+ }
+ case EventType.CHANGE:
+ const monitor = this.#monitors.find(
+ (monitor) => monitor.name === event.output,
+ );
+ monitor?.sync(event.adapter!);
+ break;
+ case EventType.DESTROY:
+ const idx = this.#monitors.findIndex(
+ (output) => output.name === event.output,
+ );
+ if (idx === -1) break;
+ this.#monitors[idx].destroyed();
+ this.#monitors.splice(idx, 1);
+ break;
+ }
+ },
+ );
+ CompositorConnection.instance
+ .getOutputs()
+ .then((compositorOutputs) => {
+ compositorOutputs.forEach((output) =>
+ this.#monitors.push(new VSMonitor(output)),
+ );
+ })
+ .catch(console.error);
+ }
+}