UNPKG

gnim

Version:

Library which brings JSX and reactivity to GNOME JavaScript.

529 lines (462 loc) 16 kB
import GObject from "gi://GObject" import Gio from "gi://Gio" import GLib from "gi://GLib" import { type Pascalify, camelify, kebabify } from "../util.js" type SubscribeCallback = () => void type DisposeFunction = () => void type SubscribeFunction = (callback: SubscribeCallback) => DisposeFunction type DeepInfer<S extends string> = ReturnType<GLib.Variant<S>["deepUnpack"]> type RecursiveInfer<S extends string> = ReturnType<GLib.Variant<S>["recursiveUnpack"]> export type Accessed<T> = T extends Accessor<infer V> ? V : never // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class Accessor<T = unknown> extends Function { static $gtype = GObject.TYPE_JSOBJECT as unknown as GObject.GType<Accessor> #get: () => T #subscribe: SubscribeFunction constructor(get: () => T, subscribe?: SubscribeFunction) { super("return arguments.callee._call.apply(arguments.callee, arguments)") this.#subscribe = subscribe ?? (() => () => void 0) this.#get = get } /** * Subscribe for value changes. * @param callback The function to run when the current value changes. * @returns Unsubscribe function. */ subscribe(callback: SubscribeCallback): DisposeFunction { // TODO: auto unsub when a scope is available? return this.#subscribe(callback) } /** * @returns The current value. */ get(): T { return this.#get() } /** * Create a new `Accessor` that applies a transformation on its value. * @param transform The transformation to apply. Should be a pure function. */ as<R = T>(transform: (value: T) => R): Accessor<R> { return new Accessor(() => transform(this.#get()), this.#subscribe) } protected _call<R = T>(transform: (value: T) => R): Accessor<R> { return this.as(transform) } toString(): string { return `Accessor<${this.get()}>` } [Symbol.toPrimitive]() { console.warn("Accessor implicitly converted to a primitive value.") return this.toString() } } export interface Accessor<T> { /** * Create a new `Accessor` that applies a transformation on its value. * @param transform The transformation to apply. Should be a pure function. */ <R = T>(transform: (value: T) => R): Accessor<R> } export type Setter<T> = { (value: T): void (value: (prev: T) => T): void } export type State<T> = [Accessor<T>, Setter<T>] /** * Create a writable signal. * * @param init The intial value of the signal * @returns An `Accessor` and a setter function */ export function createState<T>(init: T): State<T> { let currentValue = init const subscribers = new Set<SubscribeCallback>() const subscribe: SubscribeFunction = (callback) => { subscribers.add(callback) return () => subscribers.delete(callback) } const set = (newValue: unknown) => { const value: T = typeof newValue === "function" ? newValue(currentValue) : newValue if (currentValue !== value) { currentValue = value // running callbacks might mutate subscribers Array.from(subscribers).forEach((cb) => cb()) } } return [new Accessor(() => currentValue, subscribe), set as Setter<T>] } const empty = Symbol("empty computed value") function createComputedProducer<T>(fn: (track: <V>(signal: Accessor<V>) => V) => T): Accessor<T> { const subscribers = new Set<SubscribeCallback>() let value: typeof empty | T let prevDeps = new Map<Accessor, DisposeFunction>() const effect = () => { const deps = new Set<Accessor>() value = fn((v) => (deps.add(v), v.get())) const newDeps = new Map<Accessor, DisposeFunction>() for (const [dep, unsub] of prevDeps) { if (!deps.has(dep)) { unsub() } else { newDeps.set(dep, unsub) } } for (const dep of deps) { if (!newDeps.has(dep)) { newDeps.set(dep, dep.subscribe(effect)) } } prevDeps = newDeps Array.from(subscribers).forEach((cb) => cb()) } const subscribe: SubscribeFunction = (callback) => { if (subscribers.size === 0) { effect() } subscribers.add(callback) return () => { subscribers.delete(callback) if (subscribers.size === 0) { value = empty for (const [, unsub] of prevDeps) { unsub() } } } } const get = (): T => { return value === empty ? fn((v) => v.get()) : value } return new Accessor(get, subscribe) } function createComputedArgs< const Deps extends Array<Accessor<any>>, Args extends { [K in keyof Deps]: Accessed<Deps[K]> }, V = Args, >(deps: Deps, transform?: (...args: Args) => V): Accessor<V> { let dispose: Array<DisposeFunction> const subscribers = new Set<SubscribeCallback>() const cache = new Array<unknown>(deps.length) const subscribe: SubscribeFunction = (callback) => { if (subscribers.size === 0) { dispose = deps.map((dep, i) => dep.subscribe(() => { const value = dep.get() if (cache[i] !== value) { cache[i] = dep.get() Array.from(subscribers).forEach((cb) => cb()) } }), ) } subscribers.add(callback) return () => { subscribers.delete(callback) if (subscribers.size === 0) { dispose.map((cb) => cb()) dispose.length = 0 cache.length = 0 } } } const get = (): V => { const args = deps.map((dep, i) => { if (!cache[i]) { cache[i] = dep.get() } return cache[i] }) return transform ? transform(...(args as Args)) : (args as V) } return new Accessor(get, subscribe) } /** * Create an `Accessor` from a producer function that tracks its dependencies. * * ```ts Example * let a: Accessor<number> * let b: Accessor<number> * const c: Accessor<number> = createComputed((get) => get(a) + get(b)) * ``` * * @experimental * @param producer The producer function which let's you track dependencies * @returns The computed `Accessor`. */ export function createComputed<T>( producer: (track: <V>(signal: Accessor<V>) => V) => T, ): Accessor<T> /** * Create an `Accessor` which is computed from a list of given `Accessor`s. * * ```ts Example * let a: Accessor<number> * let b: Accessor<string> * const c: Accessor<[number, string]> = createComputed([a, b]) * const d: Accessor<string> = createComputed([a, b], (a: number, b: string) => `${a} ${b}`) * ``` * * @param deps List of `Accessors`. * @param transform An optional transform function. * @returns The computed `Accessor`. */ export function createComputed< const Deps extends Array<Accessor<any>>, Args extends { [K in keyof Deps]: Accessed<Deps[K]> }, T = Args, >(deps: Deps, transform?: (...args: Args) => T): Accessor<T> export function createComputed( ...args: | [producer: (track: <V>(signal: Accessor<V>) => V) => unknown] | [deps: Array<Accessor>, transform?: (...args: unknown[]) => unknown] ) { const [depsOrProducer, transform] = args if (typeof depsOrProducer === "function") { return createComputedProducer(depsOrProducer) } else { return createComputedArgs(depsOrProducer, transform) } } /** * Create an `Accessor` on a `GObject.Object`'s `property`. * * @param object The `GObject.Object` to create the `Accessor` on. * @param property One of its registered properties. */ export function createBinding<T extends GObject.Object, P extends keyof T>( object: T, property: Extract<P, string>, ): Accessor<T[P]> // TODO: support nested bindings // export function createBinding< // T extends GObject.Object, // P1 extends keyof T, // P2 extends keyof NonNullable<T[P1]>, // >( // object: T, // property1: Extract<P1, string>, // property2: Extract<P2, string>, // ): Accessor<NonNullable<T[P1]>[P2]> /** * Create an `Accessor` on a `Gio.Settings`'s `key`. * Values are recursively unpacked. * * @deprecated prefer using {@link createSettings}. * @param object The `Gio.Settings` to create the `Accessor` on. * @param key The settings key */ export function createBinding<T>(settings: Gio.Settings, key: string): Accessor<T> export function createBinding<T>(object: GObject.Object | Gio.Settings, key: string): Accessor<T> { const prop = kebabify(key) as keyof typeof object const subscribe: SubscribeFunction = (callback) => { const sig = object instanceof Gio.Settings ? "changed" : "notify" const id = object.connect(`${sig}::${prop}`, () => callback()) return () => object.disconnect(id) } const get = (): T => { if (object instanceof Gio.Settings) { return object.get_value(key).recursiveUnpack() as T } else { const getter = `get_${prop.replaceAll("-", "_")}` as keyof typeof object if (getter in object && typeof object[getter] === "function") { return (object[getter] as () => unknown)() as T } if (prop in object) return object[prop] as T if (key in object) return object[key as keyof typeof object] as T throw Error(`cannot get property ${key}`) } } return new Accessor(get, subscribe) } type ConnectionHandler< O extends GObject.Object, S extends keyof O["$signals"], T, > = O["$signals"][S] extends (...args: any[]) => infer R ? void extends R ? (...args: [...Parameters<O["$signals"][S]>, currentValue: T]) => T : never : never /** * Create an `Accessor` which sets up a list of `GObject.Object` signal connections. * * ```ts Example * const value: Accessor<string> = createConnection( * "initial value", * [obj1, "sig-name", (...args) => "str"], * [obj2, "sig-name", (...args) => "str"] * ) * ``` * * @param init The initial value * @param signals A list of `GObject.Object`, signal name and callback pairs to connect. */ export function createConnection< T, O1 extends GObject.Object, S1 extends keyof O1["$signals"], O2 extends GObject.Object, S2 extends keyof O2["$signals"], O3 extends GObject.Object, S3 extends keyof O3["$signals"], O4 extends GObject.Object, S4 extends keyof O4["$signals"], O5 extends GObject.Object, S5 extends keyof O5["$signals"], O6 extends GObject.Object, S6 extends keyof O6["$signals"], O7 extends GObject.Object, S7 extends keyof O7["$signals"], O8 extends GObject.Object, S8 extends keyof O8["$signals"], O9 extends GObject.Object, S9 extends keyof O9["$signals"], >( init: T, h1: [O1, S1, ConnectionHandler<O1, S1, T>], h2?: [O2, S2, ConnectionHandler<O2, S2, T>], h3?: [O3, S3, ConnectionHandler<O3, S3, T>], h4?: [O4, S4, ConnectionHandler<O4, S4, T>], h5?: [O5, S5, ConnectionHandler<O5, S5, T>], h6?: [O6, S6, ConnectionHandler<O6, S6, T>], h7?: [O7, S7, ConnectionHandler<O7, S7, T>], h8?: [O8, S8, ConnectionHandler<O8, S8, T>], h9?: [O9, S9, ConnectionHandler<O9, S9, T>], ) { let value = init let dispose: Array<DisposeFunction> const subscribers = new Set<SubscribeCallback>() const signals = [h1, h2, h3, h4, h5, h6, h7, h8, h9].filter((h) => h !== undefined) const subscribe: SubscribeFunction = (callback) => { if (subscribers.size === 0) { dispose = signals.map(([object, signal, callback]) => { const id = GObject.Object.prototype.connect.call( object, signal as string, (_, ...args) => { const newValue = callback(...args, value) if (value !== newValue) { value = newValue Array.from(subscribers).forEach((cb) => cb()) } }, ) return () => GObject.Object.prototype.disconnect.call(object, id) }) } subscribers.add(callback) return () => { subscribers.delete(callback) if (subscribers.size === 0) { dispose.map((cb) => cb()) dispose.length = 0 } } } return new Accessor(() => value, subscribe) } /** * Create a signal from a provier function. * The provider is called when the first subscriber appears and the returned dispose * function from the provider will be called when the number of subscribers drop to zero. * * Example: * * ```ts * const value = createExternal(0, (set) => { * const interval = setInterval(() => set((v) => v + 1)) * return () => clearInterval(interval) * }) * ``` * * @param init The initial value * @param producer The producer function which should return a cleanup function */ export function createExternal<T>( init: T, producer: (set: Setter<T>) => DisposeFunction, ): Accessor<T> { let currentValue = init let dispose: DisposeFunction const subscribers = new Set<SubscribeCallback>() const subscribe: SubscribeFunction = (callback) => { if (subscribers.size === 0) { dispose = producer((v: unknown) => { const newValue: T = typeof v === "function" ? v(currentValue) : v if (newValue !== currentValue) { currentValue = newValue Array.from(subscribers).forEach((cb) => cb()) } }) } subscribers.add(callback) return () => { subscribers.delete(callback) if (subscribers.size === 0) { dispose() } } } return new Accessor(() => currentValue, subscribe) } /** @experimental */ type Settings<T extends Record<string, string>> = { [K in keyof T as Uncapitalize<Pascalify<K>>]: Accessor<RecursiveInfer<T[K]>> } & { [K in keyof T as `set${Pascalify<K>}`]: Setter<DeepInfer<T[K]>> } /** * @experimental * * Wrap a {@link Gio.Settings} into a collection of setters and accessors. * * Example: * * ```ts * const s = createSettings(settings, { * "complex-key": "a{sa{ss}}", * "simple-key": "s", * }) * * s.complexKey.subscribe(() => { * print(s.complexKey.get()) * }) * * s.setComplexKey((prev) => ({ * ...prev, * key: { nested: "" }, * })) * ``` */ export function createSettings<const T extends Record<string, string>>( settings: Gio.Settings, keys: T, ): Settings<T> { return Object.fromEntries( Object.entries(keys).flatMap(([key, type]) => [ [ camelify(key), new Accessor( () => settings.get_value(key).recursiveUnpack(), (callback) => { const id = settings.connect(`changed::${key}`, callback) return () => settings.disconnect(id) }, ), ], [ `set${key[0].toUpperCase() + camelify(key).slice(1)}`, (v: unknown) => { settings.set_value( key, new GLib.Variant( type, typeof v === "function" ? v(settings.get_value(key).deepUnpack()) : v, ), ) }, ], ]), ) }