From c9a9091b483dd33f0d214604488d72a2e2e34ff9 Mon Sep 17 00:00:00 2001 From: Ezri Brimhall Date: Thu, 13 Mar 2025 09:38:07 -0600 Subject: [PATCH] Added config service --- app.ts | 35 +++- services/compositor/connection.ts | 1 + services/compositor/connections/sway.ts | 15 +- services/compositor/connections/sway_types.ts | 8 +- services/compositor/types.ts | 5 +- services/config/service.ts | 150 ++++++++++++++++++ services/config/types.ts | 103 ++++++++++++ services/dbus.ts | 14 +- services/outputs/monitor.ts | 45 ++++-- services/outputs/output.ts | 13 ++ services/outputs/service.ts | 78 +++++---- widget/Bar.tsx | 4 +- 12 files changed, 401 insertions(+), 70 deletions(-) create mode 100644 services/config/service.ts create mode 100644 services/config/types.ts diff --git a/app.ts b/app.ts index 21bb966..432b5c9 100644 --- a/app.ts +++ b/app.ts @@ -2,11 +2,42 @@ import { App } from "astal/gtk3"; import style from "./style.css"; import Bar from "./widget/Bar"; import "@/globals"; -import { CompositorConnection, SwayConnection } from "@services/compositor"; -import { OutputService } from "./services/outputs/service"; +import { SwayConnection } from "@services/compositor"; +import { OutputService } from "@/services/outputs/service"; +import { ConfigService } from "@/services/config/service"; new SwayConnection(); new OutputService(); +const confSrv = new ConfigService(); +confSrv.connect("config-reloaded", () => { + console.log(confSrv.config); + console.log( + confSrv.doConfigMath( + String(confSrv.config.layouts?.battlestation.primary.position[0] ?? ""), + { + auxiliary: 50, + "left-wing": 250, + }, + ), + ); + console.log( + confSrv.formatConfigString("{this.is.a.test.of.nested.parsing}", { + this: { + is: { + a: { + test: { + of: { + nested: { + parsing: "This was a successful test of nested parsing!", + }, + }, + }, + }, + }, + }, + }), + ); +}); App.start({ css: style, diff --git a/services/compositor/connection.ts b/services/compositor/connection.ts index 4758c89..c2672fc 100644 --- a/services/compositor/connection.ts +++ b/services/compositor/connection.ts @@ -118,6 +118,7 @@ export class CompositorConnection extends GObject.Object { * Set an output property without fully reconfiguring the output */ async setOutputProperty( + outputName: string, property: Prop, value: Exclude, ) { diff --git a/services/compositor/connections/sway.ts b/services/compositor/connections/sway.ts index 1e9f445..823e234 100644 --- a/services/compositor/connections/sway.ts +++ b/services/compositor/connections/sway.ts @@ -276,12 +276,15 @@ export class SwayConnection extends CompositorConnection { 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, - }, + mode: + output.current_mode === undefined + ? null + : { + height: output.current_mode.height, + width: output.current_mode.width, + refresh: output.current_mode.refresh, + aspect_ratio: output.current_mode.picture_aspect_ratio, + }, }); } diff --git a/services/compositor/connections/sway_types.ts b/services/compositor/connections/sway_types.ts index bdf6756..99b2fd9 100644 --- a/services/compositor/connections/sway_types.ts +++ b/services/compositor/connections/sway_types.ts @@ -141,6 +141,10 @@ export interface OutputMode { * The refresh rate of the mode in millihertz */ refresh: number; + /** + * The aspect ratio of the mode (if it stretches pixels) + */ + picture_aspect_ratio: string; } /** @@ -198,9 +202,9 @@ export interface OutputResponse { */ modes: OutputMode[]; /** - * The current mode of the output. + * The current mode of the output. Undefined if the output is disabled. */ - current_mode: OutputMode; + current_mode?: OutputMode; } export enum NodeType { diff --git a/services/compositor/types.ts b/services/compositor/types.ts index 22f4e60..82f8378 100644 --- a/services/compositor/types.ts +++ b/services/compositor/types.ts @@ -171,7 +171,6 @@ export class VSWrapper extends GObject.Object { if (thisValue instanceof VSWrapper && typeof otherValue === "object") { return thisValue.equals(otherValue); } - console.log(`items differ: ${thisValue} !== ${otherValue}`); return false; } @@ -301,7 +300,7 @@ export interface OutputAdapter { readonly model: string; readonly serial: string; readonly availableModes: OutputMode[]; - readonly mode: OutputMode; + readonly mode: OutputMode | null; } @register({ @@ -340,7 +339,7 @@ export class VSOutputAdapter @property(Object) declare readonly availableModes: VSOutputMode[]; @property(VSOutputMode) - declare readonly mode: VSOutputMode; + declare readonly mode: VSOutputMode | null; } /** diff --git a/services/config/service.ts b/services/config/service.ts new file mode 100644 index 0000000..f5a6166 --- /dev/null +++ b/services/config/service.ts @@ -0,0 +1,150 @@ +import GObject, { register, signal } from "astal/gobject"; +import { readFileAsync, monitorFile } from "astal/file"; +import GLib from "gi://GLib"; +import Gio from "gi://Gio"; +import yaml from "yaml"; +import { ConfigFile, Vars } from "./types"; + +@register({ + GTypeName: "ConfigService", +}) +export class ConfigService extends GObject.Object { + private get configPath(): string { + const envVar = GLib.getenv("VOIDSHELL_CONFIG"); + if (envVar !== null) { + return envVar; + } + const defaultPath = `${GLib.getenv("XDG_CONFIG_HOME") ?? GLib.getenv("HOME") + "/.config"}/voidshell/config.yml`; + return defaultPath; + } + + private fileMonitors: [path: string, mon: Gio.FileMonitor][] = []; + + /** + * Normalizes a path to be relative to the config directory, unless it is absolute. + */ + private normalizePath(path: string) { + if (path.startsWith("/")) { + return path; + } + if (path.startsWith("~")) { + return `${GLib.getenv("HOME")}/${path.slice(2)}`; + } + const basedir = this.configPath.split("/").slice(0, -1).join("/"); + return `${basedir}/${path}`; + } + + /** + * Recursively parse config files + */ + private async parseConfig(path: string): Promise { + try { + const contents = await readFileAsync(this.normalizePath(path)); + const parsed = yaml.parse(contents) as ConfigFile; + const importedFiles = + (await parsed.import?.amap(this.parseConfig.bind(this))) ?? []; + + // Merge config files + return importedFiles.reduce((config, imported) => { + // Merge output definitions + if (config.outputs !== undefined) { + Object.entries(imported.outputs ?? {}).forEach(([output, def]) => { + config.outputs![output] = { ...config.outputs?.[output], ...def }; + }); + } else { + config.outputs = imported.outputs; + } + + // Merge workspace definitions + config.workspaces = { ...config.workspaces, ...imported.workspaces }; + + // Merge layout definitions + config.layouts = { ...config.layouts, ...imported.layouts }; + + // Merge context definitions + config.contexts = config.contexts ?? {}; + Object.entries(imported.contexts ?? {}).forEach(([context, def]) => { + if (context in config.contexts!) { + config.contexts![context].vars = { + ...config.contexts![context].vars, + ...def.vars, + }; + config.contexts![context].groups = + def.groups ?? config.contexts![context].groups; + } else { + config.contexts![context] = def; + } + }); + + // Merge variables + config.vars = { ...config.vars, ...imported.vars }; + + return config; + }, parsed); + } catch (e) { + console.warn(e); + return {}; + } + } + + /** + * Format a string, usually taken from the config, using the variables set in the config and extra variables passed in + */ + public formatConfigString(value: string, extraArgs?: Vars): string { + const args = { ...this.config.vars, ...extraArgs }; + return value.replace( + /{([a-zA-Z_][a-zA-Z0-9_\-\.]*)}/g, + (match, identifier: string) => { + // @ts-expect-error + const replacer = identifier.split(".").reduce((val, id) => { + if (val === undefined) return undefined; + if (typeof val !== "object") return undefined; + if (id in val) return val[id]; + return undefined; + }, args); + if (replacer === undefined) return match; + if (typeof replacer === "object") return match; + return replacer; + }, + ); + } + + /** + * Pass the given string to formatConfigString and then evaluate it as a mathematical expression. Only addition and subtraction are supported. + */ + public doConfigMath(value: string, extraArgs?: Vars): number { + const str = this.formatConfigString(value, extraArgs); + let total = 0; + const matches = str.match(/[+\-]*(\.\d+|\d+(\.\d+)?)/g) ?? []; + while (matches.length) { + total += parseFloat(matches.shift() ?? "0"); + } + return total; + } + + public get config(): Readonly { + return this._config; + } + + private _config: ConfigFile = {}; + + /** + * Fully refresh the config file + */ + async refreshConfig() { + this.fileMonitors.forEach(([_, mon]) => { + mon.cancel(); + }); + this.fileMonitors = []; + this._config = await this.parseConfig(this.configPath); + this.configReloaded(); + } + + @signal() + declare configReloaded: () => void; + + constructor() { + super(); + this.refreshConfig(); + } +} diff --git a/services/config/types.ts b/services/config/types.ts new file mode 100644 index 0000000..2a6f7b1 --- /dev/null +++ b/services/config/types.ts @@ -0,0 +1,103 @@ +/** + * Generic variable interface for user-defined variables referenced elsewhere + * in the configuration + */ +export interface Vars { + [key: string]: string | number | boolean | Vars; +} + +export interface ConfigFile { + vars?: Vars; + import?: string[]; + outputs?: Outputs.Outputs; + layouts?: Layouts.Layouts; + contexts?: Contexts.Contexts; + workspaces?: Workspaces.Workspaces; +} + +export namespace Outputs { + export interface Outputs { + [name: string]: Output; + } + + export interface Criteria { + name?: string | string[]; + make?: string; + model?: string; + serial?: string | number; + hostname?: string | string[]; + modes: Partial[]; + } + + export interface Mode { + width: number; + height: number; + refresh: number; + picture_aspect_ratio: string; + } + + export interface Options + extends Partial<{ + resolution: string; + refresh: string; + "custom-mode": string; + scale: number; + "calibration-profile": string; + "adaptive-sync": boolean; + "allow-tearing": boolean; + }> {} + + export interface Output { + criteria: Criteria; + windows: string[] | Record>; + options: Options; + } +} + +export namespace Workspaces { + export interface Workspaces { + [id: string]: Workspace; + } + + export interface Workspace { + name: string; + application?: string; + exec?: string; + args?: string[]; + environ?: Record; + unit?: string; + systemd?: boolean; + "log-output"?: boolean; + } +} + +export namespace Layouts { + export interface Layouts { + [name: string]: Layout; + } + + export interface Layout { + [screenName: string]: Screen; + } + + export interface Screen { + required?: boolean; + score?: number; + position: [x: string | number, y: string | number]; + outputs: string[]; + options?: Outputs.Options; + } +} + +export namespace Contexts { + export interface Contexts { + [name: string]: Context; + } + + export interface Context { + vars?: Vars; + groups: { + [screenName: string]: string[]; + }; + } +} diff --git a/services/dbus.ts b/services/dbus.ts index 1351fb8..36b904e 100644 --- a/services/dbus.ts +++ b/services/dbus.ts @@ -81,12 +81,8 @@ export class DBusObject extends GObject.Object { 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); @@ -106,9 +102,15 @@ export class DBusObject extends GObject.Object { value.map((val) => val.objectPath), ); } - return new GLib.Variant("av", value); + return new GLib.Variant( + "av", + value.map(this.marshallValue.bind(this)), + ); } else { - return new GLib.Variant("a{sv}", value); + console.warn( + "No marshall function found for object type, cannot implicitly marshall JS objects. Returning null.", + ); + return null; } case "undefined": return null; diff --git a/services/outputs/monitor.ts b/services/outputs/monitor.ts index adbc558..c106e7c 100644 --- a/services/outputs/monitor.ts +++ b/services/outputs/monitor.ts @@ -18,6 +18,8 @@ import { CompositorConnection } from "@services/compositor"; export class VSMonitor extends DBusObject { static _nextId: number = 1; + private valid: boolean = true; + /** * The simplified name of the monitor, such as HDMI-A-1 or DP-3 */ @@ -45,7 +47,7 @@ export class VSMonitor extends DBusObject { @property(String) public readonly serial: string; - _mode: OutputMode; + _mode: OutputMode | null; _active: boolean; _powered: boolean; _rect: Rect; @@ -57,28 +59,30 @@ export class VSMonitor extends DBusObject { "dev.ezri.voidshell.Monitor", `/dev/ezri/VoidShell/Monitor/_${VSMonitor._nextId++}`, ); + console.log("Creating monitor", adapter.name); 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._mode = adapter.mode?.simplify() ?? null; 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) { + if (!this.valid) { + console.error( + "Cannot sync a destroyed output! You have dangling objects!", + ); + return; + } // EDID data shouldn't ever change, so we'll ignore it. const toEmit = new Set(); - if (!adapter.mode.equals(this._mode)) { + if (!adapter.mode?.equals(this._mode)) { toEmit.add("mode"); } if (this._active !== adapter.active) { @@ -99,7 +103,7 @@ export class VSMonitor extends DBusObject { if (this._transform !== adapter.transform) { toEmit.add("transform"); } - this._mode = adapter.mode.simplify(); + this._mode = adapter.mode?.simplify() ?? null; this._active = adapter.active; this._powered = adapter.powered; this._rect = adapter.rect.simplify(); @@ -126,10 +130,10 @@ export class VSMonitor extends DBusObject { protected marshall_mode(): GLib.Variant { return new GLib.Variant("(iiis)", [ - this._mode.width, - this._mode.height, - this._mode.refresh, - this._mode.aspect_ratio, + this._mode?.width ?? 0, + this._mode?.height ?? 0, + this._mode?.refresh ?? 0, + this._mode?.aspect_ratio ?? "", ]); } @@ -151,7 +155,10 @@ export class VSMonitor extends DBusObject { * will not be immediately reflected. */ @property(VSOutputMode) - public get mode(): VSOutputMode { + public get mode(): VSOutputMode | null { + if (!this._mode) { + return null; + } return VSOutputMode.wrap(this._mode); } @@ -304,10 +311,16 @@ export class VSMonitor extends DBusObject { ); } + public destroy() { + this.dbusObj?.unexport(); + this.valid = false; + this.emit("destroyed"); + } + /** - * Fired when the monitor is "destroyed" (disconnected). Invalidates the object. + * Fired when the monitor is "destroyed" (disconnected). Indicates the object is now invalid. */ - @signal(VSMonitor) + @signal() declare destroyed: () => void; /** diff --git a/services/outputs/output.ts b/services/outputs/output.ts index 5350f6f..04d1972 100644 --- a/services/outputs/output.ts +++ b/services/outputs/output.ts @@ -1 +1,14 @@ import GObject, { property, register } from "astal/gobject"; +import { VSMonitor } from "./monitor"; +import { DBusObject } from "@services/dbus"; + +@register({ + GTypeName: "VSOutput", +}) +export class VSOutput extends DBusObject { + @property(String) + public readonly name: string; + + @property(Object) + public readonly compatibleMonitors: VSMonitor[] = []; +} diff --git a/services/outputs/service.ts b/services/outputs/service.ts index 7adc066..6af1f46 100644 --- a/services/outputs/service.ts +++ b/services/outputs/service.ts @@ -22,39 +22,47 @@ export class OutputService extends GObject.Object { @signal(VSMonitor) declare monitorConnected: (monitor: VSMonitor) => void; + private onOutputEvent(event: VSOutputEvent) { + console.log("processing output change event"); + const idx = this.#monitors.findIndex( + (monitor) => monitor.name === event.output, + ); + switch (event.type) { + case EventType.CREATE: + if (idx === -1) { + console.log(`new monitor ${event.output} connected`); + const newMonitor = new VSMonitor(event.adapter!); + this.#monitors.push(newMonitor); + this.monitorConnected(newMonitor); + } else { + console.warn( + `Received create event for monitor ${event.output} we already know about. This is probably a bug!`, + ); + } + break; + case EventType.CHANGE: + if (idx === -1) { + console.warn( + `Received change event for monitor ${event.output} we don't know about. This is probably a bug!`, + ); + } else { + this.#monitors[idx].sync(event.adapter!); + } + break; + case EventType.DESTROY: + if (idx === -1) { + console.warn( + `Received destroy event for monitor ${event.output} we don't know about. This is probably a bug!`, + ); + } else { + this.#monitors[idx].destroy(); + this.#monitors.splice(idx, 1); + } + } + } + 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) => { @@ -62,6 +70,12 @@ export class OutputService extends GObject.Object { this.#monitors.push(new VSMonitor(output)), ); }) - .catch(console.error); + .catch(console.error) + .then(() => + CompositorConnection.instance.connect( + "output-change", + (_, event: VSOutputEvent) => this.onOutputEvent(event), + ), + ); } } diff --git a/widget/Bar.tsx b/widget/Bar.tsx index 70c304d..03b1bdd 100644 --- a/widget/Bar.tsx +++ b/widget/Bar.tsx @@ -6,8 +6,6 @@ import { Tree } from "@/services/sway/workspaces"; const time = Variable("").poll(1000, "date"); export default function Bar(monitor: number) { - const tree = Tree.instance; - const currentContext = bind(tree, "currentContextName"); return ( -