summaryrefslogtreecommitdiff
path: root/lang/gjs/src
diff options
context:
space:
mode:
Diffstat (limited to 'lang/gjs/src')
-rw-r--r--lang/gjs/src/binding.ts89
-rw-r--r--lang/gjs/src/file.ts47
-rw-r--r--lang/gjs/src/gobject.ts180
-rw-r--r--lang/gjs/src/gtk3/app.ts105
-rw-r--r--lang/gjs/src/gtk3/astalify.ts325
-rw-r--r--lang/gjs/src/gtk3/index.ts9
-rw-r--r--lang/gjs/src/gtk3/jsx-runtime.ts96
-rw-r--r--lang/gjs/src/gtk3/widget.ts159
-rw-r--r--lang/gjs/src/gtk4/app.ts1
-rw-r--r--lang/gjs/src/gtk4/astalify.ts1
-rw-r--r--lang/gjs/src/gtk4/index.ts1
-rw-r--r--lang/gjs/src/gtk4/jsx-runtime.ts1
-rw-r--r--lang/gjs/src/index.ts7
-rw-r--r--lang/gjs/src/process.ts70
-rw-r--r--lang/gjs/src/time.ts15
-rw-r--r--lang/gjs/src/variable.ts230
16 files changed, 1336 insertions, 0 deletions
diff --git a/lang/gjs/src/binding.ts b/lang/gjs/src/binding.ts
new file mode 100644
index 0000000..95d905f
--- /dev/null
+++ b/lang/gjs/src/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/src/file.ts b/lang/gjs/src/file.ts
new file mode 100644
index 0000000..6ad8be3
--- /dev/null
+++ b/lang/gjs/src/file.ts
@@ -0,0 +1,47 @@
+import Astal from "gi://AstalIO"
+import Gio from "gi://Gio?version=2.0"
+
+export { 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/src/gobject.ts b/lang/gjs/src/gobject.ts
new file mode 100644
index 0000000..aeb3d9e
--- /dev/null
+++ b/lang/gjs/src/gobject.ts
@@ -0,0 +1,180 @@
+import GObject from "gi://GObject"
+
+export { default as GLib } from "gi://GLib?version=2.0"
+export { GObject, GObject as default }
+
+const meta = Symbol("meta")
+const priv = Symbol("priv")
+
+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) {
+ Object.defineProperty(target, prop, {
+ get() {
+ return this[priv]?.[prop] ?? defaultValue(declaration)
+ },
+ set(v: any) {
+ if (v !== this[prop]) {
+ this[priv] ??= {}
+ this[priv][prop] = v
+ this.notify(name)
+ }
+ },
+ })
+
+ Object.defineProperty(target, `set_${name.replace("-", "_")}`, {
+ value(v: any) {
+ this[prop] = v
+ },
+ })
+
+ Object.defineProperty(target, `get_${name.replace("-", "_")}`, {
+ value() {
+ 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/src/gtk3/app.ts b/lang/gjs/src/gtk3/app.ts
new file mode 100644
index 0000000..1191dc4
--- /dev/null
+++ b/lang/gjs/src/gtk3/app.ts
@@ -0,0 +1,105 @@
+import IO from "gi://AstalIO"
+import GObject from "gi://GObject"
+import Astal from "gi://Astal?version=3.0"
+import Gio from "gi://Gio?version=2.0"
+import Gtk from "gi://Gtk?version=3.0"
+
+Gtk.init(null)
+
+type RequestHandler = {
+ (request: string, res: (response: any) => void): void
+}
+
+type Config = Partial<{
+ icons: string
+ instanceName: string
+ gtkTheme: string
+ iconTheme: string
+ cursorTheme: string
+ css: string
+ requestHandler: RequestHandler
+ main(...args: string[]): void
+ client(message: (msg: string) => string, ...args: string[]): void
+ hold: boolean
+}>
+
+import { setConsoleLogDomain } from "console"
+import { exit, programArgs } from "system"
+
+export default new (class AstalJS extends Astal.Application {
+ static { GObject.registerClass({ GTypeName: "AstalJS" }, this) }
+
+ eval(body: string): Promise<any> {
+ return new Promise((res, rej) => {
+ try {
+ const fn = Function(`return (async function() {
+ ${body.includes(";") ? body : `return ${body};`}
+ })`)
+ fn()()
+ .then(res)
+ .catch(rej)
+ }
+ catch (error) {
+ rej(error)
+ }
+ })
+ }
+
+ requestHandler?: RequestHandler
+
+ vfunc_request(msg: string, conn: Gio.SocketConnection): void {
+ if (typeof this.requestHandler === "function") {
+ this.requestHandler(msg, (response) => {
+ IO.write_sock(conn, String(response), (_, res) =>
+ IO.write_sock_finish(res),
+ )
+ })
+ }
+ else {
+ super.vfunc_request(msg, conn)
+ }
+ }
+
+ apply_css(style: string, reset = false) {
+ super.apply_css(style, reset)
+ }
+
+ quit(code?: number): void {
+ super.quit()
+ exit(code ?? 0)
+ }
+
+ start({ requestHandler, css, hold, main, client, icons, ...cfg }: Config = {}) {
+ client ??= () => {
+ print(`Astal instance "${this.instanceName}" already running`)
+ exit(1)
+ }
+
+ Object.assign(this, cfg)
+ setConsoleLogDomain(this.instanceName)
+
+ this.requestHandler = requestHandler
+ this.connect("activate", () => {
+ main?.(...programArgs)
+ })
+
+ try {
+ this.acquire_socket()
+ }
+ catch (error) {
+ return client(msg => IO.send_message(this.instanceName, msg)!, ...programArgs)
+ }
+
+ if (css)
+ this.apply_css(css, false)
+
+ if (icons)
+ this.add_icons(icons)
+
+ hold ??= true
+ if (hold)
+ this.hold()
+
+ this.runAsync([])
+ }
+})
diff --git a/lang/gjs/src/gtk3/astalify.ts b/lang/gjs/src/gtk3/astalify.ts
new file mode 100644
index 0000000..2cd6984
--- /dev/null
+++ b/lang/gjs/src/gtk3/astalify.ts
@@ -0,0 +1,325 @@
+import Astal from "gi://Astal?version=3.0"
+import Gtk from "gi://Gtk?version=3.0"
+import Gdk from "gi://Gdk?version=3.0"
+import GObject from "gi://GObject"
+import { execAsync } from "../process.js"
+import Variable from "../variable.js"
+import Binding, { kebabify, snakeify, type Connectable, type Subscribable } from "../binding.js"
+
+export function mergeBindings(array: any[]) {
+ function getValues(...args: any[]) {
+ let i = 0
+ return array.map(value => value instanceof Binding
+ ? args[i++]
+ : value,
+ )
+ }
+
+ const bindings = array.filter(i => i instanceof Binding)
+
+ if (bindings.length === 0)
+ return array
+
+ if (bindings.length === 1)
+ return bindings[0].as(getValues)
+
+ return Variable.derive(bindings, getValues)()
+}
+
+function setProp(obj: any, prop: string, value: any) {
+ try {
+ // the setter method has to be used because
+ // array like properties are not bound correctly as props
+ const setter = `set_${snakeify(prop)}`
+ if (typeof obj[setter] === "function")
+ return obj[setter](value)
+
+ return (obj[prop] = value)
+ }
+ catch (error) {
+ console.error(`could not set property "${prop}" on ${obj}:`, error)
+ }
+}
+
+export default function astalify<
+ C extends { new(...args: any[]): Gtk.Widget },
+>(cls: C) {
+ class Widget extends cls {
+ get css(): string { return Astal.widget_get_css(this) }
+ set css(css: string) { Astal.widget_set_css(this, css) }
+ get_css(): string { return this.css }
+ set_css(css: string) { this.css = css }
+
+ get className(): string { return Astal.widget_get_class_names(this).join(" ") }
+ set className(className: string) { Astal.widget_set_class_names(this, className.split(/\s+/)) }
+ get_class_name(): string { return this.className }
+ set_class_name(className: string) { this.className = className }
+
+ get cursor(): Cursor { return Astal.widget_get_cursor(this) as Cursor }
+ set cursor(cursor: Cursor) { Astal.widget_set_cursor(this, cursor) }
+ get_cursor(): Cursor { return this.cursor }
+ set_cursor(cursor: Cursor) { this.cursor = cursor }
+
+ get clickThrough(): boolean { return Astal.widget_get_click_through(this) }
+ set clickThrough(clickThrough: boolean) { Astal.widget_set_click_through(this, clickThrough) }
+ get_click_through(): boolean { return this.clickThrough }
+ set_click_through(clickThrough: boolean) { this.clickThrough = clickThrough }
+
+ declare __no_implicit_destroy: boolean
+ get noImplicitDestroy(): boolean { return this.__no_implicit_destroy }
+ set noImplicitDestroy(value: boolean) { this.__no_implicit_destroy = value }
+
+ _setChildren(children: Gtk.Widget[]) {
+ children = children.flat(Infinity).map(ch => ch instanceof Gtk.Widget
+ ? ch
+ : new Gtk.Label({ visible: true, label: String(ch) }))
+
+ // remove
+ if (this instanceof Gtk.Bin) {
+ const ch = this.get_child()
+ if (ch)
+ this.remove(ch)
+ if (ch && !children.includes(ch) && !this.noImplicitDestroy)
+ ch?.destroy()
+ }
+ else if (this instanceof Gtk.Container) {
+ for (const ch of this.get_children()) {
+ this.remove(ch)
+ if (!children.includes(ch) && !this.noImplicitDestroy)
+ ch?.destroy()
+ }
+ }
+
+ // TODO: add more container types
+ if (this instanceof Astal.Box) {
+ this.set_children(children)
+ }
+
+ else if (this instanceof Astal.Stack) {
+ this.set_children(children)
+ }
+
+ else if (this instanceof Astal.CenterBox) {
+ this.startWidget = children[0]
+ this.centerWidget = children[1]
+ this.endWidget = children[2]
+ }
+
+ else if (this instanceof Astal.Overlay) {
+ const [child, ...overlays] = children
+ this.set_child(child)
+ this.set_overlays(overlays)
+ }
+
+ else if (this instanceof Gtk.Container) {
+ for (const ch of children)
+ this.add(ch)
+ }
+ }
+
+ toggleClassName(cn: string, cond = true) {
+ Astal.widget_toggle_class_name(this, cn, cond)
+ }
+
+ hook(
+ object: Connectable,
+ signal: string,
+ callback: (self: this, ...args: any[]) => void,
+ ): this
+ hook(
+ object: Subscribable,
+ callback: (self: this, ...args: any[]) => void,
+ ): this
+ hook(
+ object: Connectable | Subscribable,
+ signalOrCallback: string | ((self: this, ...args: any[]) => void),
+ callback?: (self: this, ...args: any[]) => void,
+ ) {
+ if (typeof object.connect === "function" && callback) {
+ const id = object.connect(signalOrCallback, (_: any, ...args: unknown[]) => {
+ callback(this, ...args)
+ })
+ this.connect("destroy", () => {
+ (object.disconnect as Connectable["disconnect"])(id)
+ })
+ }
+
+ else if (typeof object.subscribe === "function" && typeof signalOrCallback === "function") {
+ const unsub = object.subscribe((...args: unknown[]) => {
+ signalOrCallback(this, ...args)
+ })
+ this.connect("destroy", unsub)
+ }
+
+ return this
+ }
+
+ constructor(...params: any[]) {
+ super()
+ const [config] = params
+
+ const { setup, child, children = [], ...props } = config
+ props.visible ??= true
+
+ if (child)
+ children.unshift(child)
+
+ // collect bindings
+ const bindings = Object.keys(props).reduce((acc: any, prop) => {
+ if (props[prop] instanceof Binding) {
+ const binding = props[prop]
+ delete props[prop]
+ return [...acc, [prop, binding]]
+ }
+ return acc
+ }, [])
+
+ // collect signal handlers
+ const onHandlers = Object.keys(props).reduce((acc: any, key) => {
+ if (key.startsWith("on")) {
+ const sig = kebabify(key).split("-").slice(1).join("-")
+ const handler = props[key]
+ delete props[key]
+ return [...acc, [sig, handler]]
+ }
+ return acc
+ }, [])
+
+ // set children
+ const mergedChildren = mergeBindings(children.flat(Infinity))
+ if (mergedChildren instanceof Binding) {
+ this._setChildren(mergedChildren.get())
+ this.connect("destroy", mergedChildren.subscribe((v) => {
+ this._setChildren(v)
+ }))
+ }
+ else {
+ if (mergedChildren.length > 0) {
+ this._setChildren(mergedChildren)
+ }
+ }
+
+ // setup signal handlers
+ for (const [signal, callback] of onHandlers) {
+ if (typeof callback === "function") {
+ this.connect(signal, callback)
+ }
+ else {
+ this.connect(signal, () => execAsync(callback)
+ .then(print).catch(console.error))
+ }
+ }
+
+ // setup bindings handlers
+ for (const [prop, binding] of bindings) {
+ if (prop === "child" || prop === "children") {
+ this.connect("destroy", binding.subscribe((v: any) => {
+ this._setChildren(v)
+ }))
+ }
+ this.connect("destroy", binding.subscribe((v: any) => {
+ setProp(this, prop, v)
+ }))
+ setProp(this, prop, binding.get())
+ }
+
+ Object.assign(this, props)
+ setup?.(this)
+ }
+ }
+
+ GObject.registerClass({
+ GTypeName: `Astal_${cls.name}`,
+ Properties: {
+ "class-name": GObject.ParamSpec.string(
+ "class-name", "", "", GObject.ParamFlags.READWRITE, "",
+ ),
+ "css": GObject.ParamSpec.string(
+ "css", "", "", GObject.ParamFlags.READWRITE, "",
+ ),
+ "cursor": GObject.ParamSpec.string(
+ "cursor", "", "", GObject.ParamFlags.READWRITE, "default",
+ ),
+ "click-through": GObject.ParamSpec.boolean(
+ "click-through", "", "", GObject.ParamFlags.READWRITE, false,
+ ),
+ "no-implicit-destroy": GObject.ParamSpec.boolean(
+ "no-implicit-destroy", "", "", GObject.ParamFlags.READWRITE, false,
+ ),
+ },
+ }, Widget)
+
+ return Widget
+}
+
+type BindableProps<T> = {
+ [K in keyof T]: Binding<T[K]> | T[K];
+}
+
+type SigHandler<
+ W extends InstanceType<typeof Gtk.Widget>,
+ Args extends Array<unknown>,
+> = ((self: W, ...args: Args) => unknown) | string | string[]
+
+export type ConstructProps<
+ Self extends InstanceType<typeof Gtk.Widget>,
+ Props extends Gtk.Widget.ConstructorProps,
+ Signals extends Record<`on${string}`, Array<unknown>> = Record<`on${string}`, any[]>,
+> = Partial<{
+ // @ts-expect-error can't assign to unknown, but it works as expected though
+ [S in keyof Signals]: SigHandler<Self, Signals[S]>
+}> & Partial<{
+ [Key in `on${string}`]: SigHandler<Self, any[]>
+}> & BindableProps<Partial<Props> & {
+ className?: string
+ css?: string
+ cursor?: string
+ clickThrough?: boolean
+}> & {
+ onDestroy?: (self: Self) => unknown
+ onDraw?: (self: Self) => unknown
+ onKeyPressEvent?: (self: Self, event: Gdk.Event) => unknown
+ onKeyReleaseEvent?: (self: Self, event: Gdk.Event) => unknown
+ onButtonPressEvent?: (self: Self, event: Gdk.Event) => unknown
+ onButtonReleaseEvent?: (self: Self, event: Gdk.Event) => unknown
+ onRealize?: (self: Self) => unknown
+ setup?: (self: Self) => void
+}
+
+export type BindableChild = Gtk.Widget | Binding<Gtk.Widget>
+
+type Cursor =
+ | "default"
+ | "help"
+ | "pointer"
+ | "context-menu"
+ | "progress"
+ | "wait"
+ | "cell"
+ | "crosshair"
+ | "text"
+ | "vertical-text"
+ | "alias"
+ | "copy"
+ | "no-drop"
+ | "move"
+ | "not-allowed"
+ | "grab"
+ | "grabbing"
+ | "all-scroll"
+ | "col-resize"
+ | "row-resize"
+ | "n-resize"
+ | "e-resize"
+ | "s-resize"
+ | "w-resize"
+ | "ne-resize"
+ | "nw-resize"
+ | "sw-resize"
+ | "se-resize"
+ | "ew-resize"
+ | "ns-resize"
+ | "nesw-resize"
+ | "nwse-resize"
+ | "zoom-in"
+ | "zoom-out"
diff --git a/lang/gjs/src/gtk3/index.ts b/lang/gjs/src/gtk3/index.ts
new file mode 100644
index 0000000..cfafbda
--- /dev/null
+++ b/lang/gjs/src/gtk3/index.ts
@@ -0,0 +1,9 @@
+import Astal from "gi://Astal?version=3.0"
+import Gtk from "gi://Gtk?version=3.0"
+import Gdk from "gi://Gdk?version=3.0"
+import astalify, { type ConstructProps } from "./astalify.js"
+
+export { Astal, Gtk, Gdk }
+export { default as App } from "./app.js"
+export { astalify, ConstructProps }
+export * as Widget from "./widget.js"
diff --git a/lang/gjs/src/gtk3/jsx-runtime.ts b/lang/gjs/src/gtk3/jsx-runtime.ts
new file mode 100644
index 0000000..22dc424
--- /dev/null
+++ b/lang/gjs/src/gtk3/jsx-runtime.ts
@@ -0,0 +1,96 @@
+import Gtk from "gi://Gtk?version=3.0"
+import { mergeBindings, type BindableChild } from "./astalify.js"
+import * as Widget from "./widget.js"
+
+function isArrowFunction(func: any): func is (args: any) => any {
+ return !Object.hasOwn(func, "prototype")
+}
+
+export function Fragment({ children = [], child }: {
+ child?: BindableChild
+ children?: Array<BindableChild>
+}) {
+ return mergeBindings([...children, child])
+}
+
+export function jsx(
+ ctor: keyof typeof ctors | typeof Gtk.Widget,
+ { children, ...props }: any,
+) {
+ children ??= []
+
+ if (!Array.isArray(children))
+ children = [children]
+
+ children = children.filter(Boolean)
+
+ if (children.length === 1)
+ props.child = children[0]
+ else if (children.length > 1)
+ props.children = children
+
+ if (typeof ctor === "string") {
+ return new ctors[ctor](props)
+ }
+
+ if (isArrowFunction(ctor))
+ return ctor(props)
+
+ // @ts-expect-error can be class or function
+ return new ctor(props)
+}
+
+const ctors = {
+ box: Widget.Box,
+ button: Widget.Button,
+ centerbox: Widget.CenterBox,
+ circularprogress: Widget.CircularProgress,
+ drawingarea: Widget.DrawingArea,
+ entry: Widget.Entry,
+ eventbox: Widget.EventBox,
+ // TODO: fixed
+ // TODO: flowbox
+ icon: Widget.Icon,
+ label: Widget.Label,
+ levelbar: Widget.LevelBar,
+ // TODO: listbox
+ overlay: Widget.Overlay,
+ revealer: Widget.Revealer,
+ scrollable: Widget.Scrollable,
+ slider: Widget.Slider,
+ stack: Widget.Stack,
+ switch: Widget.Switch,
+ window: Widget.Window,
+}
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/no-namespace
+ namespace JSX {
+ type Element = Gtk.Widget
+ type ElementClass = Gtk.Widget
+ interface IntrinsicElements {
+ box: Widget.BoxProps
+ button: Widget.ButtonProps
+ centerbox: Widget.CenterBoxProps
+ circularprogress: Widget.CircularProgressProps
+ drawingarea: Widget.DrawingAreaProps
+ entry: Widget.EntryProps
+ eventbox: Widget.EventBoxProps
+ // TODO: fixed
+ // TODO: flowbox
+ icon: Widget.IconProps
+ label: Widget.LabelProps
+ levelbar: Widget.LevelBarProps
+ // TODO: listbox
+ overlay: Widget.OverlayProps
+ revealer: Widget.RevealerProps
+ scrollable: Widget.ScrollableProps
+ slider: Widget.SliderProps
+ stack: Widget.StackProps
+ switch: Widget.SwitchProps
+ window: Widget.WindowProps
+ }
+ }
+}
+
+export const jsxs = jsx
diff --git a/lang/gjs/src/gtk3/widget.ts b/lang/gjs/src/gtk3/widget.ts
new file mode 100644
index 0000000..b4e8497
--- /dev/null
+++ b/lang/gjs/src/gtk3/widget.ts
@@ -0,0 +1,159 @@
+/* eslint-disable max-len */
+import Astal from "gi://Astal?version=3.0"
+import Gtk from "gi://Gtk?version=3.0"
+import GObject from "gi://GObject"
+import astalify, { type ConstructProps, type BindableChild } from "./astalify.js"
+
+// Box
+Object.defineProperty(Astal.Box.prototype, "children", {
+ get() { return this.get_children() },
+ set(v) { this.set_children(v) },
+})
+
+export type BoxProps = ConstructProps<Box, Astal.Box.ConstructorProps>
+export class Box extends astalify(Astal.Box) {
+ static { GObject.registerClass({ GTypeName: "Box" }, this) }
+ constructor(props?: BoxProps, ...children: Array<BindableChild>) { super({ children, ...props } as any) }
+}
+
+// Button
+export type ButtonProps = ConstructProps<Button, Astal.Button.ConstructorProps, {
+ onClicked: []
+ onClick: [event: Astal.ClickEvent]
+ onClickRelease: [event: Astal.ClickEvent]
+ onHover: [event: Astal.HoverEvent]
+ onHoverLost: [event: Astal.HoverEvent]
+ onScroll: [event: Astal.ScrollEvent]
+}>
+export class Button extends astalify(Astal.Button) {
+ static { GObject.registerClass({ GTypeName: "Button" }, this) }
+ constructor(props?: ButtonProps, child?: BindableChild) { super({ child, ...props } as any) }
+}
+
+// CenterBox
+export type CenterBoxProps = ConstructProps<CenterBox, Astal.CenterBox.ConstructorProps>
+export class CenterBox extends astalify(Astal.CenterBox) {
+ static { GObject.registerClass({ GTypeName: "CenterBox" }, this) }
+ constructor(props?: CenterBoxProps, ...children: Array<BindableChild>) { super({ children, ...props } as any) }
+}
+
+// CircularProgress
+export type CircularProgressProps = ConstructProps<CircularProgress, Astal.CircularProgress.ConstructorProps>
+export class CircularProgress extends astalify(Astal.CircularProgress) {
+ static { GObject.registerClass({ GTypeName: "CircularProgress" }, this) }
+ constructor(props?: CircularProgressProps, child?: BindableChild) { super({ child, ...props } as any) }
+}
+
+// DrawingArea
+export type DrawingAreaProps = ConstructProps<DrawingArea, Gtk.DrawingArea.ConstructorProps, {
+ onDraw: [cr: any] // TODO: cairo types
+}>
+export class DrawingArea extends astalify(Gtk.DrawingArea) {
+ static { GObject.registerClass({ GTypeName: "DrawingArea" }, this) }
+ constructor(props?: DrawingAreaProps) { super(props as any) }
+}
+
+// Entry
+export type EntryProps = ConstructProps<Entry, Gtk.Entry.ConstructorProps, {
+ onChanged: []
+ onActivate: []
+}>
+export class Entry extends astalify(Gtk.Entry) {
+ static { GObject.registerClass({ GTypeName: "Entry" }, this) }
+ constructor(props?: EntryProps) { super(props as any) }
+}
+
+// EventBox
+export type EventBoxProps = ConstructProps<EventBox, Astal.EventBox.ConstructorProps, {
+ onClick: [event: Astal.ClickEvent]
+ onClickRelease: [event: Astal.ClickEvent]
+ onHover: [event: Astal.HoverEvent]
+ onHoverLost: [event: Astal.HoverEvent]
+ onScroll: [event: Astal.ScrollEvent]
+}>
+export class EventBox extends astalify(Astal.EventBox) {
+ static { GObject.registerClass({ GTypeName: "EventBox" }, this) }
+ constructor(props?: EventBoxProps, child?: BindableChild) { super({ child, ...props } as any) }
+}
+
+// // TODO: Fixed
+// // TODO: FlowBox
+//
+// Icon
+export type IconProps = ConstructProps<Icon, Astal.Icon.ConstructorProps>
+export class Icon extends astalify(Astal.Icon) {
+ static { GObject.registerClass({ GTypeName: "Icon" }, this) }
+ constructor(props?: IconProps) { super(props as any) }
+}
+
+// Label
+export type LabelProps = ConstructProps<Label, Astal.Label.ConstructorProps>
+export class Label extends astalify(Astal.Label) {
+ static { GObject.registerClass({ GTypeName: "Label" }, this) }
+ constructor(props?: LabelProps) { super(props as any) }
+}
+
+// LevelBar
+export type LevelBarProps = ConstructProps<LevelBar, Astal.LevelBar.ConstructorProps>
+export class LevelBar extends astalify(Astal.LevelBar) {
+ static { GObject.registerClass({ GTypeName: "LevelBar" }, this) }
+ constructor(props?: LevelBarProps) { super(props as any) }
+}
+
+// TODO: ListBox
+
+// Overlay
+Object.defineProperty(Astal.Overlay.prototype, "overlays", {
+ get() { return this.get_overlays() },
+ set(v) { this.set_overlays(v) },
+})
+
+export type OverlayProps = ConstructProps<Overlay, Astal.Overlay.ConstructorProps>
+export class Overlay extends astalify(Astal.Overlay) {
+ static { GObject.registerClass({ GTypeName: "Overlay" }, this) }
+ constructor(props?: OverlayProps, ...children: Array<BindableChild>) { super({ children, ...props } as any) }
+}
+
+// Revealer
+export type RevealerProps = ConstructProps<Revealer, Gtk.Revealer.ConstructorProps>
+export class Revealer extends astalify(Gtk.Revealer) {
+ static { GObject.registerClass({ GTypeName: "Revealer" }, this) }
+ constructor(props?: RevealerProps, child?: BindableChild) { super({ child, ...props } as any) }
+}
+
+// Scrollable
+export type ScrollableProps = ConstructProps<Scrollable, Astal.Scrollable.ConstructorProps>
+export class Scrollable extends astalify(Astal.Scrollable) {
+ static { GObject.registerClass({ GTypeName: "Scrollable" }, this) }
+ constructor(props?: ScrollableProps, child?: BindableChild) { super({ child, ...props } as any) }
+}
+
+// Slider
+export type SliderProps = ConstructProps<Slider, Astal.Slider.ConstructorProps, {
+ onDragged: []
+}>
+export class Slider extends astalify(Astal.Slider) {
+ static { GObject.registerClass({ GTypeName: "Slider" }, this) }
+ constructor(props?: SliderProps) { super(props as any) }
+}
+
+// Stack
+export type StackProps = ConstructProps<Stack, Astal.Stack.ConstructorProps>
+export class Stack extends astalify(Astal.Stack) {
+ static { GObject.registerClass({ GTypeName: "Stack" }, this) }
+ constructor(props?: StackProps, ...children: Array<BindableChild>) { super({ children, ...props } as any) }
+}
+
+// Switch
+export type SwitchProps = ConstructProps<Switch, Gtk.Switch.ConstructorProps>
+export class Switch extends astalify(Gtk.Switch) {
+ static { GObject.registerClass({ GTypeName: "Switch" }, this) }
+ constructor(props?: SwitchProps) { super(props as any) }
+}
+
+// Window
+export type WindowProps = ConstructProps<Window, Astal.Window.ConstructorProps>
+export class Window extends astalify(Astal.Window) {
+ static { GObject.registerClass({ GTypeName: "Window" }, this) }
+ constructor(props?: WindowProps, child?: BindableChild) { super({ child, ...props } as any) }
+}
diff --git a/lang/gjs/src/gtk4/app.ts b/lang/gjs/src/gtk4/app.ts
new file mode 100644
index 0000000..d931f73
--- /dev/null
+++ b/lang/gjs/src/gtk4/app.ts
@@ -0,0 +1 @@
+// TODO: gtk4
diff --git a/lang/gjs/src/gtk4/astalify.ts b/lang/gjs/src/gtk4/astalify.ts
new file mode 100644
index 0000000..d931f73
--- /dev/null
+++ b/lang/gjs/src/gtk4/astalify.ts
@@ -0,0 +1 @@
+// TODO: gtk4
diff --git a/lang/gjs/src/gtk4/index.ts b/lang/gjs/src/gtk4/index.ts
new file mode 100644
index 0000000..d931f73
--- /dev/null
+++ b/lang/gjs/src/gtk4/index.ts
@@ -0,0 +1 @@
+// TODO: gtk4
diff --git a/lang/gjs/src/gtk4/jsx-runtime.ts b/lang/gjs/src/gtk4/jsx-runtime.ts
new file mode 100644
index 0000000..d931f73
--- /dev/null
+++ b/lang/gjs/src/gtk4/jsx-runtime.ts
@@ -0,0 +1 @@
+// TODO: gtk4
diff --git a/lang/gjs/src/index.ts b/lang/gjs/src/index.ts
new file mode 100644
index 0000000..cabc961
--- /dev/null
+++ b/lang/gjs/src/index.ts
@@ -0,0 +1,7 @@
+export { default as AstalIO } from "gi://AstalIO?version=0.1"
+export * from "./process.js"
+export * from "./time.js"
+export * from "./file.js"
+export * from "./gobject.js"
+export { bind, default as Binding } from "./binding.js"
+export { Variable } from "./variable.js"
diff --git a/lang/gjs/src/process.ts b/lang/gjs/src/process.ts
new file mode 100644
index 0000000..c41adc1
--- /dev/null
+++ b/lang/gjs/src/process.ts
@@ -0,0 +1,70 @@
+import Astal from "gi://AstalIO"
+
+type Args = {
+ cmd: string | string[]
+ out?: (stdout: string) => void
+ err?: (stderr: string) => void
+}
+
+export const { Process } = Astal
+
+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/src/time.ts b/lang/gjs/src/time.ts
new file mode 100644
index 0000000..1939d98
--- /dev/null
+++ b/lang/gjs/src/time.ts
@@ -0,0 +1,15 @@
+import Astal from "gi://AstalIO"
+
+export const { Time } = Astal
+
+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/src/variable.ts b/lang/gjs/src/variable.ts
new file mode 100644
index 0000000..9b3d3d2
--- /dev/null
+++ b/lang/gjs/src/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