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