> | Props[K]
109 | } & {
110 | [S in keyof Self["$signals"] as S extends `notify::${infer P}`
111 | ? `onNotify${Pascalify}`
112 | : S extends `${infer E}::${infer D}`
113 | ? `on${Pascalify<`${E}:${D}`>}`
114 | : S extends string
115 | ? `on${Pascalify}`
116 | : never]?: GObject.SignalCallback
117 | }
118 |
119 | // prettier-ignore
120 | type JsxProps =
121 | C extends typeof Fragment ? (Props & {})
122 | // intrinsicElements always resolve as FC
123 | // so we can't narrow it down, and in some cases
124 | // the setup function is typed as a union of Object and actual type
125 | // as a fix users can and should use FCProps
126 | : C extends FC ? Props & Omit, Props>, "$">
127 | : C extends CC ? CCProps, Props>
128 | : never
129 |
130 | function isGObjectCtor(ctor: any): ctor is CC {
131 | return ctor.prototype instanceof GObject.Object
132 | }
133 |
134 | function isFunctionCtor(ctor: any): ctor is FC {
135 | return typeof ctor === "function" && !isGObjectCtor(ctor)
136 | }
137 |
138 | // onNotifyPropName -> notify::prop-name
139 | // onPascalName:detailName -> pascal-name::detail-name
140 | export function signalName(key: string): string {
141 | const [sig, detail] = kebabify(key.slice(2)).split(":")
142 |
143 | if (sig.startsWith("notify-")) {
144 | return `notify::${sig.slice(7)}`
145 | }
146 |
147 | return detail ? `${sig}::${detail}` : sig
148 | }
149 |
150 | export function remove(parent: GObject.Object, child: GObject.Object) {
151 | if (parent instanceof Fragment) {
152 | parent.remove(child)
153 | return
154 | }
155 |
156 | if (removeChild in parent && typeof parent[removeChild] === "function") {
157 | parent[removeChild](child)
158 | return
159 | }
160 |
161 | env.removeChild(parent, child)
162 | }
163 |
164 | export function append(parent: GObject.Object, child: GObject.Object) {
165 | if (parent instanceof Fragment) {
166 | parent.append(child)
167 | return
168 | }
169 |
170 | if (child instanceof Fragment) {
171 | for (const ch of child) {
172 | append(parent, ch)
173 | }
174 |
175 | const appendHandler = child.connect("append", (_, ch) => {
176 | if (!(ch instanceof GObject.Object)) {
177 | return console.error(TypeError(`cannot add ${ch} to ${parent}`))
178 | }
179 | append(parent, ch)
180 | })
181 |
182 | const removeHandler = child.connect("remove", (_, ch) => {
183 | if (!(ch instanceof GObject.Object)) {
184 | return console.error(TypeError(`cannot remove ${ch} from ${parent}`))
185 | }
186 | remove(parent, ch)
187 | })
188 |
189 | onCleanup(() => {
190 | child.disconnect(appendHandler)
191 | child.disconnect(removeHandler)
192 | })
193 |
194 | return
195 | }
196 |
197 | if (appendChild in parent && typeof parent[appendChild] === "function") {
198 | parent[appendChild](child, getType(child))
199 | return
200 | }
201 |
202 | env.appendChild(parent, child)
203 | }
204 |
205 | /** @internal */
206 | export function setType(object: object, type: string) {
207 | if (gtkType in object && object[gtkType] !== "") {
208 | console.warn(`type overriden from ${object[gtkType]} to ${type} on ${object}`)
209 | }
210 |
211 | Object.assign(object, { [gtkType]: type })
212 | }
213 |
214 | export function jsx GObject.Object>(
215 | ctor: T,
216 | props: JsxProps[0]>,
217 | ): ReturnType
218 |
219 | export function jsx GObject.Object>(
220 | ctor: T,
221 | props: JsxProps[0]>,
222 | ): InstanceType
223 |
224 | export function jsx(
225 | ctor: keyof (typeof env)["intrinsicElements"] | (new (props: any) => T) | ((props: any) => T),
226 | inprops: any,
227 | // key is a special prop in jsx which is passed as a third argument and not in props
228 | key?: string,
229 | ): T {
230 | const { $, $type, $constructor, children, ...rest } = inprops as CCProps
231 | const props = rest as Record
232 |
233 | if (key) Object.assign(props, { key })
234 |
235 | const deferProps = env.initProps(ctor, props) ?? []
236 | const deferredProps: Record = {}
237 |
238 | for (const [key, value] of Object.entries(props)) {
239 | if (value === undefined) {
240 | delete props[key]
241 | }
242 |
243 | if (deferProps.includes(key)) {
244 | deferredProps[key] = props[key]
245 | delete props[key]
246 | }
247 | }
248 |
249 | if (typeof ctor === "string") {
250 | if (ctor in env.intrinsicElements) {
251 | ctor = env.intrinsicElements[ctor] as FC | CC
252 | } else {
253 | throw Error(`unknown intrinsic element "${ctor}"`)
254 | }
255 | }
256 |
257 | if (isFunctionCtor(ctor)) {
258 | const object = ctor({ children, ...props })
259 | if ($type) setType(object, $type)
260 | $?.(object)
261 | return object
262 | }
263 |
264 | // collect css and className
265 | const { css, class: className } = props
266 | delete props.css
267 | delete props.class
268 |
269 | const signals: Array<[string, (...props: unknown[]) => unknown]> = []
270 | const bindings: Array<[string, Accessor]> = []
271 |
272 | // collect signals and bindings
273 | for (const [key, value] of Object.entries(props)) {
274 | if (key.startsWith("on")) {
275 | signals.push([key, value as () => unknown])
276 | delete props[key]
277 | }
278 | if (value instanceof Accessor) {
279 | bindings.push([key, value])
280 | props[key] = value.peek()
281 | }
282 | }
283 |
284 | // construct
285 | const object = $constructor ? $constructor(props) : new (ctor as CC)(props)
286 | if ($constructor) Object.assign(object, props)
287 | if ($type) setType(object, $type)
288 |
289 | if (css) env.setCss(object, css)
290 | if (className) env.setClass(object, className)
291 |
292 | // add children
293 | for (let child of Array.isArray(children) ? children : [children]) {
294 | if (child === true) {
295 | console.warn(Error("Trying to add boolean value of `true` as a child."))
296 | continue
297 | }
298 |
299 | if (Array.isArray(child)) {
300 | for (const ch of child) {
301 | append(object, ch)
302 | }
303 | } else if (child) {
304 | if (!(child instanceof GObject.Object)) {
305 | child = env.textNode(child)
306 | }
307 | append(object, child)
308 | }
309 | }
310 |
311 | // handle signals
312 | const disposeHandlers = signals.map(([sig, handler]) => {
313 | const id = object.connect(signalName(sig), handler)
314 | return () => object.disconnect(id)
315 | })
316 |
317 | // deferred props
318 | for (const [key, value] of Object.entries(deferredProps)) {
319 | if (value instanceof Accessor) {
320 | bindings.push([key, value])
321 | } else {
322 | Object.assign(object, { [key]: value })
323 | }
324 | }
325 |
326 | // handle bindings
327 | const disposeBindings = bindings.map(([prop, binding]) => {
328 | const dispose = binding.subscribe(() => {
329 | set(object, prop, binding.peek())
330 | })
331 | set(object, prop, binding.peek())
332 | return dispose
333 | })
334 |
335 | // cleanup
336 | if (disposeBindings.length > 0 || disposeHandlers.length > 0) {
337 | onCleanup(() => {
338 | disposeHandlers.forEach((cb) => cb())
339 | disposeBindings.forEach((cb) => cb())
340 | })
341 | }
342 |
343 | $?.(object)
344 | return object
345 | }
346 |
347 | // TODO: make use of jsxs
348 | export const jsxs = jsx
349 |
350 | declare global {
351 | // eslint-disable-next-line @typescript-eslint/no-namespace
352 | namespace JSX {
353 | type ElementType = keyof IntrinsicElements | FC | CC
354 | type Element = GObject.Object
355 | type ElementClass = GObject.Object
356 |
357 | type LibraryManagedAttributes = JsxProps & {
358 | // FIXME: why does an intrinsic element always resolve as FC?
359 | // __type?: C extends CC ? "CC" : C extends FC ? "FC" : never
360 | }
361 |
362 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type
363 | interface IntrinsicElements {
364 | // cc: CCProps
365 | // fc: FCProps
366 | }
367 |
368 | interface ElementChildrenAttribute {
369 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type
370 | children: {}
371 | }
372 | }
373 | }
374 |
--------------------------------------------------------------------------------
/src/fetch.ts:
--------------------------------------------------------------------------------
1 | import GLib from "gi://GLib"
2 | import Gio from "gi://Gio"
3 | import Soup from "gi://Soup?version=3.0"
4 |
5 | type ResponseType = "basic" | "cors" | "default" | "error" | "opaque" | "opaqueredirect"
6 | export type HeadersInit = Headers | Record | [string, string][]
7 | export type ResponseInit = {
8 | headers?: HeadersInit
9 | status?: number
10 | statusText?: string
11 | }
12 | export type RequestInit = {
13 | body?: string
14 | headers?: HeadersInit
15 | method?: string
16 | }
17 |
18 | export class Headers {
19 | private headers: Map = new Map()
20 |
21 | constructor(init: HeadersInit = {}) {
22 | if (Array.isArray(init)) {
23 | for (const [name, value] of init) {
24 | this.append(name, value)
25 | }
26 | } else if (init instanceof Headers) {
27 | init.forEach((value, name) => this.set(name, value))
28 | } else if (typeof init === "object") {
29 | for (const name in init) {
30 | this.set(name, init[name])
31 | }
32 | }
33 | }
34 |
35 | append(name: string, value: string): void {
36 | name = name.toLowerCase()
37 | if (!this.headers.has(name)) {
38 | this.headers.set(name, [])
39 | }
40 | this.headers.get(name)!.push(value)
41 | }
42 |
43 | delete(name: string): void {
44 | this.headers.delete(name.toLowerCase())
45 | }
46 |
47 | get(name: string): string | null {
48 | const values = this.headers.get(name.toLowerCase())
49 | return values ? values.join(", ") : null
50 | }
51 |
52 | getAll(name: string): string[] {
53 | return this.headers.get(name.toLowerCase()) || []
54 | }
55 |
56 | has(name: string): boolean {
57 | return this.headers.has(name.toLowerCase())
58 | }
59 |
60 | set(name: string, value: string): void {
61 | this.headers.set(name.toLowerCase(), [value])
62 | }
63 |
64 | forEach(
65 | callbackfn: (value: string, name: string, parent: Headers) => void,
66 | thisArg?: any,
67 | ): void {
68 | for (const [name, values] of this.headers.entries()) {
69 | callbackfn.call(thisArg, values.join(", "), name, this)
70 | }
71 | }
72 |
73 | *entries(): IterableIterator<[string, string]> {
74 | for (const [name, values] of this.headers.entries()) {
75 | yield [name, values.join(", ")]
76 | }
77 | }
78 |
79 | *keys(): IterableIterator {
80 | for (const name of this.headers.keys()) {
81 | yield name
82 | }
83 | }
84 |
85 | *values(): IterableIterator {
86 | for (const values of this.headers.values()) {
87 | yield values.join(", ")
88 | }
89 | }
90 |
91 | [Symbol.iterator](): IterableIterator<[string, string]> {
92 | return this.entries()
93 | }
94 | }
95 |
96 | export class URLSearchParams {
97 | private params = new Map>()
98 |
99 | constructor(init: string[][] | Record | string | URLSearchParams = "") {
100 | if (typeof init === "string") {
101 | this.parseString(init)
102 | } else if (Array.isArray(init)) {
103 | for (const [key, value] of init) {
104 | this.append(key, value)
105 | }
106 | } else if (init instanceof URLSearchParams) {
107 | init.forEach((value, key) => this.append(key, value))
108 | } else if (typeof init === "object") {
109 | for (const key in init) {
110 | this.set(key, init[key])
111 | }
112 | }
113 | }
114 |
115 | private parseString(query: string) {
116 | query
117 | .replace(/^\?/, "")
118 | .split("&")
119 | .forEach((pair) => {
120 | if (!pair) return
121 | const [key, value] = pair.split("=").map(decodeURIComponent)
122 | this.append(key, value ?? "")
123 | })
124 | }
125 |
126 | get size() {
127 | return this.params.size
128 | }
129 |
130 | append(name: string, value: string): void {
131 | if (!this.params.has(name)) {
132 | this.params.set(name, [])
133 | }
134 | this.params.get(name)!.push(value)
135 | }
136 |
137 | delete(name: string, value?: string): void {
138 | if (value === undefined) {
139 | this.params.delete(name)
140 | } else {
141 | const values = this.params.get(name) || []
142 | this.params.set(
143 | name,
144 | values.filter((v) => v !== value),
145 | )
146 | if (this.params.get(name)!.length === 0) {
147 | this.params.delete(name)
148 | }
149 | }
150 | }
151 |
152 | get(name: string): string | null {
153 | const values = this.params.get(name)
154 | return values ? values[0] : null
155 | }
156 |
157 | getAll(name: string): Array {
158 | return this.params.get(name) || []
159 | }
160 |
161 | has(name: string, value?: string): boolean {
162 | if (!this.params.has(name)) return false
163 | if (value === undefined) return true
164 | return this.params.get(name)?.includes(value) || false
165 | }
166 |
167 | set(name: string, value: string): void {
168 | this.params.set(name, [value])
169 | }
170 |
171 | sort(): void {
172 | this.params = new Map([...this.params.entries()].sort())
173 | }
174 |
175 | toString(): string {
176 | return [...this.params.entries()]
177 | .flatMap(([key, values]) =>
178 | values.map((value) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`),
179 | )
180 | .join("&")
181 | }
182 |
183 | forEach(
184 | callbackfn: (value: string, key: string, parent: URLSearchParams) => void,
185 | thisArg?: any,
186 | ): void {
187 | for (const [key, values] of this.params.entries()) {
188 | for (const value of values) {
189 | callbackfn.call(thisArg, value, key, this)
190 | }
191 | }
192 | }
193 |
194 | [Symbol.iterator](): MapIterator<[string, Array]> {
195 | return this.params.entries()
196 | }
197 | }
198 |
199 | // TODO: impl setters
200 | export class URL {
201 | readonly uri: GLib.Uri
202 |
203 | readonly searchParams: URLSearchParams
204 |
205 | constructor(url: string | URL, base?: string | URL) {
206 | if (base) {
207 | url = GLib.Uri.resolve_relative(
208 | base instanceof URL ? base.toString() : base,
209 | url instanceof URL ? url.toString() : url,
210 | GLib.UriFlags.HAS_PASSWORD,
211 | )
212 | }
213 | this.uri = GLib.Uri.parse(
214 | url instanceof URL ? url.toString() : url,
215 | GLib.UriFlags.HAS_PASSWORD,
216 | )
217 | this.searchParams = new URLSearchParams(this.uri.get_query() ?? "")
218 | }
219 |
220 | get href(): string {
221 | const uri = GLib.Uri.build_with_user(
222 | GLib.UriFlags.HAS_PASSWORD,
223 | this.uri.get_scheme(),
224 | this.uri.get_user(),
225 | this.uri.get_password(),
226 | null,
227 | this.uri.get_host(),
228 | this.uri.get_port(),
229 | this.uri.get_path(),
230 | this.searchParams.toString(),
231 | this.uri.get_fragment(),
232 | )
233 |
234 | return uri.to_string()
235 | }
236 |
237 | get origin(): string {
238 | return "null" // TODO:
239 | }
240 |
241 | get protocol(): string {
242 | return this.uri.get_scheme() + ":"
243 | }
244 |
245 | get username(): string {
246 | return this.uri.get_user() ?? ""
247 | }
248 |
249 | get password(): string {
250 | return this.uri.get_password() ?? ""
251 | }
252 |
253 | get host(): string {
254 | const host = this.hostname
255 | const port = this.port
256 | return host ? host + (port ? ":" + port : "") : ""
257 | }
258 |
259 | get hostname(): string {
260 | return this.uri.get_host() ?? ""
261 | }
262 |
263 | get port(): string {
264 | const p = this.uri.get_port()
265 | return p >= 0 ? p.toString() : ""
266 | }
267 |
268 | get pathname(): string {
269 | return this.uri.get_path()
270 | }
271 |
272 | get hash(): string {
273 | const frag = this.uri.get_fragment()
274 | return frag ? "#" + frag : ""
275 | }
276 |
277 | get search(): string {
278 | const q = this.searchParams.toString()
279 | return q ? "?" + q : ""
280 | }
281 |
282 | toString(): string {
283 | return this.href
284 | }
285 |
286 | toJSON(): string {
287 | return this.href
288 | }
289 | }
290 |
291 | export class Response {
292 | readonly body: Gio.InputStream | null = null
293 | readonly bodyUsed: boolean = false
294 |
295 | readonly headers: Headers
296 | readonly ok: boolean
297 | readonly redirected: boolean = false
298 | readonly status: number
299 | readonly statusText: string
300 | readonly type: ResponseType = "default"
301 | readonly url: string = ""
302 |
303 | static error(): Response {
304 | throw Error("Not yet implemented")
305 | }
306 |
307 | static json(_data: any, _init?: ResponseInit): Response {
308 | throw Error("Not yet implemented")
309 | }
310 |
311 | static redirect(_url: string | URL, _status?: number): Response {
312 | throw Error("Not yet implemented")
313 | }
314 |
315 | constructor(body: Gio.InputStream | null = null, options: ResponseInit = {}) {
316 | this.body = body
317 | this.headers = new Headers(options.headers ?? {})
318 | this.status = options.status ?? 200
319 | this.statusText = options.statusText ?? ""
320 | this.ok = this.status >= 200 && this.status < 300
321 | }
322 |
323 | async blob(): Promise {
324 | throw Error("Not implemented")
325 | }
326 |
327 | async bytes() {
328 | const { CLOSE_SOURCE, CLOSE_TARGET } = Gio.OutputStreamSpliceFlags
329 | const outputStream = Gio.MemoryOutputStream.new_resizable()
330 | if (!this.body) return null
331 |
332 | await new Promise((resolve, reject) => {
333 | outputStream.splice_async(
334 | this.body!,
335 | CLOSE_TARGET | CLOSE_SOURCE,
336 | GLib.PRIORITY_DEFAULT,
337 | null,
338 | (_, res) => {
339 | try {
340 | resolve(outputStream.splice_finish(res))
341 | } catch (error) {
342 | reject(error)
343 | }
344 | },
345 | )
346 | })
347 |
348 | Object.assign(this, { bodyUsed: true })
349 | return outputStream.steal_as_bytes()
350 | }
351 |
352 | async formData(): Promise {
353 | throw Error("Not yet implemented")
354 | }
355 |
356 | async arrayBuffer() {
357 | const blob = await this.bytes()
358 | if (!blob) return null
359 |
360 | return blob.toArray().buffer
361 | }
362 |
363 | async text() {
364 | const blob = await this.bytes()
365 | return blob ? new TextDecoder().decode(blob.toArray()) : ""
366 | }
367 |
368 | async json() {
369 | const text = await this.text()
370 | return JSON.parse(text)
371 | }
372 |
373 | clone(): Response {
374 | throw Error("Not yet implemented")
375 | }
376 | }
377 |
378 | export async function fetch(url: string | URL, { method, headers, body }: RequestInit = {}) {
379 | const session = new Soup.Session()
380 |
381 | const message = new Soup.Message({
382 | method: method || "GET",
383 | uri: url instanceof URL ? url.uri : GLib.Uri.parse(url, GLib.UriFlags.NONE),
384 | })
385 |
386 | if (headers) {
387 | for (const [key, value] of Object.entries(headers))
388 | message.get_request_headers().append(key, String(value))
389 | }
390 |
391 | if (typeof body === "string") {
392 | message.set_request_body_from_bytes(null, new GLib.Bytes(new TextEncoder().encode(body)))
393 | }
394 |
395 | const inputStream: Gio.InputStream = await new Promise((resolve, reject) => {
396 | session.send_async(message, 0, null, (_, res) => {
397 | try {
398 | resolve(session.send_finish(res))
399 | } catch (error) {
400 | reject(error)
401 | }
402 | })
403 | })
404 |
405 | return new Response(inputStream, {
406 | statusText: message.reason_phrase,
407 | status: message.status_code,
408 | })
409 | }
410 |
411 | export default fetch
412 |
--------------------------------------------------------------------------------
/docs/public/scope-light.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/docs/public/scope-dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/docs/tutorial/gnim.md:
--------------------------------------------------------------------------------
1 | # Gnim
2 |
3 | While GTK has its own templating system, it lacks in the DX department.
4 | [Blueprint](https://gnome.pages.gitlab.gnome.org/blueprint-compiler/) tries to
5 | solve this, but it is still not as convenient as JSX. Gnim aims to bring the
6 | kind of developer experience to GJS that libraries like React and Solid offer
7 | for the web.
8 |
9 | > [!WARNING] Gnim is not React
10 | >
11 | > While some concepts are the same, Gnim has nothing in common with React other
12 | > than the JSX syntax.
13 |
14 | ## Scopes
15 |
16 | Before jumping into JSX, you have to understand the concept of scopes first. A
17 | scope's purpose in Gnim is to collect cleanup functions and hold context values.
18 |
19 | A scope is essentially an object that synchronously executed code has access to.
20 |
21 | ```ts
22 | let scope: Scope | null = null
23 |
24 | function printScope() {
25 | print(scope)
26 | }
27 |
28 | function nested() {
29 | printScope() // scope
30 |
31 | setTimeout(() => {
32 | // this block of code gets executed after the last line
33 | // at which point scope no longer exists
34 | printScope() // null
35 | })
36 | }
37 |
38 | function main() {
39 | printScope() // scope
40 | nested()
41 | }
42 |
43 | scope = new Scope()
44 |
45 | // at this point synchronously executed code can access scope
46 | main()
47 |
48 | scope = null
49 | ```
50 |
51 | The reason we need scopes is so that Gnim can cleanup any kind of gobject
52 | connection, signal subscription and effect.
53 |
54 | {.dark-only}
55 | {.light-only}
56 |
57 |
61 |
62 | In this example we want to render a list based on `State2`. It is accomplished
63 | by running each `Child` in their own scope so that when they need to be removed
64 | we can just cleanup the scope. This behaviour also cascades: if the root scope
65 | were to be cleaned up the nested scope would also be cleaned up as a result.
66 |
67 | Gnim manages scopes for you, the only scope you need to take care of is the
68 | root, which is usually tied to a window or the application.
69 |
70 | :::code-group
71 |
72 | ```ts [Root tied to a window Window]
73 | import { createRoot } from "gnim"
74 |
75 | const win = createRoot((dispose) => {
76 | const win = new Gtk.Window()
77 | win.connect("close-request", dispose)
78 | return win
79 | })
80 | ```
81 |
82 | ```ts [Root tied to the Application]
83 | import { createRoot } from "gnim"
84 |
85 | class App extends Gtk.Application {
86 | vfunc_activate() {
87 | createRoot((dispose) => {
88 | this.connect("shutdown", dispose)
89 | new Gtk.Window()
90 | })
91 | }
92 | }
93 | ```
94 |
95 | :::
96 |
97 | To attach a cleanup function to the current scope, simply use `onCleanup`.
98 |
99 | ```ts
100 | import { onCleanup } from "gnim"
101 |
102 | function fn() {
103 | onCleanup(() => {
104 | console.log("scope cleaned up")
105 | })
106 | }
107 | ```
108 |
109 | ## JSX Markup
110 |
111 | JSX is a syntax extension to JavaScript. It is simply a syntactic sugar for
112 | function composition. In Gnim, JSX is also used to enhance
113 | [GObject construction](../jsx#class-components).
114 |
115 | ### Creating and nesting widgets
116 |
117 | ```tsx
118 | function MyButton() {
119 | return (
120 | console.log(self, "clicked")}>
121 |
122 |
123 | )
124 | }
125 | ```
126 |
127 | Now that you have declared `MyButton`, you can nest it into another component.
128 |
129 | ```tsx
130 | function MyWindow() {
131 | return (
132 |
133 |
134 | Click The button
135 |
136 |
137 |
138 | )
139 | }
140 | ```
141 |
142 | Notice that widgets start with a capital letter. Lower case widgets are
143 | [intrinsic elements](../jsx#intrinsic-elements)
144 |
145 | ### Displaying Data
146 |
147 | JSX lets you put markup into JavaScript. Curly braces let you “escape back” into
148 | JavaScript so that you can embed some variable from your code and display it.
149 |
150 | ```tsx
151 | function MyButton() {
152 | const label = "hello"
153 |
154 | return {label}
155 | }
156 | ```
157 |
158 | You can also pass JavaScript to markup properties.
159 |
160 | ```tsx
161 | function MyButton() {
162 | const label = "hello"
163 |
164 | return
165 | }
166 | ```
167 |
168 | ### Conditional Rendering
169 |
170 | You can use the same techniques as you use when writing regular JavaScript code.
171 | For example, you can use an if statement to conditionally include JSX:
172 |
173 | ```tsx
174 | function MyWidget() {
175 | let content
176 |
177 | if (condition) {
178 | content =
179 | } else {
180 | content =
181 | }
182 |
183 | return {content}
184 | }
185 | ```
186 |
187 | You can also inline a
188 | [conditional `?`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_operator)
189 | (ternary) expression.
190 |
191 | ```tsx
192 | function MyWidget() {
193 | return {condition ? : }
194 | }
195 | ```
196 |
197 | When you don’t need the `else` branch, you can also use a shorter
198 | [logical && syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_AND#short-circuit_evaluation):
199 |
200 | ```tsx
201 | function MyWidget() {
202 | return {condition && }
203 | }
204 | ```
205 |
206 | > [!TIP]
207 | >
208 | > [falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy) values are
209 | > not rendered and are simply ignored.
210 |
211 | ### Rendering lists
212 |
213 | You can use
214 | [`for` loops](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for)
215 | or
216 | [array `map()` function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map).
217 |
218 | ```tsx
219 | function MyWidget() {
220 | const labels = ["label1", "label2", "label3"]
221 |
222 | return (
223 |
224 | {labels.map((label) => (
225 |
226 | ))}
227 |
228 | )
229 | }
230 | ```
231 |
232 | ### Widget signal handlers
233 |
234 | You can respond to events by declaring event handler functions inside your
235 | widget:
236 |
237 | ```tsx
238 | function MyButton() {
239 | function onClicked(self: Gtk.Button) {
240 | console.log(self, "was clicked")
241 | }
242 |
243 | return
244 | }
245 | ```
246 |
247 | ### How properties are passed
248 |
249 | Using JSX, a custom widget will always have a single object as its parameter.
250 |
251 | ```ts
252 | type Props = {
253 | myprop: string
254 | children?: JSX.Element | Array
255 | }
256 |
257 | function MyWidget({ myprop, children }: Props) {
258 | //
259 | }
260 | ```
261 |
262 | > [!TIP]
263 | >
264 | > `JSX.Element` is an alias to `GObject.Object`
265 |
266 | The `children` property is a special one which is used to pass the children
267 | given in the JSX expression.
268 |
269 | ```tsx
270 | // `children` prop of MyWidget is the Box
271 | return (
272 |
273 |
274 |
275 | )
276 | ```
277 |
278 | ```tsx
279 | // `children` prop of MyWidget is [Box, Box]
280 | return (
281 |
282 |
283 |
284 |
285 | )
286 | ```
287 |
288 | ## State management
289 |
290 | State is managed using reactive values (also known as
291 | signals or observables in some other libraries) through the
292 | [`Accessor`](../jsx#state-management) class. The most common primitives you will
293 | use is [`createState`](../jsx#createstate),
294 | [`createBinding`](../jsx#createbinding) and
295 | [`createComputed`](../jsx#createcomputed). `createState` is a writable reactive
296 | value, `createBinding` is used to hook into GObject properties and
297 | `createComputed` is used to derive values.
298 |
299 | :::code-group
300 |
301 | ```tsx [State example]
302 | import { createState } from "gnim"
303 |
304 | function Counter() {
305 | const [count, setCount] = createState(0)
306 |
307 | function increment() {
308 | setCount((v) => v + 1)
309 | }
310 |
311 | const label = count((num) => num.toString())
312 |
313 | return (
314 |
315 |
316 |
317 |
318 | )
319 | }
320 | ```
321 |
322 | ```tsx [GObject example]
323 | import GObject, { register, property } from "gnim/gobject"
324 | import { createBinding } from "gnim"
325 |
326 | @register()
327 | class CountStore extends GObject.Object {
328 | @property(Number) counter = 0
329 | }
330 |
331 | function Counter() {
332 | const count = new CountStore()
333 |
334 | function increment() {
335 | count.counter += 1
336 | }
337 |
338 | const counter = createBinding(count, "counter")
339 | const label = counter((num) => num.toString())
340 |
341 | return (
342 |
343 |
344 |
345 |
346 | )
347 | }
348 | ```
349 |
350 | :::
351 |
352 | Accessors can be called as a function which lets you transform its value. In the
353 | case of a `Gtk.Label` in this example, its label property expects a string, so
354 | it needs to be turned into a string first.
355 |
356 | ## Dynamic rendering
357 |
358 | When you want to render based on a value, you can use the `` component.
359 |
360 | ```tsx
361 | import { With, Accessor } from "gnim"
362 |
363 | let value: Accessor<{ member: string } | null>
364 |
365 | return (
366 |
367 |
368 | {(value) => value && }
369 |
370 |
371 | )
372 | ```
373 |
374 | > [!TIP]
375 | >
376 | > In a lot of cases it is better to always render the component and set its
377 | > `visible` property instead.
378 |
379 |
380 |
381 | > [!WARNING]
382 | >
383 | > When the value changes and the widget is re-constructed, the previous one is
384 | > removed from the parent component and the new one is _appended_. Order of
385 | > widgets are _not_ kept, so make sure to wrap `` in a container to avoid
386 | > it. This is due to Gtk not having a generic API on containers to sort widgets.
387 |
388 | ## Dynamic list rendering
389 |
390 | The `` component let's you render based on an array dynamically. Each time
391 | the array changes it is compared with its previous state. Widgets for new items
392 | are inserted while widgets associated with removed items are removed.
393 |
394 | ```tsx
395 | import { For, Accessor } from "gnim"
396 |
397 | let list: Accessor>
398 |
399 | return (
400 |
401 |
402 | {(item, index: Accessor) => (
403 |
406 |
407 | )
408 | ```
409 |
410 | > [!WARNING]
411 | >
412 | > Similarly to ``, when the list changes and a new item is added, it is
413 | > simply **appended** to the parent. Order of sibling widgets are _not_ kept, so
414 | > make sure to wrap `` in a container to avoid this.
415 |
416 | ## Effects
417 |
418 | Effects are functions that run when state changes. It can be used to react to
419 | value changes and run _side-effects_ such as async tasks, logging or writing Gtk
420 | widget properties directly. In general, an effect is considered something of an
421 | escape hatch rather than a tool you should use frequently. In particular, avoid
422 | using it to synchronise state. See
423 | [when not to use an effect](#when-not-to-use-an-effect) for alternatives.
424 |
425 | The `createEffect` primitive runs the given function tracking reactive values
426 | accessed within and re-runs it whenever any of its dependencies change.
427 |
428 | ```ts
429 | const [count, setCount] = createState(0)
430 | const [message, setMessage] = createState("Hello")
431 |
432 | createEffect(() => {
433 | console.log(count(), message())
434 | })
435 |
436 | setCount(1) // Output: 1, "Hello"
437 | setMessage("World") // Output: 1, "World"
438 | ```
439 |
440 | If you wish to read a value without tracking it as a dependency you can use the
441 | `.peek()` method.
442 |
443 | ```ts
444 | createEffect(() => {
445 | console.log(count(), message.peek())
446 | })
447 |
448 | setCount(1) // Output: 1, "Hello"
449 | setMessage("World") // nothing happens
450 | ```
451 |
452 | ### Nested effects
453 |
454 | When working with effects, it is possible to nest them within each other. This
455 | allows each effect to independently track its own dependencies, without
456 | affecting the effect that it is nested within.
457 |
458 | ```ts
459 | createEffect(() => {
460 | console.log("Outer effect")
461 | createEffect(() => console.log("Inner effect"))
462 | })
463 | ```
464 |
465 | The order of execution is important to note. An inner effect will not affect the
466 | outer effect. Signals that are accessed within an inner effect, will not be
467 | registered as dependencies for the outer effect. When the signal located within
468 | the inner effect changes, it will trigger only the inner effect to re-run, not
469 | the outer one.
470 |
471 | ```ts
472 | createEffect(() => {
473 | console.log("Outer effect")
474 | createEffect(() => {
475 | // when count changes, only this effect will re-run
476 | console.log(count())
477 | })
478 | })
479 | ```
480 |
481 | ### Root effects
482 |
483 | If you wish to create an effect in the global scope you have to manage its
484 | life-cycle with `createRoot`.
485 |
486 | ```ts
487 | const globalObject: GObject.Object
488 |
489 | const field = createBinding(globalObject, "field")
490 |
491 | createRoot((dispose) => {
492 | createEffect(() => {
493 | console.log("field is", field())
494 | })
495 |
496 | dispose() // effect should be cleaned up when no longer needed
497 | })
498 | ```
499 |
500 | ### When not to use an effect
501 |
502 | Do not use an effect to synchronise state.
503 |
504 | ```ts
505 | const [count, setCount] = createState(1)
506 | // [!code --:5]
507 | const [double, setDouble] = createState(count() * 2)
508 | createEffect(() => {
509 | setDouble(count() * 2)
510 | })
511 | // [!code ++]
512 | const double = createComputed(() => count() * 2)
513 | ```
514 |
515 | Same logic applies when an Accessor is passed as a prop.
516 |
517 | ```ts
518 | function Counter(props: { count: Accessor }) {
519 | // [!code --:5]
520 | const [double, setDouble] = createState(props.count() * 2)
521 | createEffect(() => {
522 | setDouble(props.count() * 2)
523 | })
524 | // [!code ++]
525 | const double = createComputed(() => props.count() * 2)
526 | }
527 | ```
528 |
529 | Do not use an effect to track values from `GObject` signals.
530 |
531 | ```ts
532 | // [!code --:5]
533 | const [count, setCount] = createState(1)
534 | const id = gobject.connect("signal", () => {
535 | setCount(gobject.prop)
536 | })
537 | onCleanup(() => gobject.disconnect(id))
538 | // [!code ++:1]
539 | const count = createConnection(0, [gobject, "signal", () => gobject.prop])
540 | ```
541 |
542 | Avoid using an effect for event specific logic.
543 |
544 | ```ts
545 | function TextEntry() {
546 | const [url, setUrl] = createState("")
547 | // [!code --:3]
548 | createEffect(() => {
549 | fetch(url())
550 | })
551 |
552 | function onTextEntered(entry: Gtk.Entry) {
553 | setUrl(entry.text)
554 | fetch(url.peek()) // [!code ++]
555 | }
556 | }
557 | ```
558 |
--------------------------------------------------------------------------------
/src/variant.ts:
--------------------------------------------------------------------------------
1 | // See: https://github.com/gjsify/ts-for-gir/issues/286
2 |
3 | /* eslint-disable @typescript-eslint/no-unused-vars */
4 | /* eslint-disable @typescript-eslint/no-empty-object-type */
5 | import type GLib from "gi://GLib"
6 |
7 | type Variant = GLib.Variant
8 |
9 | // prettier-ignore
10 | type CreateIndexType =
11 | Key extends `s` | `o` | `g` ? { [key: string]: Value } :
12 | Key extends `n` | `q` | `t` | `d` | `u` | `i` | `x` | `y` ? { [key: number]: Value } : never;
13 |
14 | type VariantTypeError = { error: true } & T
15 |
16 | /**
17 | * Handles the {kv} of a{kv} where k is a basic type and v is any possible variant type string.
18 | */
19 | // prettier-ignore
20 | type $ParseDeepVariantDict = {}> =
21 | string extends State
22 | ? VariantTypeError<"$ParseDeepVariantDict: 'string' is not a supported type.">
23 | // Hitting the first '}' indicates the dictionary type is complete
24 | : State extends `}${infer State}`
25 | ? [Memo, State]
26 | // This separates the key (basic type) from the rest of the remaining expression.
27 | : State extends `${infer Key}${''}${infer State}`
28 | ? $ParseDeepVariantValue extends [infer Value, `${infer State}`]
29 | ? State extends `}${infer State}`
30 | ? [CreateIndexType, State]
31 | : VariantTypeError<`$ParseDeepVariantDict encountered an invalid variant string: ${State} (1)`>
32 | : VariantTypeError<`$ParseDeepVariantValue returned unexpected value for: ${State}`>
33 | : VariantTypeError<`$ParseDeepVariantDict encountered an invalid variant string: ${State} (2)`>;
34 |
35 | /**
36 | * Handles parsing values within a tuple (e.g. (vvv)) where v is any possible variant type string.
37 | */
38 | // prettier-ignore
39 | type $ParseDeepVariantArray =
40 | string extends State
41 | ? VariantTypeError<"$ParseDeepVariantArray: 'string' is not a supported type.">
42 | : State extends `)${infer State}`
43 | ? [Memo, State]
44 | : $ParseDeepVariantValue extends [infer Value, `${infer State}`]
45 | ? State extends `${infer _NextValue})${infer _NextState}`
46 | ? $ParseDeepVariantArray
47 | : State extends `)${infer State}`
48 | ? [[...Memo, Value], State]
49 | : VariantTypeError<`1: $ParseDeepVariantArray encountered an invalid variant string: ${State}`>
50 | : VariantTypeError<`2: $ParseDeepVariantValue returned unexpected value for: ${State}`>;
51 |
52 | /**
53 | * Handles parsing {kv} without an 'a' prefix (key-value pair) where k is a basic type
54 | * and v is any possible variant type string.
55 | */
56 | // prettier-ignore
57 | type $ParseDeepVariantKeyValue =
58 | string extends State
59 | ? VariantTypeError<"$ParseDeepVariantKeyValue: 'string' is not a supported type.">
60 | : State extends `}${infer State}`
61 | ? [Memo, State]
62 | : State extends `${infer Key}${''}${infer State}`
63 | ? $ParseDeepVariantValue extends [infer Value, `${infer State}`]
64 | ? State extends `}${infer State}`
65 | ? [[...Memo, $ParseVariant, Value], State]
66 | : VariantTypeError<`$ParseDeepVariantKeyValue encountered an invalid variant string: ${State} (1)`>
67 | : VariantTypeError<`$ParseDeepVariantKeyValue returned unexpected value for: ${State}`>
68 | : VariantTypeError<`$ParseDeepVariantKeyValue encountered an invalid variant string: ${State} (2)`>;
69 |
70 | /**
71 | * Handles parsing any variant 'value' or base unit.
72 | *
73 | * - ay - Array of bytes (Uint8Array)
74 | * - a* - Array of type *
75 | * - a{k*} - Dictionary
76 | * - {k*} - KeyValue
77 | * - (**) - tuple
78 | * - s | o | g - string types
79 | * - n | q | t | d | u | i | x | y - number types
80 | * - b - boolean type
81 | * - v - unknown Variant type
82 | * - h | ? - unknown types
83 | */
84 | // prettier-ignore
85 | type $ParseDeepVariantValue =
86 | string extends State
87 | ? unknown
88 | : State extends `${`s` | `o` | `g`}${infer State}`
89 | ? [string, State]
90 | : State extends `${`n` | `q` | `t` | `d` | `u` | `i` | `x` | `y`}${infer State}`
91 | ? [number, State]
92 | : State extends `b${infer State}`
93 | ? [boolean, State]
94 | : State extends `v${infer State}`
95 | ? [Variant, State]
96 | : State extends `${'h' | '?'}${infer State}`
97 | ? [unknown, State]
98 | : State extends `(${infer State}`
99 | ? $ParseDeepVariantArray
100 | : State extends `a{${infer State}`
101 | ? $ParseDeepVariantDict
102 | : State extends `{${infer State}`
103 | ? $ParseDeepVariantKeyValue
104 | : State extends `ay${infer State}` ?
105 | [Uint8Array, State]
106 | : State extends `m${infer State}`
107 | ? $ParseDeepVariantValue extends [infer Value, `${infer State}`]
108 | ? [Value | null, State]
109 | : VariantTypeError<`$ParseDeepVariantValue encountered an invalid variant string: ${State} (3)`>
110 | : State extends `a${infer State}` ?
111 | $ParseDeepVariantValue extends [infer Value, `${infer State}`] ?
112 | [Value[], State]
113 | : VariantTypeError<`$ParseDeepVariantValue encountered an invalid variant string: ${State} (1)`>
114 | : VariantTypeError<`$ParseDeepVariantValue encountered an invalid variant string: ${State} (2)`>;
115 |
116 | // prettier-ignore
117 | type $ParseDeepVariant =
118 | $ParseDeepVariantValue extends infer Result
119 | ? Result extends [infer Value, string]
120 | ? Value
121 | : Result extends VariantTypeError
122 | ? Result
123 | : VariantTypeError<"$ParseDeepVariantValue returned unexpected Result">
124 | : VariantTypeError<"$ParseDeepVariantValue returned uninferrable Result">;
125 |
126 | // prettier-ignore
127 | type $ParseRecursiveVariantDict = {}> =
128 | string extends State
129 | ? VariantTypeError<"$ParseRecursiveVariantDict: 'string' is not a supported type.">
130 | : State extends `}${infer State}`
131 | ? [Memo, State]
132 | : State extends `${infer Key}${''}${infer State}`
133 | ? $ParseRecursiveVariantValue extends [infer Value, `${infer State}`]
134 | ? State extends `}${infer State}`
135 | ? [CreateIndexType, State]
136 | : VariantTypeError<`$ParseRecursiveVariantDict encountered an invalid variant string: ${State} (1)`>
137 | : VariantTypeError<`$ParseRecursiveVariantValue returned unexpected value for: ${State}`>
138 | : VariantTypeError<`$ParseRecursiveVariantDict encountered an invalid variant string: ${State} (2)`>;
139 |
140 | // prettier-ignore
141 | type $ParseRecursiveVariantArray =
142 | string extends State
143 | ? VariantTypeError<"$ParseRecursiveVariantArray: 'string' is not a supported type.">
144 | : State extends `)${infer State}`
145 | ? [Memo, State]
146 | : $ParseRecursiveVariantValue extends [infer Value, `${infer State}`]
147 | ? State extends `${infer _NextValue})${infer _NextState}`
148 | ? $ParseRecursiveVariantArray
149 | : State extends `)${infer State}`
150 | ? [[...Memo, Value], State]
151 | : VariantTypeError<`$ParseRecursiveVariantArray encountered an invalid variant string: ${State} (1)`>
152 | : VariantTypeError<`$ParseRecursiveVariantValue returned unexpected value for: ${State} (2)`>;
153 |
154 | // prettier-ignore
155 | type $ParseRecursiveVariantKeyValue =
156 | string extends State
157 | ? VariantTypeError<"$ParseRecursiveVariantKeyValue: 'string' is not a supported type.">
158 | : State extends `}${infer State}`
159 | ? [Memo, State]
160 | : State extends `${infer Key}${''}${infer State}`
161 | ? $ParseRecursiveVariantValue extends [infer Value, `${infer State}`]
162 | ? State extends `}${infer State}`
163 | ? [[...Memo, Key, Value], State]
164 | : VariantTypeError<`$ParseRecursiveVariantKeyValue encountered an invalid variant string: ${State} (1)`>
165 | : VariantTypeError<`$ParseRecursiveVariantKeyValue returned unexpected value for: ${State}`>
166 | : VariantTypeError<`$ParseRecursiveVariantKeyValue encountered an invalid variant string: ${State} (2)`>;
167 |
168 | // prettier-ignore
169 | type $ParseRecursiveVariantValue =
170 | string extends State
171 | ? unknown
172 | : State extends `${`s` | `o` | `g`}${infer State}`
173 | ? [string, State]
174 | : State extends `${`n` | `q` | `t` | `d` | `u` | `i` | `x` | `y`}${infer State}`
175 | ? [number, State]
176 | : State extends `b${infer State}`
177 | ? [boolean, State]
178 | : State extends `v${infer State}`
179 | ? [unknown, State]
180 | : State extends `${'h' | '?'}${infer State}`
181 | ? [unknown, State]
182 | : State extends `(${infer State}`
183 | ? $ParseRecursiveVariantArray
184 | : State extends `a{${infer State}`
185 | ? $ParseRecursiveVariantDict
186 | : State extends `{${infer State}`
187 | ? $ParseRecursiveVariantKeyValue
188 | : State extends `ay${infer State}` ?
189 | [Uint8Array, State]
190 | : State extends `m${infer State}`
191 | ? $ParseRecursiveVariantValue extends [infer Value, `${infer State}`]
192 | ? [Value | null, State]
193 | : VariantTypeError<`$ParseRecursiveVariantValue encountered an invalid variant string: ${State} (3)`>
194 | : State extends `a${infer State}` ?
195 | $ParseRecursiveVariantValue extends [infer Value, `${infer State}`] ?
196 | [Value[], State]
197 | : VariantTypeError<`$ParseRecursiveVariantValue encountered an invalid variant string: ${State} (1)`>
198 | : VariantTypeError<`$ParseRecursiveVariantValue encountered an invalid variant string: ${State} (2)`>;
199 |
200 | // prettier-ignore
201 | type $ParseRecursiveVariant =
202 | $ParseRecursiveVariantValue extends infer Result
203 | ? Result extends [infer Value, string]
204 | ? Value
205 | : Result extends VariantTypeError
206 | ? Result
207 | : never
208 | : never;
209 |
210 | // prettier-ignore
211 | type $ParseVariantDict = {}> =
212 | string extends State
213 | ? VariantTypeError<"$ParseVariantDict: 'string' is not a supported type.">
214 | : State extends `}${infer State}`
215 | ? [Memo, State]
216 | : State extends `${infer Key}${''}${infer State}`
217 | ? $ParseVariantValue extends [infer Value, `${infer State}`]
218 | ? State extends `}${infer State}`
219 | ? [CreateIndexType>, State]
220 | : VariantTypeError<`$ParseVariantDict encountered an invalid variant string: ${State} (1)`>
221 | : VariantTypeError<`$ParseVariantValue returned unexpected value for: ${State}`>
222 | : VariantTypeError<`$ParseVariantDict encountered an invalid variant string: ${State} (2)`>;
223 |
224 | // prettier-ignore
225 | type $ParseVariantArray =
226 | string extends State
227 | ? VariantTypeError<"$ParseVariantArray: 'string' is not a supported type.">
228 | : State extends `)${infer State}`
229 | ? [Memo, State]
230 | : $ParseVariantValue extends [infer Value, `${infer State}`]
231 | ? State extends `${infer _NextValue})${infer _NextState}`
232 | ? $ParseVariantArray]>
233 | : State extends `)${infer State}`
234 | ? [[...Memo, Variant], State]
235 | : VariantTypeError<`$ParseVariantArray encountered an invalid variant string: ${State} (1)`>
236 | : VariantTypeError<`$ParseVariantValue returned unexpected value for: ${State} (2)`>;
237 |
238 | // prettier-ignore
239 | type $ParseVariantKeyValue =
240 | string extends State
241 | ? VariantTypeError<"$ParseVariantKeyValue: 'string' is not a supported type.">
242 | : State extends `}${infer State}`
243 | ? [Memo, State]
244 | : State extends `${infer Key}${''}${infer State}`
245 | ? $ParseVariantValue extends [infer Value, `${infer State}`]
246 | ? State extends `}${infer State}`
247 | ? [[...Memo, Variant, Variant], State]
248 | : VariantTypeError<`$ParseVariantKeyValue encountered an invalid variant string: ${State} (1)`>
249 | : VariantTypeError<`$ParseVariantKeyValue returned unexpected value for: ${State}`>
250 | : VariantTypeError<`$ParseVariantKeyValue encountered an invalid variant string: ${State} (2)`>;
251 |
252 | // prettier-ignore
253 | type $ParseShallowRootVariantValue =
254 | string extends State
255 | ? unknown
256 | : State extends `${`s` | `o` | `g`}${infer State}`
257 | ? [string, State]
258 | : State extends `${`n` | `q` | `t` | `d` | `u` | `i` | `x` | `y`}${infer State}`
259 | ? [number, State]
260 | : State extends `b${infer State}`
261 | ? [boolean, State]
262 | : State extends `v${infer State}`
263 | ? [Variant, State]
264 | : State extends `h${infer State}`
265 | ? [unknown, State]
266 | : State extends `?${infer State}`
267 | ? [unknown, State]
268 | : State extends `(${infer State}`
269 | ? $ParseVariantArray
270 | : State extends `a{${infer State}`
271 | ? $ParseVariantDict
272 | : State extends `{${infer State}`
273 | ? $ParseVariantKeyValue
274 | : State extends `ay${infer State}` ?
275 | [Uint8Array, State]
276 | : State extends `m${infer State}`
277 | ? $ParseVariantValue extends [infer Value, `${infer State}`]
278 | ? [Value | null, State]
279 | : VariantTypeError<`$ParseShallowRootVariantValue encountered an invalid variant string: ${State} (2)`>
280 | : State extends `a${infer State}` ?
281 | [Variant[], State]
282 | : VariantTypeError<`$ParseShallowRootVariantValue encountered an invalid variant string: ${State} (1)`>;
283 |
284 | // prettier-ignore
285 | type $ParseVariantValue =
286 | string extends State
287 | ? unknown
288 | : State extends `s${infer State}`
289 | ? ['s', State]
290 | : State extends `o${infer State}`
291 | ? ['o', State]
292 | : State extends `g${infer State}`
293 | ? ['g', State]
294 | : State extends `n${infer State}`
295 | ? ["n", State]
296 | : State extends `q${infer State}`
297 | ? ["q", State]
298 | : State extends `t${infer State}`
299 | ? ["t", State]
300 | : State extends `d${infer State}`
301 | ? ["d", State]
302 | : State extends `u${infer State}`
303 | ? ["u", State]
304 | : State extends `i${infer State}`
305 | ? ["i", State]
306 | : State extends `x${infer State}`
307 | ? ["x", State]
308 | : State extends `y${infer State}`
309 | ? ["y", State]
310 | : State extends `b${infer State}`
311 | ? ['b', State]
312 | : State extends `v${infer State}`
313 | ? ['v', State]
314 | : State extends `h${infer State}`
315 | ? ['h', State]
316 | : State extends `?${infer State}`
317 | ? ['?', State]
318 | : State extends `(${infer State}`
319 | ? $ParseVariantArray
320 | : State extends `a{${infer State}`
321 | ? $ParseVariantDict
322 | : State extends `{${infer State}`
323 | ? $ParseVariantKeyValue
324 | : State extends `ay${infer State}` ?
325 | [Uint8Array, State]
326 | : State extends `m${infer State}`
327 | ? $ParseVariantValue extends [infer Value, `${infer State}`]
328 | ? [Value | null, State]
329 | : VariantTypeError<`$ParseVariantValue encountered an invalid variant string: ${State} (3)`>
330 | : State extends `a${infer State}` ?
331 | $ParseVariantValue extends [infer Value, `${infer State}`] ?
332 | [Value[], State]
333 | : VariantTypeError<`$ParseVariantValue encountered an invalid variant string: ${State} (1)`>
334 | : VariantTypeError<`$ParseVariantValue encountered an invalid variant string: ${State} (2)`>;
335 |
336 | // prettier-ignore
337 | type $ParseVariant =
338 | $ParseShallowRootVariantValue extends infer Result
339 | ? Result extends [infer Value, string]
340 | ? Value
341 | : Result extends VariantTypeError
342 | ? Result
343 | : never
344 | : never;
345 |
346 | export type Infer = $ParseVariant
347 | export type DeepInfer = $ParseDeepVariant
348 | export type RecursiveInfer = $ParseRecursiveVariant
349 |
--------------------------------------------------------------------------------
/src/gobject.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * In the future I would like to make type declaration in decorators optional
3 | * and infer it from typescript types at transpile time. Currently, we could
4 | * either use stage 2 decorators with the "emitDecoratorMetadata" and
5 | * "experimentalDecorators" tsconfig options. However, metadata is not supported
6 | * by esbuild which is what I'm mostly targeting as the bundler for performance
7 | * reasons. https://github.com/evanw/esbuild/issues/257
8 | * However, I believe that we should not use stage 2 anymore,
9 | * so I'm waiting for a better alternative.
10 | */
11 |
12 | import GObject from "gi://GObject"
13 | import GLib from "gi://GLib"
14 | import { definePropertyGetter, kebabify } from "./util.js"
15 |
16 | const priv = Symbol("gobject private")
17 | const { defineProperty, fromEntries, entries } = Object
18 | const { Object: GObj, registerClass } = GObject
19 |
20 | export { GObject as default }
21 | export { GObj as Object }
22 |
23 | export const SignalFlags = GObject.SignalFlags
24 | export type SignalFlags = GObject.SignalFlags
25 |
26 | export const AccumulatorType = GObject.AccumulatorType
27 | export type AccumulatorType = GObject.AccumulatorType
28 |
29 | export type ParamSpec = GObject.ParamSpec
30 | export const ParamSpec = GObject.ParamSpec
31 |
32 | export type ParamFlags = GObject.ParamFlags
33 | export const ParamFlags = GObject.ParamFlags
34 |
35 | export type GType = GObject.GType
36 |
37 | type GObj = GObject.Object
38 |
39 | interface GObjPrivate extends GObj {
40 | [priv]: Record
41 | }
42 |
43 | type Meta = {
44 | properties?: {
45 | [fieldName: string]: {
46 | flags: ParamFlags
47 | type: PropertyTypeDeclaration
48 | }
49 | }
50 | signals?: {
51 | [key: string]: {
52 | default?: boolean
53 | flags?: SignalFlags
54 | accumulator?: AccumulatorType
55 | return_type?: GType
56 | param_types?: Array
57 | method: (...arg: any[]) => unknown
58 | }
59 | }
60 | }
61 |
62 | type Context = { private: false; static: false; name: string }
63 | type PropertyContext = ClassFieldDecoratorContext & Context
64 | type GetterContext = ClassGetterDecoratorContext & Context
65 | type SetterContext = ClassSetterDecoratorContext & Context
66 | type SignalContext any> = ClassMethodDecoratorContext & Context
67 |
68 | type SignalOptions = {
69 | default?: boolean
70 | flags?: SignalFlags
71 | accumulator?: AccumulatorType
72 | }
73 |
74 | type PropertyTypeDeclaration =
75 | | ((name: string, flags: ParamFlags) => ParamSpec)
76 | | ParamSpec
77 | | { $gtype: GType }
78 |
79 | function assertField(
80 | ctx: ClassFieldDecoratorContext | ClassGetterDecoratorContext | ClassSetterDecoratorContext,
81 | ): string {
82 | if (ctx.private) throw Error("private fields are not supported")
83 | if (ctx.static) throw Error("static fields are not supported")
84 |
85 | if (typeof ctx.name !== "string") {
86 | throw Error("only strings can be gobject property keys")
87 | }
88 |
89 | return ctx.name
90 | }
91 |
92 | /**
93 | * Defines a readable *and* writeable property to be registered when using the {@link register} decorator.
94 | *
95 | * Example:
96 | * ```ts
97 | * class {
98 | * \@property(String) myProp = ""
99 | * }
100 | * ```
101 | */
102 | export function property(typeDeclaration: PropertyTypeDeclaration) {
103 | return function (
104 | _: void,
105 | ctx: PropertyContext,
106 | options?: { metaOnly: true },
107 | ): (this: GObj, init: T) => any {
108 | const fieldName = assertField(ctx)
109 | const key = kebabify(fieldName)
110 | const meta: Partial = ctx.metadata!
111 |
112 | meta.properties ??= {}
113 | meta.properties[fieldName] = { flags: ParamFlags.READWRITE, type: typeDeclaration }
114 |
115 | ctx.addInitializer(function () {
116 | definePropertyGetter(this, fieldName as Extract)
117 |
118 | if (options && options.metaOnly) return
119 |
120 | defineProperty(this, fieldName, {
121 | enumerable: true,
122 | configurable: false,
123 | set(v: T) {
124 | if (this[priv][key] !== v) {
125 | this[priv][key] = v
126 | this.notify(key)
127 | }
128 | },
129 | get(): T {
130 | return this[priv][key]
131 | },
132 | } satisfies ThisType)
133 | })
134 |
135 | return function (init: T) {
136 | const dict = ((this as GObjPrivate)[priv] ??= {})
137 | dict[key] = init
138 | return init
139 | }
140 | }
141 | }
142 |
143 | /**
144 | * Defines a read-only property to be registered when using the {@link register} decorator.
145 | * If the getter has a setter pair decorated with the {@link setter} decorator the property will be readable *and* writeable.
146 | *
147 | * Example:
148 | * ```ts
149 | * class {
150 | * \@setter(String)
151 | * set myProp(value: string) {
152 | * //
153 | * }
154 | *
155 | * \@getter(String)
156 | * get myProp(): string {
157 | * return ""
158 | * }
159 | * }
160 | * ```
161 | */
162 | export function getter(typeDeclaration: PropertyTypeDeclaration) {
163 | return function (get: (this: GObj) => any, ctx: GetterContext) {
164 | const fieldName = assertField(ctx)
165 | const meta: Partial = ctx.metadata!
166 | const props = (meta.properties ??= {})
167 | if (fieldName in props) {
168 | const { flags, type } = props[fieldName]
169 | props[fieldName] = { flags: flags | ParamFlags.READABLE, type }
170 | } else {
171 | props[fieldName] = { flags: ParamFlags.READABLE, type: typeDeclaration }
172 | }
173 | return get
174 | }
175 | }
176 |
177 | /**
178 | * Defines a write-only property to be registered when using the {@link register} decorator.
179 | * If the setter has a getter pair decorated with the {@link getter} decorator the property will be writeable *and* readable.
180 | *
181 | * Example:
182 | * ```ts
183 | * class {
184 | * \@setter(String)
185 | * set myProp(value: string) {
186 | * //
187 | * }
188 | *
189 | * \@getter(String)
190 | * get myProp(): string {
191 | * return ""
192 | * }
193 | * }
194 | * ```
195 | */
196 | export function setter(typeDeclaration: PropertyTypeDeclaration) {
197 | return function (set: (this: GObj, value: any) => void, ctx: SetterContext) {
198 | const fieldName = assertField(ctx)
199 | const meta: Partial = ctx.metadata!
200 | const props = (meta.properties ??= {})
201 | if (fieldName in props) {
202 | const { flags, type } = props[fieldName]
203 | props[fieldName] = { flags: flags | ParamFlags.WRITABLE, type }
204 | } else {
205 | props[fieldName] = { flags: ParamFlags.WRITABLE, type: typeDeclaration }
206 | }
207 | return set
208 | }
209 | }
210 |
211 | type ParamType = P extends { $gtype: GType } ? T : P extends GType ? T : never
212 |
213 | type ParamTypes = {
214 | [K in keyof Params]: ParamType
215 | }
216 |
217 | /**
218 | * Defines a signal to be registered when using the {@link register} decorator.
219 | *
220 | * Example:
221 | * ```ts
222 | * class {
223 | * \@signal([String, Number], Boolean, {
224 | * accumulator: AccumulatorType.FIRST_WINS
225 | * })
226 | * mySignal(str: string, n: number): boolean {
227 | * // default handler
228 | * return false
229 | * }
230 | * }
231 | * ```
232 | */
233 | export function signal<
234 | const Params extends Array<{ $gtype: GType } | GType>,
235 | Return extends { $gtype: GType } | GType,
236 | >(
237 | params: Params,
238 | returnType: Return,
239 | options?: SignalOptions,
240 | ): (
241 | method: (this: GObj, ...args: any) => ParamType,
242 | ctx: SignalContext,
243 | ) => (this: GObj, ...args: ParamTypes) => any
244 |
245 | /**
246 | * Defines a signal to be registered when using the {@link register} decorator.
247 | *
248 | * Example:
249 | * ```ts
250 | * class {
251 | * \@signal(String, Number)
252 | * mySignal(str: string, n: number): void {
253 | * // default handler
254 | * }
255 | * }
256 | * ```
257 | */
258 | export function signal>(
259 | ...params: Params
260 | ): (
261 | method: (this: GObject.Object, ...args: any) => void,
262 | ctx: SignalContext,
263 | ) => (this: GObject.Object, ...args: ParamTypes) => void
264 |
265 | export function signal<
266 | Params extends Array<{ $gtype: GType } | GType>,
267 | Return extends { $gtype: GType } | GType,
268 | >(
269 | ...args: Params | [params: Params, returnType?: Return, options?: SignalOptions]
270 | ): (
271 | method: (this: GObj, ...args: ParamTypes) => ParamType | void,
272 | ctx: SignalContext,
273 | ) => typeof method {
274 | return function (method, ctx) {
275 | if (ctx.private) throw Error("private fields are not supported")
276 | if (ctx.static) throw Error("static fields are not supported")
277 |
278 | if (typeof ctx.name !== "string") {
279 | throw Error("only strings can be gobject signals")
280 | }
281 |
282 | const signalName = kebabify(ctx.name)
283 | const meta: Partial = ctx.metadata!
284 | const signals = (meta.signals ??= {})
285 |
286 | if (Array.isArray(args[0])) {
287 | const [params, returnType, options] = args as [
288 | params: Params,
289 | returnType?: Return,
290 | options?: SignalOptions,
291 | ]
292 |
293 | signals[signalName] = {
294 | method,
295 | default: options?.default ?? true,
296 | param_types: params.map((i) => ("$gtype" in i ? i.$gtype : i)),
297 | ...(returnType && {
298 | return_type: "$gtype" in returnType ? returnType.$gtype : returnType,
299 | }),
300 | ...(options?.flags && {
301 | flags: options.flags,
302 | }),
303 | ...(typeof options?.accumulator === "number" && {
304 | accumulator: options.accumulator,
305 | }),
306 | }
307 | } else {
308 | const params = args as Params
309 | signals[signalName] = {
310 | method,
311 | default: true,
312 | param_types: params.map((i) => ("$gtype" in i ? i.$gtype : i)),
313 | }
314 | }
315 |
316 | return function (...params) {
317 | return this.emit(signalName, ...params) as ParamType
318 | }
319 | }
320 | }
321 |
322 | const MAXINT = 2 ** 31 - 1
323 | const MININT = -(2 ** 31)
324 | const MAXUINT = 2 ** 32 - 1
325 | const MAXFLOAT = 3.4028235e38
326 | const MINFLOAT = -3.4028235e38
327 | const MININT64 = Number.MIN_SAFE_INTEGER
328 | const MAXINT64 = Number.MAX_SAFE_INTEGER
329 |
330 | function pspecFromGType(type: GType, name: string, flags: ParamFlags) {
331 | switch (type) {
332 | case GObject.TYPE_BOOLEAN:
333 | return ParamSpec.boolean(name, "", "", flags, false)
334 | case GObject.TYPE_STRING:
335 | return ParamSpec.string(name, "", "", flags, "")
336 | case GObject.TYPE_INT:
337 | return ParamSpec.int(name, "", "", flags, MININT, MAXINT, 0)
338 | case GObject.TYPE_UINT:
339 | return ParamSpec.uint(name, "", "", flags, 0, MAXUINT, 0)
340 | case GObject.TYPE_INT64:
341 | return ParamSpec.int64(name, "", "", flags, MININT64, MAXINT64, 0)
342 | case GObject.TYPE_UINT64:
343 | return ParamSpec.uint64(name, "", "", flags, 0, Number.MAX_SAFE_INTEGER, 0)
344 | case GObject.TYPE_FLOAT:
345 | return ParamSpec.float(name, "", "", flags, MINFLOAT, MAXFLOAT, 0)
346 | case GObject.TYPE_DOUBLE:
347 | return ParamSpec.double(name, "", "", flags, Number.MIN_VALUE, Number.MIN_VALUE, 0)
348 | case GObject.TYPE_JSOBJECT:
349 | return ParamSpec.jsobject(name, "", "", flags)
350 | case GObject.TYPE_VARIANT:
351 | return ParamSpec.object(name, "", "", flags as any, GLib.Variant)
352 |
353 | case GObject.TYPE_ENUM:
354 | case GObject.TYPE_INTERFACE:
355 | case GObject.TYPE_BOXED:
356 | case GObject.TYPE_POINTER:
357 | case GObject.TYPE_PARAM:
358 | case GObject.type_from_name("GType"):
359 | throw Error(`cannot guess ParamSpec from GType "${type}"`)
360 | case GObject.TYPE_OBJECT:
361 | default:
362 | return ParamSpec.object(name, "", "", flags as any, type)
363 | }
364 | }
365 |
366 | function pspec(name: string, flags: ParamFlags, declaration: PropertyTypeDeclaration) {
367 | if (declaration instanceof ParamSpec) return declaration
368 |
369 | if (declaration === Object || declaration === Function || declaration === Array) {
370 | return ParamSpec.jsobject(name, "", "", flags)
371 | }
372 |
373 | if (declaration === String) {
374 | return ParamSpec.string(name, "", "", flags, "")
375 | }
376 |
377 | if (declaration === Number) {
378 | return ParamSpec.double(name, "", "", flags, -Number.MAX_VALUE, Number.MAX_VALUE, 0)
379 | }
380 |
381 | if (declaration === Boolean) {
382 | return ParamSpec.boolean(name, "", "", flags, false)
383 | }
384 |
385 | if ("$gtype" in declaration) {
386 | return pspecFromGType(declaration.$gtype, name, flags)
387 | }
388 |
389 | if (typeof declaration === "function") {
390 | return declaration(name, flags)
391 | }
392 |
393 | throw Error("invalid PropertyTypeDeclaration")
394 | }
395 |
396 | type MetaInfo = GObject.MetaInfo }>, never>
397 |
398 | /**
399 | * Replacement for {@link GObject.registerClass}
400 | * This decorator consumes metadata needed to register types where the provided decorators are used:
401 | * - {@link signal}
402 | * - {@link property}
403 | * - {@link getter}
404 | * - {@link setter}
405 | *
406 | * Example:
407 | * ```ts
408 | * \@register({ GTypeName: "MyClass" })
409 | * class MyClass extends GObject.Object { }
410 | * ```
411 | */
412 | export function register(options: MetaInfo = {}) {
413 | return function (cls: Cls, ctx: ClassDecoratorContext) {
414 | const t = options.Template
415 |
416 | if (typeof t === "string" && !t.startsWith("resource://") && !t.startsWith("file://")) {
417 | options.Template = new TextEncoder().encode(t)
418 | }
419 |
420 | const meta = ctx.metadata! as Meta
421 |
422 | const props: Record> = fromEntries(
423 | entries(meta.properties ?? {}).map(([fieldName, { flags, type }]) => {
424 | const key = kebabify(fieldName)
425 | const spec = pspec(key, flags, type)
426 | return [key, spec]
427 | }),
428 | )
429 |
430 | const signals = fromEntries(
431 | entries(meta.signals ?? {}).map(([signalName, { default: def, method, ...signal }]) => {
432 | if (def) {
433 | defineProperty(cls.prototype, `on_${signalName.replaceAll("-", "_")}`, {
434 | enumerable: false,
435 | configurable: false,
436 | value: method,
437 | })
438 | }
439 | return [signalName, signal]
440 | }),
441 | )
442 |
443 | delete meta.properties
444 | delete meta.signals
445 |
446 | registerClass({ ...options, Properties: props, Signals: signals }, cls)
447 | }
448 | }
449 |
450 | /**
451 | * @experimental
452 | * Asserts a gtype in cases where the type is too loose/strict.
453 | *
454 | * Example:
455 | * ```ts
456 | * type Tuple = [number, number]
457 | * const Tuple = gtype(Array)
458 | *
459 | * class {
460 | * \@property(Tuple) value = [1, 2] as Tuple
461 | * }
462 | * ```
463 | */
464 | export function gtype(type: GType | { $gtype: GType }): {
465 | $gtype: GType
466 | } {
467 | return "$gtype" in type ? type : { $gtype: type }
468 | }
469 |
470 | declare global {
471 | interface FunctionConstructor {
472 | $gtype: GType<(...args: any[]) => any>
473 | }
474 |
475 | interface ArrayConstructor {
476 | $gtype: GType
477 | }
478 |
479 | interface DateConstructor {
480 | $gtype: GType
481 | }
482 |
483 | interface MapConstructor {
484 | $gtype: GType