diff options
author | Aylur <[email protected]> | 2024-10-15 01:26:32 +0200 |
---|---|---|
committer | Aylur <[email protected]> | 2024-10-15 01:26:32 +0200 |
commit | 2f71cd4c08bb4514efe43533e6a5d03535204c29 (patch) | |
tree | fc991a12e159ad645187862c90f40731794d6e47 /lang/gjs/lib | |
parent | 9fab13452a26ed55c01047d4225f699f43bba20d (diff) |
refactor lua and gjs lib
Diffstat (limited to 'lang/gjs/lib')
-rw-r--r-- | lang/gjs/lib/binding.ts | 89 | ||||
-rw-r--r-- | lang/gjs/lib/file.ts | 45 | ||||
-rw-r--r-- | lang/gjs/lib/gobject.ts | 180 | ||||
-rw-r--r-- | lang/gjs/lib/process.ts | 68 | ||||
-rw-r--r-- | lang/gjs/lib/time.ts | 13 | ||||
-rw-r--r-- | lang/gjs/lib/variable.ts | 230 |
6 files changed, 625 insertions, 0 deletions
diff --git a/lang/gjs/lib/binding.ts b/lang/gjs/lib/binding.ts new file mode 100644 index 0000000..95d905f --- /dev/null +++ b/lang/gjs/lib/binding.ts @@ -0,0 +1,89 @@ +export const snakeify = (str: string) => str + .replace(/([a-z])([A-Z])/g, "$1_$2") + .replaceAll("-", "_") + .toLowerCase() + +export const kebabify = (str: string) => str + .replace(/([a-z])([A-Z])/g, "$1-$2") + .replaceAll("_", "-") + .toLowerCase() + +export interface Subscribable<T = unknown> { + subscribe(callback: (value: T) => void): () => void + get(): T + [key: string]: any +} + +export interface Connectable { + connect(signal: string, callback: (...args: any[]) => unknown): number + disconnect(id: number): void + [key: string]: any +} + +export default class Binding<Value> { + private transformFn = (v: any) => v + + #emitter: Subscribable<Value> | Connectable + #prop?: string + + static bind< + T extends Connectable, + P extends keyof T, + >(object: T, property: P): Binding<T[P]> + + static bind<T>(object: Subscribable<T>): Binding<T> + + static bind(emitter: Connectable | Subscribable, prop?: string) { + return new Binding(emitter, prop) + } + + private constructor(emitter: Connectable | Subscribable<Value>, prop?: string) { + this.#emitter = emitter + this.#prop = prop && kebabify(prop) + } + + toString() { + return `Binding<${this.#emitter}${this.#prop ? `, "${this.#prop}"` : ""}>` + } + + as<T>(fn: (v: Value) => T): Binding<T> { + const bind = new Binding(this.#emitter, this.#prop) + bind.transformFn = (v: Value) => fn(this.transformFn(v)) + return bind as unknown as Binding<T> + } + + get(): Value { + if (typeof this.#emitter.get === "function") + return this.transformFn(this.#emitter.get()) + + if (typeof this.#prop === "string") { + const getter = `get_${snakeify(this.#prop)}` + if (typeof this.#emitter[getter] === "function") + return this.transformFn(this.#emitter[getter]()) + + return this.transformFn(this.#emitter[this.#prop]) + } + + throw Error("can not get value of binding") + } + + subscribe(callback: (value: Value) => void): () => void { + if (typeof this.#emitter.subscribe === "function") { + return this.#emitter.subscribe(() => { + callback(this.get()) + }) + } + else if (typeof this.#emitter.connect === "function") { + const signal = `notify::${this.#prop}` + const id = this.#emitter.connect(signal, () => { + callback(this.get()) + }) + return () => { + (this.#emitter.disconnect as Connectable["disconnect"])(id) + } + } + throw Error(`${this.#emitter} is not bindable`) + } +} + +export const { bind } = Binding diff --git a/lang/gjs/lib/file.ts b/lang/gjs/lib/file.ts new file mode 100644 index 0000000..7b9de3a --- /dev/null +++ b/lang/gjs/lib/file.ts @@ -0,0 +1,45 @@ +import Astal from "gi://AstalIO" +import Gio from "gi://Gio" + +export function readFile(path: string): string { + return Astal.read_file(path) || "" +} + +export function readFileAsync(path: string): Promise<string> { + return new Promise((resolve, reject) => { + Astal.read_file_async(path, (_, res) => { + try { + resolve(Astal.read_file_finish(res) || "") + } + catch (error) { + reject(error) + } + }) + }) +} + +export function writeFile(path: string, content: string): void { + Astal.write_file(path, content) +} + +export function writeFileAsync(path: string, content: string): Promise<void> { + return new Promise((resolve, reject) => { + Astal.write_file_async(path, content, (_, res) => { + try { + resolve(Astal.write_file_finish(res)) + } + catch (error) { + reject(error) + } + }) + }) +} + +export function monitorFile( + path: string, + callback: (file: string, event: Gio.FileMonitorEvent) => void, +): Gio.FileMonitor { + return Astal.monitor_file(path, (file: string, event: Gio.FileMonitorEvent) => { + callback(file, event) + })! +} diff --git a/lang/gjs/lib/gobject.ts b/lang/gjs/lib/gobject.ts new file mode 100644 index 0000000..4740764 --- /dev/null +++ b/lang/gjs/lib/gobject.ts @@ -0,0 +1,180 @@ +export { default as GObject, default as default } from "gi://GObject" +export { default as Gio } from "gi://Gio" +export { default as GLib } from "gi://GLib" + +import GObject from "gi://GObject" +const meta = Symbol("meta") + +const { ParamSpec, ParamFlags } = GObject + +const kebabify = (str: string) => str + .replace(/([a-z])([A-Z])/g, "$1-$2") + .replaceAll("_", "-") + .toLowerCase() + +type SignalDeclaration = { + flags?: GObject.SignalFlags + accumulator?: GObject.AccumulatorType + return_type?: GObject.GType + param_types?: Array<GObject.GType> +} + +type PropertyDeclaration = + | InstanceType<typeof GObject.ParamSpec> + | { $gtype: GObject.GType } + | typeof String + | typeof Number + | typeof Boolean + | typeof Object + +type GObjectConstructor = { + [meta]?: { + Properties?: { [key: string]: GObject.ParamSpec } + Signals?: { [key: string]: GObject.SignalDefinition } + } + new(...args: any[]): any +} + +type MetaInfo = GObject.MetaInfo<never, Array<{ $gtype: GObject.GType }>, never> + +export function register(options: MetaInfo = {}) { + return function (cls: GObjectConstructor) { + GObject.registerClass({ + Signals: { ...cls[meta]?.Signals }, + Properties: { ...cls[meta]?.Properties }, + ...options, + }, cls) + } +} + +export function property(declaration: PropertyDeclaration = Object) { + return function (target: any, prop: any, desc?: PropertyDescriptor) { + target.constructor[meta] ??= {} + target.constructor[meta].Properties ??= {} + + const name = kebabify(prop) + + if (!desc) { + let value = defaultValue(declaration) + + Object.defineProperty(target, prop, { + get() { + return value + }, + set(v) { + if (v !== value) { + value = v + this.notify(name) + } + }, + }) + + Object.defineProperty(target, `set_${name.replace("-", "_")}`, { + value: function (v: any) { + this[prop] = v + }, + }) + + Object.defineProperty(target, `get_${name.replace("-", "_")}`, { + value: function () { + return this[prop] + }, + }) + + target.constructor[meta].Properties[kebabify(prop)] = pspec(name, ParamFlags.READWRITE, declaration) + } + + else { + let flags = 0 + if (desc.get) flags |= ParamFlags.READABLE + if (desc.set) flags |= ParamFlags.WRITABLE + + target.constructor[meta].Properties[kebabify(prop)] = pspec(name, flags, declaration) + } + } +} + +export function signal(...params: Array<{ $gtype: GObject.GType } | typeof Object>): +(target: any, signal: any, desc?: PropertyDescriptor) => void + +export function signal(declaration?: SignalDeclaration): +(target: any, signal: any, desc?: PropertyDescriptor) => void + +export function signal( + declaration?: SignalDeclaration | { $gtype: GObject.GType } | typeof Object, + ...params: Array<{ $gtype: GObject.GType } | typeof Object> +) { + return function (target: any, signal: any, desc?: PropertyDescriptor) { + target.constructor[meta] ??= {} + target.constructor[meta].Signals ??= {} + + const name = kebabify(signal) + + if (declaration || params.length > 0) { + // @ts-expect-error TODO: type assert + const arr = [declaration, ...params].map(v => v.$gtype) + target.constructor[meta].Signals[name] = { + param_types: arr, + } + } + else { + target.constructor[meta].Signals[name] = declaration + } + + if (!desc) { + Object.defineProperty(target, signal, { + value: function (...args: any[]) { + this.emit(name, ...args) + }, + }) + } + else { + const og: ((...args: any[]) => void) = desc.value + desc.value = function (...args: any[]) { + // @ts-expect-error not typed + this.emit(name, ...args) + } + Object.defineProperty(target, `on_${name.replace("-", "_")}`, { + value: function (...args: any[]) { + return og(...args) + }, + }) + } + } +} + +function pspec(name: string, flags: number, declaration: PropertyDeclaration) { + if (declaration instanceof ParamSpec) + return declaration + + switch (declaration) { + case String: + return ParamSpec.string(name, "", "", flags, "") + case Number: + return ParamSpec.double(name, "", "", flags, -Number.MAX_VALUE, Number.MAX_VALUE, 0) + case Boolean: + return ParamSpec.boolean(name, "", "", flags, false) + case Object: + return ParamSpec.jsobject(name, "", "", flags) + default: + // @ts-expect-error misstyped + return ParamSpec.object(name, "", "", flags, declaration.$gtype) + } +} + +function defaultValue(declaration: PropertyDeclaration) { + if (declaration instanceof ParamSpec) + return declaration.get_default_value() + + switch (declaration) { + case String: + return "default-string" + case Number: + return 0 + case Boolean: + return false + case Object: + default: + return null + } +} diff --git a/lang/gjs/lib/process.ts b/lang/gjs/lib/process.ts new file mode 100644 index 0000000..2f7816b --- /dev/null +++ b/lang/gjs/lib/process.ts @@ -0,0 +1,68 @@ +import Astal from "gi://AstalIO" + +type Args = { + cmd: string | string[] + out?: (stdout: string) => void + err?: (stderr: string) => void +} + +export function subprocess(args: Args): Astal.Process + +export function subprocess( + cmd: string | string[], + onOut?: (stdout: string) => void, + onErr?: (stderr: string) => void, +): Astal.Process + +export function subprocess( + argsOrCmd: Args | string | string[], + onOut: (stdout: string) => void = print, + onErr: (stderr: string) => void = printerr, +) { + const args = Array.isArray(argsOrCmd) || typeof argsOrCmd === "string" + const { cmd, err, out } = { + cmd: args ? argsOrCmd : argsOrCmd.cmd, + err: args ? onErr : argsOrCmd.err || onErr, + out: args ? onOut : argsOrCmd.out || onOut, + } + + const proc = Array.isArray(cmd) + ? Astal.Process.subprocessv(cmd) + : Astal.Process.subprocess(cmd) + + proc.connect("stdout", (_, stdout: string) => out(stdout)) + proc.connect("stderr", (_, stderr: string) => err(stderr)) + return proc +} + +/** @throws {GLib.Error} Throws stderr */ +export function exec(cmd: string | string[]) { + return Array.isArray(cmd) + ? Astal.Process.execv(cmd) + : Astal.Process.exec(cmd) +} + +export function execAsync(cmd: string | string[]): Promise<string> { + return new Promise((resolve, reject) => { + if (Array.isArray(cmd)) { + Astal.Process.exec_asyncv(cmd, (_, res) => { + try { + resolve(Astal.Process.exec_asyncv_finish(res)) + } + catch (error) { + reject(error) + } + }) + } + else { + Astal.Process.exec_async(cmd, (_, res) => { + try { + resolve(Astal.Process.exec_finish(res)) + } + catch (error) { + reject(error) + } + }) + } + }) +} diff --git a/lang/gjs/lib/time.ts b/lang/gjs/lib/time.ts new file mode 100644 index 0000000..a7e1e61 --- /dev/null +++ b/lang/gjs/lib/time.ts @@ -0,0 +1,13 @@ +import Astal from "gi://AstalIO" + +export function interval(interval: number, callback?: () => void) { + return Astal.Time.interval(interval, () => void callback?.()) +} + +export function timeout(timeout: number, callback?: () => void) { + return Astal.Time.timeout(timeout, () => void callback?.()) +} + +export function idle(callback?: () => void) { + return Astal.Time.idle(() => void callback?.()) +} diff --git a/lang/gjs/lib/variable.ts b/lang/gjs/lib/variable.ts new file mode 100644 index 0000000..9b3d3d2 --- /dev/null +++ b/lang/gjs/lib/variable.ts @@ -0,0 +1,230 @@ +import Astal from "gi://AstalIO" +import Binding, { type Connectable, type Subscribable } from "./binding.js" +import { interval } from "./time.js" +import { execAsync, subprocess } from "./process.js" + +class VariableWrapper<T> extends Function { + private variable!: Astal.VariableBase + private errHandler? = console.error + + private _value: T + private _poll?: Astal.Time + private _watch?: Astal.Process + + private pollInterval = 1000 + private pollExec?: string[] | string + private pollTransform?: (stdout: string, prev: T) => T + private pollFn?: (prev: T) => T | Promise<T> + + private watchTransform?: (stdout: string, prev: T) => T + private watchExec?: string[] | string + + constructor(init: T) { + super() + this._value = init + this.variable = new Astal.VariableBase() + this.variable.connect("dropped", () => { + this.stopWatch() + this.stopPoll() + }) + this.variable.connect("error", (_, err) => this.errHandler?.(err)) + return new Proxy(this, { + apply: (target, _, args) => target._call(args[0]), + }) + } + + private _call<R = T>(transform?: (value: T) => R): Binding<R> { + const b = Binding.bind(this) + return transform ? b.as(transform) : b as unknown as Binding<R> + } + + toString() { + return String(`Variable<${this.get()}>`) + } + + get(): T { return this._value } + set(value: T) { + if (value !== this._value) { + this._value = value + this.variable.emit("changed") + } + } + + startPoll() { + if (this._poll) + return + + if (this.pollFn) { + this._poll = interval(this.pollInterval, () => { + const v = this.pollFn!(this.get()) + if (v instanceof Promise) { + v.then(v => this.set(v)) + .catch(err => this.variable.emit("error", err)) + } + else { + this.set(v) + } + }) + } + else if (this.pollExec) { + this._poll = interval(this.pollInterval, () => { + execAsync(this.pollExec!) + .then(v => this.set(this.pollTransform!(v, this.get()))) + .catch(err => this.variable.emit("error", err)) + }) + } + } + + startWatch() { + if (this._watch) + return + + this._watch = subprocess({ + cmd: this.watchExec!, + out: out => this.set(this.watchTransform!(out, this.get())), + err: err => this.variable.emit("error", err), + }) + } + + stopPoll() { + this._poll?.cancel() + delete this._poll + } + + stopWatch() { + this._watch?.kill() + delete this._watch + } + + isPolling() { return !!this._poll } + isWatching() { return !!this._watch } + + drop() { + this.variable.emit("dropped") + } + + onDropped(callback: () => void) { + this.variable.connect("dropped", callback) + return this as unknown as Variable<T> + } + + onError(callback: (err: string) => void) { + delete this.errHandler + this.variable.connect("error", (_, err) => callback(err)) + return this as unknown as Variable<T> + } + + subscribe(callback: (value: T) => void) { + const id = this.variable.connect("changed", () => { + callback(this.get()) + }) + return () => this.variable.disconnect(id) + } + + poll( + interval: number, + exec: string | string[], + transform?: (stdout: string, prev: T) => T + ): Variable<T> + + poll( + interval: number, + callback: (prev: T) => T | Promise<T> + ): Variable<T> + + poll( + interval: number, + exec: string | string[] | ((prev: T) => T | Promise<T>), + transform: (stdout: string, prev: T) => T = out => out as T, + ) { + this.stopPoll() + this.pollInterval = interval + this.pollTransform = transform + if (typeof exec === "function") { + this.pollFn = exec + delete this.pollExec + } + else { + this.pollExec = exec + delete this.pollFn + } + this.startPoll() + return this as unknown as Variable<T> + } + + watch( + exec: string | string[], + transform: (stdout: string, prev: T) => T = out => out as T, + ) { + this.stopWatch() + this.watchExec = exec + this.watchTransform = transform + this.startWatch() + return this as unknown as Variable<T> + } + + observe( + objs: Array<[obj: Connectable, signal: string]>, + callback: (...args: any[]) => T, + ): Variable<T> + + observe( + obj: Connectable, + signal: string, + callback: (...args: any[]) => T, + ): Variable<T> + + observe( + objs: Connectable | Array<[obj: Connectable, signal: string]>, + sigOrFn: string | ((obj: Connectable, ...args: any[]) => T), + callback?: (obj: Connectable, ...args: any[]) => T, + ) { + const f = typeof sigOrFn === "function" ? sigOrFn : callback ?? (() => this.get()) + const set = (obj: Connectable, ...args: any[]) => this.set(f(obj, ...args)) + + if (Array.isArray(objs)) { + for (const obj of objs) { + const [o, s] = obj + const id = o.connect(s, set) + this.onDropped(() => o.disconnect(id)) + } + } + else { + if (typeof sigOrFn === "string") { + const id = objs.connect(sigOrFn, set) + this.onDropped(() => objs.disconnect(id)) + } + } + + return this as unknown as Variable<T> + } + + static derive< + const Deps extends Array<Subscribable<any>>, + Args extends { + [K in keyof Deps]: Deps[K] extends Subscribable<infer T> ? T : never + }, + V = Args, + >(deps: Deps, fn: (...args: Args) => V = (...args) => args as unknown as V) { + const update = () => fn(...deps.map(d => d.get()) as Args) + const derived = new Variable(update()) + const unsubs = deps.map(dep => dep.subscribe(() => derived.set(update()))) + derived.onDropped(() => unsubs.map(unsub => unsub())) + return derived + } +} + +export interface Variable<T> extends Omit<VariableWrapper<T>, "bind"> { + <R>(transform: (value: T) => R): Binding<R> + (): Binding<T> +} + +export const Variable = new Proxy(VariableWrapper as any, { + apply: (_t, _a, args) => new VariableWrapper(args[0]), +}) as { + derive: typeof VariableWrapper["derive"] + <T>(init: T): Variable<T> + new<T>(init: T): Variable<T> +} + +export default Variable |