├── docs ├── index.md ├── .vitepress │ └── config.ts ├── tutorial │ ├── packaging.md │ ├── intro.md │ ├── gtk.md │ ├── gobject.md │ ├── app.md │ └── gnim.md ├── polyfills.md ├── vitepress.config.ts ├── dbus.md ├── gobject.md ├── public │ ├── scope-light.svg │ └── scope-dark.svg └── jsx.md ├── .gitignore ├── CREDITS.md ├── scripts └── build.sh ├── src ├── env.d.ts ├── index.ts ├── jsx │ ├── env.ts │ ├── Fragment.ts │ ├── With.ts │ ├── This.ts │ ├── For.ts │ ├── scope.ts │ └── jsx.ts ├── resource.xml ├── util.ts ├── gnome │ └── jsx-runtime.ts ├── gtk3 │ └── jsx-runtime.ts ├── gtk4 │ └── jsx-runtime.ts ├── fetch.ts ├── variant.ts └── gobject.ts ├── eslint.config.js ├── tsconfig.json ├── .github ├── workflows │ ├── lint.yml │ ├── publish.yml │ └── vitepress.yml └── FUNDING.yml ├── LICENSE ├── README.md └── package.json /docs/index.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | export { default as default } from "../vitepress.config" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | dist/ 4 | docs/.vitepress/dist 5 | docs/.vitepress/cache 6 | demo.tsx 7 | -------------------------------------------------------------------------------- /CREDITS.md: -------------------------------------------------------------------------------- 1 | # Credits 2 | 3 | - [JU12000](https://github.com/JU12000) for suggesting the name Gnim 4 | - [Azazel-Woodwind](https://github.com/Azazel-Woodwind) for a lot of early 5 | testing 6 | -------------------------------------------------------------------------------- /docs/tutorial/packaging.md: -------------------------------------------------------------------------------- 1 | # Packaging 2 | 3 | Yet to be written. 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euxo pipefail 4 | 5 | rm -rf dist 6 | rm -rf build 7 | tsc 8 | mkdir dist 9 | glib-compile-resources src/resource.xml --sourcedir=build --target=dist/gnim.gresource 10 | cp -r src/* dist/ 11 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | import "@girs/adw-1" 2 | import "@girs/gtk-3.0" 3 | import "@girs/gtk-4.0" 4 | import "@girs/soup-3.0" 5 | import "@girs/clutter-16" 6 | import "@girs/shell-16" 7 | import "@girs/st-16" 8 | import "@girs/gjs" 9 | import "@girs/gjs/dom" 10 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js" 2 | import tseslint from "typescript-eslint" 3 | 4 | export default tseslint.config( 5 | eslint.configs.recommended, 6 | ...tseslint.configs.recommended, 7 | { 8 | ignores: ["docs/.vitepress/", "dist/"], 9 | }, 10 | { 11 | rules: { 12 | "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], 13 | "@typescript-eslint/no-explicit-any": "off", 14 | }, 15 | }, 16 | ) 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ES2022", 4 | "target": "ES2020", 5 | "lib": ["ESNext"], 6 | "outDir": "build", 7 | "removeComments": true, 8 | "inlineSourceMap": true, 9 | "strict": true, 10 | "moduleResolution": "Bundler", 11 | "skipLibCheck": true, 12 | "jsx": "react-jsx", 13 | "baseUrl": ".", 14 | "jsxImportSource": "src/gtk4", 15 | "verbatimModuleSyntax": true 16 | }, 17 | "include": ["./src/**/*"] 18 | } 19 | -------------------------------------------------------------------------------- /docs/polyfills.md: -------------------------------------------------------------------------------- 1 | # Polyfills 2 | 3 | GJS does not implement some common APIs that you would expect from a JavaScript 4 | runtime. See this [gjs issue](https://gitlab.gnome.org/GNOME/gjs/-/issues/265) 5 | for context. 6 | 7 | ## fetch 8 | 9 | Gnim provides a basic implementation for the `fetch` API. 10 | 11 | ```ts 12 | import { fetch, URL } from "gnim/fetch" 13 | 14 | const url = new URL("https://some-site.com/api") 15 | url.searchParams.set("hello", "world") 16 | 17 | const res = await fetch(url, { 18 | method: "POST", 19 | body: JSON.stringify({ hello: "world" }), 20 | headers: { 21 | "Content-Type": "application/json", 22 | }, 23 | }) 24 | 25 | const json = await res.json() 26 | ``` 27 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup pnpm 17 | uses: pnpm/action-setup@v4 18 | with: 19 | version: 10 20 | 21 | - name: Setup Node 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | cache: "pnpm" 26 | 27 | - name: Install dependencies 28 | run: pnpm install --frozen-lockfile 29 | 30 | - name: Lint 31 | run: pnpm lint 32 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | type Node, 3 | type CCProps, 4 | type FCProps, 5 | getType, 6 | jsx, 7 | appendChild, 8 | removeChild, 9 | } from "./jsx/jsx.js" 10 | export { Fragment } from "./jsx/Fragment.js" 11 | export { For } from "./jsx/For.js" 12 | export { With } from "./jsx/With.js" 13 | export { This } from "./jsx/This.js" 14 | export { 15 | type Context, 16 | type Scope, 17 | createRoot, 18 | getScope, 19 | onCleanup, 20 | onMount, 21 | createContext, 22 | } from "./jsx/scope.js" 23 | export { 24 | type Accessed, 25 | type State, 26 | type Setter, 27 | Accessor, 28 | createState, 29 | createEffect, 30 | createComputed, 31 | createMemo, 32 | createBinding, 33 | createConnection, 34 | createExternal, 35 | createSettings, 36 | } from "./jsx/state.js" 37 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [aylur] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: aylur 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Aylur 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gnim 2 | 3 | Library which brings JSX and reactivity to GNOME JavaScript. 4 | 5 | If you are not already familiar with GJS and GObject, you should read 6 | [gjs.guide](https://gjs.guide/) first. 7 | 8 | This library provides: 9 | 10 | - [JSX and reactivity](https://aylur.github.io/gnim/jsx) for both Gtk 11 | Applications and Gnome extensions 12 | - [GObject decorators](https://aylur.github.io/gnim/gobject) for a convenient 13 | and type safe way for subclassing GObjects 14 | - [DBus decorators](https://aylur.github.io/gnim/dbus) for a convenient and type 15 | safe way for implementing DBus services and proxies. 16 | 17 | ## Obligatory Counter Example 18 | 19 | ```tsx 20 | function Counter() { 21 | const [count, setCount] = createState(0) 22 | 23 | function increment() { 24 | setCount((v) => v + 1) 25 | } 26 | 27 | createEffect(() => { 28 | console.log("count is", count()) 29 | }) 30 | 31 | return ( 32 | 33 | c.toString())} /> 34 | Increment 35 | 36 | ) 37 | } 38 | ``` 39 | 40 | ## Templates 41 | 42 | - [gnome-extension](https://github.com/Aylur/gnome-shell-extension-template/) 43 | - [gtk4](https://github.com/Aylur/gnim-gtk4-template/) 44 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Check if tag matches version 17 | run: | 18 | [[ "${GITHUB_REF}" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1 19 | 20 | - name: Setup pnpm 21 | uses: pnpm/action-setup@v4 22 | with: 23 | version: 10 24 | 25 | - name: Setup Node 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 20 29 | cache: "pnpm" 30 | 31 | - name: Install glib-compile-resources 32 | run: sudo apt install -y libglib2.0-dev-bin 33 | 34 | - name: Install dependencies 35 | run: pnpm install --frozen-lockfile 36 | 37 | - name: Lint 38 | run: pnpm lint 39 | 40 | - name: Build project 41 | run: pnpm build 42 | 43 | - name: Publish to npm 44 | run: npm publish 45 | env: 46 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 47 | -------------------------------------------------------------------------------- /src/jsx/env.ts: -------------------------------------------------------------------------------- 1 | import type GObject from "gi://GObject" 2 | import { type Accessor } from "./state.js" 3 | 4 | type GObj = GObject.Object 5 | export type CC = { new (props: any): T } 6 | export type FC = (props: any) => T 7 | 8 | type CssSetter = (object: GObj, css: string | Accessor) => void 9 | 10 | export function configue(conf: Partial) { 11 | return Object.assign(env, conf) 12 | } 13 | 14 | type JsxEnv = { 15 | intrinsicElements: Record 16 | textNode(node: string | number): GObj 17 | appendChild(parent: GObj, child: GObj): void 18 | removeChild(parent: GObj, child: GObj): void 19 | setCss: CssSetter 20 | setClass: CssSetter 21 | // string[] can be use to delay setting props after children 22 | // e.g Gtk.Stack["visibleChildName"] depends on children 23 | initProps(ctor: unknown, props: any): void | string[] 24 | defaultCleanup(object: GObj): void 25 | } 26 | 27 | function missingImpl(): any { 28 | throw Error("missing impl") 29 | } 30 | 31 | export const env: JsxEnv = { 32 | intrinsicElements: {}, 33 | textNode: missingImpl, 34 | appendChild: missingImpl, 35 | removeChild: missingImpl, 36 | setCss: missingImpl, 37 | setClass: missingImpl, 38 | initProps: () => void 0, 39 | defaultCleanup: () => void 0, 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/vitepress.yml: -------------------------------------------------------------------------------- 1 | name: Deploy VitePress site 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | permissions: 8 | contents: read 9 | pages: write 10 | id-token: write 11 | 12 | jobs: 13 | build-vitepress: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 # Not needed if lastUpdated is not enabled 20 | 21 | - name: Setup pnpm 22 | uses: pnpm/action-setup@v4 23 | with: 24 | version: 10 25 | 26 | - name: Setup Node 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: 20 30 | 31 | - name: Setup Pages 32 | uses: actions/configure-pages@v4 33 | 34 | - name: Install dependencies 35 | run: pnpm install --frozen-lockfile 36 | 37 | - name: Build with VitePress 38 | run: pnpm run docs:build 39 | 40 | - name: Upload artifact 41 | uses: actions/upload-pages-artifact@v3 42 | with: 43 | path: docs/.vitepress/dist 44 | 45 | deploy-vitepress: 46 | environment: 47 | name: github-pages 48 | url: ${{ steps.deployment.outputs.page_url }} 49 | needs: build-vitepress 50 | runs-on: ubuntu-latest 51 | steps: 52 | - name: Deploy to GitHub Pages 53 | id: deployment 54 | uses: actions/deploy-pages@v4 55 | -------------------------------------------------------------------------------- /src/jsx/Fragment.ts: -------------------------------------------------------------------------------- 1 | import GObject from "gi://GObject" 2 | 3 | interface FragmentSignals extends GObject.Object.SignalSignatures { 4 | append: (child: T) => void 5 | remove: (child: T) => void 6 | } 7 | 8 | export class Fragment extends GObject.Object { 9 | declare $signals: FragmentSignals 10 | 11 | static [GObject.signals] = { 12 | append: { param_types: [GObject.TYPE_OBJECT] }, 13 | remove: { param_types: [GObject.TYPE_OBJECT] }, 14 | } 15 | 16 | static [GObject.properties] = { 17 | children: GObject.ParamSpec.jsobject("children", "", "", GObject.ParamFlags.READABLE), 18 | } 19 | 20 | static { 21 | GObject.registerClass(this) 22 | } 23 | 24 | *[Symbol.iterator]() { 25 | yield* this._children 26 | } 27 | 28 | private _children: Array 29 | 30 | append(child: T): void { 31 | if (child instanceof Fragment) { 32 | throw Error(`nesting Fragments are not yet supported`) 33 | } 34 | 35 | this._children.push(child) 36 | this.emit("append", child) 37 | this.notify("children") 38 | } 39 | 40 | remove(child: T): void { 41 | const index = this._children.findIndex((i) => i === child) 42 | this._children.splice(index, 1) 43 | 44 | this.emit("remove", child) 45 | this.notify("children") 46 | } 47 | 48 | constructor({ children = [] }: Partial<{ children: Array | T }> = {}) { 49 | super() 50 | this._children = Array.isArray(children) ? children : [children] 51 | } 52 | 53 | connect>( 54 | signal: S, 55 | callback: GObject.SignalCallback[S]>, 56 | ): number { 57 | return super.connect(signal, callback) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/jsx/With.ts: -------------------------------------------------------------------------------- 1 | import { Fragment } from "./Fragment.js" 2 | import { Accessor, createEffect, createMemo } from "./state.js" 3 | import { env } from "./env.js" 4 | import { getScope, onCleanup, Scope } from "./scope.js" 5 | 6 | interface WithProps { 7 | value: Accessor 8 | children: (value: T) => E | "" | false | null | undefined 9 | 10 | /** 11 | * Function to run for each removed element. 12 | * The default value depends on the environment: 13 | * 14 | * - **Gtk4**: null 15 | * - **Gtk3**: Gtk.Widget.prototype.destroy 16 | * - **Gnome**: Clutter.Actor.prototype.destroy 17 | */ 18 | cleanup?: null | ((element: E) => void) 19 | } 20 | 21 | export function With({ 22 | value, 23 | children: mkChild, 24 | cleanup, 25 | }: WithProps): Fragment { 26 | const currentScope = getScope() 27 | const fragment = new Fragment() 28 | 29 | let scope: Scope 30 | 31 | function remove(child: E) { 32 | fragment.remove(child) 33 | if (scope) scope.dispose() 34 | 35 | if (typeof cleanup === "function") { 36 | cleanup(child) 37 | } else if (cleanup !== null) { 38 | env.defaultCleanup(child) 39 | } 40 | } 41 | 42 | function callback(v: T) { 43 | for (const child of fragment) { 44 | remove(child) 45 | } 46 | 47 | scope = new Scope(currentScope) 48 | const ch = scope.run(() => mkChild(v)) 49 | if (ch !== "" && ch !== false && ch !== null && ch !== undefined) { 50 | fragment.append(ch) 51 | } 52 | } 53 | 54 | const v = createMemo(value) 55 | createEffect(() => callback(v()), { immediate: true }) 56 | 57 | onCleanup(() => { 58 | for (const child of fragment) { 59 | remove(child) 60 | } 61 | }) 62 | 63 | return fragment 64 | } 65 | -------------------------------------------------------------------------------- /src/resource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | 42 | 43 | dbus.js 44 | fetch.js 45 | gobject.js 46 | util.js 47 | index.js 48 | 49 | gnome/jsx-runtime.js 50 | gtk3/jsx-runtime.js 51 | gtk4/jsx-runtime.js 52 | 53 | jsx/env.js 54 | jsx/For.js 55 | jsx/Fragment.js 56 | jsx/jsx.js 57 | jsx/scope.js 58 | jsx/state.js 59 | jsx/This.js 60 | jsx/With.js 61 | 62 | 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gnim", 3 | "version": "1.9.0", 4 | "type": "module", 5 | "author": "Aylur", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/Aylur/gnim.git" 10 | }, 11 | "funding": { 12 | "type": "kofi", 13 | "url": "https://ko-fi.com/aylur" 14 | }, 15 | "scripts": { 16 | "build": "./scripts/build.sh", 17 | "lint": "eslint . --fix", 18 | "docs:dev": "vitepress dev docs", 19 | "docs:build": "vitepress build docs", 20 | "docs:preview": "vitepress preview docs" 21 | }, 22 | "devDependencies": { 23 | "@eslint/js": "latest", 24 | "@girs/adw-1": "latest", 25 | "@girs/clutter-16": "latest", 26 | "@girs/gtk-3.0": "latest", 27 | "@girs/gtk-4.0": "latest", 28 | "@girs/soup-3.0": "latest", 29 | "@girs/shell-16": "latest", 30 | "@girs/st-16": "latest", 31 | "@girs/gnome-shell": "latest", 32 | "@girs/gjs": "latest", 33 | "esbuild": "latest", 34 | "eslint": "latest", 35 | "typescript": "latest", 36 | "typescript-eslint": "latest", 37 | "vitepress": "latest" 38 | }, 39 | "exports": { 40 | ".": "./dist/index.ts", 41 | "./dbus": "./dist/dbus.ts", 42 | "./fetch": "./dist/fetch.ts", 43 | "./gobject": "./dist/gobject.ts", 44 | "./gnome/jsx-runtime": "./dist/gnome/jsx-runtime.ts", 45 | "./gtk3/jsx-runtime": "./dist/gtk3/jsx-runtime.ts", 46 | "./gtk4/jsx-runtime": "./dist/gtk4/jsx-runtime.ts" 47 | }, 48 | "files": [ 49 | "dist" 50 | ], 51 | "engines": { 52 | "gjs": ">=1.79.0" 53 | }, 54 | "keywords": [ 55 | "GJS", 56 | "Gnome", 57 | "GTK", 58 | "JSX" 59 | ], 60 | "prettier": { 61 | "semi": false, 62 | "tabWidth": 4, 63 | "quoteProps": "consistent", 64 | "trailingComma": "all", 65 | "printWidth": 100, 66 | "experimentalTernaries": false, 67 | "overrides": [ 68 | { 69 | "files": "**/*.md", 70 | "options": { 71 | "tabWidth": 2, 72 | "printWidth": 80, 73 | "proseWrap": "always" 74 | } 75 | } 76 | ] 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/jsx/This.ts: -------------------------------------------------------------------------------- 1 | import GObject from "gi://GObject" 2 | import { env } from "./env.js" 3 | import { Accessor, createEffect } from "./state.js" 4 | import { set } from "../util.js" 5 | import { onCleanup } from "./scope.js" 6 | import { append, setType, signalName, type CCProps } from "./jsx.js" 7 | 8 | type ThisProps = Partial< 9 | Omit, "$" | "$constructor"> 10 | > & { 11 | this: Self 12 | } 13 | 14 | /** @experimental */ 15 | export function This({ 16 | this: self, 17 | children, 18 | $type, 19 | ...props 20 | }: ThisProps) { 21 | const cleanup = new Array<() => void>() 22 | 23 | if ($type) setType(self, $type) 24 | 25 | for (const [key, value] of Object.entries(props)) { 26 | if (key === "css") { 27 | if (value instanceof Accessor) { 28 | createEffect(() => env.setCss(self, value()), { immediate: true }) 29 | } else if (typeof value === "string") { 30 | env.setCss(self, value) 31 | } 32 | } else if (key === "class") { 33 | if (value instanceof Accessor) { 34 | createEffect(() => env.setClass(self, value()), { immediate: true }) 35 | } else if (typeof value === "string") { 36 | env.setClass(self, value) 37 | } 38 | } else if (key.startsWith("on")) { 39 | const id = self.connect(signalName(key), value) 40 | cleanup.push(() => self.disconnect(id)) 41 | } else if (value instanceof Accessor) { 42 | createEffect(() => set(self, key, value()), { immediate: true }) 43 | } else { 44 | set(self, key, value) 45 | } 46 | } 47 | 48 | for (let child of Array.isArray(children) ? children : [children]) { 49 | if (child === true) { 50 | console.warn(Error("Trying to add boolean value of `true` as a child.")) 51 | continue 52 | } 53 | 54 | if (Array.isArray(child)) { 55 | for (const ch of child) { 56 | append(self, ch) 57 | } 58 | } else if (child) { 59 | if (!(child instanceof GObject.Object)) { 60 | child = env.textNode(child) 61 | } 62 | append(self, child) 63 | } 64 | } 65 | 66 | if (cleanup.length > 0) { 67 | onCleanup(() => cleanup.forEach((cb) => cb())) 68 | } 69 | 70 | return self 71 | } 72 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import type GObject from "gi://GObject" 2 | 3 | export function kebabify(str: string) { 4 | return str 5 | .replace(/([a-z])([A-Z])/g, "$1-$2") 6 | .replaceAll("_", "-") 7 | .toLowerCase() 8 | } 9 | 10 | export function snakeify(str: string) { 11 | return str 12 | .replace(/([a-z])([A-Z])/g, "$1-$2") 13 | .replaceAll("-", "_") 14 | .toLowerCase() 15 | } 16 | 17 | export function camelify(str: string) { 18 | return str.replace(/[-_](.)/g, (_, char) => char.toUpperCase()) 19 | } 20 | 21 | export type Pascalify = S extends `${infer Head}${"-" | "_"}${infer Tail}` 22 | ? `${Capitalize}${Pascalify}` 23 | : S extends string 24 | ? Capitalize 25 | : never 26 | 27 | export type XmlNode = { 28 | name: string 29 | attributes?: Record 30 | children?: Array 31 | } 32 | 33 | export function xml({ name, attributes, children }: XmlNode) { 34 | let builder = `<${name}` 35 | 36 | const attrs = Object.entries(attributes ?? []) 37 | 38 | if (attrs.length > 0) { 39 | for (const [key, value] of attrs) { 40 | builder += ` ${key}="${value}"` 41 | } 42 | } 43 | 44 | if (children && children.length > 0) { 45 | builder += ">" 46 | for (const node of children) { 47 | builder += xml(node) 48 | } 49 | builder += `` 50 | } else { 51 | builder += " />" 52 | } 53 | 54 | return builder 55 | } 56 | 57 | // Bindings work over properties in kebab-case because thats the convention of gobject 58 | // however in js its either snake_case or camelCase 59 | // also on DBus interfaces its PascalCase by convention 60 | // so as a workaround we use get_property_name and only use the property field as a fallback 61 | export function definePropertyGetter(object: T, prop: Extract) { 62 | Object.defineProperty(object, `get_${kebabify(prop).replaceAll("-", "_")}`, { 63 | configurable: false, 64 | enumerable: true, 65 | value: () => object[prop], 66 | }) 67 | } 68 | 69 | // attempt setting a property of GObject.Object 70 | export function set(obj: GObject.Object, prop: string, value: any) { 71 | const key = snakeify(prop) 72 | const getter = `get_${key}` as keyof typeof obj 73 | const setter = `set_${key}` as keyof typeof obj 74 | 75 | let current: unknown 76 | 77 | if (getter in obj && typeof obj[getter] === "function") { 78 | current = (obj[getter] as () => unknown)() 79 | } else { 80 | current = obj[prop as keyof typeof obj] 81 | } 82 | 83 | if (current !== value) { 84 | if (setter in obj && typeof obj[setter] === "function") { 85 | ;(obj[setter] as (v: any) => void)(value) 86 | } else { 87 | Object.assign(obj, { [prop]: value }) 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/gnome/jsx-runtime.ts: -------------------------------------------------------------------------------- 1 | import Clutter from "gi://Clutter" 2 | import St from "gi://St" 3 | import { configue } from "../jsx/env.js" 4 | import { onCleanup, Accessor, Fragment } from "../index.js" 5 | 6 | const { intrinsicElements } = configue({ 7 | setCss(object, css) { 8 | if (!(object instanceof St.Widget)) { 9 | return console.warn(Error(`cannot set css on ${object}`)) 10 | } 11 | 12 | if (css instanceof Accessor) { 13 | object.style = css.get() 14 | const dispose = css.subscribe(() => (object.style = css.get())) 15 | onCleanup(dispose) 16 | } else { 17 | object.set_style(css) 18 | } 19 | }, 20 | setClass(object, className) { 21 | if (!(object instanceof St.Widget)) { 22 | return console.warn(Error(`cannot set className on ${object}`)) 23 | } 24 | 25 | if (className instanceof Accessor) { 26 | object.styleClass = className.get() 27 | const dispose = className.subscribe(() => (object.styleClass = className.get())) 28 | onCleanup(dispose) 29 | } else { 30 | object.set_style_class_name(className) 31 | } 32 | }, 33 | textNode(text) { 34 | return St.Label.new(text.toString()) 35 | }, 36 | removeChild(parent, child) { 37 | if (parent instanceof Clutter.Actor) { 38 | if (child instanceof Clutter.Action) { 39 | return parent.remove_action(child) 40 | } 41 | if (child instanceof Clutter.Actor) { 42 | return parent.remove_child(child) 43 | } 44 | if (child instanceof Clutter.Constraint) { 45 | return parent.remove_constraint(child) 46 | } 47 | if (child instanceof Clutter.LayoutManager) { 48 | return parent.set_layout_manager(null) 49 | } 50 | } 51 | 52 | throw Error(`cannot remove ${child} from ${parent}`) 53 | }, 54 | appendChild(parent, child) { 55 | if (parent instanceof Clutter.Actor) { 56 | if (child instanceof Clutter.Action) { 57 | return parent.add_action(child) 58 | } 59 | if (child instanceof Clutter.Constraint) { 60 | return parent.add_constraint(child) 61 | } 62 | if (child instanceof Clutter.LayoutManager) { 63 | return parent.set_layout_manager(child) 64 | } 65 | if (child instanceof Clutter.Actor) { 66 | return parent.add_child(child) 67 | } 68 | } 69 | 70 | throw Error(`cannot add ${child} to ${parent}`) 71 | }, 72 | defaultCleanup(object) { 73 | if (object instanceof Clutter.Actor) { 74 | object.destroy() 75 | } 76 | }, 77 | }) 78 | 79 | export { Fragment, intrinsicElements } 80 | export { jsx, jsxs } from "../jsx/jsx.js" 81 | -------------------------------------------------------------------------------- /docs/vitepress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitepress" 2 | 3 | export default defineConfig({ 4 | title: "Gnim", 5 | base: "/gnim/", 6 | description: "Library which brings JSX and reactivity to GNOME JavaScript.", 7 | cleanUrls: true, 8 | 9 | sitemap: { 10 | hostname: "https://github.com/aylur/gnim", 11 | }, 12 | 13 | vite: { 14 | resolve: { 15 | preserveSymlinks: true, 16 | }, 17 | }, 18 | 19 | themeConfig: { 20 | outline: "deep", 21 | 22 | sidebar: [ 23 | { 24 | text: "Tutorial", 25 | items: [ 26 | { text: "Intro", link: "/tutorial/intro" }, 27 | { text: "GObject", link: "/tutorial/gobject" }, 28 | { text: "Gtk", link: "/tutorial/gtk" }, 29 | { text: "Gnim", link: "/tutorial/gnim" }, 30 | { text: "App", link: "/tutorial/app" }, 31 | { text: "Packaging", link: "/tutorial/packaging" }, 32 | ], 33 | }, 34 | { 35 | text: "Reference", 36 | items: [ 37 | { text: "JSX", link: "/jsx" }, 38 | { text: "GObject", link: "/gobject" }, 39 | { text: "DBus", link: "/dbus" }, 40 | { text: "Polyfills", link: "/polyfills" }, 41 | ], 42 | }, 43 | ], 44 | socialLinks: [ 45 | { icon: "npm", link: "https://www.npmjs.com/package/gnim" }, 46 | { icon: "github", link: "https://github.com/aylur/gnim/" }, 47 | { icon: "discord", link: "https://discord.gg/CXQpHwDuhY" }, 48 | { 49 | link: "https://ko-fi.com/aylur", 50 | 51 | icon: { 52 | svg: ``, 53 | }, 54 | }, 55 | ], 56 | 57 | editLink: { 58 | pattern: "https://github.com/aylur/gnim/edit/main/docs/:path", 59 | text: "Edit this page on GitHub", 60 | }, 61 | 62 | search: { 63 | provider: "local", 64 | }, 65 | 66 | lastUpdated: { 67 | text: "Last updated", 68 | }, 69 | }, 70 | }) 71 | -------------------------------------------------------------------------------- /src/jsx/For.ts: -------------------------------------------------------------------------------- 1 | import { Fragment } from "./Fragment.js" 2 | import { Accessor, type State, createEffect, createState } from "./state.js" 3 | import { env } from "./env.js" 4 | import { getScope, onCleanup, Scope } from "./scope.js" 5 | 6 | interface ForProps { 7 | each: Accessor> 8 | children: (item: Item, index: Accessor) => El 9 | 10 | /** 11 | * Function to run for each removed element. 12 | * The default value depends on the environment: 13 | * 14 | * - **Gtk4**: null 15 | * - **Gtk3**: Gtk.Widget.prototype.destroy 16 | * - **Gnome**: Clutter.Actor.prototype.destroy 17 | */ 18 | cleanup?: null | ((element: El, item: Item, index: number) => void) 19 | 20 | /** 21 | * Function that generates the key for each item. 22 | * 23 | * By default items are mapped by: 24 | * - value in case of primitive values 25 | * - reference otherwise 26 | */ 27 | id?: (item: Item) => Key | Item 28 | } 29 | 30 | // TODO: support Gio.ListModel 31 | 32 | export function For({ 33 | each, 34 | children: mkChild, 35 | cleanup, 36 | id = (item: Item) => item, 37 | }: ForProps): Fragment { 38 | type MapItem = { item: Item; child: El; index: State; scope: Scope } 39 | 40 | const currentScope = getScope() 41 | const map = new Map() 42 | const fragment = new Fragment() 43 | 44 | function remove({ item, child, index: [index], scope }: MapItem) { 45 | scope.dispose() 46 | if (typeof cleanup === "function") { 47 | cleanup(child, item, index.peek()) 48 | } else if (cleanup !== null) { 49 | env.defaultCleanup(child) 50 | } 51 | } 52 | 53 | function callback(itareable: Iterable) { 54 | const items = [...itareable] 55 | const ids = items.map(id) 56 | const idSet = new Set(ids) 57 | 58 | // cleanup children missing from arr 59 | for (const [key, value] of map.entries()) { 60 | // there is no generic way to insert child at index 61 | // so we sort by removing every child and reappending in order 62 | fragment.remove(value.child) 63 | 64 | if (!idSet.has(key)) { 65 | remove(value) 66 | map.delete(key) 67 | } 68 | } 69 | 70 | // update index and add new items 71 | items.map((item, i) => { 72 | const key = ids[i] 73 | if (map.has(key)) { 74 | const { 75 | index: [, setIndex], 76 | child, 77 | } = map.get(key)! 78 | setIndex(i) 79 | if ([...fragment].some((ch) => ch === child)) { 80 | console.warn(`duplicate keys found: ${key}`) 81 | } else { 82 | fragment.append(child) 83 | } 84 | } else { 85 | const [index, setIndex] = createState(i) 86 | const scope = new Scope(currentScope) 87 | const child = scope.run(() => mkChild(item, index)) 88 | map.set(key, { item, child, index: [index, setIndex], scope }) 89 | fragment.append(child) 90 | } 91 | }) 92 | } 93 | 94 | createEffect(() => callback(each()), { immediate: true }) 95 | 96 | onCleanup(() => { 97 | for (const value of map.values()) { 98 | remove(value) 99 | } 100 | 101 | map.clear() 102 | }) 103 | 104 | return fragment 105 | } 106 | -------------------------------------------------------------------------------- /src/gtk3/jsx-runtime.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=3.0" 2 | import { configue } from "../jsx/env.js" 3 | import { getType, onCleanup, Accessor, Fragment } from "../index.js" 4 | 5 | const dummyBuilder = new Gtk.Builder() 6 | 7 | const { intrinsicElements } = configue({ 8 | initProps(ctor, props) { 9 | props.visible ??= true 10 | if (ctor === Gtk.Stack) { 11 | const keys: Array> = [ 12 | "visibleChildName", 13 | "visible_child_name", 14 | ] 15 | return keys 16 | } 17 | }, 18 | setCss(object, css) { 19 | if (!(object instanceof Gtk.Widget)) { 20 | return console.warn(Error(`cannot set css on ${object}`)) 21 | } 22 | 23 | const ctx = object.get_style_context() 24 | let provider: Gtk.CssProvider 25 | 26 | const setter = (css: string) => { 27 | if (!css.includes("{") || !css.includes("}")) css = `* { ${css} }` 28 | 29 | if (provider) ctx.remove_provider(provider) 30 | 31 | provider = new Gtk.CssProvider() 32 | provider.load_from_data(new TextEncoder().encode(css)) 33 | ctx.add_provider(provider, Gtk.STYLE_PROVIDER_PRIORITY_USER) 34 | } 35 | 36 | if (css instanceof Accessor) { 37 | setter(css.get()) 38 | const dispose = css.subscribe(() => setter(css.get())) 39 | onCleanup(dispose) 40 | } else { 41 | setter(css) 42 | } 43 | }, 44 | setClass(object, className) { 45 | if (!(object instanceof Gtk.Widget)) { 46 | return console.warn(Error(`cannot set className on ${object}`)) 47 | } 48 | 49 | const ctx = object.get_style_context() 50 | const setter = (names: string) => { 51 | for (const name of ctx.list_classes()) { 52 | ctx.remove_class(name) 53 | } 54 | 55 | for (const name of names.split(/\s+/)) { 56 | ctx.add_class(name) 57 | } 58 | } 59 | 60 | if (className instanceof Accessor) { 61 | setter(className.get()) 62 | const dispose = className.subscribe(() => setter(className.get())) 63 | onCleanup(dispose) 64 | } else { 65 | setter(className) 66 | } 67 | }, 68 | textNode(text) { 69 | return new Gtk.Label({ label: text.toString(), visible: true }) 70 | }, 71 | removeChild(parent, child) { 72 | if (parent instanceof Gtk.Container && child instanceof Gtk.Widget) { 73 | return parent.remove(child) 74 | } 75 | 76 | throw Error(`cannot remove ${child} from ${parent}`) 77 | }, 78 | appendChild(parent, child) { 79 | if ( 80 | child instanceof Gtk.Adjustment && 81 | "set_adjustment" in parent && 82 | typeof parent.set_adjustment === "function" 83 | ) { 84 | return parent.set_adjustment(child) 85 | } 86 | 87 | if ( 88 | child instanceof Gtk.Widget && 89 | parent instanceof Gtk.Stack && 90 | child.name !== "" && 91 | child.name !== null && 92 | getType(child) === "named" 93 | ) { 94 | return parent.add_named(child, child.name) 95 | } 96 | 97 | if (child instanceof Gtk.Window && parent instanceof Gtk.Application) { 98 | return parent.add_window(child) 99 | } 100 | 101 | if (child instanceof Gtk.TextBuffer && parent instanceof Gtk.TextView) { 102 | return parent.set_buffer(child) 103 | } 104 | 105 | if (parent instanceof Gtk.Buildable) { 106 | return parent.vfunc_add_child(dummyBuilder, child, getType(child)) 107 | } 108 | 109 | throw Error(`cannot add ${child} to ${parent}`) 110 | }, 111 | defaultCleanup(object) { 112 | if (object instanceof Gtk.Widget) { 113 | object.destroy() 114 | } 115 | }, 116 | }) 117 | 118 | export { Fragment, intrinsicElements } 119 | export { jsx, jsxs } from "../jsx/jsx.js" 120 | -------------------------------------------------------------------------------- /docs/tutorial/intro.md: -------------------------------------------------------------------------------- 1 | # Intro 2 | 3 | This tutorial will walk you through creating a Gtk4 application from scratch 4 | using Gnim. Before jumping in, you are expected to know 5 | [TypeScript](https://learnxinyminutes.com/typescript/) or at least JavaScript. 6 | 7 | ## JavaScript Runtime 8 | 9 | The JavaScript runtime Gnim uses is [GJS](https://gitlab.gnome.org/GNOME/gjs). 10 | It is built on Firefox's SpiderMonkey JavaScript engine and the GNOME platform 11 | libraries. 12 | 13 | > [!IMPORTANT] 14 | > 15 | > GJS is **not** Node, **not** Deno, and **not** Bun. GJS does not implement 16 | > some common Web APIs you might be used to from these other runtimes such as 17 | > `fetch`. The standard library of GJS comes from 18 | > [`GLib`](https://docs.gtk.org/glib/), [`Gio`](https://docs.gtk.org/gio//) and 19 | > [`GObject`](https://docs.gtk.org/gobject/) which are libraries written in C 20 | > and exposed to GJS through 21 | > [FFI](https://en.wikipedia.org/wiki/Foreign_function_interface) using 22 | > [GObject Introspection](https://gi.readthedocs.io/en/latest/) 23 | 24 | ## Development Environment 25 | 26 | For setting up a development environment you will need the following 27 | dependencies installed: 28 | 29 | - gjs 30 | - gtk4 31 | - JavaScript package manager of your choice 32 | 33 | ::: code-group 34 | 35 | ```sh [Arch] 36 | sudo pacman -Syu gjs gtk4 npm 37 | ``` 38 | 39 | ```sh [Fedora] 40 | sudo dnf install gjs-devel gtk4-devel npm 41 | ``` 42 | 43 | ```sh [Ubuntu] 44 | sudo apt install libgjs-dev libgtk-3-dev npm 45 | ``` 46 | 47 | ```nix [Nix] 48 | # flake.nix 49 | { 50 | inputs.nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; 51 | 52 | outputs = { 53 | self, 54 | nixpkgs, 55 | }: let 56 | forAllSystems = nixpkgs.lib.genAttrs ["x86_64-linux" "aarch64-linux"]; 57 | in { 58 | devShells = forAllSystems (system: let 59 | pkgs = nixpkgs.legacyPackages.${system}; 60 | in { 61 | # enter this shell using `nix develop` 62 | default = pkgs.mkShell { 63 | packages = with pkgs; [ 64 | gobject-introspection 65 | glib 66 | nodePackages.npm 67 | gtk4 68 | gjs 69 | ]; 70 | }; 71 | }); 72 | }; 73 | } 74 | ``` 75 | 76 | ::: 77 | 78 | Since GJS does not support `node_modules` we have to use a bundler. For this 79 | tutorial we will use `esbuild` which you can either install using your system 80 | package manager or `npm`. You also have to configure `tsconfig.json` which will 81 | tell the bundler about the environment and JSX runtime. 82 | 83 | 1. init a directory 84 | 85 | ```sh 86 | mkdir gnim-app 87 | cd gnim-app 88 | npm install gnim 89 | npm install typescript esbuild @girs/gtk-4.0 @girs/gjs -D 90 | ``` 91 | 92 | 2. configure `tsconfig.json` 93 | 94 | ```json 95 | { 96 | "compilerOptions": { 97 | "target": "ES2020", 98 | "module": "ES2022", 99 | "lib": ["ES2024"], 100 | "outDir": "dist", 101 | "strict": true, 102 | "moduleResolution": "Bundler", 103 | "skipLibCheck": true, 104 | "jsx": "react-jsx", 105 | "jsxImportSource": "gnim/gtk4" 106 | } 107 | } 108 | ``` 109 | 110 | 3. by convention, source files go in the `src` directory 111 | 112 | ```sh 113 | mkdir src 114 | ``` 115 | 116 | 4. create an `env.d.ts` file 117 | 118 | ```ts 119 | import "@girs/gtk-4.0" 120 | import "@girs/gjs" 121 | import "@girs/gjs/dom" 122 | ``` 123 | 124 | 5. create the entry point 125 | 126 | ```ts 127 | console.log("hello world") 128 | ``` 129 | 130 | 6. write a build script 131 | 132 | ```sh 133 | # scripts/build.sh 134 | esbuild --bundle src/main.ts \ 135 | --outdir=dist \ 136 | --external:gi://* \ 137 | --external:resource://* \ 138 | --external:system \ 139 | --external:gettext \ 140 | --format=esm \ 141 | --sourcemap=inline 142 | ``` 143 | 144 | Finally, your project structure should like like this: 145 | 146 | ```txt 147 | . 148 | ├── node_modules 149 | ├── package-lock.json 150 | ├── package.json 151 | ├── scripts 152 | │ └── build.sh 153 | ├── src 154 | │ ├── env.d.ts 155 | │ └── main.ts 156 | └── tsconfig.json 157 | ``` 158 | 159 | To make running the project easier you can add a `dev` script in `package.json`. 160 | 161 | ```json 162 | { 163 | "scripts": { 164 | "dev": "bash scripts/build.sh ; gjs -m dist/main.js" 165 | }, 166 | "dependencies": {}, 167 | "devDependencies": {} 168 | } 169 | ``` 170 | 171 | Running the project then will consist of this short command: 172 | 173 | ```sh 174 | npm run dev 175 | ``` 176 | -------------------------------------------------------------------------------- /src/jsx/scope.ts: -------------------------------------------------------------------------------- 1 | export class Scope { 2 | static current?: Scope | null 3 | 4 | parent?: Scope | null 5 | contexts = new Map() 6 | 7 | private cleanups = new Set<() => void>() 8 | private mounts = new Set<() => void>() 9 | private mounted = false 10 | 11 | constructor(parent?: Scope | null) { 12 | this.parent = parent 13 | } 14 | 15 | onCleanup(callback: () => void) { 16 | this.cleanups?.add(callback) 17 | } 18 | 19 | onMount(callback: () => void) { 20 | if (this.parent && !this.parent.mounted) { 21 | this.parent.onMount(callback) 22 | } else { 23 | this.mounts.add(callback) 24 | } 25 | } 26 | 27 | run(fn: () => T) { 28 | const prev = Scope.current 29 | Scope.current = this 30 | 31 | try { 32 | return fn() 33 | } finally { 34 | this.mounts.forEach((cb) => cb()) 35 | this.mounts.clear() 36 | this.mounted = true 37 | Scope.current = prev 38 | } 39 | } 40 | 41 | dispose() { 42 | this.cleanups.forEach((cb) => cb()) 43 | this.cleanups.clear() 44 | this.contexts.clear() 45 | delete this.parent 46 | } 47 | } 48 | 49 | export type Context = { 50 | use(): T 51 | provide(value: T, fn: () => R): R 52 | (props: { value: T; children: () => JSX.Element }): JSX.Element 53 | } 54 | 55 | /** 56 | * Example Usage: 57 | * ```tsx 58 | * const MyContext = createContext("fallback-value") 59 | * 60 | * function ConsumerComponent() { 61 | * const value = MyContext.use() 62 | * 63 | * return 64 | * } 65 | * 66 | * function ProviderComponent() { 67 | * return ( 68 | * 69 | * 70 | * {() => } 71 | * 72 | * 73 | * ) 74 | * } 75 | * ``` 76 | */ 77 | export function createContext(defaultValue: T): Context { 78 | let ctx: Context 79 | 80 | function provide(value: T, fn: () => R): R { 81 | const scope = getScope() 82 | scope.contexts.set(ctx, value) 83 | return scope.run(fn) 84 | } 85 | 86 | function use(): T { 87 | let scope = Scope.current 88 | while (scope) { 89 | const value = scope.contexts.get(ctx) 90 | if (value !== undefined) return value as T 91 | scope = scope.parent 92 | } 93 | return defaultValue 94 | } 95 | 96 | function context({ value, children }: { value: T; children: () => JSX.Element }) { 97 | return provide(value, children) 98 | } 99 | 100 | return (ctx = Object.assign(context, { 101 | provide, 102 | use, 103 | })) 104 | } 105 | 106 | /** 107 | * Gets the scope that owns the currently running code. 108 | * 109 | * Example: 110 | * ```ts 111 | * const scope = getScope() 112 | * setTimeout(() => { 113 | * // This callback gets run without an owner scope. 114 | * // Restore owner via scope.run: 115 | * scope.run(() => { 116 | * const foo = FooContext.use() 117 | * onCleanup(() => { 118 | * print("some cleanup") 119 | * }) 120 | * }) 121 | * }, 1000) 122 | * ``` 123 | */ 124 | export function getScope(): Scope { 125 | const scope = Scope.current 126 | if (!scope) { 127 | throw Error("cannot get scope: out of tracking context") 128 | } 129 | 130 | return scope 131 | } 132 | 133 | /** 134 | * Attach a cleanup callback to the current {@link Scope}. 135 | */ 136 | export function onCleanup(cleanup: () => void) { 137 | if (!Scope.current) { 138 | console.error(Error("out of tracking context: will not be able to cleanup")) 139 | } 140 | 141 | Scope.current?.onCleanup(cleanup) 142 | } 143 | 144 | /** 145 | * Attach a callback to run when the currently running {@link Scope} returns. 146 | */ 147 | export function onMount(cleanup: () => void) { 148 | if (!Scope.current) { 149 | console.error(Error("cannot attach onMount: out of tracking context")) 150 | } 151 | 152 | Scope.current?.onMount(cleanup) 153 | } 154 | 155 | /** 156 | * Creates a root {@link Scope} that when disposed will remove 157 | * any child signal handler or state subscriber. 158 | * 159 | * Example: 160 | * ```tsx 161 | * createRoot((dispose) => { 162 | * let root: Gtk.Window 163 | * 164 | * const [state] = createState("value") 165 | * 166 | * const remove = () => { 167 | * root.destroy() 168 | * dispose() 169 | * } 170 | * 171 | * return ( 172 | * (root = self)}> 173 | * 174 | * 175 | * 176 | * 177 | * 178 | * ) 179 | * }) 180 | * ``` 181 | */ 182 | export function createRoot(fn: (dispose: () => void) => T) { 183 | const scope = new Scope(null) 184 | return scope.run(() => fn(() => scope.dispose())) 185 | } 186 | -------------------------------------------------------------------------------- /docs/tutorial/gtk.md: -------------------------------------------------------------------------------- 1 | # Gtk 2 | 3 | This page is merely an intro to Gtk and not a comprehensive guide. For more 4 | in-depth concepts you can read the [Gtk docs](https://docs.gtk.org/gtk4/#extra). 5 | 6 | ## Running Gtk 7 | 8 | To run Gtk you will have to initialize it, create widgets and run a GLib main 9 | loop. 10 | 11 | ```ts 12 | import GLib from "gi://GLib" 13 | import Gtk from "gi://Gtk?version=4.0" 14 | 15 | Gtk.init() 16 | 17 | const loop = GLib.MainLoop.new(null, false) 18 | 19 | // create widgets here 20 | 21 | loop.runAsync() 22 | ``` 23 | 24 | ## Your first widget 25 | 26 | For a list of available widgets you can refer to the 27 | [Gtk docs](https://docs.gtk.org/gtk4/visual_index.html). If you are planning to 28 | write an app for the Gnome platform you might be interested in using 29 | [Adwaita](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/). 30 | 31 | The top level widget that makes it possible to display something on the screen 32 | is `Gtk.Window` and its various subclasses such as `Gtk.ApplicationWindow` and 33 | `Adw.Window`. 34 | 35 | ```ts 36 | const win = new Gtk.Window({ 37 | defaultWidth: 300, 38 | defaultHeight: 200, 39 | title: "My App", 40 | }) 41 | 42 | const titlebar = new Gtk.HeaderBar() 43 | 44 | const label = new Gtk.Label({ 45 | label: "Hello World", 46 | }) 47 | 48 | win.set_titlebar(titlebar) 49 | win.set_child(label) 50 | 51 | win.connect("close-request", () => loop.quit()) 52 | 53 | win.present() 54 | ``` 55 | 56 | ## Layout system 57 | 58 | Gtk uses [LayoutManagers](https://docs.gtk.org/gtk4/class.LayoutManager.html) to 59 | decide how a widget positions its children. You will only directly interact with 60 | layout managers when implementing a custom widget. Gtk provides widgets that 61 | implement some common layouts: 62 | 63 | - [`Box`](https://docs.gtk.org/gtk4/class.Box.html) which positions its children 64 | in a horizontal/vertical row. 65 | 66 | ```ts 67 | const box = new Gtk.Box({ 68 | orientation: Gtk.Orientation.HORIZONTAL, 69 | }) 70 | 71 | box.append(Gtk.Label.new("1")) 72 | box.append(Gtk.Label.new("2")) 73 | ``` 74 | 75 | - [`CenterBox`](https://docs.gtk.org/gtk4/class.CenterBox.html) which positions 76 | its children in three separate sections similar to `Box` 77 | 78 | ```ts 79 | const centerBox = new Gtk.CenterBox({ 80 | orientation: Gtk.Orientation.HORIZONTAL, 81 | }) 82 | 83 | centerBox.set_start_widget(Gtk.Label.new("start")) 84 | centerBox.set_center_widget(Gtk.Label.new("center")) 85 | centerBox.set_end_widget(Gtk.Label.new("end")) 86 | ``` 87 | 88 | - [`Overlay`](https://docs.gtk.org/gtk4/class.Overlay.html) which has a single 89 | child that dictates the size of the widget and positions each children on top. 90 | 91 | ```ts 92 | const overlay = new Gtk.Overlay() 93 | 94 | overlay.set_child(Gtk.Label.new("main child")) 95 | overlay.add_overlay(Gtk.Label.new("overlay")) 96 | ``` 97 | 98 | - [`Grid`](https://docs.gtk.org/gtk4/class.Grid.html) which positions its 99 | children in a table layout. 100 | 101 | ```ts 102 | const grid = new Gtk.Grid() 103 | 104 | grid.attach(Gtk.Label.new("0x0"), 0, 0, 1, 1) 105 | grid.attach(Gtk.Label.new("0x1"), 0, 1, 1, 1) 106 | ``` 107 | 108 | - [`Stack`](https://docs.gtk.org/gtk4/class.Stack.html) which displays only one 109 | of its children at once and lets you animate between them. 110 | 111 | ```ts 112 | const stack = new Gtk.Stack() 113 | 114 | stack.add_named(Gtk.Label.new("1"), "1") 115 | stack.add_named(Gtk.Label.new("2"), "2") 116 | 117 | stack.set_visible_child_name("2") 118 | ``` 119 | 120 | - [`ScrolledWindow`](https://docs.gtk.org/gtk4/class.ScrolledWindow.html) 121 | displays a single child in a viewport and adds scrollbars so that the whole 122 | widget can be displayed. 123 | 124 | ## Events 125 | 126 | Gtk uses event controllers that you can assign to widgets that handle user 127 | input. You can read more about event controllers on 128 | [Gtk docs](https://docs.gtk.org/gtk4/input-handling.html#event-controllers-and-gestures). 129 | 130 | Some common controllers: 131 | 132 | - [EventControllerFocus](https://docs.gtk.org/gtk4/class.EventControllerFocus.html) 133 | - [EventControllerKey](https://docs.gtk.org/gtk4/class.EventControllerKey.html) 134 | - [EventControllerMotion](https://docs.gtk.org/gtk4/class.EventControllerMotion.html) 135 | - [EventControllerScroll](https://docs.gtk.org/gtk4/class.EventControllerScroll.html) 136 | - [GestureClick](https://docs.gtk.org/gtk4/class.GestureClick.html) 137 | - [GestureDrag](https://docs.gtk.org/gtk4/class.GestureDrag.html) 138 | - [GestureSwipe](https://docs.gtk.org/gtk4/class.GestureDrag.html) 139 | 140 | ```ts 141 | let widget: Gtk.Widget 142 | 143 | const gestureClick = new Gtk.GestureClick({ 144 | propagationPhase: Gtk.PropagationPhase.BUBBLE, 145 | }) 146 | 147 | gestureClick.connect("pressed", () => { 148 | console.log("clicked") 149 | return true 150 | }) 151 | 152 | widget.add_controller(gestureClick) 153 | ``` 154 | 155 | Gtk provides widgets for various forms of user input so you might not need an 156 | event controller. 157 | 158 | - [`Button`](https://docs.gtk.org/gtk4/class.Button.html) 159 | - [`Switch`](https://docs.gtk.org/gtk4/class.Switch.html) 160 | - [`Scale`](https://docs.gtk.org/gtk4/class.Scale.html) 161 | - [`Entry`](https://docs.gtk.org/gtk4/class.Entry.html) 162 | -------------------------------------------------------------------------------- /src/gtk4/jsx-runtime.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0" 2 | import Gio from "gi://Gio?version=2.0" 3 | import { configue } from "../jsx/env.js" 4 | import { getType, onCleanup, Accessor, Fragment } from "../index.js" 5 | 6 | import type Adw from "gi://Adw" 7 | const adw = await import("gi://Adw").then((m) => m.default).catch(() => null) 8 | 9 | const dummyBuilder = new Gtk.Builder() 10 | 11 | const { intrinsicElements } = configue({ 12 | initProps(ctor) { 13 | if (ctor === Gtk.Stack) { 14 | const keys: Array> = [ 15 | "visibleChildName", 16 | "visible_child_name", 17 | ] 18 | return keys 19 | } 20 | if (adw && ctor === adw.ToggleGroup) { 21 | const keys: Array> = [ 22 | "active", 23 | "activeName", 24 | "active_name", 25 | ] 26 | return keys 27 | } 28 | }, 29 | setCss(object, css) { 30 | if (!(object instanceof Gtk.Widget)) { 31 | return console.warn(Error(`cannot set css on ${object}`)) 32 | } 33 | 34 | const ctx = object.get_style_context() 35 | let provider: Gtk.CssProvider 36 | 37 | const setter = (css: string) => { 38 | if (!css.includes("{") || !css.includes("}")) { 39 | css = `* { ${css} }` 40 | } 41 | 42 | if (provider) ctx.remove_provider(provider) 43 | 44 | provider = new Gtk.CssProvider() 45 | provider.load_from_string(css) 46 | ctx.add_provider(provider, Gtk.STYLE_PROVIDER_PRIORITY_USER) 47 | } 48 | 49 | if (css instanceof Accessor) { 50 | setter(css.get()) 51 | const dispose = css.subscribe(() => setter(css.get())) 52 | onCleanup(dispose) 53 | } else { 54 | setter(css) 55 | } 56 | }, 57 | 58 | setClass(object, className) { 59 | if (!(object instanceof Gtk.Widget)) { 60 | return console.warn(Error(`cannot set className on ${object}`)) 61 | } 62 | 63 | if (className instanceof Accessor) { 64 | object.cssClasses = className.get().split(/\s+/) 65 | const dispose = className.subscribe( 66 | () => (object.cssClasses = className.get().split(/\s+/)), 67 | ) 68 | onCleanup(dispose) 69 | } else { 70 | object.set_css_classes(className.split(/\s+/)) 71 | } 72 | }, 73 | 74 | textNode(text) { 75 | return Gtk.Label.new(text.toString()) 76 | }, 77 | 78 | // `set_child` and especially `remove` might be way too generic and there might 79 | // be cases where it does not actually do what we want it to do 80 | // 81 | // if there is a usecase for either of these two that does something else than 82 | // we expect it to do here in a JSX context we have to check for known instances 83 | removeChild(parent, child) { 84 | if (parent instanceof Gtk.Widget && child instanceof Gtk.EventController) { 85 | return parent.remove_controller(child) 86 | } 87 | 88 | if ("set_child" in parent && typeof parent.set_child == "function") { 89 | return parent.set_child(null) 90 | } 91 | 92 | if ("remove" in parent && typeof parent.remove == "function") { 93 | return parent.remove(child) 94 | } 95 | 96 | throw Error(`cannot remove ${child} from ${parent}`) 97 | }, 98 | appendChild(parent, child) { 99 | if ( 100 | child instanceof Gtk.Adjustment && 101 | "set_adjustment" in parent && 102 | typeof parent.set_adjustment === "function" 103 | ) { 104 | return parent.set_adjustment(child) 105 | } 106 | 107 | if ( 108 | child instanceof Gtk.Widget && 109 | parent instanceof Gtk.Stack && 110 | child.name !== "" && 111 | child.name !== null && 112 | getType(child) === "named" 113 | ) { 114 | return parent.add_named(child, child.name) 115 | } 116 | 117 | if (child instanceof Gtk.Popover && parent instanceof Gtk.MenuButton) { 118 | return parent.set_popover(child) 119 | } 120 | 121 | if ( 122 | child instanceof Gio.MenuModel && 123 | (parent instanceof Gtk.MenuButton || parent instanceof Gtk.PopoverMenu) 124 | ) { 125 | return parent.set_menu_model(child) 126 | } 127 | 128 | if (child instanceof Gio.MenuItem && parent instanceof Gio.Menu) { 129 | // TODO: 130 | } 131 | 132 | if (child instanceof Gtk.Window && parent instanceof Gtk.Application) { 133 | return parent.add_window(child) 134 | } 135 | 136 | if (child instanceof Gtk.TextBuffer && parent instanceof Gtk.TextView) { 137 | return parent.set_buffer(child) 138 | } 139 | 140 | if (parent instanceof Gtk.Buildable) { 141 | return parent.vfunc_add_child(dummyBuilder, child, getType(child)) 142 | } 143 | 144 | throw Error(`cannot add ${child} to ${parent}`) 145 | }, 146 | }) 147 | 148 | export { Fragment, intrinsicElements } 149 | export { jsx, jsxs } from "../jsx/jsx.js" 150 | -------------------------------------------------------------------------------- /docs/tutorial/gobject.md: -------------------------------------------------------------------------------- 1 | # GObject 2 | 3 | Before jumping into Gtk, you have to understand a few concepts about 4 | `GObject.Object` which is the base type everything inherits from. 5 | 6 | ## GObject Construction 7 | 8 | ::: tip 9 | 10 | In rare cases, like the `Gio.File` interface, objects can not be constructed 11 | with the `new` operator and a constructor method must always be used. 12 | 13 | ::: 14 | 15 | The most common way to create a GObject instance is using the `new` operator. 16 | When constructing a GObject this way, you can pass a dictionary of properties: 17 | 18 | ```ts 19 | const labelWidget = new Gtk.Label({ 20 | label: "Text", 21 | useMarkup: true, 22 | }) 23 | ``` 24 | 25 | Many classes also have static constructor methods you can use directly: 26 | 27 | ```ts 28 | const labelWidget = Gtk.Label.new("Text") 29 | ``` 30 | 31 | ## Signals 32 | 33 | GObjects support a signaling system, similar to events and EventListeners in the 34 | JavaScript Web API. Here we will cover the basics of connecting and 35 | disconnecting signals, as well as using callbacks. 36 | 37 | ### Connecting Signals 38 | 39 | Signals are connected by calling `GObject.Object.prototype.connect()`, which 40 | returns a handler ID. Signals are disconnected by passing that ID to 41 | `GObject.Object.prototype.disconnect()`: 42 | 43 | ```ts 44 | const button = new Gtk.Button({ label: "Lorem Ipsum" }) 45 | 46 | // Connecting a signal 47 | const handlerId = button.connect("clicked", () => { 48 | console.log("Button clicked!") 49 | }) 50 | 51 | // Disconnecting a signal 52 | button.disconnect(handlerId) 53 | ``` 54 | 55 | ### Callback Arguments 56 | 57 | Signals often have multiple callback arguments, but the first is always the 58 | emitting object: 59 | 60 | ```ts 61 | const selectLabel = Gtk.Label.new("") 62 | 63 | selectLabel.connect("move-cursor", (label, step, count, extendSelection) => { 64 | if (label === selectLabel) { 65 | console.log("selectLabel emitted the signal!") 66 | } 67 | 68 | if (step === Gtk.MovementStep.WORDS) { 69 | console.log(`The cursor was moved ${count} word(s)`) 70 | } 71 | 72 | if (extendSelection) { 73 | console.log("The selection was extended") 74 | } 75 | }) 76 | ``` 77 | 78 | ### Callback Return Values 79 | 80 | ::: warning 81 | 82 | A callback with no return value will implicitly return `undefined`, while an 83 | `async` function will implicitly return a `Promise`. 84 | 85 | ::: 86 | 87 | Some signals expect a return value, usually a `boolean`. The type and behavior 88 | of the return value will be described in the documentation for the signal. 89 | 90 | ```ts 91 | const linkLabel = new Gtk.Label({ 92 | label: 'GNOME', 93 | use_markup: true, 94 | }) 95 | 96 | linkLabel.connect("activate-link", (label, uri) => { 97 | if (uri.startsWith("file://")) { 98 | console.log(`Ignoring ${uri}`) 99 | return true 100 | } 101 | 102 | return false 103 | }) 104 | ``` 105 | 106 | Using an `async` function as a signal handler will return an implicit `Promise`, 107 | which will be coerced to a _truthy_ value. If necessary, use a traditional 108 | `Promise` chain and return the expected value type explicitly. 109 | 110 | ```ts 111 | linkLabel.connect("activate-link", (label, uri) => { 112 | // Do something asynchronous with the signal arguments 113 | Promise.resolve(uri).catch(console.error) 114 | 115 | return true 116 | }) 117 | ``` 118 | 119 | ## Properties 120 | 121 | GObject supports a property system that is slightly more powerful than native 122 | JavaScript properties. 123 | 124 | ### Accessing Properties 125 | 126 | GObject properties may be retrieved and set using native property style access 127 | or GObject get and set methods. 128 | 129 | ```ts 130 | const invisibleLabel = new Gtk.Label({ 131 | visible: false, 132 | }) 133 | let visible 134 | 135 | // Three different ways to get or set properties 136 | visible = invisibleLabel.visible 137 | visible = invisibleLabel["visible"] 138 | visible = invisibleLabel.get_visible() 139 | 140 | invisibleLabel.visible = false 141 | invisibleLabel["visible"] = false 142 | invisibleLabel.set_visible(false) 143 | ``` 144 | 145 | GObject property names have a canonical form that is `kebab-cased`, however they 146 | are accessed differently depending on the situation: 147 | 148 | ```ts 149 | const markupLabel = new Gtk.Label({ 150 | label: "Italics", 151 | use_markup: true, 152 | }) 153 | let useMarkup 154 | 155 | // If using native accessors, you can use `underscore_case` or `camelCase` 156 | useMarkup = markupLabel.use_markup 157 | useMarkup = markupLabel.useMarkup 158 | 159 | // Anywhere the property name is a string, you must use `kebab-case` 160 | markupLabel["use-markup"] = true 161 | markupLabel.connect("notify::use-markup", () => {}) 162 | 163 | // Getter and setter functions are always case sensitive 164 | useMarkup = markupLabel.get_use_markup() 165 | markupLabel.set_use_markup(true) 166 | ``` 167 | 168 | ### Property Change Notification 169 | 170 | Most GObject properties will emit 171 | [`GObject.Object::notify`](https://gjs-docs.gnome.org/gobject20/gobject.object#signals-notify) 172 | signal when the value is changed. You can connect to this signal in the form of 173 | `notify::property-name` to invoke a callback when it changes: 174 | 175 | ```ts 176 | const changingLabel = Gtk.Label.new("Original Label") 177 | 178 | const labelId = changingLabel.connect("notify::label", (object, _pspec) => { 179 | console.log(`New label is "${object.label}"`) 180 | }) 181 | ``` 182 | -------------------------------------------------------------------------------- /docs/dbus.md: -------------------------------------------------------------------------------- 1 | # DBus decorators 2 | 3 | These decorators let you use classes to define a DBus interface and use them as 4 | proxies or servers. 5 | 6 | Read more about using DBus in GJS on 7 | [gjs.guide](https://gjs.guide/guides/gio/dbus.html). 8 | 9 | > [!INFO] Required TypeScript settings 10 | > 11 | > Make sure `experimentalDecorators` is set to `false` and `target` is _less 12 | > than or equal_ to `ES2020` in `tsconfig.json`. 13 | > 14 | > ```json 15 | > { 16 | > "compilerOptions": { 17 | > "experimentalDecorators": false, 18 | > "target": "ES2020" 19 | > } 20 | > } 21 | > ``` 22 | 23 | ```ts 24 | import { Service, iface, methodAsync, signal, property } from "gnim/dbus" 25 | 26 | @iface("example.gjs.MyService") 27 | export class MyService extends Service { 28 | @property("s") MyProperty = "" 29 | 30 | @methodAsync(["s"], ["s"]) 31 | async MyMethod(str: string): Promise<[string]> { 32 | this.MySignal(str) 33 | return [str] 34 | } 35 | 36 | @signal("s") 37 | MySignal(str: string) {} 38 | } 39 | ``` 40 | 41 | > [!NOTE] 42 | > 43 | > Optionally, you can declare the name of the arguments for DBus inspection by 44 | > passing a `{ name: string, type: string }` object as the parameter to the 45 | > decorators instead of just the type string. 46 | 47 | Use them as servers 48 | 49 | ```ts 50 | const service = await new MyService().serve() 51 | 52 | service.connect("my-signal", (_, str: string) => { 53 | console.log(`MySignal invoked with argument: "${str}"`) 54 | }) 55 | 56 | service.connect("notify::my-property", () => { 57 | console.log(`MyProperty set to ${service.MyProperty}`) 58 | }) 59 | ``` 60 | 61 | Use them as proxies 62 | 63 | ```ts 64 | const proxy = await new MyService().proxy() 65 | 66 | proxy.MyProperty = "new value" 67 | 68 | const value = await proxy.MyMethod("hello") 69 | console.log(value) // "hello" 70 | ``` 71 | 72 | ## `Service` 73 | 74 | Base class of every DBus service for both proxies and exported objects. Derived 75 | from `GObject.Object`. DBus signals are also GObject signals, and DBus 76 | properties are also GObject properties. 77 | 78 | ```ts 79 | import { Service, iface } from "gnim/dbus" 80 | 81 | @iface("example.gjs.MyService") 82 | class MyService extends Service {} 83 | ``` 84 | 85 | ### `serve` 86 | 87 | Attempt to own `name` and export this object at `objectPath` on `busType`. 88 | 89 | ```ts 90 | class Service { 91 | async serve(props: { 92 | busType?: Gio.BusType 93 | name?: string 94 | objectPath?: string 95 | flags?: Gio.BusNameOwnerFlags 96 | timeout?: number 97 | }): Promise 98 | } 99 | ``` 100 | 101 | ### `proxy` 102 | 103 | Attempt to proxy `name`'s object at `objectPath` on `busType`. 104 | 105 | ```ts 106 | class Service { 107 | async proxy(props: { 108 | bus?: Gio.DBusConnection 109 | name?: string 110 | objectPath?: string 111 | flags?: Gio.DBusProxyFlags 112 | timeout?: number 113 | }): Promise 114 | } 115 | ``` 116 | 117 | Method, signal and property access implementations are ignored. When acting as a 118 | proxy, they work over the remote object. 119 | 120 | Example 121 | 122 | ```ts 123 | @iface("some.dbus.interface") 124 | class MyProxy extends Service { 125 | @method() 126 | Method() { 127 | console.log("this is never invoked when working as a proxy") 128 | } 129 | } 130 | 131 | const proxy = await new MyProxy().proxy() 132 | 133 | proxy.Method() 134 | ``` 135 | 136 | ## `method` 137 | 138 | Registers a DBus method. 139 | 140 | ```ts 141 | type Arg = string | { name: string; type: string } 142 | 143 | function method(inArgs: Arg[], outArgs: Arg[]) 144 | 145 | function method(...inArgs: Arg[]) 146 | ``` 147 | 148 | Example 149 | 150 | ```ts 151 | class { 152 | @method("s", "i") 153 | Simple(arg0: string, arg1: number): void {} 154 | 155 | @method(["s", "i"], ["s"]) 156 | SimpleReturn(arg0: string, arg1: number): [string] { 157 | return ["return valule"] 158 | } 159 | } 160 | ``` 161 | 162 | > [!TIP] 163 | > 164 | > When writing an interface to be used as a proxy, prefer using 165 | > [methodAsync](./dbus#methodAsync) instead, as it does not block IO. 166 | 167 | ## `methodAsync` 168 | 169 | Async version of the `method` decorator, which is useful for proxies. 170 | 171 | ```ts 172 | type Arg = string | { name: string; type: string } 173 | 174 | function methodAsync(inArgs: Arg[], outArgs: Arg[]) 175 | 176 | function methodAsync(...inArgs: Arg[]) 177 | ``` 178 | 179 | Example 180 | 181 | ```ts 182 | class { 183 | @methodAsync("s", "i") 184 | async Simple(arg0: string, arg1: number): Promise {} 185 | 186 | @methodAsync(["s", "i"], ["s"]) 187 | async SimpleReturn(arg0: string, arg1: number): Promise<[string]> { 188 | return ["return valule"] 189 | } 190 | } 191 | ``` 192 | 193 | > [!NOTE] 194 | > 195 | > On exported objects, this is functionally the same as [method](./dbus#method). 196 | 197 | ## `property` 198 | 199 | Registers a property, similarly to the 200 | [gobject property](./gobject#property-decorator) decorator, except that it works 201 | over `Variant` types. 202 | 203 | ```ts 204 | function property(type: string) 205 | ``` 206 | 207 | ```ts 208 | class { 209 | @property("s") Value = "value" 210 | } 211 | ``` 212 | 213 | ## `getter` 214 | 215 | Registers a read-only property, similarly to the 216 | [gobject](./gobject#property-decorator) getter decorator. 217 | 218 | ```ts 219 | function getter(type: string) 220 | ``` 221 | 222 | ```ts 223 | class { 224 | @getter("s") 225 | get Value() { return "" } 226 | } 227 | ``` 228 | 229 | > [!TIP] 230 | > 231 | > Can be used in combination with the `setter` decorator to define read-write 232 | > properties. 233 | 234 | ## `setter` 235 | 236 | Registers a write-only property, similarly to the 237 | [gobject](./gobject#property-decorator) setter decorator. 238 | 239 | ```ts 240 | function setter(type: string) 241 | ``` 242 | 243 | ```ts 244 | class { 245 | @setter("s") 246 | set Value(value: string) { } 247 | } 248 | ``` 249 | 250 | > [!TIP] 251 | > 252 | > Can be used in combination with the `getter` decorator to define read-write 253 | > properties. 254 | 255 | ## `signal` 256 | 257 | Registers a DBus signal. 258 | 259 | ```ts 260 | type Param = string | { name: string; type: string } 261 | 262 | function method(...parameters: Param[]) 263 | ``` 264 | 265 | Example 266 | 267 | ```ts 268 | class { 269 | @signal("s", "i") 270 | MySignal(arg0: string, arg1: number) {} 271 | } 272 | ``` 273 | -------------------------------------------------------------------------------- /docs/tutorial/app.md: -------------------------------------------------------------------------------- 1 | # Writing an Application 2 | 3 | So far this tutorial used a simple `GLib.MainLoop` to display Gtk Widgets which 4 | works, but it does not let you integrate your app into the desktop. No way to 5 | name your app and launching the script will simply open a new window. This is 6 | where `Gtk.Application` comes in, which does most of the heavy lifting. 7 | 8 | > [!TIP] 9 | > 10 | > In case you are writing an Adwiata application you want to use 11 | > `Adw.Application`. 12 | 13 | ## `Gtk.Application` 14 | 15 | To use `Gtk.Application`, you can either create an instance and connect signal 16 | handlers, or create a subclass and implement its methods. 17 | 18 | ::: code-group 19 | 20 | ```ts [Subclassing] 21 | import Gtk from "gi://Gtk" 22 | import Gio from "gi://Gio" 23 | import { register } from "./gobject" 24 | import { createRoot } from "./jsx/scope" 25 | import { programInvocationName, programArgs } from "system" 26 | 27 | @register() 28 | class MyApp extends Gtk.Application { 29 | constructor() { 30 | super({ 31 | applicationId: "my.awesome.app", 32 | flags: Gio.ApplicationFlags.FLAGS_NONE, 33 | }) 34 | } 35 | 36 | vfunc_activate(): void { 37 | createRoot((dispose) => { 38 | this.connect("shutdown", dispose) 39 | // show windows here 40 | }) 41 | } 42 | } 43 | 44 | export const app = new MyApp() 45 | app.runAsync([programInvocationName, ...programArgs]) 46 | ``` 47 | 48 | ```ts [Without subclassing] 49 | import Gtk from "gi://Gtk" 50 | import Gio from "gi://Gio" 51 | import { createRoot } from "./jsx/scope" 52 | import { programInvocationName, programArgs } from "system" 53 | 54 | export const app = new Gtk.Application({ 55 | applicationId: "my.awesome.app", 56 | flags: Gio.ApplicationFlags.NON_UNIQUE, 57 | }) 58 | 59 | app.connect("activate", () => { 60 | createRoot((dispose) => { 61 | app.connect("shutdown", dispose) 62 | // show windows here 63 | }) 64 | }) 65 | 66 | app.runAsync([programInvocationName, ...programArgs]) 67 | ``` 68 | 69 | ::: 70 | 71 | The main benefit of using an application is that in most cases you want a single 72 | instance of your app running and every subsequent invocation to do something on 73 | this main instance. For example, when your app is already running, and the user 74 | clicks on the app icon in a status panel/dock you want your window to reappear 75 | on screen instead of launching another instance. 76 | 77 | ```tsx 78 | class MyApp extends Gtk.Application { 79 | declare window?: Gtk.Window 80 | 81 | vfunc_activate(): void { 82 | if (this.window) { 83 | return this.window.present() 84 | } 85 | 86 | createRoot((dispose) => { 87 | this.connect("shutdown", dispose) 88 | 89 | return (this.window = self).present()} /> 90 | }) 91 | } 92 | } 93 | ``` 94 | 95 | ## Application Settings 96 | 97 | If you want to persist some data, for example some setting values, Gtk provides 98 | you the [Gio.Settings](https://docs.gtk.org/gio/class.Settings.html) API which 99 | is a way to store key value pairs in a predefined schema. 100 | 101 | First you have to define a schema in XML format named `.gschema.xml` so in 102 | our case `my.awesome.app.gschema.xml`. 103 | 104 | ```xml 105 | 106 | 107 | 108 | 'default value in gvariant serialized format' 109 | 110 | 111 | 112 | 118 | 119 | 120 | 121 | 122 | ``` 123 | 124 | Then you have to install it to `//glib-2.0/schemas` which is 125 | usually `/usr/share/glib-2.0/schemas`. As a last step you have to compile it 126 | before writing/reading it. 127 | 128 | ```sh 129 | cp my.awesome.app.gschema.xml /usr/share/glib-2.0/schemas 130 | glib-compile-schemas /usr/share/glib-2.0/schemas 131 | ``` 132 | 133 | > [!TIP] 134 | > 135 | > You usually don't install it manually. Instead, you do it as part of your 136 | > build and install phase using a build tool such as meson as shown in the 137 | > [packaging](./packaging) section. 138 | 139 | You can then create a `Gio.Settings` and optionally wrap it in a 140 | [`createSettings`](../jsx#createsettings). 141 | 142 | ```ts 143 | const settings = new Gio.Settings({ schemaId: "my.awesome.app" }) 144 | 145 | const { simpleString, setSimpleString } = createSettings(settings, { 146 | "simple-string": "s", 147 | "string-dictionary": "a{ss}", 148 | }) 149 | 150 | console.log(simpleString.get()) 151 | setSimpleString("new value") 152 | ``` 153 | 154 | ## Exposing a D-Bus interface 155 | 156 | If you want other apps or processes to communicate with your application, the 157 | standard way to do IPC on Linux is via D-Bus. Gnim offers a convenient 158 | [decorator API](../dbus) that lets you easily implement interfaces for your app 159 | through D-Bus. 160 | 161 | At a very high level, D-Bus lets you export _objects_ that have _interfaces_ on 162 | a system bus, identified by a _name_. 163 | 164 | You can read more about D-Bus in detail on 165 | [freedesktop.org](https://www.freedesktop.org/wiki/Software/dbus/) or check out 166 | [gjs.guide](https://gjs.guide/guides/gio/dbus.html), which covers it at a 167 | slightly lower level. 168 | 169 | > [!TIP] 170 | > 171 | > Use [D-Spy](https://flathub.org/apps/org.gnome.dspy) to introspect D-Bus on 172 | > your desktop. 173 | 174 | First define an interface. 175 | 176 | ```ts 177 | import { Service, iface, method } from "gnim/dbus" 178 | 179 | @iface("my.awesome.app.MyService") 180 | class MyService extends Service { 181 | @method("s") MyMethod(arg: string) { 182 | console.log("MyMethod has been invoked: ", arg) 183 | } 184 | } 185 | ``` 186 | 187 | Then instantiate it and export it. 188 | 189 | ```ts 190 | @register() 191 | class MyApp extends Gtk.Application { 192 | private service: MyService 193 | 194 | constructor() { 195 | super({ applicationId: "my.awesome.app" }) 196 | this.service = new MyService() 197 | } 198 | 199 | vfunc_shutdown(): void { 200 | super.vfunc_shutdown() 201 | this.service.stop() 202 | } 203 | 204 | vfunc_activate(): void { 205 | this.service.serve({ 206 | name: "my.awesome.app", 207 | objectPath: "/my/awesome/app/MyService", 208 | }) 209 | } 210 | } 211 | ``` 212 | 213 | Now you can invoke this from other processes. 214 | 215 | ```sh 216 | gdbus call \ 217 | --session \ 218 | --dest my.awesome.app \ 219 | --object-path /my/awesome/app/MyService \ 220 | --method my.awesome.app.MyService.MyMethod \ 221 | 'Hello World!' 222 | ``` 223 | -------------------------------------------------------------------------------- /docs/gobject.md: -------------------------------------------------------------------------------- 1 | # GObject decorators 2 | 3 | Decorators that wrap 4 | [`GObject.registerClass`](https://gitlab.gnome.org/GNOME/gjs/-/blob/master/doc/Overrides.md?ref_type=heads#gobjectregisterclassmetainfo-klass). 5 | 6 | > [!INFO] Required TypeScript settings 7 | > 8 | > Make sure `experimentalDecorators` is set to `false` and `target` is _less 9 | > than or equal_ to `ES2020` in `tsconfig.json`. 10 | > 11 | > ```json 12 | > { 13 | > "compilerOptions": { 14 | > "experimentalDecorators": false, 15 | > "target": "ES2020" 16 | > } 17 | > } 18 | > ``` 19 | 20 | ## Example Usage 21 | 22 | ```ts 23 | import GObject, { register, property, signal } from "gnim/gobject" 24 | 25 | @register({ GTypeName: "MyObj" }) 26 | class MyObj extends GObject.Object { 27 | @property(String) myProp = "" 28 | 29 | @signal(String, GObject.TYPE_UINT) 30 | mySignal(a: string, b: number) { 31 | // default handler 32 | } 33 | } 34 | ``` 35 | 36 | ::: details What it (roughly) transpiles to 37 | 38 | ```js 39 | const priv = Symbol("private props") 40 | 41 | class MyObj extends GObject.Object { 42 | [priv] = { "my-prop": "" } 43 | 44 | constructors() { 45 | super() 46 | Object.defineProperty(this, "myProp", { 47 | enumerable: true, 48 | configurable: false, 49 | set(value) { 50 | if (this[priv]["my-prop"] !== value) { 51 | this[priv]["my-prop"] = v 52 | this.notify("my-prop") 53 | } 54 | }, 55 | get() { 56 | return this[priv]["my-prop"] 57 | }, 58 | }) 59 | } 60 | 61 | mySignal(a, b) { 62 | return this.emit("my-signal", a, b) 63 | } 64 | 65 | on_my_signal(a, b) { 66 | // default handler 67 | } 68 | } 69 | 70 | GObject.registerClass( 71 | { 72 | GTypeName: "MyObj", 73 | Properties: { 74 | "my-prop": GObject.ParamSpec.string( 75 | "my-prop", 76 | "", 77 | "", 78 | GObject.ParamFlags.READWRITE, 79 | "", 80 | ), 81 | }, 82 | Signals: { 83 | "my-signal": { 84 | param_types: [String.$gtype, GObject.TYPE_UINT], 85 | }, 86 | }, 87 | }, 88 | MyObj, 89 | ) 90 | ``` 91 | 92 | > [!NOTE] 93 | > 94 | > Property accessors are defined on the object instance and not the prototype. 95 | > This might change in the future. Stage 3 decorators are adding a new keyword 96 | > [`accessor`](https://github.com/tc39/proposal-decorators?tab=readme-ov-file#class-auto-accessors) 97 | > for declaring properties, which marks properties to expand as `get` and `set` 98 | > methods on the prototype. The `accessor` keyword is currently not supported by 99 | > these decorators. 100 | 101 | ::: 102 | 103 | ## Property decorator 104 | 105 | Property declarations are split into three decorators: 106 | 107 | ```ts 108 | type PropertyTypeDeclaration = 109 | | ((name: string, flags: ParamFlags) => ParamSpec) 110 | | { $gtype: GType } 111 | 112 | function property(typeDeclaration: PropertyTypeDeclaration): void 113 | function setter(typeDeclaration: PropertyTypeDeclaration): void 114 | function getter(typeDeclaration: PropertyTypeDeclaration): void 115 | ``` 116 | 117 | These decorators take a single parameter that defines the type: 118 | 119 | - any class that has a registered `GType`. This includes the globally available 120 | `String`, `Number`, `Boolean` and `Object` JavaScript constructors, which are 121 | mapped to their relative `GObject.ParamSpec`. 122 | 123 | - `Object`: `ParamSpec.jsobject` 124 | - `String`: `ParamSpec.string` 125 | - `Number`: `ParamSpec.double` 126 | - `Boolean`: `ParamSpec.boolean` 127 | - `GObject.Object` and its subclasses 128 | 129 | - a function that produces a `ParamSpec` where the passed name is a kebab-cased 130 | name of the property (for example `myProp` -> `my-prop`), and flags is one of: 131 | `ParamFlags.READABLE`, `ParamFlags.WRITABLE`, `ParamFlags.READWRITE`. 132 | 133 | ```ts 134 | const Percent = (name: string, flags: ParamFlags) => 135 | GObject.ParamSpec.double(name, "", "", flags, 0, 1, 0) 136 | 137 | @register() 138 | class MyObj extends GObject.Object { 139 | @property(Percent) percent = 0 140 | } 141 | ``` 142 | 143 | ### `property` 144 | 145 | The `property` decorator lets you declare a read-write property. 146 | 147 | ```ts {3} 148 | @register() 149 | class MyObj extends GObject.Object { 150 | @property(String) myProp = "" 151 | } 152 | ``` 153 | 154 | This will create a getter and setter for the property and will also emit the 155 | notify signal when the value is set to a new value. 156 | 157 | > [!WARNING] 158 | > 159 | > The value is checked by reference, which is important if your property is an 160 | > object type. 161 | > 162 | > ```ts 163 | > const dict = obj.prop 164 | > dict["key"] = 0 165 | > obj.prop = dict // This will not emit notify::prop // [!code error] 166 | > obj.prop = { ...dict } // This will emit notify::prop 167 | > ``` 168 | 169 | When using custom subclasses as properties, you might want to annotate its 170 | `$gtype`. 171 | 172 | ```ts {3,8} 173 | @register() 174 | class DeepProp extends GObject.Object { 175 | declare static $gtype: GObject.GType 176 | } 177 | 178 | @register() 179 | class MyClass extends GObject.Object { 180 | @property(DeepProp) prop: DeepProp 181 | } 182 | ``` 183 | 184 | ### `getter` 185 | 186 | The `getter` decorator lets you declare a read-only property. 187 | 188 | ```ts {3} 189 | @register() 190 | class MyObj extends GObject.Object { 191 | @getter(String) 192 | get readOnly() { 193 | return "readonly value" 194 | } 195 | } 196 | ``` 197 | 198 | ### `setter` 199 | 200 | The `setter` decorator lets you declare a write-only property. 201 | 202 | ```ts {5} 203 | @register() 204 | class MyObj extends GObject.Object { 205 | #prop = "" 206 | 207 | @setter(String) 208 | set myProp(value: string) { 209 | if (value !== this.#prop) { 210 | this.#prop = value 211 | this.notify("my-prop") 212 | } 213 | } 214 | } 215 | ``` 216 | 217 | > [!NOTE] 218 | > 219 | > When using `setter` you will have to explicitly emit the notify signal. 220 | 221 | 222 | 223 | > [!TIP] 224 | > 225 | > You can use the `setter` and `getter` decorators in combination to declare a 226 | > read-write property. 227 | 228 | ## Signal decorator 229 | 230 | ```ts 231 | function signal( 232 | params: Array, 233 | returnType?: GType, 234 | options?: { 235 | default?: default 236 | flags?: SignalFlags 237 | accumulator?: AccumulatorType 238 | }, 239 | ) 240 | 241 | function signal(...params: Array) 242 | ``` 243 | 244 | You can apply the signal decorator to a method where the method is the default 245 | handler of the signal. 246 | 247 | ```ts {3,4,5,10} 248 | @register() 249 | class MyObj extends GObject.Object { 250 | @signal([String, Number], Boolean, { 251 | default: true, 252 | accumulator: GObject.AccumulatorType.FIRST_WINS, 253 | }) 254 | myFirstHandledSignal(str: string, n: number): boolean { 255 | return false 256 | } 257 | 258 | @signal(String, GObject.TYPE_STRING) 259 | mySignal(a: string, b: string): void { 260 | // default signal handler 261 | } 262 | } 263 | ``` 264 | 265 | > [!TIP] 266 | > 267 | > It is required to provide a function implementation which becomes the default 268 | > signal handler. In case you don't want to implement a default handler you can 269 | > set the `default` option to `false`. 270 | > 271 | > ```ts 272 | > class { 273 | > @signal([], Boolean, { 274 | > default: false, 275 | > }) 276 | > withoutDefaultImpl(): boolean { 277 | > throw "this never runs" 278 | > } 279 | > } 280 | > ``` 281 | 282 | You can emit the signal by calling the signal method or using `emit`. 283 | 284 | ```ts 285 | const obj = new MyObj() 286 | obj.connect("my-signal", (obj, a: string, b: string) => {}) 287 | 288 | obj.mySig("a", "b") 289 | obj.emit("my-signal", "a", "b") 290 | ``` 291 | 292 | > [!TIP] 293 | > 294 | > To make the `connect` method aware of signals, you can override it. 295 | > 296 | > ```ts 297 | > interface MyObjSignals extends GObject.Object.SignalSignatures { 298 | > "my-signal": MyObj["mySignal"] 299 | > } 300 | > 301 | > @register() 302 | > class MyObj extends GObject.Object { 303 | > declare $signals: MyObjSignals // this makes signals inferable in JSX 304 | > 305 | > override connect( 306 | > signal: S, 307 | > callback: GObject.SignalCallback, 308 | > ): number { 309 | > return super.connect(signal, callback) 310 | > } 311 | > } 312 | > ``` 313 | 314 | ## Register decorator 315 | 316 | Every `GObject.Object` subclass has to be registered. You can pass the same 317 | options to this decorator as you would to `GObject.registerClass`. 318 | 319 | ```ts 320 | @register({ GTypeName: "MyObj" }) 321 | class MyObj extends GObject.Object {} 322 | ``` 323 | 324 | > [!TIP] 325 | > 326 | > This decorator registers properties and signals defined with decorators, so 327 | > make sure to use this and **not** `GObject.registerClass` if you define any. 328 | -------------------------------------------------------------------------------- /src/jsx/jsx.ts: -------------------------------------------------------------------------------- 1 | import GObject from "gi://GObject" 2 | import { Fragment } from "./Fragment.js" 3 | import { Accessor } from "./state.js" 4 | import { type CC, type FC, env } from "./env.js" 5 | import { kebabify, type Pascalify, set } from "../util.js" 6 | import { onCleanup } from "./scope.js" 7 | 8 | /** 9 | * Represents all of the things that can be passed as a child to class components. 10 | */ 11 | export type Node = 12 | | Array 13 | | GObject.Object 14 | | number 15 | | string 16 | | boolean 17 | | null 18 | | undefined 19 | 20 | export const gtkType = Symbol("gtk builder type") 21 | 22 | /** 23 | * Special symbol which lets you implement how widgets are appended in JSX. 24 | * 25 | * Example: 26 | * 27 | * ```ts 28 | * class MyComponent extends GObject.Object { 29 | * [appendChild](child: GObject.Object, type: string | null) { 30 | * // implement 31 | * } 32 | * } 33 | * ``` 34 | */ 35 | export const appendChild = Symbol("JSX add child method") 36 | 37 | /** 38 | * Special symbol which lets you implement how widgets are removed in JSX. 39 | * 40 | * Example: 41 | * 42 | * ```ts 43 | * class MyComponent extends GObject.Object { 44 | * [removeChild](child: GObject.Object) { 45 | * // implement 46 | * } 47 | * } 48 | * ``` 49 | */ 50 | export const removeChild = Symbol("JSX add remove method") 51 | 52 | /** 53 | * Get the type of the object specified through the `$type` property 54 | */ 55 | export function getType(object: GObject.Object) { 56 | return gtkType in object ? (object[gtkType] as string) : null 57 | } 58 | 59 | /** 60 | * Function Component Properties 61 | */ 62 | export type FCProps = Props & { 63 | /** 64 | * Gtk.Builder type 65 | * its consumed internally and not actually passed as a parameters 66 | */ 67 | $type?: string 68 | /** 69 | * setup function 70 | * its consumed internally and not actually passed as a parameters 71 | */ 72 | $?(self: Self): void 73 | } 74 | 75 | /** 76 | * Class Component Properties 77 | */ 78 | export type CCProps = { 79 | /** 80 | * @internal children elements 81 | * its consumed internally and not actually passed to class component constructors 82 | */ 83 | children?: Array | Node 84 | /** 85 | * Gtk.Builder type 86 | * its consumed internally and not actually passed to class component constructors 87 | */ 88 | $type?: string 89 | /** 90 | * function to use as a constructor, 91 | * its consumed internally and not actually passed to class component constructors 92 | */ 93 | $constructor?(props: Partial): Self 94 | /** 95 | * setup function, 96 | * its consumed internally and not actually passed to class component constructors 97 | */ 98 | $?(self: Self): void 99 | /** 100 | * CSS class names 101 | */ 102 | class?: string | Accessor 103 | /** 104 | * inline CSS 105 | */ 106 | css?: string | Accessor 107 | } & { 108 | [K in keyof Props]: Accessor> | 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 | WindowBoxState2<For>ChildBoxLabelRoot ScopeNested ScopeState1 -------------------------------------------------------------------------------- /docs/public/scope-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | WindowBoxState2<For>ChildBoxLabelRoot ScopeNested ScopeState1 -------------------------------------------------------------------------------- /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 | ![Scope Diagram](/scope-dark.svg){.dark-only} 55 | ![Scope Diagram](/scope-light.svg){.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 | 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 | 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 && 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> 485 | } 486 | 487 | interface SetConstructor { 488 | $gtype: GType> 489 | } 490 | } 491 | 492 | Function.$gtype = Object.$gtype as FunctionConstructor["$gtype"] 493 | Array.$gtype = Object.$gtype as ArrayConstructor["$gtype"] 494 | Date.$gtype = Object.$gtype as DateConstructor["$gtype"] 495 | Map.$gtype = Object.$gtype as MapConstructor["$gtype"] 496 | Set.$gtype = Object.$gtype as SetConstructor["$gtype"] 497 | -------------------------------------------------------------------------------- /docs/jsx.md: -------------------------------------------------------------------------------- 1 | # JSX 2 | 3 | Syntactic sugar for creating objects declaratively. 4 | 5 | > [!WARNING] This is not React 6 | > 7 | > This works nothing like React and has nothing in common with React other than 8 | > the XML syntax. 9 | 10 | Consider the following example: 11 | 12 | ```ts 13 | function Box() { 14 | let counter = 0 15 | 16 | const button = new Gtk.Button() 17 | const icon = new Gtk.Image({ 18 | iconName: "system-search-symbolic", 19 | }) 20 | const label = new Gtk.Label({ 21 | label: `clicked ${counter} times`, 22 | }) 23 | const box = new Gtk.Box({ 24 | orientation: Gtk.Orientation.VERTICAL, 25 | }) 26 | 27 | function onClicked() { 28 | label.label = `clicked ${counter} times` 29 | } 30 | 31 | button.set_child(icon) 32 | box.append(button) 33 | box.append(label) 34 | button.connect("clicked", onClicked) 35 | return box 36 | } 37 | ``` 38 | 39 | Can be written as 40 | 41 | ```tsx 42 | function Box() { 43 | const [counter, setCounter] = createState(0) 44 | const label = createComputed(() => `clicked ${counter()} times`) 45 | 46 | function onClicked() { 47 | setCounter((c) => c + 1) 48 | } 49 | 50 | return ( 51 | 52 | 53 | 54 | 55 | 56 | 57 | ) 58 | } 59 | ``` 60 | 61 | ## JSX expressions and `jsx` function 62 | 63 | A JSX expression transpiles to a `jsx` function call. A JSX expression's type 64 | however is **always** the base `GObject.Object` type, while the `jsx` return 65 | type is the instance type of the class or the return type of the function you 66 | pass to it. If you need the actual type of an object, either use the `jsx` 67 | function directly or type assert the JSX expression. 68 | 69 | ```tsx 70 | import { jsx } from "gnim" 71 | 72 | const menubutton = new Gtk.MenuButton() 73 | 74 | menubutton.popover = // cannot assign Object to Popover // [!code error] 75 | menubutton.popover = jsx(Gtk.Popover, {}) // works as expected 76 | 77 | function MyPopover(): Gtk.Popover 78 | menubutton.popover = // cannot assign Object to Popover // [!code error] 79 | menubutton.popover = jsx(MyPopover, {}) // works as expected 80 | ``` 81 | 82 | ## JSX Element 83 | 84 | A valid JSX component must either be a function that returns a `GObject.Object` 85 | instance, or a class that inherits from `GObject.Object`. 86 | 87 | > [!TIP] 88 | > 89 | > `JSX.Element` is simply an alias for `GObject.Object`. 90 | 91 | When two types have a parent-child relationship, they can be composed naturally 92 | using JSX syntax. For example, this applies to types like `Gtk.EventController`: 93 | 94 | ```tsx 95 | 96 | print("clicked")} /> 97 | 98 | ``` 99 | 100 | ## Class components 101 | 102 | When defining custom components, choosing between using classes vs. functions is 103 | mostly down to preference. There are cases when one or the other is more 104 | convenient to use, but you will mostly be using class components from libraries 105 | such as Gtk, and defining function components for custom components. 106 | 107 | Using classes in JSX expressions lets you set some additional properties. 108 | 109 | ### Constructor function 110 | 111 | By default, classes are instantiated with the `new` keyword and initial values 112 | are passed in. In cases where you need to use a static constructor function 113 | instead, you can specify it with `$constructor`. 114 | 115 | > [!WARNING] 116 | > 117 | > Initial values this way cannot be passed to the constructor and are set 118 | > **after** construction. This means construct-only properties like `css-name` 119 | > cannot be set. 120 | 121 | ```tsx 122 | Gtk.DropDown.new_from_strings(["item1", "item2"])} 124 | /> 125 | ``` 126 | 127 | ### Type string 128 | 129 | Under the hood, the `jsx` function uses the 130 | [Gtk.Buildable](https://docs.gtk.org/gtk4/iface.Buildable.html) interface, which 131 | lets you use a type string to specify the type the `child` is meant to be. 132 | 133 | > [!NOTE] In Gnome extensions, it has no effect. 134 | 135 | ```tsx 136 | 137 | 138 | 139 | 140 | 141 | ``` 142 | 143 | ### Signal handlers 144 | 145 | Signal handlers can be defined with an `on` prefix, and `notify::` signal 146 | handlers can be defined with an `onNotify` prefix. 147 | 148 | ```tsx 149 | print(self, "child-revealed")} 151 | onDestroy={(self) => print(self, "destroyed")} 152 | /> 153 | ``` 154 | 155 | ### Setup function 156 | 157 | It is possible to define an arbitrary function to do something with the instance 158 | imperatively. It is run **after** properties are set, signals are connected, and 159 | children are appended, but **before** the `jsx` function returns. 160 | 161 | ```tsx 162 | print(self, "is about to be returned")} /> 163 | ``` 164 | 165 | The most common use case is to acquire a reference to the widget in the scope of 166 | the function. 167 | 168 | ```tsx 169 | function MyWidget() { 170 | let box: Gtk.Box 171 | 172 | function someHandler() { 173 | console.log(box) 174 | } 175 | 176 | return (box = self)} /> 177 | } 178 | ``` 179 | 180 | Another common use case is to initialize relations between widgets in the tree. 181 | 182 | ```tsx 183 | function MyWidget() { 184 | let searchbar: Gtk.SearchBar 185 | 186 | function init(win: Gtk.Window) { 187 | searchbar.set_key_capture_widget(win) 188 | } 189 | 190 | return ( 191 | 192 | (searchbar = self)}> 193 | 194 | 195 | 196 | ) 197 | } 198 | ``` 199 | 200 | ### Bindings 201 | 202 | Properties can be set as a static value. Alternatively, they can be passed an 203 | [Accessor](#state-management), in which case whenever its value changes, it will 204 | be reflected on the widget. 205 | 206 | ```tsx 207 | const [revealed, setRevealed] = createState(false) 208 | 209 | return ( 210 | setRevealed((v) => !v)}> 211 | 212 | 213 | 214 | 215 | ) 216 | ``` 217 | 218 | ### How children are passed to class components 219 | 220 | Class components can only take `GObject.Object` instances as children. They are 221 | set through 222 | [`Gtk.Buildable.add_child`](https://docs.gtk.org/gtk4/iface.Buildable.html). 223 | 224 | > [!NOTE] 225 | > 226 | > In Gnome extensions, they are set with `Clutter.Actor.add_child`. 227 | 228 | ```ts 229 | @register({ Implements: [Gtk.Buildable] }) 230 | class MyContainer extends Gtk.Widget { 231 | vfunc_add_child( 232 | builder: Gtk.Builder, 233 | child: GObject.Object, 234 | type?: string | null, 235 | ): void { 236 | if (child instanceof Gtk.Widget) { 237 | // set children here 238 | } else { 239 | super.vfunc_add_child(builder, child, type) 240 | } 241 | } 242 | } 243 | ``` 244 | 245 | ### Class names and inline CSS 246 | 247 | JSX supports setting `class` and `css` properties. `css` is mostly meant to be 248 | used as a debugging tool, e.g. with `css="border: 1px solid red;"`. `class` is a 249 | space-separated list of class names. 250 | 251 | ```tsx 252 | 253 | ``` 254 | 255 | > [!NOTE] 256 | > 257 | > Besides `class`, you can also use `css-classes` in Gtk4 and `style-class` in 258 | > Gnome. 259 | 260 | ### This component 261 | 262 | In most cases, you will use JSX to instantiate objects. However, there are cases 263 | when you have a reference to an instance that you would like to use in a JSX 264 | expression, for example, in subclasses. 265 | 266 | ```tsx 267 | @register() 268 | class Row extends Gtk.ListBoxRow { 269 | constructor(props: Partial) { 270 | super(props) 271 | 272 | void ( 273 | print("activated")}> 274 | 275 | 276 | ) 277 | } 278 | } 279 | ``` 280 | 281 | ## Function components 282 | 283 | ### Setup function 284 | 285 | Just like class components, function components can also have a setup function. 286 | 287 | ```tsx 288 | import { FCProps } from "gnim" 289 | 290 | type MyComponentProps = FCProps< 291 | Gtk.Button, 292 | { 293 | prop?: string 294 | } 295 | > 296 | 297 | function MyComponent({ prop }: MyComponentProps) { 298 | return 299 | } 300 | 301 | return print(self, "is a Button")} prop="hello" /> 302 | ``` 303 | 304 | > [!NOTE] 305 | > 306 | > `FCProps` is required for TypeScript to be aware of the `$` prop. 307 | 308 | ### How children are passed to function components 309 | 310 | They are passed in through the `children` property. They can be of any type. 311 | 312 | ```tsx 313 | interface MyButtonProps { 314 | children: string 315 | } 316 | 317 | function MyButton({ children }: MyButtonProps) { 318 | return 319 | } 320 | 321 | return Click Me 322 | ``` 323 | 324 | When multiple children are passed in, `children` is an `Array`. 325 | 326 | ```tsx 327 | interface MyBoxProps { 328 | children: Array 329 | } 330 | 331 | function MyBox({ children }: MyBoxProps) { 332 | return ( 333 | 334 | {children.map((item) => 335 | item instanceof Gtk.Widget ? ( 336 | item 337 | ) : ( 338 | 339 | ), 340 | )} 341 | 342 | ) 343 | } 344 | 345 | return ( 346 | 347 | Some Content 348 | 349 | 350 | ) 351 | ``` 352 | 353 | ### Everything has to be handled explicitly in function components 354 | 355 | There is no builtin way to define signal handlers or bindings automatically. 356 | With function components, they have to be explicitly declared and handled. 357 | 358 | ```tsx 359 | interface MyWidgetProps { 360 | label: Accessor | string 361 | onClicked: (self: Gtk.Button) => void 362 | } 363 | 364 | function MyWidget({ label, onClicked }: MyWidgetProps) { 365 | return 366 | } 367 | ``` 368 | 369 | > [!TIP] 370 | > 371 | > To make reusable function components more convenient to use, you should 372 | > annotate props as either static or dynamic and handle both cases as if it was 373 | > dynamic. 374 | > 375 | > ```ts 376 | > type $ = T | Accessor 377 | > const $ = (value: $): Accessor => 378 | > value instanceof Accessor ? value : new Accessor(() => value) 379 | > ``` 380 | 381 | ```tsx 382 | function Counter(props: { 383 | count?: $ 384 | label?: $ 385 | onClicked?: () => void 386 | }) { 387 | const count = $(props.count)((v) => v ?? 0) 388 | const label = $(props.label)((v) => v ?? `Fallback label ${count()}`) 389 | 390 | return 391 | } 392 | ``` 393 | 394 | ## Control flow 395 | 396 | ### Dynamic rendering 397 | 398 | When you want to render based on a value, you can use the `` component. 399 | 400 | ```tsx 401 | let value: Accessor<{ member: string } | null> 402 | 403 | return ( 404 | 405 | {(value) => value && } 406 | 407 | ) 408 | ``` 409 | 410 | > [!TIP] 411 | > 412 | > In a lot of cases, it is better to always render the component and set its 413 | > `visible` property instead. 414 | 415 | > [!WARNING] 416 | > 417 | > When the value changes and the widget is re-rendered, the previous one is 418 | > removed from the parent component and the new one is **appended**. The order 419 | > of widgets is not kept, so make sure to wrap `` in a container to avoid 420 | > this. 421 | 422 | ### List rendering 423 | 424 | The `` component lets you render based on an array dynamically. Each time 425 | the array changes, it is compared with its previous state. Widgets for new items 426 | are inserted, while widgets associated with removed items are removed. 427 | 428 | ```tsx 429 | let list: Accessor> 430 | 431 | return ( 432 | 433 | {(item, index: Accessor) => ( 434 | `${i}. ${item}`)} /> 435 | )} 436 | 437 | ) 438 | ``` 439 | 440 | > [!WARNING] 441 | > 442 | > Similarly to ``, when the list changes and a new item is added, it is 443 | > simply **appended** to the parent. The order of widgets is not kept, so make 444 | > sure to wrap `` in a container to avoid this. 445 | 446 | ### Fragments 447 | 448 | Both `` and `` are `Fragment`s. A `Fragment` is a collection of 449 | children. Whenever the children array changes, it is reflected on the parent 450 | widget the `Fragment` was assigned to. When implementing custom widgets, you 451 | need to take into consideration the API being used for child insertion and 452 | removing. 453 | 454 | - Both Gtk3 and Gtk4 uses the `Gtk.Buildable` interface to append children. 455 | - Gtk3 uses the `Gtk.Container` interface to remove children. 456 | - Gtk4 checks for a method called `remove`. 457 | - Clutter uses `Clutter.Actor.add_child` and `Clutter.Actor.remove_child`. 458 | 459 | ## State management 460 | 461 | There is a single primitive called `Accessor`, which is a read-only reactive 462 | value. It is the base of Gnim's reactive system. They are essentially functions 463 | that let you read a value and track it in reactive scopes so that when they 464 | change the reader is notified. 465 | 466 | ```ts 467 | interface Accessor { 468 | (): T 469 | peek(): T 470 | subscribe(callback: Callback): DisposeFn 471 | } 472 | ``` 473 | 474 | There are two ways to read the current value: 475 | 476 | - `(): T`: which returns the current value and tracks it as a dependency in 477 | reactive scopes 478 | - `peek(): T` which returns the current value **without** tracking it as a 479 | dependency 480 | 481 | To subscribe for value changes you can use the `subscribe` method. 482 | 483 | ```ts 484 | const accessor: Accessor 485 | 486 | const unsubscribe = accessor.subscribe(() => { 487 | console.log("value of accessor changed to", accessor.get()) 488 | }) 489 | 490 | unsubscribe() 491 | ``` 492 | 493 | > [!WARNING] 494 | > 495 | > The subscribe method is not scope aware. Do not forget to clean them up when 496 | > no longer needed. Alternatively, use an [`effect`](#createeffect) instead. 497 | 498 | ### `createState` 499 | 500 | Creates a writable reactive value. 501 | 502 | ```ts 503 | function createState(init: T): [Accessor, Setter] 504 | ``` 505 | 506 | Example: 507 | 508 | ```ts 509 | const [value, setValue] = createState(0) 510 | 511 | // setting its value 512 | setValue(2) 513 | setValue((prev) => prev + 1) 514 | ``` 515 | 516 | > [!IMPORTANT] 517 | > 518 | > Effects and computations are only triggered when the value changes. 519 | 520 | By default, equality between the previous and new value is checked with 521 | [Object.is](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is) 522 | and so this would not trigger an update: 523 | 524 | ```ts 525 | const [object, setObject] = createState({}) 526 | 527 | // this does NOT trigger an update by default 528 | setObject((obj) => { 529 | obj.field = "mutated" 530 | return obj 531 | }) 532 | ``` 533 | 534 | You can pass in a custom `equals` function to customize this behavior: 535 | 536 | ```ts 537 | const [value, setValue] = createState("initial value", { 538 | equals: (prev, next): boolean => { 539 | return prev != next 540 | }, 541 | }) 542 | ``` 543 | 544 | ### `createComputed` 545 | 546 | Create a computed value which tracks dependencies and invalidates the value 547 | whenever they change. The result is cached and is only computed on access. 548 | 549 | ```ts 550 | function createComputed(compute: () => T): Accessor 551 | ``` 552 | 553 | Example: 554 | 555 | ```ts 556 | let a: Accessor 557 | let b: Accessor 558 | 559 | const c: Accessor = createComputed(() => a() + b()) 560 | ``` 561 | 562 | > [!TIP] 563 | > 564 | > There is a shorthand for computed values. 565 | > 566 | > ```ts 567 | > let a: Accessor 568 | > const b = createComputed(() => `transformed ${a()}`) 569 | > const b = a((v) => `transformed ${v}`) // alias for the above line 570 | > ``` 571 | 572 | ### `createBinding` 573 | 574 | Creates an `Accessor` on a `GObject.Object`'s `property`. 575 | 576 | ```ts 577 | function createBinding( 578 | object: T, 579 | property: Extract, 580 | ): Accessor 581 | ``` 582 | 583 | Example: 584 | 585 | ```ts 586 | const styleManager = Adw.StyleManager.get_default() 587 | const style = createBinding(styleManager, "colorScheme") 588 | ``` 589 | 590 | It also supports nested bindings. 591 | 592 | ```ts 593 | interface Outer extends GObject.Object { 594 | nested: Inner | null 595 | } 596 | 597 | interface Inner extends GObject.Object { 598 | field: string 599 | } 600 | 601 | const value: Accessor = createBinding(outer, "nested", "field") 602 | ``` 603 | 604 | ### `createEffect` 605 | 606 | Schedule a function to run after the current Scope created with 607 | [`createRoot`](#createroot) returns, tracking dependencies and re-running the 608 | function whenever they change. 609 | 610 | ```ts 611 | function createEffect(fn: () => void): void 612 | ``` 613 | 614 | Example: 615 | 616 | ```ts 617 | const count: Accessor 618 | 619 | createEffect(() => { 620 | console.log(count()) // reruns whenever count changes 621 | }) 622 | 623 | createEffect(() => { 624 | console.log(count.peek()) // only runs once 625 | }) 626 | ``` 627 | 628 | > [!CAUTION] 629 | > 630 | > Effects are a common pitfall for beginners to understand when to use and when 631 | > not to use them. You can read about 632 | > [when it is discouraged and their alternatives](./tutorial/gnim.md#when-not-to-use-an-effect). 633 | 634 | ### `createConnection` 635 | 636 | ```ts 637 | function createConnection< 638 | T, 639 | O extends GObject.Object, 640 | S extends keyof O["$signals"], 641 | >( 642 | init: T, 643 | handler: [ 644 | object: O, 645 | signal: S, 646 | callback: ( 647 | ...args: [...Parameters, currentValue: T] 648 | ) => T, 649 | ], 650 | ): Accessor 651 | ``` 652 | 653 | Creates an `Accessor` which sets up a list of `GObject.Object` signal 654 | connections. It expects an initial value and a list of 655 | `[object, signal, callback]` tuples where the callback is called with the 656 | arguments passed by the signal and the current value as the last parameter. 657 | 658 | Example: 659 | 660 | ```ts 661 | const value: Accessor = createConnection( 662 | "initial value", 663 | [obj1, "notify", (pspec, currentValue) => currentValue + pspec.name], 664 | [obj2, "sig-name", (sigArg1, sigArg2, currentValue) => "str"], 665 | ) 666 | ``` 667 | 668 | > [!IMPORTANT] 669 | > 670 | > The connection will only get attached when the first subscriber appears, and 671 | > is dropped when the last one disappears. 672 | 673 | ### `createMemo` 674 | 675 | Create a derived reactive value which tracks its dependencies and re-runs the 676 | computation whenever a dependency changes. The resulting `Accessor` will only 677 | notify subscribers when the computed value has changed. 678 | 679 | ```ts 680 | function createMemo(compute: () => T): Accessor 681 | ``` 682 | 683 | It is useful to memoize values that are dependencies of expensive computations. 684 | 685 | Example: 686 | 687 | ```ts 688 | const value = createBinding(gobject, "field") 689 | 690 | createEffect(() => { 691 | console.log("effect1", value()) 692 | }) 693 | 694 | const memoValue = createMemo(() => value()) 695 | 696 | createEffect(() => { 697 | console.log("effect2", memoValue()) 698 | }) 699 | 700 | value.notify("field") // triggers effect1 but not effect2 701 | ``` 702 | 703 | ### `createSettings` 704 | 705 | Wraps a `Gio.Settings` into a collection of setters and accessors. 706 | 707 | ```ts 708 | function createSettings>( 709 | settings: Gio.Settings, 710 | keys: T, 711 | ): Settings 712 | ``` 713 | 714 | Example: 715 | 716 | ```ts 717 | const s = createSettings(settings, { 718 | "complex-key": "a{sa{ss}}", 719 | "simple-key": "s", 720 | }) 721 | 722 | s.complexKey.subscribe(() => { 723 | print(s.complexKey.get()) 724 | }) 725 | 726 | s.setComplexKey((prev) => ({ 727 | ...prev, 728 | neyKey: { nested: "" }, 729 | })) 730 | ``` 731 | 732 | ### `createExternal` 733 | 734 | Creates a signal from a `provider` function. The provider is called when the 735 | first subscriber appears. The returned dispose function from the provider will 736 | be called when the number of subscribers drops to zero. 737 | 738 | ```ts 739 | function createExternal( 740 | init: T, 741 | producer: (set: Setter) => DisposeFunction, 742 | ): Accessor 743 | ``` 744 | 745 | Example: 746 | 747 | ```ts 748 | const counter = createExternal(0, (set) => { 749 | const interval = setInterval(() => set((v) => v + 1)) 750 | return () => clearInterval(interval) 751 | }) 752 | ``` 753 | 754 | ## Scopes and Life cycle 755 | 756 | A [scope](./tutorial/gnim.md#scopes) is essentially a global object which holds 757 | cleanup functions and context values. 758 | 759 | ```js 760 | let scope = new Scope() 761 | 762 | // Inside this function, synchronously executed code will have access 763 | // to `scope` and will attach any allocated resources, such as signal 764 | // subscriptions. 765 | scopedFuntion() 766 | 767 | // At a later point it can be disposed. 768 | scope.dispose() 769 | ``` 770 | 771 | ### `createRoot` 772 | 773 | ```ts 774 | function createRoot(fn: (dispose: () => void) => T) 775 | ``` 776 | 777 | Creates a root scope. Other than wrapping the main entry function in this, you 778 | likely won't need this elsewhere. `` and `` components run their 779 | children in their own scopes, for example. 780 | 781 | Example: 782 | 783 | ```tsx 784 | createRoot((dipose) => { 785 | return 786 | }) 787 | ``` 788 | 789 | ### `getScope` 790 | 791 | Gets the current scope. You might need to reference the scope in cases where 792 | async functions need to run in the scope. 793 | 794 | Example: 795 | 796 | ```ts 797 | const scope = getScope() 798 | setTimeout(() => { 799 | // This callback gets run without an owner scope. 800 | // Restore owner via scope.run: 801 | scope.run(() => { 802 | const foo = FooContext.use() 803 | onCleanup(() => { 804 | print("some cleanup") 805 | }) 806 | }) 807 | }, 1000) 808 | ``` 809 | 810 | ### `onCleanup` 811 | 812 | Attaches a cleanup function to the current scope. 813 | 814 | Example: 815 | 816 | ```tsx 817 | function MyComponent() { 818 | const dispose = signal.subscribe(() => {}) 819 | 820 | onCleanup(() => { 821 | dispose() 822 | }) 823 | 824 | return <> 825 | } 826 | ``` 827 | 828 | ### `onMount` 829 | 830 | Attaches a function to run when the farthest non-mounted scope returns. 831 | 832 | Example: 833 | 834 | ```tsx 835 | function MyComponent() { 836 | onMount(() => { 837 | console.log("root scope returned") 838 | }) 839 | 840 | return <> 841 | } 842 | ``` 843 | 844 | ### Contexts 845 | 846 | Context provides a form of dependency injection. It lets you avoid the need to 847 | pass data as props through intermediate components (a.k.a. prop drilling). The 848 | default value is used when no Provider is found above in the hierarchy. 849 | 850 | Example: 851 | 852 | ```tsx 853 | const MyContext = createContext("fallback-value") 854 | 855 | function ConsumerComponent() { 856 | const value = MyContext.use() 857 | 858 | return 859 | } 860 | 861 | function ProviderComponent() { 862 | return ( 863 | 864 | {() => } 865 | 866 | ) 867 | } 868 | ``` 869 | 870 | ## Intrinsic Elements 871 | 872 | Intrinsic elements are globally available components, which in web frameworks 873 | are usually HTMLElements such as `

` `` `

`. There are no intrinsic 874 | elements by default, but they can be set. 875 | 876 | > [!TIP] 877 | > 878 | > It should always be preferred to use function/class components directly. 879 | 880 | - Function components 881 | 882 | ```tsx 883 | import { FCProps } from "gnim" 884 | import { intrinsicElements } from "gnim/gtk4/jsx-runtime" 885 | 886 | type MyLabelProps = FCProps< 887 | Gtk.Label, 888 | { 889 | someProp: string 890 | } 891 | > 892 | 893 | function MyLabel({ someProp }: MyLabelProps) { 894 | return 895 | } 896 | 897 | intrinsicElements["my-label"] = MyLabel 898 | 899 | declare global { 900 | namespace JSX { 901 | interface IntrinsicElements { 902 | "my-label": MyLabelProps 903 | } 904 | } 905 | } 906 | 907 | return 908 | ``` 909 | 910 | - Class components 911 | 912 | ```tsx 913 | import { CCProps } from "gnim" 914 | import { intrinsicElements } from "gnim/gtk4/jsx-runtime" 915 | import { property, register } from "gnim/gobject" 916 | 917 | interface MyWidgetProps extends Gtk.Widget.ConstructorProps { 918 | someProp: string 919 | } 920 | 921 | @register() 922 | class MyWidget extends Gtk.Widget { 923 | @property(String) someProp = "" 924 | 925 | constructor(props: Partial) { 926 | super(props) 927 | } 928 | } 929 | 930 | intrinsicElements["my-widget"] = MyWidget 931 | 932 | declare global { 933 | namespace JSX { 934 | interface IntrinsicElements { 935 | "my-widget": CCProps 936 | } 937 | } 938 | } 939 | 940 | return 941 | ``` 942 | --------------------------------------------------------------------------------