diff options
author | kotontrion <[email protected]> | 2024-10-29 13:50:41 +0100 |
---|---|---|
committer | kotontrion <[email protected]> | 2024-10-29 13:50:41 +0100 |
commit | 57f20666e716fde56579b8aa638eed1264f793de (patch) | |
tree | 59b2ebbd770c80049cea4df82109d28f617675fe /docs/guide/typescript | |
parent | 4d9ae88b0bab75779876d465f986791d052414ca (diff) | |
parent | 7e484188e7492ac7945c854bcc3f26cec1863c91 (diff) |
Merge branch 'main' into feat/cava
Diffstat (limited to 'docs/guide/typescript')
-rw-r--r-- | docs/guide/typescript/binding.md | 235 | ||||
-rw-r--r-- | docs/guide/typescript/cli-app.md | 174 | ||||
-rw-r--r-- | docs/guide/typescript/faq.md | 266 | ||||
-rw-r--r-- | docs/guide/typescript/first-widgets.md | 412 | ||||
-rw-r--r-- | docs/guide/typescript/gobject.md | 165 | ||||
-rw-r--r-- | docs/guide/typescript/installation.md | 90 | ||||
-rw-r--r-- | docs/guide/typescript/theming.md | 179 | ||||
-rw-r--r-- | docs/guide/typescript/utilities.md | 170 | ||||
-rw-r--r-- | docs/guide/typescript/variable.md | 153 | ||||
-rw-r--r-- | docs/guide/typescript/widget.md | 216 |
10 files changed, 2060 insertions, 0 deletions
diff --git a/docs/guide/typescript/binding.md b/docs/guide/typescript/binding.md new file mode 100644 index 0000000..15fe3cc --- /dev/null +++ b/docs/guide/typescript/binding.md @@ -0,0 +1,235 @@ +# Binding + +As mentioned before binding an object's state to another - +so in most cases a `Variable` or a `GObject.Object` property to a widget's property - +is done through the `bind` function which returns a `Binding` object. + +`Binding` objects simply hold information about the source and how it should be transformed +which Widget constructors can use to setup a connection between themselves and the source. + +```ts +class Binding<Value> { + private transformFn: (v: any) => unknown + private emitter: Subscribable<Value> | Connectable + private prop?: string + + as<T>(fn: (v: Value) => T): Binding<T> + get(): Value + subscribe(callback: (value: Value) => void): () => void +} +``` + +A `Binding` can be constructed from an object implementing +the `Subscribable` interface (usually a `Variable`) +or an object implementing the `Connectable` interface and one of its properties +(usually a `GObject.Object` instance). + +```ts +function bind<T>(obj: Subscribable<T>): Binding<T> + +function bind< + Obj extends Connectable, + Prop extends keyof Obj, +>(obj: Obj, prop: Prop): Binding<Obj[Prop]> +``` + +## Subscribable and Connectable interface + +Any object implementing one of these interfaces can be used with `bind`. + +```ts +interface Subscribable<T> { + subscribe(callback: (value: T) => void): () => void + get(): T +} + +interface Connectable { + connect(signal: string, callback: (...args: any[]) => unknown): number + disconnect(id: number): void +} +``` + +## Example Custom Subscribable + +When binding the children of a box from an array, usually not all elements +of the array changes each time, so it would make sense to not destroy +the widget which represents the element. + +::: code-group + +```ts :line-numbers [varmap.ts] +import { type Subscribable } from "astal/binding" +import { Gtk } from "astal" + +export class VarMap<K, T = Gtk.Widget> implements Subscribable { + #subs = new Set<(v: Array<[K, T]>) => void>() + #map: Map<K, T> + + #notifiy() { + const value = this.get() + for (const sub of this.#subs) { + sub(value) + } + } + + #delete(key: K) { + const v = this.#map.get(key) + + if (v instanceof Gtk.Widget) { + v.destroy() + } + + this.#map.delete(key) + } + + constructor(initial?: Iterable<[K, T]>) { + this.#map = new Map(initial) + } + + set(key: K, value: T) { + this.#delete(key) + this.#map.set(key, value) + this.#notifiy() + } + + delete(key: K) { + this.#delete(key) + this.#notifiy() + } + + get() { + return [...this.#map.entries()] + } + + subscribe(callback: (v: Array<[K, T]>) => void) { + this.#subs.add(callback) + return () => this.#subs.delete(callback) + } +} +``` + +::: + +And this `VarMap<key, Widget>` can be used as an alternative to `Variable<Array<Widget>>`. + +```tsx +function MappedBox() { + const map = new VarMap([ + [1, <MyWidget id={id} />] + [2, <MyWidget id={id} />] + ]) + + const conns = [ + gobject.connect("added", (_, id) => map.set(id, MyWidget({ id }))), + gobject.connect("removed", (_, id) => map.delete(id, MyWidget({ id }))), + ] + + return <box onDestroy={() => conns.map(id => gobject.disconnect(id))}> + {bind(map).as(arr => arr.sort(([a], [b]) => a - b).map(([,w]) => w))} + </box> +} +``` + +## Example Custom Connectable + +Astal provides [decorator functions](./gobject#example-usage) +that make it easy to subclass gobjects, however +you can read more about GObjects and subclassing +on [gjs.guide](https://gjs.guide/guides/gobject/subclassing.html#gobject-subclassing). + +Objects coming from [libraries](../libraries/references#astal-libraries) +usually have a singleton gobject you can access with `.get_default()`. + +Here is an example of a Brightness library by wrapping the `brightnessctl` cli utility +and by monitoring `/sys/class/backlight` + +::: code-group + +```ts :line-numbers [brightness.ts] +import GObject, { register, property } from "astal/gobject" +import { monitorFile, readFileAsync } from "astal/file" +import { exec, execAsync } from "astal/process" + +const get = (args: string) => Number(exec(`brightnessctl ${args}`)) +const screen = exec(`bash -c "ls -w1 /sys/class/backlight | head -1"`) +const kbd = exec(`bash -c "ls -w1 /sys/class/leds | head -1"`) + +@register({ GTypeName: "Brightness" }) +export default class Brightness extends GObject.Object { + static instance: Brightness + static get_default() { + if (!this.instance) + this.instance = new Brightness() + + return this.instance + } + + #kbdMax = get(`--device ${kbd} max`) + #kbd = get(`--device ${kbd} get`) + #screenMax = get("max") + #screen = get("get") / (get("max") || 1) + + @property(Number) + get kbd() { return this.#kbd } + + set kbd(value) { + if (value < 0 || value > this.#kbdMax) + return + + execAsync(`brightnessctl -d ${kbd} s ${value} -q`).then(() => { + this.#kbd = value + this.notify("kbd") + }) + } + + @property(Number) + get screen() { return this.#screen } + + set screen(percent) { + if (percent < 0) + percent = 0 + + if (percent > 1) + percent = 1 + + execAsync(`brightnessctl set ${Math.floor(percent * 100)}% -q`).then(() => { + this.#screen = percent + this.notify("screen") + }) + } + + constructor() { + super() + + const screenPath = `/sys/class/backlight/${screen}/brightness` + const kbdPath = `/sys/class/leds/${kbd}/brightness` + + monitorFile(screenPath, async f => { + const v = await readFileAsync(f) + this.#screen = Number(v) / this.#screenMax + this.notify("screen") + }) + + monitorFile(kbdPath, async f => { + const v = await readFileAsync(f) + this.#kbd = Number(v) / this.#kbdMax + this.notify("kbd") + }) + } +} +``` + +::: + +And it can be used like any other library object. + +```tsx +function BrightnessSlider() { + const brightness = Brightness.get_default() + + return <slider + value={bind(brightness, "screen")} + onDragged={({ value }) => brightness.screen = value} + /> +} +``` diff --git a/docs/guide/typescript/cli-app.md b/docs/guide/typescript/cli-app.md new file mode 100644 index 0000000..f409176 --- /dev/null +++ b/docs/guide/typescript/cli-app.md @@ -0,0 +1,174 @@ +# CLI and App + +`App` is a singleton **instance** of an [Astal.Application](https://aylur.github.io/libastal/astal3/class.Application.html). + +Depending on gtk version import paths will differ + +```ts +import { App } from "astal/gtk3" + +import { App } from "astal/gtk4" +``` + +## Entry point + +:::code-group + +```ts [app.ts] +App.start({ + main() { + // setup anything + // instantiate widgets + }, +}) +``` + +::: + +## Instance identifier + +You can run multiple instance by defining a unique instance name. + +```ts +App.start({ + instanceName: "my-instance", // defaults to "astal" + main() { }, +}) +``` + +## Messaging from CLI + +If you want to interact with an instance from the CLI, +you can do so by sending a message. + +```ts +App.start({ + requestHandler(request: string, res: (response: any) => void) { + if (request == "say hi") { + res("hi cli") + } + res("unknown command") + }, + main() { }, +}) +``` + +:::code-group + +```sh [astal] +astal say hi +# hi cli +``` + +```sh [ags] +ags -m "say hi" +# hi cli +``` + +::: + +If you want to run arbitrary JavaScript from CLI, you can use `App.eval` +which will evaluate the passed string as the body of an `async` function. + +```ts +App.start({ + main() {}, + requestHandler(js, res) { + App.eval(js).then(res).catch(res) + }, +}) +``` + +If the string does not contain a semicolon, a single expression is assumed and returned implicity. + +```sh +astal "'hello'" +# hello +``` + +If the string contains a semicolon, you have to return explicitly + +```sh +astal "'hello';" +# undefined + +astal "return 'hello';" +# hello +``` + +## Toggling Windows by their name + +In order for Astal to know about your windows, you have to register them. +You can do this by specifying a **unique** `name` and calling `App.add_window` + +```tsx {4} +import { App } from "astal" + +function Bar() { + return <window name="Bar" setup={self => App.add_window(self)}> + <box /> + </window> +} +``` + +You can also invoke `App.add_window` by simply passing the `App` to the `application` prop. + +```tsx {4} +import { App } from "astal" + +function Bar() { + return <window name="Bar" application={App}> + <box /> + </window> +} +``` + +:::warning +When assigning the `application` prop make sure `name` comes before. +Props are set sequentially and if name is applied after application it won't work. +::: + +:::code-group + +```sh [astal] +astal -t Bar +``` + +```sh [ags] +ags -t Bar +``` + +::: + +## Bundled scripts + +The produced scripts when bundling can run as the main instance +and a "client" instance. + +The first time you execute your bundled script the `main` function gets called. +While that instance is running any subsequent execution of the script will call +the `client` function. + +:::code-group + +```ts [main.ts] +App.start({ + // main instance + main(...args: Array<string>) { + print(...args) + }, + + // every subsequent calls + client(message: (msg: string) => string, ...args: Array<string>) { + const res = message("you can message the main instance") + console.log(res) + }, + + // this runs in the main instance + requestHandler(request: string, res: (response: any) => void) { + res("response from main") + }, +}) +``` + +::: diff --git a/docs/guide/typescript/faq.md b/docs/guide/typescript/faq.md new file mode 100644 index 0000000..48c802c --- /dev/null +++ b/docs/guide/typescript/faq.md @@ -0,0 +1,266 @@ +# Frequently asked question, common issues, tips and tricks + +## Monitor id does not match compositor + +The monitor id property that windows expect is mapped by Gdk, which is not always +the same as the compositor. Instead use the `gdkmonitor` property which expects +a `Gdk.Monitor` object. + +```tsx +import { App } from "astal" + +function Bar(gdkmonitor) { + return <window gdkmonitor={gdkmonitor} /> +} + +function main() { + for (const monitor of App.get_monitors()) { + if (monitor.model == "your-desired-model") { + Bar(monitor) + } + } +} + +App.start({ main }) +``` + +## Environment variables + +JavaScript is **not** an bash. + +```ts +const HOME = exec("echo $HOME") // does not work +``` + +`exec` and `execAsync` runs the passed program as is, its **not** run in a +shell environment, so the above example just passes `$HOME` as a string literal +to the `echo` program. + +:::danger Please don't do this +You could pass it to bash, but that is a horrible approach. + +```ts +const HOME = exec("bash -c 'echo $HOME'") +``` + +::: + +You can read environment variables with [GLib.getenv](https://gjs-docs.gnome.org/glib20~2.0/glib.getenv). + +```ts +import GLib from "gi://GLib" + +const HOME = GLib.getenv("HOME") +``` + +## Custom SVG symbolic icons + +Put the svgs in a directory, named `<icon-name>-symbolic.svg` +and use `App.add_icons` or `icons` parameter in `App.start` + +:::code-group + +```ts [app.ts] +App.start({ + icons: `${SRC}/icons`, + main() { + Widget.Icon({ + icon: "custom-symbolic", // custom-symbolic.svg + css: "color: green;", // can be colored, like other named icons + }) + }, +}) +``` + +::: + +:::info +If there is a name clash with an icon from your current icon pack +the icon pack will take precedence +::: + +## Logging + +The `console` API in gjs uses glib logging functions. +If you just want to print some text as is to stdout +use the globally available `print` function or `printerr` for stderr. + +```ts +print("print this line to stdout") +printerr("print this line to stderr") +``` + +## Populate the global scope with frequently accessed variables + +It might be annoying to always import Gtk only for `Gtk.Align` enums. + +:::code-group + +```ts [globals.ts] +import Gtk from "gi://Gtk" + +declare global { + const START: number + const CENTER: number + const END: number + const FILL: number +} + +Object.assign(globalThis, { + START: Gtk.Align.START, + CENTER: Gtk.Align.CENTER, + END: Gtk.Align.END, + FILL: Gtk.Align.FILL, +}) +``` + +::: + +:::code-group + +```tsx [Bar.tsx] +export default function Bar() { + return <window> + <box halign={START} /> + </window> +} +``` + +::: + +:::code-group + +```ts [app.ts] +import "./globals" +import Bar from "./Bar" + +App.start({ + main: Bar +}) +``` + +::: + +:::info +It is considered bad practice to populate the global scope, but its your code, not a public library. +::: + +## Auto create Window for each Monitor + +To have Window widgets appear on a monitor when its plugged in, listen to `App.monitor_added`. + +:::code-group + +```tsx [Bar.tsx] +export default function Bar(gdkmonitor: Gdk.Monitor) { + return <window gdkmonitor={gdkmonitor} /> +} +``` + +::: + +:::code-group + +```ts [app.ts] +import { Gdk, Gtk } from "astal" +import Bar from "./Bar" + +function main() { + const bars = new Map<Gdk.Monitor, Gtk.Widget>() + + // initialize + for (const gdkmonitor of App.get_monitors()) { + bars.set(gdkmonitor, Bar(gdkmonitor)) + } + + App.connect("monitor-added", (_, gdkmonitor) => { + bars.set(gdkmonitor, Bar(gdkmonitor)) + }) + + App.connect("monitor-removed", (_, gdkmonitor) => { + bars.get(gdkmonitor)?.destroy() + bars.delete(gdkmonitor) + }) +} + +App.start({ main }) +``` + +::: + +## Error: Can't convert non-null pointer to JS value + +These happen when accessing list type properties. Gjs fails to correctly bind +`List` and other array like types of Vala as a property. + +```ts +import Notifd from "gi://AstalNotifd" +const notifd = Notifd.get_default() + +notifd.notifications // β // [!code error] + +notifd.get_notifications() // β
+``` + +## How to create regular floating windows + +Use `Gtk.Window` with [Widget.astalify](/guide/typescript/widget#how-to-use-non-builtin-gtk-widgets). + +By default `Gtk.Window` is destroyed on close. To prevent this add a handler for `delete-event`. + +```tsx {4-7} +const RegularWindow = Widget.astalify(Gtk.Window) + +return <RegularWindow + onDeleteEvent={(self) => { + self.hide() + return true + }} +> + {child} +</RegularWindow> +``` + +## Is there a way to limit the width/height of a widget? + +Unfortunately not. You can set a minimum size with `min-width` and `min-heigth` css attributes, +but you can not set max size. + +## Custom widgets with bindable properties + +In function components you can wrap any primitive to handle both +binding and value cases as one. + +```tsx +function MyWidget(props: { prop: string | Binding<string> }) { + const prop = props.prop instanceof Binding + ? props.prop + : bind({ get: () => props.prop, subscribe: () => () => {} }) + + function setup(self: Widget.Box) { + self.hook(prop, () => { + const value = prop.get() + // handler + }) + } + + return <box setup={setup}> + </box> +} +``` + +You can pass the prop the super constructor in subclasses + +```tsx +@register() +class MyWidget extends Widget.Box { + @property(String) + set prop(v: string) { + // handler + } + + constructor(props: { prop: string | Binding<string> }) { + super(props) + } +} +``` diff --git a/docs/guide/typescript/first-widgets.md b/docs/guide/typescript/first-widgets.md new file mode 100644 index 0000000..a7372a8 --- /dev/null +++ b/docs/guide/typescript/first-widgets.md @@ -0,0 +1,412 @@ +# First Widgets + +## Getting Started + +Start by initializing a project + +```sh +ags --init +``` + +then run `ags` in the terminal + +```sh +ags +``` + +:::details Usage without AGS +π§ Not yet documented. π§ +::: + +That's it! You have now a custom written bar using Gtk. + +:::tip +AGS will transpile every `.ts`, `.jsx` and `.tsx` files into regular JavaScript, then +it will bundle everything into a single JavaScript file which then GJS can execute. +::: + +The AGS init command will generate the following files + +```txt +. +βββ @girs/ # generated types +βββ widget/ +β βββ Bar.tsx +βββ app.ts # entry proint +βββ env.d.ts # additional types +βββ style.css +βββ tsconfig.json # needed by LSPs +``` + +## Root of every shell component: Window + +Astal apps are composed of widgets. A widget is a piece of UI that has its own logic and style. +A widget can be as small as a button or an entire bar. +The top level widget is always a [Window](https://aylur.github.io/libastal/astal3/class.Window.html) which will hold all widgets. + +::: code-group + +```tsx [widget/Bar.tsx] +function Bar(monitor = 0) { + return <window className="Bar" monitor={monitor}> + <box>Content of the widget</box> + </window> +} +``` + +::: + +::: code-group + +```ts [app.ts] +import Bar from "./widget/Bar" + +App.start({ + main() { + Bar(0) + Bar(1) // instantiate for each monitor + }, +}) +``` + +::: + +## Creating and nesting widgets + +Widgets are JavaScript functions which return Gtk widgets, +either by using JSX or using a widget constructor. + +:::code-group + +```tsx [MyButton.tsx] +function MyButton(): JSX.Element { + return <button onClicked="echo hello"> + <label label="Click me!" /> + </button> +} +``` + +```ts [MyButton.ts] +import { Widget } from "astal/gtk3" + +function MyButton(): Widget.Button { + return new Widget.Button( + { onClicked: "echo hello" }, + new Widget.Label({ label: "Click me!" }), + ) +} +``` + +::: + +:::info +The only difference between the two is the return type. +Using markup the return type is always `Gtk.Widget` (globally available as `JSX.Element`), +while using constructors the return type is the actual type of the widget. +It is rare to need the actual return type, so most if not all of the time, you can use markup. +::: + +Now that you have declared `MyButton`, you can nest it into another component. + +```tsx +function MyBar() { + return <window> + <box> + Click The button + <MyButton /> + </box> + </window> +} +``` + +Notice that widgets you defined start with a capital letter `<MyButton />`. +Lowercase tags are builtin widgets, while capital letter is for custom widgets. + +## Displaying Data + +JSX lets you put markup into JavaScript. +Curly braces let you βescape backβ into JavaScript so that you can embed some variable +from your code and display it. + +```tsx +function MyWidget() { + const label = "hello" + + return <button>{label}</button> +} +``` + +You can also pass JavaScript to markup attributes + +```tsx +function MyWidget() { + const label = "hello" + + return <button label={label} /> +} +``` + +## Conditional Rendering + +You can use the same techniques as you use when writing regular JavaScript code. +For example, you can use an if statement to conditionally include JSX: + +```tsx +function MyWidget() { + let content + + if (condition) { + content = <True /> + } else { + content = <False /> + } + + return <box>{content}</box> +} +``` + +You can also inline a [conditional `?`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_operator) (ternary) expression. + +```tsx +function MyWidget() { + return <box>{condition ? <True /> : <False />}</box> +} +``` + +When you donβt need the `else` branch, you can also use a shorter [logical && syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_AND#short-circuit_evaluation): + +```tsx +function MyWidget() { + return <box>{condition && <True />}</box> +} +``` + +:::info +As you can guess from the above snippet, [falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy) values are not rendered. +::: + +## Rendering lists + +You can use [`for` loops](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for) or [array `map()` function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map). + +```tsx +function MyWidget() { + const labels = [ + "label1" + "label2" + "label3" + ] + + return <box> + {labels.map(label => ( + <label label={label} /> + ))} + </box> +} +``` + +## Widget signal handlers + +You can respond to events by declaring event handler functions inside your widget: + +```tsx +function MyButton() { + function onClicked(self: Widget.Button, ...args) { + console.log(self, "was clicked") + } + + return <button onClicked={onClicked} /> +} +``` + +The handler can also be a string, which will get executed in a subprocess asynchronously. + +```tsx +function MyButton() { + return <button onClicked="echo hello" /> +} +``` + +:::info +Attributes prefixed with `on` will connect to a `signal` of the widget. +Their types are not generated, but written by hand, which means not all of them are typed. +Refer to the Gtk and Astal docs to have a full list of them. +::: + +## How properties are passed + +Using JSX, a custom widget will always have a single object as its parameter. + +```ts +type Props = { + myprop: string + child?: JSX.Element // when only one child is passed + children?: Array<JSX.Element> // when multiple children are passed +} + +function MyWidget({ myprop, child, children }: Props) { + // +} +``` + +```tsx +// child prop of MyWidget is the box +return <MyWidget myprop="hello"> + <box /> +</MyWidget> +``` + +```tsx +// children prop of MyWidget is [box, box, box] +return <MyWidget myprop="hello"> + <box /> + <box /> + <box /> +</MyWidget> +``` + +## State management + +The state of widgets are handled with Bindings. A [Binding](./binding) lets you +connect the state of an [object](./binding#subscribable-and-connectable-interface) +to a widget so it re-renders when that state changes. + +Use the `bind` function to create a `Binding` object from a `Variable` or +a regular `GObject` and one of its properties. + +Here is an example of a Counter widget that uses a `Variable` as its state: + +```tsx +import { Variable, bind } from "astal" + +function Counter() { + const count = Variable(0) + + function increment() { + count.set(count.get() + 1) + } + + return <box> + <label label={bind(count).as(num => num.toString())} /> + <button onClicked={increment}> + Click to increment + <button> + </box> +} +``` + +:::info +Bindings have an `.as()` method which lets you transform the assigned value. +In the case of a Label, its label property expects a string, so it needs to be +turned to a string first. +::: + +:::tip +`Variables` have a shorthand for `bind(variable).as(transform)` + +```tsx +const v = Variable(0) +const transform = (v) => v.toString() + +return <box> + {/* these two are equivalent */} + <label label={bind(v).as(transform)} /> + <label label={v(transform)} /> +</box> +``` + +::: + +Here is an example of a battery percent label that binds the `percentage` +property of the Battery object from the [Battery Library](/guide/libraries/battery): + +```tsx +import Battery from "gi://AstalBattery" +import { bind } from "astal" + +function BatteryPercentage() { + const bat = Battery.get_default() + + return <label label={bind(bat, "percentage").as((p) => p * 100 + " %")} /> +} +``` + +## Dynamic children + +You can also use a `Binding` for `child` and `children` properties. + +```tsx +const child = Variable(<box />) + +return <box>{child}</box> +``` + +```tsx +const num = Variable(3) +const range = (n) => [...Array(n).keys()] + +return <box> + {num(n => range(n).map(i => ( + <button> + {i.toString()} + <button/> + )))} +<box> +``` + +:::tip +Binding children of widgets will implicitly call `.destroy()` on widgets +that would be left without a parent. You can opt out of this behavior +by setting `noImplicityDestroy` property on the container widget. +::: + +:::info +The above example destroys and recreates every widget in the list **every time** +the value of the `Variable` changes. There might be cases where you would +want to [handle child creation and deletion](/guide/typescript/faq#avoiding-unnecessary-re-rendering) +yourself, because you don't want to lose the +inner state of widgets that does not need to be recreated. In this case +you can create a [custom reactive structure](./binding#example-custom-subscribable) +::: + +When there is at least one `Binding` passed as a child, the `children` +parameter will always be a flattened `Binding<Array<JSX.Element>>`. +When there is a single `Binding` passed as a child, the `child` parameter will +be a `Binding<JSX.Element>` or a flattened `Binding<Array<JSX.Element>>`. + +```tsx +import { type Binding } from "astal" + +function MyContainer({ children }: { + children?: Binding<Array<JSX.Element>> +}) { + // children is a Binding over an Array of widgets +} + +return <MyContainer> + <box /> + {num(n => range(n).map(i => ( + <button> + {i.toString()} + <button/> + )))} + [ + [ + <button /> + ] + <button /> + ] +</MyContainer> +``` + +:::info +You can pass the followings as children: + +- widgets +- deeply nested arrays of widgets +- bindings of widgets, +- bindings of deeply nested arrays of widgets + +[falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy) values are not rendered and anything not from this list +will be coerced into a string and rendered as a label +::: diff --git a/docs/guide/typescript/gobject.md b/docs/guide/typescript/gobject.md new file mode 100644 index 0000000..f7f001d --- /dev/null +++ b/docs/guide/typescript/gobject.md @@ -0,0 +1,165 @@ +# Subclassing GObject.Object + +Astal provides decorator functions that make it easy to subclass gobjects. + +## Example Usage + +```ts +import GObject, { register, property } from "astal/gobject" + +@register() +class MyObj extends GObject.Object { + @property(String) + declare myProp: string + + @signal(String, Number) + declare mySignal: (a: string, b: number) => void +} +``` + +## Property decorator + +```ts +type PropertyDeclaration = + | GObject.ParamSpec + | { $gtype: GObject.GType } + +function property(declaration: PropertyDeclaration) +``` + +The `property` decorator can take any class that has a registered GType. +This includes the globally available `String`, `Number`, `Boolean` and `Object` +javascript constructors. They are mapped to their relative `GObject.ParamSpec`. + +The property decorator can be applied in the following ways: + +1. On a property declaration + +```ts {3,4} +@register() +class MyObj extends GObject.Object { + @property(String) + declare myProp: string +} +``` + +This will create a getter and setter for the property and will also +emit the notify signal when the value is set to a new value. + +:::info +The `declare` keyword is required so that the property declaration +is not transpiled into JavaScript, otherwise the initial value of the +property would be `undefined`. +::: + +:::warning +The value is checked by reference, this is important if your +property is an object type. + +```ts +const dict = obj.prop +dict["key"] = 0 +obj.prop = dict // This will not emit notify::prop // [!code error] +obj.prop = { ...dict } // This will emit notify::prop +``` + +::: + +If you want to set a custom default value, do so in the constructor of your class. + +```ts {7} +@register() +class MyObj extends GObject.Object { + @property(String) + declare myProp: string + + constructor() { + super({ myProp: "default-value" }) + } +} +``` + +2. On a getter + +```ts {3,4} +@register() +class MyObj extends GObject.Object { + @property(String) + get myProp () { + return "value" + } +} +``` + +This will create a read-only property. + +3. On a getter and setter + +```ts {5,6,10} +@register() +class MyObj extends GObject.Object { + declare private _prop: string + + @property(String) + get myProp () { + return "value" + } + + set myProp (v: string) { + if (v !== this._prop) { + this._prop = v + this.notify("my-prop") + } + } +} +``` + +This will create a read-write property. + +:::info +When defining getter/setters for the property, notify signal emission has to be done explicitly. +::: + +## Signal decorator + +```ts +function signal(...params: Array<{ $gtype: GObject.GType }) + +function signal(declaration?: SignalDeclaration) // Object you would pass to GObject.registerClass +``` + +You can apply the signal decorator to either a property declaration or a method. + +```ts {3,4,6,7} +@register() +class MyObj extends GObject.Object { + @signal(String, String) + declare mySig: (a: String, b: String) => void + + @signal(String, String) + mySig(a: string, b: string) { + // default signal handler + } +} +``` + +You can emit the signal by calling the signal method or using `emit`. + +```ts +const obj = new MyObj() +obj.connect("my-sig", (obj, a: string, b: string) => {}) + +obj.mySig("a", "b") +obj.emit("my-sig", "a", "b") +``` + +## Register decorator + +Every GObject subclass has to be registered. You can pass the same options +to this decorator as you would to `GObject.registerClass` + +```ts +@register({ GTypeName: "MyObj" }) +class MyObj extends GObject.Object { +} +``` diff --git a/docs/guide/typescript/installation.md b/docs/guide/typescript/installation.md new file mode 100644 index 0000000..f1d20c5 --- /dev/null +++ b/docs/guide/typescript/installation.md @@ -0,0 +1,90 @@ +# Installation + +It is recommended to use [AGS](https://github.com/aylur/ags) +to scaffold and run projects in TypeScript. + +It lets you + +- generate TypeScript types using [ts-for-gir](https://github.com/gjsify/ts-for-gir) +- generate a tsconfig which is used by LSPs +- bundle your TypeScript and JavaScript code using [esbuild](https://esbuild.github.io/). + +:::details Trivia +AGS is the predecessor of Astal, which was written purely in TypeScript and so only supported +JavaScript/TypeScript. Now it serves as a scaffolding tool for Astal+TypeScript+JSX projects. +::: + +## Nix + +maintainer: [@Aylur](https://github.com/Aylur) + +Read more about it on the [nix page](../getting-started/nix#ags) + +You can try without installing. + +<!--TODO: remove v2 after merge--> +```sh +nix run github:aylur/ags/v2#agsFull -- --help +``` + +## From source + +1. [Install Astal](../getting-started/installation.md) if you have not already + +2. Install the Astal GJS package + +```sh +git clone https://github.com/aylur/astal /tmp/astal +cd /tmp/astal/lang/gjs +meson setup --prefix /usr build +meson install -C build +``` + +:::tip +You might be wondering why it is recommended to install a JavaScript +package on the system instead of installing it as a node module. +It is solely to keep it in **sync** with the core `astal-io` and `astal3`/`astal4` package. +::: + +3. Install the following dependencies + +:::code-group + +```sh [<i class="devicon-archlinux-plain"></i> Arch] +sudo pacman -Syu go npm gjs +``` + +```sh [<i class="devicon-fedora-plain"></i> Fedora] +sudo dnf install golang npm gjs +``` + +```sh [<i class="devicon-ubuntu-plain"></i> Ubuntu] +sudo apt install golang-go npm gjs +``` + +::: + +3. Clone the repo and Install + +<!--TODO: remove v2 after merge--> +```sh +git clone https://github.com/aylur/ags.git /tmp/ags +cd /tmp/ags +git checkout v2 # https://github.com/Aylur/ags/pull/504 +cd src +go install +``` + +:::tip +`go install` installs the `ags` binary to `$GOPATH/bin` so make sure its in your `$PATH`. +You can move it to another directory if you like. For example + +```sh +sudo mv $GOPATH/bin/ags /usr/bin/ags +``` + +::: + +## Without AGS + +π§ Setting up a dev environment without AGS is not yet documented. π§ diff --git a/docs/guide/typescript/theming.md b/docs/guide/typescript/theming.md new file mode 100644 index 0000000..10a3981 --- /dev/null +++ b/docs/guide/typescript/theming.md @@ -0,0 +1,179 @@ +# Theming + +Since the widget toolkit is **GTK3** theming is done with **CSS**. + +- [CSS tutorial](https://www.w3schools.com/css/) +- [GTK CSS Overview wiki](https://docs.gtk.org/gtk3/css-overview.html) +- [GTK CSS Properties Overview wiki](https://docs.gtk.org/gtk3/css-properties.html) + +:::warning GTK is not the web +While most features are implemented in GTK, +you can't assume anything that works on the web will work with GTK. +Refer to the [GTK docs](https://docs.gtk.org/gtk3/css-overview.html) +to see what is available. +::: + +So far every widget you made used your default GTK3 theme. +To make them more custom, you can apply stylesheets to them. + +## From file at startup + +You can pass a path to a file or css as a string in `App.start` + +:::code-group + +```ts [app.ts] +import style from "inline:./style.css" + +const inlineCssInCode = ` +window { + background-color: transparent; +} +` + +App.start({ + css: "./style.css", + css: style, + css: `${SRC}/style.css'`, + css: inlineCss, +}) +``` + +::: + +:::info +When using AGS the global `SRC` will point to the directory `app.ts` is in. +AGS will set the current working directory to `--config`, so `./style.css` also works. +If you are not using AGS you should inline import CSS instead. +::: + +## Css Property on Widgets + +```ts +Widget.Label({ + css: "color: blue; padding: 1em;", + label: "hello", +}) +``` + +:::info +The `css` property of a widget will not cascade to its children. +::: + +## Apply Stylesheets at Runtime + +You can apply additional styles at runtime. + +```ts +App.apply_css("/path/to/file.css") +``` + +```ts +App.apply_css(` +window { + background-color: transparent; +} +`) +``` + +```ts +App.reset_css() // reset if need +``` + +:::warning +`App.apply_css` will apply on top of other stylesheets applied before. +You can reset stylesheets with `App.resetCss` +::: + +## Inspector + +If you are not sure about the widget hierarchy or any CSS selector, +you can use the [GTK inspector](https://wiki.gnome.org/Projects/GTK/Inspector) + +::: code-group + +```sh [astal] +# to bring up the inspector run +astal --inspector +``` + +```sh [ags] +# to bring up the inspector run +ags --inspector +``` + +::: + +## Using SCSS + +Gtk's CSS only supports a subset of what the web offers. +Most notably nested selectors are unsupported by Gtk, but this can be +workaround by using preprocessors like [SCSS](https://sass-lang.com/). + +:::code-group + +```sh [<i class="devicon-archlinux-plain"></i> Arch] +sudo pacman -Syu dart-sass +``` + +```sh [<i class="devicon-fedora-plain"></i> Fedora] +npm install -g sass # not packaged on Fedora +``` + +```sh [<i class="devicon-ubuntu-plain"></i> Ubuntu] +npm install -g sass # not packaged on Ubuntu +``` + +::: + +Importing `scss` files will simply return transpiled css. + +:::code-group + +```ts [app.ts] +import style from "./style.scss" + +App.start({ + css: style, + main() {}, +}) +``` + +::: + +:::details Without AGS +AGS uses a plugin to transpile scss files before importing them. +If you are not using AGS, you can use a plugin for your chosen bundler. +::: + +:::tip +If you for example want to set scss varibles from JS, +You can inline import, compose, and transpile yourself. + +```ts [app.ts] +import style1 from "inline:./style1.scss" +import style2 from "inline:./style2.scss" + +const tmpscss = "/tmp/style.scss" +const target = "/tmp/style.css" + +writeFile(tmpscss, ` + $var1: red; + $var1: blue; + ${style1} + ${style1} +`) + +exec(`sass ${tmpscss} ${target}`) + +App.start({ + css: target, +}) + +``` + +::: + +:::info +If you want other preprocessors support builtin open an Issue. +::: diff --git a/docs/guide/typescript/utilities.md b/docs/guide/typescript/utilities.md new file mode 100644 index 0000000..361c33b --- /dev/null +++ b/docs/guide/typescript/utilities.md @@ -0,0 +1,170 @@ +# Utilities + +## File functions + +Import from `astal` or `astal/file` + +```ts +import { readFile, readFileAsync, writeFile, writeFileAsync, monitorFile, } from "astal/file" +``` + +### Reading files + +```ts +function readFile(path: string): string +function readFileAsync(path: string): Promise<string> +``` + +### Writing files + +```ts +function writeFile(path: string, content: string): void +function writeFileAsync(path: string, content: string): Promise<void> +``` + +### Monitoring files + +If `path` is a directory it will be recursively monitored. + +```ts +function monitorFile( + path: string, + callback: (file: string, event: Gio.FileMonitorEvent) => void, +): Gio.FileMonitor +``` + +## Timeouts and Intervals + +Import from `astal` or `astal/time` + +```ts +import { interval, timeout, idle } from "astal/time" +``` + +You can use javascript native `setTimeout` or `setInterval` +they return a [GLib.Source](https://docs.gtk.org/glib/struct.Source.html) instance. +Alternatively you can use these functions provided by Astal, +which return an [AstalIO.Time](https://aylur.github.io/libastal/io/class.Time.html) instance. + +`AstalIO.Time` has a `now` signal and a `cancelled` signal. + +### Interval + +Will immediately execute the function and every `interval` millisecond. + +```ts +function interval(interval: number, callback?: () => void): AstalIO.Time +``` + +### Timeout + +Will execute the `callback` after `timeout` millisecond. + +```ts +function timeout(timeout: number, callback?: () => void): AstalIO.Time +``` + +### Idle + +Executes `callback` whenever there are no higher priority events pending. + +```ts +function idle(callback?: () => void): AstalIO.Time +``` + +Example: + +```ts +const timer = interval(1000, () => { + console.log("optional callback") +}) + +timer.connect("now", () => { + console.log("tick") +}) + +timer.connect("cancelled", () => { + console.log("cancelled") +}) + +timer.cancel() +``` + +## Process functions + +Import from `astal` or `astal/process` + +```ts +import { subprocess, exec, execAsync } from "astal/process" +``` + +### Subprocess + +You can start a subprocess and run callback functions whenever it outputs to +stdout or stderr. [AstalIO.Process](https://aylur.github.io/libastal/io/class.Process.html) has a `stdout` and `stderr` signal. + +```ts +function subprocess(args: { + cmd: string | string[] + out?: (stdout: string) => void + err?: (stderr: string) => void +}): AstalIO.Process + +function subprocess( + cmd: string | string[], + onOut?: (stdout: string) => void, + onErr?: (stderr: string) => void, +): AstalIO.Process +``` + +Example: + +```ts +const proc = subprocess( + "some-command", + (out) => console.log(out), // optional + (err) => console.error(out), // optional +) + +// or with signals +const proc = subprocess("some-command") +proc.connect("stdout", (_, out) => console.log(out)) +proc.connect("stderr", (_, err) => console.error(err)) +``` + +### Executing external commands and scripts + +```ts +function exec(cmd: string | string[]): string +function execAsync(cmd: string | string[]): Promise<string> +``` + +Example: + +```ts +try { + const out = exec("/path/to/script") + console.log(out) +} catch (err) { + console.error(err) +} + +execAsync(["bash", "-c", "/path/to/script.sh"]) + .then((out) => console.log(out)) + .catch((err) => console.error(err)) +``` + +:::warning +`subprocess`, `exec`, and `execAsync` executes the passed executable as is. +They are **not** executed in a shell environment, +they do **not** expand ENV variables like `$HOME`, +and they do **not** handle logical operators like `&&` and `||`. + +If you want bash, run them with bash. + +```ts +exec(["bash", "-c", "command $VAR && command"]) +exec("bash -c 'command $VAR' && command") +``` + +::: diff --git a/docs/guide/typescript/variable.md b/docs/guide/typescript/variable.md new file mode 100644 index 0000000..e6f3434 --- /dev/null +++ b/docs/guide/typescript/variable.md @@ -0,0 +1,153 @@ +# Variable + +```js +import { Variable } from "astal" +``` + +Variable is just a simple object which holds a single value. +It also has some shortcuts for hooking up subprocesses, intervals and other gobjects. + +:::info +The `Variable` object imported from the `"astal"` package is **not** [Astal.Variable](https://aylur.github.io/libastal/io/class.Variable.html). +::: + +## Example Usage + +```typescript +const myvar = Variable("initial-value") + +// whenever its value changes, callback will be executed +myvar.subscribe((value: string) => { + console.log(value) +}) + +// settings its value +myvar.set("new value") + +// getting its value +const value = myvar.get() + +// binding them to widgets +Widget.Label({ + label: bind(myvar).as((value) => `transformed ${value}`), + label: myvar((value) => `transformed ${value}`), // shorthand for the above +}) +``` + +:::warning +Make sure to the transform functions you pass to `:as()` are pure. +The `.get()` function can be called anytime by `astal` especially when `deriving`, +so make sure there are no sideeffects. +::: + +## Variable Composition + +Using `Variable.derive` any `Subscribable` object can be composed. + +```typescript +const v1: Variable<number> = Variable(1) +const v2: Binding<number> = bind(obj, "prop") +const v3: Subscribable<number> = { + get: () => 3, + subscribe: () => () => {}, +} + +// first argument is a list of dependencies +// second argument is a transform function, +// where the parameters are the values of the dependencies in the order they were passed +const v4: Variable<number> = Variable.derive( + [v1, v2, v3], + (v1: number, v2: number, v3: number) => { + return v1 * v2 * v3 + } +) +``` + +:::info +The types are only for demonstration purposes, you do not have to declare +the type of a Variable, they will be inferred from their initial value. +::: + +## Subprocess shorthands + +Using `.poll` and `.watch` we can start subprocesses and capture their +output. They can poll and watch at the same time, but they +can only poll/watch once. + +:::warning +The command parameter is passed to [execAsync](/guide/typescript/utilities#executing-external-commands-and-scripts) +which means they are **not** executed in a shell environment, +they do **not** expand ENV variables like `$HOME`, +and they do **not** handle logical operators like `&&` and `||`. + +If you want bash, run them with bash. + +```js +Variable("").poll(1000, ["bash", "-c", "command $VAR && command"]) +``` + +::: + +```typescript +const myVar = Variable(0) + .poll(1000, "command", (out: string, prev: number) => parseInt(out)) + .poll(1000, ["bash", "-c", "command"], (out, prev) => parseInt(out)) + .poll(1000, (prev) => prev + 1) +``` + +```typescript +const myVar = Variable(0) + .watch("command", (out: string, prev: number) => parseInt(out)) + .watch(["bash", "-c", "command"], (out, prev) => parseInt(out)) +``` + +You can temporarily stop them and restart them whenever. + +```js +myvar.stopWatch() // this kills the subprocess +myvar.stopPoll() + +myvar.startListen() // launches the subprocess again +myvar.startPoll() + +console.log(myvar.isListening()) +console.log(myvar.isPolling()) +``` + +## Gobject connection shorthands + +Using `.observe` you can connect gobject signals and capture their value. + +```typescript +const myvar = Variable("") + .observe(obj1, "signal", () => "") + .observe(obj2, "signal", () => "") +``` + +## Dispose if no longer needed + +This will stop the interval, force exit the subprocess and disconnect gobjects. + +```js +myVar.drop() +``` + +:::warning +Don't forget to drop derived variables or variables with +either `.poll`, `.watch` or `.observe` when they are defined inside closures. + +```tsx +function MyWidget() { + const myvar = Variable().poll() + const derived = Variable.derive() + + return <box + onDestroy={() => { + myvar.drop() + derived.drop() + }} + /> +} +``` + +::: diff --git a/docs/guide/typescript/widget.md b/docs/guide/typescript/widget.md new file mode 100644 index 0000000..7ed69e3 --- /dev/null +++ b/docs/guide/typescript/widget.md @@ -0,0 +1,216 @@ +# Widget + +## Gtk3 + +### Additional widget properties + +These are properties that Astal additionally adds to Gtk.Widgets + +- className: `string` - List of class CSS selectors separated by white space. +- css: `string` - Inline CSS. e.g `label { color: white; }`. If no selector is specified `*` will be assumed. e.g `color: white;` will be inferred as `* { color: white; }`. +- cursor: `string` - Cursor style when hovering over widgets that have hover states, e.g it won't work on labels. [list of valid values](https://docs.gtk.org/gdk3/ctor.Cursor.new_from_name.html). +- clickThrough: `boolean` - Lets click events through. + +To have a full list of available properties, reference the documentation of the widget. + +- [Astal3 widgets](https://aylur.github.io/libastal/astal3/index.html#classes) +- [Gtk widgets](https://docs.gtk.org/gtk3/#classes) + +### Additional widget methods + +#### setup + +`setup` is a convenience prop to remove the need to predefine widgets +before returning them in cases where a reference is needed. + +without `setup` + +```tsx +function MyWidget() { + const button = Widget.Button() + // setup button + return button +} +``` + +using `setup` + +```tsx +function MyWidget() { + function setup(button: Widget.Button) { + // setup button + } + + return <buttons setup={setup} /> +} +``` + +#### hook + +Shorthand for connection and disconnecting to [Subscribable and Connectable](./binding#subscribable-and-connectable-interface) objects. + +without `hook` + +```tsx +function MyWidget() { + const id = gobject.connect("signal", callback) + const unsub = variable.subscribe(callback) + + return <box + onDestroy={() => { + gobject.disconnect(id) + unsub() + }} + /> +} +``` + +with `hook` + +```tsx +function MyWidget() { + return <box + setup={(self) => { + self.hook(gobject, "signal", callback) + self.hook(variable, callback) + }} + /> +} +``` + +#### toggleClassName + +Toggle classNames based on a condition + +```tsx +function MyWidget() { + return <box + setup={(self) => { + self.toggleClassName("classname", someCondition) + }} + /> +} +``` + +### How to use non builtin Gtk widgets + +Using the `Widget.astalify` mixin you can subclass widgets +to behave like builtin widgets. +The `astalify` mixin will apply the following: + +- set `visible` to true by default (Gtk3 widgets are invisible by default) +- make gobject properties accept and consume `Binding` objects +- add properties and methods listed above +- sets up signal handlers that are passed as props prefixed with `on` + +```tsx +import GObject from "gi://GObject" +import { Gtk, Gdk, Widget, astalify, type ConstructProps } from "astal/gtk3" + +// subclass, register, define constructor props +class ColorButton extends astalify(Gtk.ColorButton) { + static { GObject.registerClass(this) } + + constructor(props: ConstructProps< + ColorButton, + Gtk.ColorButton.ConstructorProps, + { onColorSet: [] } // signals of Gtk.ColorButton have to be manually typed + >) { + super(props as any) + } +} + +function MyWidget() { + function setup(button: ColorButton) { + + } + + return <ColorButton + setup={setup} + useAlpha + rgba={new Gdk.RGBA({ + red: 1, + green: 0, + blue: 0, + alpha: 0.5, + })} + onColorSet={(self) => { + console.log(self.rgba) + }} + /> +} +``` + +:::info +Signal properties have to be annotated manually for TypeScript. +You can reference [Gtk3](https://gjs-docs.gnome.org/gtk30~3.0/) +and [Astal](https://aylur.github.io/libastal/index.html#classes) for available signals. +::: + +### TypeScript + +Type of widgets are available through `Widget`. +Here is an example Widget that takes in and handles a possibly `Binding` prop. + +```tsx +import { Binding, Variable } from "astal" +import { Widget } from "astal/gtk3" + +export interface ToggleButtonProps extends Widget.ButtonProps { + onToggled?: (self: Widget.Button, on: boolean) => void + state?: Binding<boolean> | boolean + child?: JSX.Element +} + +export default function ToggleButton(btnprops: ToggleButtonProps) { + const { state = false, onToggled, setup, child, ...props } = btnprops + const innerState = Variable(state instanceof Binding ? state.get() : state) + + return <button + {...props} + setup={self => { + setup?.(self) + + self.toggleClassName("active", innerState.get()) + self.hook(innerState, () => self.toggleClassName("active", innerState.get())) + + if (state instanceof Binding) { + self.hook(state, () => innerState.set(state.get())) + } + }} + onClicked={self => { + onToggled?.(self, !innerState.get()) + }} + > + {child} + </button> +} +``` + +### Builtin Widgets + +You can check the [source code](https://github.com/aylur/astal/blob/main/lang/gjs/src/gtk3/index.ts) to have a full list of builtin widgets. + +These widgets are available by default in JSX. + +- box: [Astal.Box](https://aylur.github.io/libastal/astal3/class.Box.html) +- button: [Astal.Button](https://aylur.github.io/libastal/astal3/class.Button.html) +- centerbox: [Astal.CenterBox](https://aylur.github.io/libastal/astal3/class.CenterBox.html) +- circularprogress: [Astal.CircularProgress](https://aylur.github.io/libastal/astal3/class.CircularProgress.html) +- drawingarea: [Gtk.DrawingArea](https://docs.gtk.org/gtk3/class.DrawingArea.html) +- entry: [Gtk.Entry](https://docs.gtk.org/gtk3/class.Entry.html) +- eventbox: [Astal.EventBox](https://aylur.github.io/libastal/astal3/class.EventBox.html) +- icon: [Astal.Icon](https://aylur.github.io/libastal/astal3/class.Icon.html) +- label: [Astal.Label](https://aylur.github.io/libastal/astal3/class.Label.html) +- levelbar: [Astal.LevelBar](https://aylur.github.io/libastal/astal3/class.LevelBar.html) +- overlay: [Astal.Overlay](https://aylur.github.io/libastal/astal3/class.Overlay.html) +- revealer: [Gtk.Revealer](https://docs.gtk.org/gtk3/class.Revealer.html) +- scrollable: [Astal.Scrollable](https://aylur.github.io/libastal/astal3/class.Scrollable.html) +- slider: [Astal.Slider](https://aylur.github.io/libastal/astal3/class.Slider.html) +- stack: [Astal.Stack](https://aylur.github.io/libastal/astal3/class.Stack.html) +- switch: [Gtk.Switch](https://docs.gtk.org/gtk3/class.Switch.html) +- window: [Astal.Window](https://aylur.github.io/libastal/astal3/class.Window.html) + +## Gtk4 + +π§ Work in Progress π§ |