diff options
author | Aylur <[email protected]> | 2024-12-25 02:38:27 +0100 |
---|---|---|
committer | GitHub <[email protected]> | 2024-12-25 02:38:27 +0100 |
commit | 37f0d24178a1516eb45eb639640e07c5dc3b8e81 (patch) | |
tree | 28ff8d1030be1919c00152e99b4ab9c229b0f01b | |
parent | 553b2186db47fb34602d4e949c1e40a018238d7a (diff) | |
parent | 0f2fefd2053203e1bfe4d66eb4e37dea07369890 (diff) |
Merge pull request #196 from Aylur/feat/jsx-gtk4
Add jsx support for gtk4
32 files changed, 1329 insertions, 302 deletions
diff --git a/docs/guide/getting-started/introduction.md b/docs/guide/getting-started/introduction.md index 782c069..43a7bd8 100644 --- a/docs/guide/getting-started/introduction.md +++ b/docs/guide/getting-started/introduction.md @@ -2,13 +2,15 @@ ## What is Astal? -Astal (_meaning "desk"_) is a suite of libraries in Vala and C. +Astal (_meaning "desk"_) is a suite of libraries written in Vala and C. The core library [astal3](https://aylur.github.io/libastal/astal3) and -[astal4](https://aylur.github.io/libastal/astal4) (not yet available) -has some Gtk widgets that come packaged, +[astal4](https://aylur.github.io/libastal/astal4) +have some Gtk widgets that come packaged, the most important one being the [Window](https://aylur.github.io/libastal/astal3/class.Window.html) which is the main toplevel component using [gtk-layer-shell](https://github.com/wmww/gtk-layer-shell). This is what allows us to use Gtk as shell components on Wayland. -The other part of the core library [astal-io](https://aylur.github.io/libastal/astal-io) +The other component is [Application](https://aylur.github.io/libastal/astal3/class.Application.html) +which provides a way to send messages from the cli to running Astal instances. +The other part of the core library is [astal-io](https://aylur.github.io/libastal/astal-io) which contains some utility GLib shortcut for running external processes, reading, writing and monitoring files, timeout and interval functions. @@ -23,4 +25,4 @@ or an applauncher, but gave up because writing a workspace widget, implementing the notification daemon or handling a search filter was too much of a hassle? Astal libraries have you [covered](../libraries/references#astal-libraries), you don't have to worry about these, -you just define the layout, style it with CSS and that's it. +you just define the layout, style with CSS hook up the state from libraries you want and that's it. diff --git a/docs/guide/typescript/cli-app.md b/docs/guide/typescript/cli-app.md index 9b299aa..41b1d7c 100644 --- a/docs/guide/typescript/cli-app.md +++ b/docs/guide/typescript/cli-app.md @@ -26,7 +26,7 @@ App.start({ ## Instance identifier -You can run multiple instance by defining a unique instance name. +You can run multiple instances by defining a unique instance name. ```ts App.start({ @@ -44,7 +44,7 @@ you can do so by sending a message. App.start({ requestHandler(request: string, res: (response: any) => void) { if (request == "say hi") { - res("hi cli") + return res("hi cli") } res("unknown command") }, @@ -140,7 +140,7 @@ App.start({ // every subsequent calls client(message: (msg: string) => string, ...args: Array<string>) { const res = message("you can message the main instance") - console.log(res) + print(res) }, // this runs in the main instance diff --git a/docs/guide/typescript/first-widgets.md b/docs/guide/typescript/first-widgets.md index 77b2f61..9b8bf32 100644 --- a/docs/guide/typescript/first-widgets.md +++ b/docs/guide/typescript/first-widgets.md @@ -71,7 +71,7 @@ function MyButton(): JSX.Element { } ``` -```ts [MyButton.ts] +```ts [MyButton.ts (gtk3)] import { Widget } from "astal/gtk3" function MyButton(): Widget.Button { @@ -82,6 +82,17 @@ function MyButton(): Widget.Button { } ``` +```ts [MyButton.ts (gtk4)] +import { Widget } from "astal/gtk4" + +function MyButton(): Widget.Button { + return Widget.Button( + { onClicked: "echo hello" }, + Widget.Label({ label: "Click me!" }), + ) +} +``` + ::: :::info @@ -218,6 +229,14 @@ Their types are not generated, but written by hand, which means not all of them Refer to the Gtk and Astal docs to have a full list of them. ::: +:::info +Attributes prefixed with `onNotify` will connect to a `notify::` signal of the widget. + +```tsx +<switch onNotifyActive={self => print("switched to", self.active)}> +``` +::: + ## How properties are passed Using JSX, a custom widget will always have a single object as its parameter. @@ -413,8 +432,7 @@ function Parent(props: { :::tip If you have a widget where you pass widgets in various ways, you can -wrap `child` in `children` in a [`Subscribable`](./faq#custom-widgets-with-bindable-properties) and handle all cases -as if they were bindings. +wrap `child` and `children` props in a [`Subscribable`](./faq#custom-widgets-with-bindable-properties) and handle all cases as if they were bindings. ::: :::info diff --git a/docs/guide/typescript/theming.md b/docs/guide/typescript/theming.md index 5944c4e..3cafa7d 100644 --- a/docs/guide/typescript/theming.md +++ b/docs/guide/typescript/theming.md @@ -1,24 +1,25 @@ # Theming -Since the widget toolkit is **GTK3** theming is done with **CSS**. +Since the widget toolkit is **GTK** 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) +- [GTK3 CSS Overview wiki](https://docs.gtk.org/gtk3/css-overview.html) +- [GTK3 CSS Properties Overview wiki](https://docs.gtk.org/gtk3/css-properties.html) +- [GTK4 CSS Overview wiki](https://docs.gtk.org/gtk4/css-overview.html) +- [GTK4 CSS Properties Overview wiki](https://docs.gtk.org/gtk4/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. +Refer to the GTK docs to see what is available. ::: -So far every widget you made used your default GTK3 theme. +So far every widget you made used your default GTK 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` +You can pass a path to a file or CSS as a string in `App.start` :::code-group diff --git a/docs/guide/typescript/widget.md b/docs/guide/typescript/widget.md index 7ed69e3..7e57c01 100644 --- a/docs/guide/typescript/widget.md +++ b/docs/guide/typescript/widget.md @@ -6,15 +6,21 @@ 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. +- `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) +- [Gtk3 widgets](https://docs.gtk.org/gtk3/#classes) + +Most common ones you will use frequently are + - [halign](https://docs.gtk.org/gtk3/property.Widget.halign.html) + - [valign](https://docs.gtk.org/gtk3/property.Widget.valign.html) + - [hexpand](https://docs.gtk.org/gtk3/property.Widget.hexpand.html) + - [vexpand](https://docs.gtk.org/gtk3/property.Widget.vexpand.html) ### Additional widget methods @@ -27,7 +33,7 @@ without `setup` ```tsx function MyWidget() { - const button = Widget.Button() + const button = new Widget.Button() // setup button return button } @@ -94,14 +100,14 @@ function MyWidget() { ### How to use non builtin Gtk widgets -Using the `Widget.astalify` mixin you can subclass widgets +Using the `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` +- set up signal handlers that are passed as props prefixed with `on` ```tsx import GObject from "gi://GObject" @@ -135,7 +141,7 @@ function MyWidget() { alpha: 0.5, })} onColorSet={(self) => { - console.log(self.rgba) + print(self.rgba) }} /> } @@ -144,7 +150,7 @@ function MyWidget() { :::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. +and [Astal3](https://aylur.github.io/libastal/astal3/#classes) for available signals. ::: ### TypeScript @@ -189,28 +195,377 @@ export default function ToggleButton(btnprops: ToggleButtonProps) { ### 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) + ```tsx + <box>Horizontal Box</box> + ``` + ```tsx + <box orientation={1}>Vertical Box</box> + ``` - button: [Astal.Button](https://aylur.github.io/libastal/astal3/class.Button.html) + ```tsx + <button onClicked={self => print(self, "was clicked")}> + Click Me + </button> + ``` - centerbox: [Astal.CenterBox](https://aylur.github.io/libastal/astal3/class.CenterBox.html) + ```tsx + <centerbox orientation={1}> + <label vexpand valign={Gtk.Align.START} label="Start Widget" /> + <label label="Center Widget" /> + <label vexpand valign={Gtk.Align.END} label="End Widget" /> + </box> + ``` - circularprogress: [Astal.CircularProgress](https://aylur.github.io/libastal/astal3/class.CircularProgress.html) + ```tsx + <circularprogress value={.5} startAt={0.75} endAt={0.75}> + <icon /> + </circularprogress> + ``` + ```css + circularprogress { + color: green; + background-color: black; + font-size: 6px; + margin: 2px; + min-width: 32px; + } + ``` + - drawingarea: [Gtk.DrawingArea](https://docs.gtk.org/gtk3/class.DrawingArea.html) + ```tsx + <drawingarea onDraw={drawingFunction} /> + ``` + - entry: [Gtk.Entry](https://docs.gtk.org/gtk3/class.Entry.html) + ```tsx + <window keymode={Astal.Keymode.ON_DEMAND}> + <entry + onChanged={self => print("text changed", self.text)} + onActivate={self => print("enter", self.text)} + /> + </window> + ``` + - eventbox: [Astal.EventBox](https://aylur.github.io/libastal/astal3/class.EventBox.html) + ```tsx + <eventbox + onClick={(_, event) => { + print(event.modifier, event.button) + }} + /> + ``` + - icon: [Astal.Icon](https://aylur.github.io/libastal/astal3/class.Icon.html) + ```tsx + <icon icon={GLib.get_os_info("LOGO") || "missing-symbolic"} /> + ``` + ```css + icon { + font-size: 16px; + } + ``` + - label: [Astal.Label](https://aylur.github.io/libastal/astal3/class.Label.html) + ```tsx + <label label="hello" maxWidthChars={16} wrap /> + ``` + - levelbar: [Astal.LevelBar](https://aylur.github.io/libastal/astal3/class.LevelBar.html) + ```tsx + <levelbar value={0.5} widthRequest={200} /> + ``` + - overlay: [Astal.Overlay](https://aylur.github.io/libastal/astal3/class.Overlay.html) + ```tsx + <overlay> + <box heightRequest={40} widthRequest={40}>Child</box> + <box className="overlay" valign={Gtk.Align.START} halign={Gtk.Align.END}>1</box> + </overlay> + ``` + - revealer: [Gtk.Revealer](https://docs.gtk.org/gtk3/class.Revealer.html) + ```tsx + <revealer + setup={self => timeout(500, () => self.revealChild = true)} + transitionType={Gtk.RevealerTransitionType.SLIDE_UP}> + <label label="Child" /> + </revealer> + ``` + - scrollable: [Astal.Scrollable](https://aylur.github.io/libastal/astal3/class.Scrollable.html) + ```tsx + <scrollable heightRequest={100}> + <box orientation={1}> + {Array.from({ length: 10 }, (_, i) => ( + <button>{i}</button> + ))} + </box> + </scrollable> + ``` + - slider: [Astal.Slider](https://aylur.github.io/libastal/astal3/class.Slider.html) + ```tsx + <slider widthRequest={100} onDragged={self => print("new value", self.value)} /> + ``` + - stack: [Astal.Stack](https://aylur.github.io/libastal/astal3/class.Stack.html) + ```tsx + <stack visibleChildName="child2"> + <label name="child1" label="child1" /> + <label name="child2" label="child2" /> + </stack> + ``` + - switch: [Gtk.Switch](https://docs.gtk.org/gtk3/class.Switch.html) + ```tsx + <switch onNotifyActive={self => print(self.active)} /> + ``` + - window: [Astal.Window](https://aylur.github.io/libastal/astal3/class.Window.html) + ```tsx + <window + className="Bar" + name="bar" + namespace="bar" + application={App} + monitor={0} + anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT} + exclusivity={Astal.Exclusivity.EXCLUSIVE} + keymode={Astal.Keymode.ON_DEMAND} + > + <centerbox /> + </window> + ``` ## Gtk4 -🚧 Work in Progress 🚧 +The Gtk4 js library does not add any additional properties to the widgets, +but it still has some additional properties that the constructors handle. + +- `type`: `string` an arbitrary string that the [Buildable](https://docs.gtk.org/gtk4/iface.Buildable.html) interface uses. +- event handlers for [EventControllers](https://docs.gtk.org/gtk4/class.EventController.html) + ```ts + type EventController<Self extends Gtk.Widget> = { + onFocusEnter?: (self: Self) => void + onFocusLeave?: (self: Self) => void + + onKeyPressed?: (self: Self, keyval: number, keycode: number, state: Gdk.ModifierType) => void + onKeyReleased?: (self: Self, keyval: number, keycode: number, state: Gdk.ModifierType) => void + onKeyModifier?: (self: Self, state: Gdk.ModifierType) => void + + onLegacy?: (self: Self, event: Gdk.Event) => void + onButtonPressed?: (self: Self, state: Gdk.ButtonEvent) => void + onButtonReleased?: (self: Self, state: Gdk.ButtonEvent) => void + + onHoverEnter?: (self: Self, x: number, y: number) => void + onHoverLeave?: (self: Self) => void + onMotion?: (self: Self, x: number, y: number) => void + + onScroll?: (self: Self, dx: number, dy: number) => void + onScrollDecelerate?: (self: Self, vel_x: number, vel_y: number) => void + } + ``` + +- `setup`: `(self): void` setup function that runs after constructor + ```tsx + // without `setup` + function MyWidget() { + const button = Widget.Button() + // setup button + return button + } + + // using `setup` + function MyWidget() { + function setup(button: Widget.Button) { + // setup button + } + + return <buttons setup={setup} /> + } + ``` + +There is also a `hook` utility + +```tsx +// without `hook` +function MyWidget() { + const id = gobject.connect("signal", callback) + const unsub = variable.subscribe(callback) + + return <box + onDestroy={() => { + gobject.disconnect(id) + unsub() + }} + /> +} + +// with `hook` +import { hook } from "astal/gtk4" + +function MyWidget() { + return <box + setup={(self) => { + self.hook(gobject, "signal", callback) + self.hook(variable, callback) + }} + /> +} +``` + +### How to use non builtin Gtk widgets + +Using the `astalify` function you can create wrappers around widget constructors +to make them behave like builtin widgets. +The `astalify` function will do the followings: + +- make `gobject` properties accept and consume `Binding` objects +- handle properties listed above +- set up signal handlers that are passed as props prefixed with `on` + +```tsx +import GObject from "gi://GObject" +import { Gtk, astalify, type ConstructProps } from "astal/gtk4" + +type CalendarProps = ConstructProps<Gtk.Calendar, Gtk.Calendar.ConstructorProps> +const Calendar = astalify<Gtk.Calendar, Gtk.Calendar.ConstructorProps>(Gtk.Calendar, { + // if it is a container widget, define children setter and getter here + getChildren(self) { return [] }, + setChildren(self, children) {}, +}) + +function MyWidget() { + function setup(button: Gtk.Calendar) { + + } + + return <Calendar + setup={setup} + onDaySelected={(self) => { + print(self.day) + }} + /> +} +``` + +### Builtin Widgets + +These widgets are available by default in JSX. + +- box: [Astal.Box](https://aylur.github.io/libastal/astal4/class.Box.html) + ```tsx + <box>Horizontal Box</box> + ``` + ```tsx + <box orientation={1}>Vertical Box</box> + ``` +- button: [Gtk.Button](https://docs.gtk.org/gtk4/class.Button.html) + ```tsx + <button onClicked={self => print(self, "was clicked")}> + Click Me + </button> + ``` +- centerbox: [Gtk.CenterBox](https://docs.gtk.org/gtk4/class.CenterBox.html) + ```tsx + <centerbox orientation={1}> + <label label="Start Widget" /> + <label label="Center Widget" /> + <label label="End Widget" /> + </box> + ``` +- entry: [Gtk.Entry](https://docs.gtk.org/gtk4/class.Entry.html) + ```tsx + <window keymode={Astal.Keymode.ON_DEMAND}> + <entry + onNotifyText={self => print("text changed", self.text)} + onActivate={self => print("enter", self.text)} + /> + </window> + ``` + +- image: [Gtk.Image](https://docs.gtk.org/gtk4/class.Image.html) + ```tsx + <image iconName={GLib.get_os_info("LOGO") || "missing-symbolic"} /> + ``` + ```css + image { + -gtk-icon-size: 16px; + } + ``` + +- label: [Gtk.Label](https://docs.gtk.org/gtk4/class.Label.html) + ```tsx + <label label="hello" maxWidthChars={16} wrap /> + ``` + +- levelbar: [Gtk.LevelBar](https://docs.gtk.org/gtk4/class.LevelBar.html) + ```tsx + <levelbar value={0.5} widthRequest={200} /> + ``` + +- overlay: [Gtk.Overlay](https://docs.gtk.org/gtk4/class.Overlay.html) + ```tsx + <overlay> + <box heightRequest={40} widthRequest={40}>Child</box> + <box type="overlay measure" >1</box> + <box type="overlay clip" >2</box> + <box type="overlay clip measure" >3</box> + </overlay> + ``` + +- revealer: [Gtk.Revealer](https://docs.gtk.org/gtk4/class.Revealer.html) + ```tsx + <revealer + setup={self => timeout(500, () => self.revealChild = true)} + transitionType={Gtk.RevealerTransitionType.SLIDE_UP}> + <label label="Child" /> + </revealer> + ``` + +- slider: [Astal.Slider](https://aylur.github.io/libastal/astal4/class.Slider.html) + ```tsx + <slider widthRequest={100} onNotifyValue={self => print("new value", self.value)} /> + ``` + +- stack: [Gtk.Stack](https://docs.gtk.org/gtk4/class.Stack.html) + ```tsx + <stack visibleChildName="child2"> + <label name="child1" label="child1" /> + <label name="child2" label="child2" /> + </stack> + ``` + +- switch: [Gtk.Switch](https://docs.gtk.org/gtk4/class.Switch.html) + ```tsx + <switch onNotifyActive={self => print(self.active)} /> + ``` + +- menubutton: [Gtk.MenuButton](https://docs.gtk.org/gtk4/class.MenuButton.html) and popover: [Gtk.Popover](https://docs.gtk.org/gtk4/class.Popover.html) + ```tsx + <menubutton> + <label label="Button Content" /> + <popover> + <label label="Popover Content" /> + </popover> + </menubutton> + ``` + +- window: [Astal.Window](https://aylur.github.io/libastal/astal4/class.Window.html) + ```tsx + <window + className="Bar" + name="bar" + namespace="bar" + application={App} + monitor={0} + anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT} + exclusivity={Astal.Exclusivity.EXCLUSIVE} + keymode={Astal.Keymode.ON_DEMAND} + > + <centerbox /> + </window> + ``` diff --git a/lang/gjs/eslint.config.mjs b/lang/gjs/eslint.config.mjs index 05e49ee..5e32355 100644 --- a/lang/gjs/eslint.config.mjs +++ b/lang/gjs/eslint.config.mjs @@ -15,5 +15,6 @@ export default tseslint.config({ rules: { "@typescript-eslint/no-explicit-any": "off", "@stylistic/new-parens": "off", + "@stylistic/brace-style": ["error", "1tbs", { allowSingleLine: true }], }, }) diff --git a/lang/gjs/meson.build b/lang/gjs/meson.build index 51496dc..402bc55 100644 --- a/lang/gjs/meson.build +++ b/lang/gjs/meson.build @@ -7,15 +7,17 @@ dependency('astal-3.0') install_data( [ + 'src/_app.ts', + 'src/_astal.ts', 'src/binding.ts', 'src/file.ts', 'src/gobject.ts', 'src/index.ts', + 'src/overrides.ts', 'src/process.ts', 'src/time.ts', 'src/variable.ts', - 'src/overrides.ts', - 'src/_app.ts', + 'src/package.json', ], install_dir: dest, ) diff --git a/lang/gjs/package.json b/lang/gjs/package.json index 43a7702..e3b7761 100644 --- a/lang/gjs/package.json +++ b/lang/gjs/package.json @@ -18,6 +18,12 @@ ".": "./index.ts", "./gtk3": "./src/gtk3/index.ts", "./gtk4": "./src/gtk4/index.ts", + "./gtk3/app": "./src/gtk3/app.ts", + "./gtk4/app": "./src/gtk4/app.ts", + "./gtk3/widget": "./src/gtk3/widget.ts", + "./gtk4/widget": "./src/gtk4/widget.ts", + "./gtk3/jsx-runtime": "./src/gtk3/jsx-runtime.ts", + "./gtk4/jsx-runtime": "./src/gtk4/jsx-runtime.ts", "./binding": "./src/binding.ts", "./file": "./src/file.ts", "./gobject": "./src/gobject.ts", @@ -42,6 +48,6 @@ }, "scripts": { "lint": "eslint . --fix", - "types": "ts-for-gir generate -o @girs" + "types": "ts-for-gir generate -o @girs --ignoreVersionConflicts" } } diff --git a/lang/gjs/src/_app.ts b/lang/gjs/src/_app.ts index 3dadd04..46497c1 100644 --- a/lang/gjs/src/_app.ts +++ b/lang/gjs/src/_app.ts @@ -53,8 +53,7 @@ export function mkApp(App: App3 | App4) { ${body.includes(";") ? body : `return ${body};`} })`) fn()().then(res).catch(rej) - } - catch (error) { + } catch (error) { rej(error) } }) @@ -69,8 +68,7 @@ export function mkApp(App: App3 | App4) { IO.write_sock_finish(res), ) }) - } - else { + } else { super.vfunc_request(msg, conn) } } @@ -102,8 +100,7 @@ export function mkApp(App: App3 | App4) { try { app.acquire_socket() - } - catch (error) { + } catch (error) { return client(msg => IO.send_message(app.instanceName, msg)!, ...programArgs) } diff --git a/lang/gjs/src/_astal.ts b/lang/gjs/src/_astal.ts new file mode 100644 index 0000000..6f3285b --- /dev/null +++ b/lang/gjs/src/_astal.ts @@ -0,0 +1,188 @@ +import Variable from "./variable.js" +import { execAsync } from "./process.js" +import Binding, { Connectable, kebabify, snakeify, Subscribable } from "./binding.js" + +export const noImplicitDestroy = Symbol("no no implicit destroy") +export const setChildren = Symbol("children setter method") + +export function mergeBindings(array: any[]) { + function getValues(...args: any[]) { + let i = 0 + return array.map(value => value instanceof Binding + ? args[i++] + : value, + ) + } + + const bindings = array.filter(i => i instanceof Binding) + + if (bindings.length === 0) + return array + + if (bindings.length === 1) + return bindings[0].as(getValues) + + return Variable.derive(bindings, getValues)() +} + +export function setProp(obj: any, prop: string, value: any) { + try { + const setter = `set_${snakeify(prop)}` + if (typeof obj[setter] === "function") + return obj[setter](value) + + return (obj[prop] = value) + } catch (error) { + console.error(`could not set property "${prop}" on ${obj}:`, error) + } +} + +export type BindableProps<T> = { + [K in keyof T]: Binding<T[K]> | T[K]; +} + +export function hook<Widget extends Connectable>( + widget: Widget, + object: Connectable | Subscribable, + signalOrCallback: string | ((self: Widget, ...args: any[]) => void), + callback?: (self: Widget, ...args: any[]) => void, +) { + if (typeof object.connect === "function" && callback) { + const id = object.connect(signalOrCallback, (_: any, ...args: unknown[]) => { + callback(widget, ...args) + }) + widget.connect("destroy", () => { + (object.disconnect as Connectable["disconnect"])(id) + }) + } else if (typeof object.subscribe === "function" && typeof signalOrCallback === "function") { + const unsub = object.subscribe((...args: unknown[]) => { + signalOrCallback(widget, ...args) + }) + widget.connect("destroy", unsub) + } +} + +export function construct<Widget extends Connectable & { [setChildren]: (children: any[]) => void }>(widget: Widget, config: any) { + const { setup, child, children = [], ...props } = config + + if (child) { + children.unshift(child) + } + + // remove undefined values + for (const [key, value] of Object.entries(props)) { + if (value === undefined) { + delete props[key] + } + } + + // collect bindings + const bindings: Array<[string, Binding<any>]> = Object + .keys(props) + .reduce((acc: any, prop) => { + if (props[prop] instanceof Binding) { + const binding = props[prop] + delete props[prop] + return [...acc, [prop, binding]] + } + return acc + }, []) + + // collect signal handlers + const onHandlers: Array<[string, string | (() => unknown)]> = Object + .keys(props) + .reduce((acc: any, key) => { + if (key.startsWith("on")) { + const sig = kebabify(key).split("-").slice(1).join("-") + const handler = props[key] + delete props[key] + return [...acc, [sig, handler]] + } + return acc + }, []) + + // set children + const mergedChildren = mergeBindings(children.flat(Infinity)) + if (mergedChildren instanceof Binding) { + widget[setChildren](mergedChildren.get()) + widget.connect("destroy", mergedChildren.subscribe((v) => { + widget[setChildren](v) + })) + } else { + if (mergedChildren.length > 0) { + widget[setChildren](mergedChildren) + } + } + + // setup signal handlers + for (const [signal, callback] of onHandlers) { + const sig = signal.startsWith("notify") + ? signal.replace("-", "::") + : signal + + if (typeof callback === "function") { + widget.connect(sig, callback) + } else { + widget.connect(sig, () => execAsync(callback) + .then(print).catch(console.error)) + } + } + + // setup bindings handlers + for (const [prop, binding] of bindings) { + if (prop === "child" || prop === "children") { + widget.connect("destroy", binding.subscribe((v: any) => { + widget[setChildren](v) + })) + } + widget.connect("destroy", binding.subscribe((v: any) => { + setProp(widget, prop, v) + })) + setProp(widget, prop, binding.get()) + } + + // filter undefined values + for (const [key, value] of Object.entries(props)) { + if (value === undefined) { + delete props[key] + } + } + + Object.assign(widget, props) + setup?.(widget) + return widget +} + +function isArrowFunction(func: any): func is (args: any) => any { + return !Object.hasOwn(func, "prototype") +} + +export function jsx( + ctors: Record<string, { new(props: any): any } | ((props: any) => any)>, + ctor: string | ((props: any) => any) | { new(props: any): any }, + { children, ...props }: any, +) { + children ??= [] + + if (!Array.isArray(children)) + children = [children] + + children = children.filter(Boolean) + + if (children.length === 1) + props.child = children[0] + else if (children.length > 1) + props.children = children + + if (typeof ctor === "string") { + if (isArrowFunction(ctors[ctor])) + return ctors[ctor](props) + + return new ctors[ctor](props) + } + + if (isArrowFunction(ctor)) + return ctor(props) + + return new ctor(props) +} diff --git a/lang/gjs/src/binding.ts b/lang/gjs/src/binding.ts index 95d905f..19a55cf 100644 --- a/lang/gjs/src/binding.ts +++ b/lang/gjs/src/binding.ts @@ -20,7 +20,7 @@ export interface Connectable { [key: string]: any } -export default class Binding<Value> { +export class Binding<Value> { private transformFn = (v: any) => v #emitter: Subscribable<Value> | Connectable @@ -46,7 +46,7 @@ export default class Binding<Value> { return `Binding<${this.#emitter}${this.#prop ? `, "${this.#prop}"` : ""}>` } - as<T>(fn: (v: Value) => T): Binding<T> { + as<T>(fn: (v: Value) => T | Binding<T>): Binding<T> { const bind = new Binding(this.#emitter, this.#prop) bind.transformFn = (v: Value) => fn(this.transformFn(v)) return bind as unknown as Binding<T> @@ -72,8 +72,7 @@ export default class Binding<Value> { return this.#emitter.subscribe(() => { callback(this.get()) }) - } - else if (typeof this.#emitter.connect === "function") { + } else if (typeof this.#emitter.connect === "function") { const signal = `notify::${this.#prop}` const id = this.#emitter.connect(signal, () => { callback(this.get()) @@ -87,3 +86,4 @@ export default class Binding<Value> { } export const { bind } = Binding +export default Binding diff --git a/lang/gjs/src/file.ts b/lang/gjs/src/file.ts index 6ad8be3..4220d9d 100644 --- a/lang/gjs/src/file.ts +++ b/lang/gjs/src/file.ts @@ -12,8 +12,7 @@ export function readFileAsync(path: string): Promise<string> { Astal.read_file_async(path, (_, res) => { try { resolve(Astal.read_file_finish(res) || "") - } - catch (error) { + } catch (error) { reject(error) } }) @@ -29,8 +28,7 @@ export function writeFileAsync(path: string, content: string): Promise<void> { Astal.write_file_async(path, content, (_, res) => { try { resolve(Astal.write_file_finish(res)) - } - catch (error) { + } catch (error) { reject(error) } }) diff --git a/lang/gjs/src/gobject.ts b/lang/gjs/src/gobject.ts index 6bd9969..7a5105f 100644 --- a/lang/gjs/src/gobject.ts +++ b/lang/gjs/src/gobject.ts @@ -90,9 +90,7 @@ export function property(declaration: PropertyDeclaration = Object) { }) target.constructor[meta].Properties[kebabify(prop)] = pspec(name, ParamFlags.READWRITE, declaration) - } - - else { + } else { let flags = 0 if (desc.get) flags |= ParamFlags.READABLE if (desc.set) flags |= ParamFlags.WRITABLE @@ -124,8 +122,7 @@ export function signal( target.constructor[meta].Signals[name] = { param_types: arr, } - } - else { + } else { target.constructor[meta].Signals[name] = declaration || { param_types: [], } @@ -137,8 +134,7 @@ export function signal( this.emit(name, ...args) }, }) - } - else { + } else { const og: ((...args: any[]) => void) = desc.value desc.value = function (...args: any[]) { // @ts-expect-error not typed diff --git a/lang/gjs/src/gtk3/astalify.ts b/lang/gjs/src/gtk3/astalify.ts index 9e6f022..9cab5b2 100644 --- a/lang/gjs/src/gtk3/astalify.ts +++ b/lang/gjs/src/gtk3/astalify.ts @@ -1,45 +1,11 @@ +import { hook, noImplicitDestroy, setChildren, mergeBindings, type BindableProps, construct } from "../_astal.js" import Astal from "gi://Astal?version=3.0" import Gtk from "gi://Gtk?version=3.0" import Gdk from "gi://Gdk?version=3.0" import GObject from "gi://GObject" -import { execAsync } from "../process.js" -import Variable from "../variable.js" -import Binding, { kebabify, snakeify, type Connectable, type Subscribable } from "../binding.js" +import Binding, { type Connectable, type Subscribable } from "../binding.js" -export function mergeBindings(array: any[]) { - function getValues(...args: any[]) { - let i = 0 - return array.map(value => value instanceof Binding - ? args[i++] - : value, - ) - } - - const bindings = array.filter(i => i instanceof Binding) - - if (bindings.length === 0) - return array - - if (bindings.length === 1) - return bindings[0].as(getValues) - - return Variable.derive(bindings, getValues)() -} - -function setProp(obj: any, prop: string, value: any) { - try { - // the setter method has to be used because - // array like properties are not bound correctly as props - const setter = `set_${snakeify(prop)}` - if (typeof obj[setter] === "function") - return obj[setter](value) - - return (obj[prop] = value) - } - catch (error) { - console.error(`could not set property "${prop}" on ${obj}:`, error) - } -} +export { BindableProps, mergeBindings } export default function astalify< C extends { new(...args: any[]): Gtk.Widget }, @@ -65,63 +31,47 @@ export default function astalify< get_click_through(): boolean { return this.clickThrough } set_click_through(clickThrough: boolean) { this.clickThrough = clickThrough } - declare private __no_implicit_destroy: boolean - get noImplicitDestroy(): boolean { return this.__no_implicit_destroy } - set noImplicitDestroy(value: boolean) { this.__no_implicit_destroy = value } + declare private [noImplicitDestroy]: boolean + get noImplicitDestroy(): boolean { return this[noImplicitDestroy] } + set noImplicitDestroy(value: boolean) { this[noImplicitDestroy] = value } set actionGroup([prefix, group]: ActionGroup) { this.insert_action_group(prefix, group) } set_action_group(actionGroup: ActionGroup) { this.actionGroup = actionGroup } - _setChildren(children: Gtk.Widget[]) { + protected getChildren(): Array<Gtk.Widget> { + if (this instanceof Gtk.Bin) { + return this.get_child() ? [this.get_child()!] : [] + } else if (this instanceof Gtk.Container) { + return this.get_children() + } + return [] + } + + protected setChildren(children: any[]) { children = children.flat(Infinity).map(ch => ch instanceof Gtk.Widget ? ch : new Gtk.Label({ visible: true, label: String(ch) })) - // remove - if (this instanceof Gtk.Bin) { - const ch = this.get_child() - if (ch) - this.remove(ch) - if (ch && !children.includes(ch) && !this.noImplicitDestroy) - ch?.destroy() + if (this instanceof Gtk.Container) { + for (const ch of children) + this.add(ch) + } else { + throw Error(`can not add children to ${this.constructor.name}`) } - else if (this instanceof Gtk.Container) { - for (const ch of this.get_children()) { + } + + [setChildren](children: any[]) { + // remove + if (this instanceof Gtk.Container) { + for (const ch of this.getChildren()) { this.remove(ch) if (!children.includes(ch) && !this.noImplicitDestroy) ch?.destroy() } } - // TODO: add more container types - if (this instanceof Astal.Box) { - this.set_children(children) - } - - else if (this instanceof Astal.Stack) { - this.set_children(children) - } - - else if (this instanceof Astal.CenterBox) { - this.startWidget = children[0] - this.centerWidget = children[1] - this.endWidget = children[2] - } - - else if (this instanceof Astal.Overlay) { - const [child, ...overlays] = children - this.set_child(child) - this.set_overlays(overlays) - } - - else if (this instanceof Gtk.Container) { - for (const ch of children) - this.add(ch) - } - - else { - throw Error(`can not add children to ${this.constructor.name}, it is not a container widget`) - } + // append + this.setChildren(children) } toggleClassName(cn: string, cond = true) { @@ -142,103 +92,15 @@ export default function astalify< signalOrCallback: string | ((self: this, ...args: any[]) => void), callback?: (self: this, ...args: any[]) => void, ) { - if (typeof object.connect === "function" && callback) { - const id = object.connect(signalOrCallback, (_: any, ...args: unknown[]) => { - callback(this, ...args) - }) - this.connect("destroy", () => { - (object.disconnect as Connectable["disconnect"])(id) - }) - } - - else if (typeof object.subscribe === "function" && typeof signalOrCallback === "function") { - const unsub = object.subscribe((...args: unknown[]) => { - signalOrCallback(this, ...args) - }) - this.connect("destroy", unsub) - } - + hook(this, object, signalOrCallback, callback) return this } constructor(...params: any[]) { super() - const [config] = params - - const { setup, child, children = [], ...props } = config + const props = params[0] || {} props.visible ??= true - - // remove undefined values - for (const [key, value] of Object.entries(props)) { - if (value === undefined) { - delete props[key] - } - } - - if (child) - children.unshift(child) - - // collect bindings - const bindings = Object.keys(props).reduce((acc: any, prop) => { - if (props[prop] instanceof Binding) { - const binding = props[prop] - delete props[prop] - return [...acc, [prop, binding]] - } - return acc - }, []) - - // collect signal handlers - const onHandlers = Object.keys(props).reduce((acc: any, key) => { - if (key.startsWith("on")) { - const sig = kebabify(key).split("-").slice(1).join("-") - const handler = props[key] - delete props[key] - return [...acc, [sig, handler]] - } - return acc - }, []) - - // set children - const mergedChildren = mergeBindings(children.flat(Infinity)) - if (mergedChildren instanceof Binding) { - this._setChildren(mergedChildren.get()) - this.connect("destroy", mergedChildren.subscribe((v) => { - this._setChildren(v) - })) - } - else { - if (mergedChildren.length > 0) { - this._setChildren(mergedChildren) - } - } - - // setup signal handlers - for (const [signal, callback] of onHandlers) { - if (typeof callback === "function") { - this.connect(signal, callback) - } - else { - this.connect(signal, () => execAsync(callback) - .then(print).catch(console.error)) - } - } - - // setup bindings handlers - for (const [prop, binding] of bindings) { - if (prop === "child" || prop === "children") { - this.connect("destroy", binding.subscribe((v: any) => { - this._setChildren(v) - })) - } - this.connect("destroy", binding.subscribe((v: any) => { - setProp(this, prop, v) - })) - setProp(this, prop, binding.get()) - } - - Object.assign(this, props) - setup?.(this) + construct(this, props) } } @@ -266,15 +128,13 @@ export default function astalify< return Widget } -export type BindableProps<T> = { - [K in keyof T]: Binding<T[K]> | T[K]; -} - type SigHandler< W extends InstanceType<typeof Gtk.Widget>, Args extends Array<unknown>, > = ((self: W, ...args: Args) => unknown) | string | string[] +export type BindableChild = Gtk.Widget | Binding<Gtk.Widget> + export type ConstructProps< Self extends InstanceType<typeof Gtk.Widget>, Props extends Gtk.Widget.ConstructorProps, @@ -284,23 +144,21 @@ export type ConstructProps< [S in keyof Signals]: SigHandler<Self, Signals[S]> }> & Partial<{ [Key in `on${string}`]: SigHandler<Self, any[]> -}> & BindableProps<Partial<Props> & { +}> & BindableProps<Partial<Props & { className?: string css?: string cursor?: string clickThrough?: boolean -}> & { - onDestroy?: (self: Self) => unknown - onDraw?: (self: Self) => unknown - onKeyPressEvent?: (self: Self, event: Gdk.Event) => unknown - onKeyReleaseEvent?: (self: Self, event: Gdk.Event) => unknown - onButtonPressEvent?: (self: Self, event: Gdk.Event) => unknown - onButtonReleaseEvent?: (self: Self, event: Gdk.Event) => unknown - onRealize?: (self: Self) => unknown - setup?: (self: Self) => void -} - -export type BindableChild = Gtk.Widget | Binding<Gtk.Widget> +}>> & Partial<{ + onDestroy: (self: Self) => unknown + onDraw: (self: Self) => unknown + onKeyPressEvent: (self: Self, event: Gdk.Event) => unknown + onKeyReleaseEvent: (self: Self, event: Gdk.Event) => unknown + onButtonPressEvent: (self: Self, event: Gdk.Event) => unknown + onButtonReleaseEvent: (self: Self, event: Gdk.Event) => unknown + onRealize: (self: Self) => unknown + setup: (self: Self) => void +}> type Cursor = | "default" diff --git a/lang/gjs/src/gtk3/index.ts b/lang/gjs/src/gtk3/index.ts index ff641af..39a1ae7 100644 --- a/lang/gjs/src/gtk3/index.ts +++ b/lang/gjs/src/gtk3/index.ts @@ -7,3 +7,4 @@ export { Astal, Gtk, Gdk } export { default as App } from "./app.js" export { astalify, ConstructProps, BindableProps } export * as Widget from "./widget.js" +export { hook } from "../_astal" diff --git a/lang/gjs/src/gtk3/jsx-runtime.ts b/lang/gjs/src/gtk3/jsx-runtime.ts index f2fe9a4..ee720af 100644 --- a/lang/gjs/src/gtk3/jsx-runtime.ts +++ b/lang/gjs/src/gtk3/jsx-runtime.ts @@ -1,11 +1,8 @@ import Gtk from "gi://Gtk?version=3.0" -import { mergeBindings, type BindableChild } from "./astalify.js" +import { type BindableChild } from "./astalify.js" +import { mergeBindings, jsx as _jsx } from "../_astal.js" import * as Widget from "./widget.js" -function isArrowFunction(func: any): func is (args: any) => any { - return !Object.hasOwn(func, "prototype") -} - export function Fragment({ children = [], child }: { child?: BindableChild children?: Array<BindableChild> @@ -16,29 +13,9 @@ export function Fragment({ children = [], child }: { export function jsx( ctor: keyof typeof ctors | typeof Gtk.Widget, - { children, ...props }: any, + props: any, ) { - children ??= [] - - if (!Array.isArray(children)) - children = [children] - - children = children.filter(Boolean) - - if (children.length === 1) - props.child = children[0] - else if (children.length > 1) - props.children = children - - if (typeof ctor === "string") { - return new ctors[ctor](props) - } - - if (isArrowFunction(ctor)) - return ctor(props) - - // @ts-expect-error can be class or function - return new ctor(props) + return _jsx(ctors, ctor as any, props) } const ctors = { diff --git a/lang/gjs/src/gtk3/widget.ts b/lang/gjs/src/gtk3/widget.ts index 9d1c409..16bcbbd 100644 --- a/lang/gjs/src/gtk3/widget.ts +++ b/lang/gjs/src/gtk3/widget.ts @@ -4,6 +4,12 @@ import Gtk from "gi://Gtk?version=3.0" import GObject from "gi://GObject" import astalify, { type ConstructProps, type BindableChild } from "./astalify.js" +function filter(children: any[]) { + return children.flat(Infinity).map(ch => ch instanceof Gtk.Widget + ? ch + : new Gtk.Label({ visible: true, label: String(ch) })) +} + // Box Object.defineProperty(Astal.Box.prototype, "children", { get() { return this.get_children() }, @@ -14,6 +20,7 @@ export type BoxProps = ConstructProps<Box, Astal.Box.ConstructorProps> export class Box extends astalify(Astal.Box) { static { GObject.registerClass({ GTypeName: "Box" }, this) } constructor(props?: BoxProps, ...children: Array<BindableChild>) { super({ children, ...props } as any) } + protected setChildren(children: any[]): void { this.set_children(filter(children)) } } // Button @@ -35,6 +42,12 @@ export type CenterBoxProps = ConstructProps<CenterBox, Astal.CenterBox.Construct export class CenterBox extends astalify(Astal.CenterBox) { static { GObject.registerClass({ GTypeName: "CenterBox" }, this) } constructor(props?: CenterBoxProps, ...children: Array<BindableChild>) { super({ children, ...props } as any) } + protected setChildren(children: any[]): void { + const ch = filter(children) + this.startWidget = ch[0] || new Gtk.Box + this.centerWidget = ch[1] || new Gtk.Box + this.endWidget = ch[2] || new Gtk.Box + } } // CircularProgress @@ -91,6 +104,7 @@ export type LabelProps = ConstructProps<Label, Astal.Label.ConstructorProps> export class Label extends astalify(Astal.Label) { static { GObject.registerClass({ GTypeName: "Label" }, this) } constructor(props?: LabelProps) { super(props as any) } + protected setChildren(children: any[]): void { this.label = String(children) } } // LevelBar @@ -119,6 +133,11 @@ export type OverlayProps = ConstructProps<Overlay, Astal.Overlay.ConstructorProp export class Overlay extends astalify(Astal.Overlay) { static { GObject.registerClass({ GTypeName: "Overlay" }, this) } constructor(props?: OverlayProps, ...children: Array<BindableChild>) { super({ children, ...props } as any) } + protected setChildren(children: any[]): void { + const [child, ...overlays] = filter(children) + this.set_child(child) + this.set_overlays(overlays) + } } // Revealer @@ -149,6 +168,7 @@ export type StackProps = ConstructProps<Stack, Astal.Stack.ConstructorProps> export class Stack extends astalify(Astal.Stack) { static { GObject.registerClass({ GTypeName: "Stack" }, this) } constructor(props?: StackProps, ...children: Array<BindableChild>) { super({ children, ...props } as any) } + protected setChildren(children: any[]): void { this.set_children(filter(children)) } } // Switch diff --git a/lang/gjs/src/gtk4/app.ts b/lang/gjs/src/gtk4/app.ts index 1c51772..7906993 100644 --- a/lang/gjs/src/gtk4/app.ts +++ b/lang/gjs/src/gtk4/app.ts @@ -4,4 +4,10 @@ import { mkApp } from "../_app" Gtk.init() +// users might want to use Adwaita in which case it has to be initialized +// it might be common pitfall to forget it because `App` is not `Adw.Application` +await import("gi://Adw?version=1") + .then(({ default: Adw }) => Adw.init()) + .catch(() => void 0) + export default mkApp(Astal.Application) diff --git a/lang/gjs/src/gtk4/astalify.ts b/lang/gjs/src/gtk4/astalify.ts index 6c8ea4d..644ac1a 100644 --- a/lang/gjs/src/gtk4/astalify.ts +++ b/lang/gjs/src/gtk4/astalify.ts @@ -1 +1,226 @@ -// TODO: +import { noImplicitDestroy, setChildren, type BindableProps, construct } from "../_astal.js" +import Gtk from "gi://Gtk?version=4.0" +import Gdk from "gi://Gdk?version=4.0" +import Binding from "../binding.js" + +export const type = Symbol("child type") +const dummyBulder = new Gtk.Builder + +function _getChildren(widget: Gtk.Widget): Array<Gtk.Widget> { + if ("get_child" in widget && typeof widget.get_child == "function") { + return widget.get_child() ? [widget.get_child()] : [] + } + + const children: Array<Gtk.Widget> = [] + let ch = widget.get_first_child() + while (ch !== null) { + children.push(ch) + ch = ch.get_next_sibling() + } + return children +} + +function _setChildren(widget: Gtk.Widget, children: any[]) { + children = children.flat(Infinity).map(ch => ch instanceof Gtk.Widget + ? ch + : new Gtk.Label({ visible: true, label: String(ch) })) + + for (const child of children) { + widget.vfunc_add_child( + dummyBulder, + child, + type in widget ? widget[type] as string : null, + ) + } +} + +type Config<T extends Gtk.Widget> = { + setChildren(widget: T, children: any[]): void + getChildren(widget: T): Array<Gtk.Widget> +} + +export default function astalify< + Widget extends Gtk.Widget, + Props extends Gtk.Widget.ConstructorProps = Gtk.Widget.ConstructorProps, + Signals extends Record<`on${string}`, Array<unknown>> = Record<`on${string}`, any[]>, +>(cls: { new(...args: any[]): Widget }, config: Partial<Config<Widget>> = {}) { + Object.assign(cls.prototype, { + [setChildren](children: any[]) { + const w = this as unknown as Widget + for (const child of (config.getChildren?.(w) || _getChildren(w))) { + if (child instanceof Gtk.Widget) { + child.unparent() + if (!children.includes(child) && noImplicitDestroy in this) + child.run_dispose() + } + } + + if (config.setChildren) { + config.setChildren(w, children) + } else { + _setChildren(w, children) + } + }, + }) + + return { + [cls.name]: ( + props: ConstructProps<Widget, Props, Signals> = {}, + ...children: any[] + ): Widget => { + const widget = new cls("cssName" in props ? { cssName: props.cssName } : {}) + + if ("cssName" in props) { + delete props.cssName + } + + if (props.noImplicitDestroy) { + Object.assign(widget, { [noImplicitDestroy]: true }) + delete props.noImplicitDestroy + } + + if (props.type) { + Object.assign(widget, { [type]: props.type }) + delete props.type + } + + if (children.length > 0) { + Object.assign(props, { children }) + } + + return construct(widget as any, setupControllers(widget, props as any)) + }, + }[cls.name] +} + +type SigHandler< + W extends InstanceType<typeof Gtk.Widget>, + Args extends Array<unknown>, +> = ((self: W, ...args: Args) => unknown) | string | string[] + +export { BindableProps } +export type BindableChild = Gtk.Widget | Binding<Gtk.Widget> + +export type ConstructProps< + Self extends InstanceType<typeof Gtk.Widget>, + Props extends Gtk.Widget.ConstructorProps, + Signals extends Record<`on${string}`, Array<unknown>> = Record<`on${string}`, any[]>, +> = Partial<{ + // @ts-expect-error can't assign to unknown, but it works as expected though + [S in keyof Signals]: SigHandler<Self, Signals[S]> +}> & Partial<{ + [Key in `on${string}`]: SigHandler<Self, any[]> +}> & Partial<BindableProps<Omit<Props, "cssName" | "css_name">>> & { + noImplicitDestroy?: true + type?: string + cssName?: string +} & EventController<Self> & { + onDestroy?: (self: Self) => unknown + setup?: (self: Self) => void +} + +type EventController<Self extends Gtk.Widget> = { + onFocusEnter?: (self: Self) => void + onFocusLeave?: (self: Self) => void + + onKeyPressed?: (self: Self, keyval: number, keycode: number, state: Gdk.ModifierType) => void + onKeyReleased?: (self: Self, keyval: number, keycode: number, state: Gdk.ModifierType) => void + onKeyModifier?: (self: Self, state: Gdk.ModifierType) => void + + onLegacy?: (self: Self, event: Gdk.Event) => void + onButtonPressed?: (self: Self, state: Gdk.ButtonEvent) => void + onButtonReleased?: (self: Self, state: Gdk.ButtonEvent) => void + + onHoverEnter?: (self: Self, x: number, y: number) => void + onHoverLeave?: (self: Self) => void + onMotion?: (self: Self, x: number, y: number) => void + + onScroll?: (self: Self, dx: number, dy: number) => void + onScrollDecelerate?: (self: Self, vel_x: number, vel_y: number) => void +} + +function setupControllers<T>(widget: Gtk.Widget, { + onFocusEnter, + onFocusLeave, + onKeyPressed, + onKeyReleased, + onKeyModifier, + onLegacy, + onButtonPressed, + onButtonReleased, + onHoverEnter, + onHoverLeave, + onMotion, + onScroll, + onScrollDecelerate, + ...props +}: EventController<Gtk.Widget> & T) { + if (onFocusEnter || onFocusLeave) { + const focus = new Gtk.EventControllerFocus + widget.add_controller(focus) + + if (onFocusEnter) + focus.connect("focus-enter", () => onFocusEnter(widget)) + + if (onFocusLeave) + focus.connect("focus-leave", () => onFocusLeave(widget)) + } + + if (onKeyPressed || onKeyReleased || onKeyModifier) { + const key = new Gtk.EventControllerKey + widget.add_controller(key) + + if (onKeyPressed) + key.connect("key-pressed", (_, val, code, state) => onKeyPressed(widget, val, code, state)) + + if (onKeyReleased) + key.connect("key-released", (_, val, code, state) => onKeyReleased(widget, val, code, state)) + + if (onKeyModifier) + key.connect("modifiers", (_, state) => onKeyModifier(widget, state)) + } + + if (onLegacy || onButtonPressed || onButtonReleased) { + const legacy = new Gtk.EventControllerLegacy + widget.add_controller(legacy) + + legacy.connect("event", (_, event) => { + if (event.get_event_type() === Gdk.EventType.BUTTON_PRESS) { + onButtonPressed?.(widget, event as Gdk.ButtonEvent) + } + + if (event.get_event_type() === Gdk.EventType.BUTTON_RELEASE) { + onButtonReleased?.(widget, event as Gdk.ButtonEvent) + } + + onLegacy?.(widget, event) + }) + } + + if (onMotion || onHoverEnter || onHoverLeave) { + const hover = new Gtk.EventControllerMotion + widget.add_controller(hover) + + if (onHoverEnter) + hover.connect("enter", (_, x, y) => onHoverEnter(widget, x, y)) + + if (onHoverLeave) + hover.connect("leave", () => onHoverLeave(widget)) + + if (onMotion) + hover.connect("motion", (_, x, y) => onMotion(widget, x, y)) + } + + if (onScroll || onScrollDecelerate) { + const scroll = new Gtk.EventControllerScroll + widget.add_controller(scroll) + + if (onScroll) + scroll.connect("scroll", (_, x, y) => onScroll(widget, x, y)) + + if (onScrollDecelerate) + scroll.connect("decelerate", (_, x, y) => onScrollDecelerate(widget, x, y)) + } + + return props +} diff --git a/lang/gjs/src/gtk4/index.ts b/lang/gjs/src/gtk4/index.ts index 3b1f737..51c75d2 100644 --- a/lang/gjs/src/gtk4/index.ts +++ b/lang/gjs/src/gtk4/index.ts @@ -1,9 +1,10 @@ import Astal from "gi://Astal?version=4.0" import Gtk from "gi://Gtk?version=4.0" import Gdk from "gi://Gdk?version=4.0" -// import astalify, { type ConstructProps } from "./astalify.js" +import astalify, { type ConstructProps } from "./astalify.js" export { Astal, Gtk, Gdk } export { default as App } from "./app.js" -// export { astalify, ConstructProps } -// export * as Widget from "./widget.js" +export { astalify, ConstructProps } +export * as Widget from "./widget.js" +export { hook } from "../_astal" diff --git a/lang/gjs/src/gtk4/jsx-runtime.ts b/lang/gjs/src/gtk4/jsx-runtime.ts index 6c8ea4d..80a3e87 100644 --- a/lang/gjs/src/gtk4/jsx-runtime.ts +++ b/lang/gjs/src/gtk4/jsx-runtime.ts @@ -1 +1,68 @@ -// TODO: +import Gtk from "gi://Gtk?version=4.0" +import { type BindableChild } from "./astalify.js" +import { mergeBindings, jsx as _jsx } from "../_astal.js" +import * as Widget from "./widget.js" + +export function Fragment({ children = [], child }: { + child?: BindableChild + children?: Array<BindableChild> +}) { + if (child) children.push(child) + return mergeBindings(children) +} + +export function jsx( + ctor: keyof typeof ctors | typeof Gtk.Widget, + props: any, +) { + return _jsx(ctors, ctor as any, props) +} + +const ctors = { + box: Widget.Box, + button: Widget.Button, + centerbox: Widget.CenterBox, + // circularprogress: Widget.CircularProgress, + // drawingarea: Widget.DrawingArea, + entry: Widget.Entry, + image: Widget.Image, + label: Widget.Label, + levelbar: Widget.LevelBar, + overlay: Widget.Overlay, + revealer: Widget.Revealer, + slider: Widget.Slider, + stack: Widget.Stack, + switch: Widget.Switch, + window: Widget.Window, + menubutton: Widget.MenuButton, + popover: Widget.Popover, +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + type Element = Gtk.Widget + type ElementClass = Gtk.Widget + interface IntrinsicElements { + box: Widget.BoxProps + button: Widget.ButtonProps + centerbox: Widget.CenterBoxProps + // circularprogress: Widget.CircularProgressProps + // drawingarea: Widget.DrawingAreaProps + entry: Widget.EntryProps + image: Widget.ImageProps + label: Widget.LabelProps + levelbar: Widget.LevelBarProps + overlay: Widget.OverlayProps + revealer: Widget.RevealerProps + slider: Widget.SliderProps + stack: Widget.StackProps + switch: Widget.SwitchProps + window: Widget.WindowProps + menubutton: Widget.MenuButtonProps + popover: Widget.PopoverProps + } + } +} + +export const jsxs = jsx diff --git a/lang/gjs/src/gtk4/widget.ts b/lang/gjs/src/gtk4/widget.ts index 6c8ea4d..bd9091b 100644 --- a/lang/gjs/src/gtk4/widget.ts +++ b/lang/gjs/src/gtk4/widget.ts @@ -1 +1,166 @@ -// TODO: +import Astal from "gi://Astal?version=4.0" +import Gtk from "gi://Gtk?version=4.0" +import astalify, { type, type ConstructProps } from "./astalify.js" + +function filter(children: any[]) { + return children.flat(Infinity).map(ch => ch instanceof Gtk.Widget + ? ch + : new Gtk.Label({ visible: true, label: String(ch) })) +} + +// Box +Object.defineProperty(Astal.Box.prototype, "children", { + get() { return this.get_children() }, + set(v) { this.set_children(v) }, +}) + +export type BoxProps = ConstructProps<Astal.Box, Astal.Box.ConstructorProps> +export const Box = astalify<Astal.Box, Astal.Box.ConstructorProps>(Astal.Box, { + getChildren(self) { return self.get_children() }, + setChildren(self, children) { return self.set_children(filter(children)) }, +}) + +// Button +type ButtonSignals = { + onClicked: [] +} + +export type ButtonProps = ConstructProps<Gtk.Button, Gtk.Button.ConstructorProps, ButtonSignals> +export const Button = astalify<Gtk.Button, Gtk.Button.ConstructorProps, ButtonSignals>(Gtk.Button) + +// CenterBox +export type CenterBoxProps = ConstructProps<Gtk.CenterBox, Gtk.CenterBox.ConstructorProps> +export const CenterBox = astalify<Gtk.CenterBox, Gtk.CenterBox.ConstructorProps>(Gtk.CenterBox, { + getChildren(box) { + return [box.startWidget, box.centerWidget, box.endWidget] + }, + setChildren(box, children) { + const ch = filter(children) + box.startWidget = ch[0] || new Gtk.Box + box.centerWidget = ch[1] || new Gtk.Box + box.endWidget = ch[2] || new Gtk.Box + }, +}) + +// TODO: CircularProgress +// TODO: DrawingArea + +// Entry +type EntrySignals = { + onActivate: [] + onNotifyText: [] +} + +export type EntryProps = ConstructProps<Gtk.Entry, Gtk.Entry.ConstructorProps, EntrySignals> +export const Entry = astalify<Gtk.Entry, Gtk.Entry.ConstructorProps, EntrySignals>(Gtk.Entry, { + getChildren() { return [] }, +}) + +// Image +export type ImageProps = ConstructProps<Gtk.Image, Gtk.Image.ConstructorProps> +export const Image = astalify<Gtk.Image, Gtk.Image.ConstructorProps>(Gtk.Image, { + getChildren() { return [] }, +}) + +// Label +export type LabelProps = ConstructProps<Gtk.Label, Gtk.Label.ConstructorProps> +export const Label = astalify<Gtk.Label, Gtk.Label.ConstructorProps>(Gtk.Label, { + getChildren() { return [] }, + setChildren(self, children) { self.label = String(children) }, +}) + +// LevelBar +export type LevelBarProps = ConstructProps<Gtk.LevelBar, Gtk.LevelBar.ConstructorProps> +export const LevelBar = astalify<Gtk.LevelBar, Gtk.LevelBar.ConstructorProps>(Gtk.LevelBar, { + getChildren() { return [] }, +}) + +// TODO: ListBox + +// Overlay +export type OverlayProps = ConstructProps<Gtk.Overlay, Gtk.Overlay.ConstructorProps> +export const Overlay = astalify<Gtk.Overlay, Gtk.Overlay.ConstructorProps>(Gtk.Overlay, { + getChildren(self) { + const children: Array<Gtk.Widget> = [] + let ch = self.get_first_child() + while (ch !== null) { + children.push(ch) + ch = ch.get_next_sibling() + } + + return children.filter(ch => ch !== self.child) + }, + setChildren(self, children) { + for (const child of filter(children)) { + const types = type in child + ? (child[type] as string).split(/\s+/) + : [] + + if (types.includes("overlay")) { + self.add_overlay(child) + } else { + self.set_child(child) + } + + self.set_measure_overlay(child, types.includes("measure")) + self.set_clip_overlay(child, types.includes("clip")) + } + }, +}) + +// Revealer +export type RevealerProps = ConstructProps<Gtk.Revealer, Gtk.Revealer.ConstructorProps> +export const Revealer = astalify<Gtk.Revealer, Gtk.Revealer.ConstructorProps>(Gtk.Revealer) + +// Slider +type SliderSignals = { + onChangeValue: [] +} + +export type SliderProps = ConstructProps<Astal.Slider, Astal.Slider.ConstructorProps, SliderSignals> +export const Slider = astalify<Astal.Slider, Astal.Slider.ConstructorProps, SliderSignals>(Astal.Slider, { + getChildren() { return [] }, +}) + +// Stack +export type StackProps = ConstructProps<Gtk.Stack, Gtk.Stack.ConstructorProps> +export const Stack = astalify<Gtk.Stack, Gtk.Stack.ConstructorProps>(Gtk.Stack, { + setChildren(self, children) { + for (const child of filter(children)) { + if (child.name != "" && child.name != null) { + self.add_named(child, child.name) + } else { + self.add_child(child) + } + } + }, +}) + +// Switch +export type SwitchProps = ConstructProps<Gtk.Switch, Gtk.Switch.ConstructorProps> +export const Switch = astalify<Gtk.Switch, Gtk.Switch.ConstructorProps>(Gtk.Switch, { + getChildren() { return [] }, +}) + +// Window +export type WindowProps = ConstructProps<Astal.Window, Astal.Window.ConstructorProps> +export const Window = astalify<Astal.Window, Astal.Window.ConstructorProps>(Astal.Window) + +// MenuButton +export type MenuButtonProps = ConstructProps<Gtk.MenuButton, Gtk.MenuButton.ConstructorProps> +export const MenuButton = astalify<Gtk.MenuButton, Gtk.MenuButton.ConstructorProps>(Gtk.MenuButton, { + getChildren(self) { return [self.popover, self.child] }, + setChildren(self, children) { + for (const child of filter(children)) { + if (child instanceof Gtk.Popover) { + self.set_popover(child) + } else { + self.set_child(child) + } + } + }, +}) + +// Popoper +export type PopoverProps = ConstructProps<Gtk.Popover, Gtk.Popover.ConstructorProps> +export const Popover = astalify<Gtk.Popover, Gtk.Popover.ConstructorProps>(Gtk.Popover) diff --git a/lang/gjs/src/index.ts b/lang/gjs/src/index.ts index 8fe8d01..f448af9 100644 --- a/lang/gjs/src/index.ts +++ b/lang/gjs/src/index.ts @@ -4,5 +4,5 @@ export * from "./process.js" export * from "./time.js" export * from "./file.js" export * from "./gobject.js" -export { bind, default as Binding } from "./binding.js" -export { Variable } from "./variable.js" +export { Binding, bind } from "./binding.js" +export { Variable, derive } from "./variable.js" diff --git a/lang/gjs/src/package.json b/lang/gjs/src/package.json new file mode 100644 index 0000000..b792213 --- /dev/null +++ b/lang/gjs/src/package.json @@ -0,0 +1,22 @@ +{ + "name": "astal", + "type": "module", + "license": "LGPL-2.1", + "exports": { + ".": "./index.ts", + "./gtk3": "./gtk3/index.ts", + "./gtk4": "./gtk4/index.ts", + "./gtk3/app": "./gtk3/app.ts", + "./gtk4/app": "./gtk4/app.ts", + "./gtk3/widget": "./gtk3/widget.ts", + "./gtk4/widget": "./gtk4/widget.ts", + "./gtk3/jsx-runtime": "./gtk3/jsx-runtime.ts", + "./gtk4/jsx-runtime": "./gtk4/jsx-runtime.ts", + "./binding": "./binding.ts", + "./file": "./file.ts", + "./gobject": "./gobject.ts", + "./process": "./process.ts", + "./time": "./time.ts", + "./variable": "./variable.ts" + } +} diff --git a/lang/gjs/src/process.ts b/lang/gjs/src/process.ts index c41adc1..6e3a4a9 100644 --- a/lang/gjs/src/process.ts +++ b/lang/gjs/src/process.ts @@ -50,18 +50,15 @@ export function execAsync(cmd: string | string[]): Promise<string> { Astal.Process.exec_asyncv(cmd, (_, res) => { try { resolve(Astal.Process.exec_asyncv_finish(res)) - } - catch (error) { + } catch (error) { reject(error) } }) - } - else { + } else { Astal.Process.exec_async(cmd, (_, res) => { try { resolve(Astal.Process.exec_finish(res)) - } - catch (error) { + } catch (error) { reject(error) } }) diff --git a/lang/gjs/src/variable.ts b/lang/gjs/src/variable.ts index 9b3d3d2..016d73a 100644 --- a/lang/gjs/src/variable.ts +++ b/lang/gjs/src/variable.ts @@ -60,13 +60,11 @@ class VariableWrapper<T> extends Function { if (v instanceof Promise) { v.then(v => this.set(v)) .catch(err => this.variable.emit("error", err)) - } - else { + } else { this.set(v) } }) - } - else if (this.pollExec) { + } else if (this.pollExec) { this._poll = interval(this.pollInterval, () => { execAsync(this.pollExec!) .then(v => this.set(this.pollTransform!(v, this.get()))) @@ -143,8 +141,7 @@ class VariableWrapper<T> extends Function { if (typeof exec === "function") { this.pollFn = exec delete this.pollExec - } - else { + } else { this.pollExec = exec delete this.pollFn } @@ -188,8 +185,7 @@ class VariableWrapper<T> extends Function { const id = o.connect(s, set) this.onDropped(() => o.disconnect(id)) } - } - else { + } else { if (typeof sigOrFn === "string") { const id = objs.connect(sigOrFn, set) this.onDropped(() => objs.disconnect(id)) @@ -227,4 +223,5 @@ export const Variable = new Proxy(VariableWrapper as any, { new<T>(init: T): Variable<T> } +export const { derive } = Variable export default Variable diff --git a/lib/astal/gtk3/src/widget/circularprogress.vala b/lib/astal/gtk3/src/widget/circularprogress.vala index df1635d..de7a5c7 100644 --- a/lib/astal/gtk3/src/widget/circularprogress.vala +++ b/lib/astal/gtk3/src/widget/circularprogress.vala @@ -41,7 +41,7 @@ public class Astal.CircularProgress : Gtk.Bin { } static construct { - set_css_name("circular-progress"); + set_css_name("circularprogress"); } public override Gtk.SizeRequestMode get_request_mode() { diff --git a/lib/astal/gtk4/src/application.vala b/lib/astal/gtk4/src/application.vala index fadf705..fe5dd8d 100644 --- a/lib/astal/gtk4/src/application.vala +++ b/lib/astal/gtk4/src/application.vala @@ -146,13 +146,10 @@ public class Astal.Application : Gtk.Application, AstalIO.Application { if (reset) reset_css(); - try { - if (FileUtils.test(style, FileTest.EXISTS)) - provider.load_from_path(style); - else - provider.load_from_string(style); - } catch (Error err) { - critical(err.message); + if (FileUtils.test(style, FileTest.EXISTS)) { + provider.load_from_path(style); + } else { + provider.load_from_string(style); } Gtk.StyleContext.add_provider_for_display( diff --git a/lib/astal/gtk4/src/meson.build b/lib/astal/gtk4/src/meson.build index 8aac969..7b9c1e0 100644 --- a/lib/astal/gtk4/src/meson.build +++ b/lib/astal/gtk4/src/meson.build @@ -25,6 +25,8 @@ deps = [ ] sources = [config] + files( + 'widget/box.vala', + 'widget/slider.vala', 'widget/window.vala', 'application.vala', ) diff --git a/lib/astal/gtk4/src/widget/box.vala b/lib/astal/gtk4/src/widget/box.vala new file mode 100644 index 0000000..28f2b00 --- /dev/null +++ b/lib/astal/gtk4/src/widget/box.vala @@ -0,0 +1,50 @@ +public class Astal.Box : Gtk.Box { + /** + * Corresponds to [[email protected] :orientation]. + */ + [CCode (notify = false)] + public bool vertical { + get { return orientation == Gtk.Orientation.VERTICAL; } + set { orientation = value ? Gtk.Orientation.VERTICAL : Gtk.Orientation.HORIZONTAL; } + } + + construct { + notify["orientation"].connect(() => { + notify_property("vertical"); + }); + } + + public List<weak Gtk.Widget> children { + set { + foreach (var child in children) { + remove(child); + } + foreach (var child in value) { + append(child); + } + } + owned get { + var list = new List<weak Gtk.Widget>(); + var child = get_first_child(); + while (child != null) { + list.append(child); + child = child.get_next_sibling(); + } + return list; + } + } + + public Gtk.Widget? child { + owned get { + foreach (var child in children) { + return child; + } + return null; + } + set { + var list = new List<weak Gtk.Widget>(); + list.append(child); + this.children = children; + } + } +} diff --git a/lib/astal/gtk4/src/widget/slider.vala b/lib/astal/gtk4/src/widget/slider.vala new file mode 100644 index 0000000..ca026a2 --- /dev/null +++ b/lib/astal/gtk4/src/widget/slider.vala @@ -0,0 +1,65 @@ +public class Astal.Slider : Gtk.Scale { + private Gtk.EventControllerLegacy controller; + private bool dragging; + + construct { + if (adjustment == null) + adjustment = new Gtk.Adjustment(0,0,0,0,0,0); + + if (max == 0 && min == 0) { + max = 1; + } + + if (step == 0) { + step = 0.05; + } + + controller = new Gtk.EventControllerLegacy(); + add_controller(controller); + controller.event.connect((event) => { + var type = event.get_event_type(); + if (type == Gdk.EventType.BUTTON_PRESS || + type == Gdk.EventType.KEY_PRESS || + type == Gdk.EventType.TOUCH_BEGIN) { + dragging = true; + } + if (type == Gdk.EventType.BUTTON_RELEASE || + type == Gdk.EventType.KEY_RELEASE || + type == Gdk.EventType.TOUCH_END) { + dragging = false; + } + }); + } + + /** + * Value of this slider. Defaults to `0`. + */ + public double value { + get { return adjustment.value; } + set { if (!dragging) adjustment.value = value; } + } + + /** + * Minimum possible value of this slider. Defaults to `0`. + */ + public double min { + get { return adjustment.lower; } + set { adjustment.lower = value; } + } + + /** + * Maximum possible value of this slider. Defaults to `1`. + */ + public double max { + get { return adjustment.upper; } + set { adjustment.upper = value; } + } + + /** + * Size of step increments. Defaults to `0.05`. + */ + public double step { + get { return adjustment.step_increment; } + set { adjustment.step_increment = value; } + } +} diff --git a/nix/devshell.nix b/nix/devshell.nix index 66c46e5..9217a8d 100644 --- a/nix/devshell.nix +++ b/nix/devshell.nix @@ -40,21 +40,34 @@ libdbusmenu-gtk3 wayland blueprint-compiler + libadwaita dart-sass lua python gjs ]; + + lsp = with pkgs; [ + nodejs + mesonlsp + vala-language-server + vtsls + vscode-langservers-extracted + ]; in { default = pkgs.mkShell { - inherit buildInputs; + packages = buildInputs ++ lsp; }; astal = pkgs.mkShell { - buildInputs = + packages = buildInputs + ++ lsp ++ builtins.attrValues ( - builtins.removeAttrs self.packages.${pkgs.system} ["docs"] + builtins.removeAttrs self.packages.${pkgs.system} [ + "docs" + "cava" # FIXME: temporary autoreconf + ] ); }; } |