summaryrefslogtreecommitdiff
path: root/examples/gtk3
diff options
context:
space:
mode:
Diffstat (limited to 'examples/gtk3')
-rw-r--r--examples/gtk3/js/.gitignore7
-rw-r--r--examples/gtk3/js/applauncher/README.md5
-rw-r--r--examples/gtk3/js/applauncher/app.ts9
-rw-r--r--examples/gtk3/js/applauncher/style.scss1
-rw-r--r--examples/gtk3/js/applauncher/widget/Applauncher.scss59
-rw-r--r--examples/gtk3/js/applauncher/widget/Applauncher.tsx87
-rw-r--r--examples/gtk3/js/media-player/README.md5
-rw-r--r--examples/gtk3/js/media-player/app.ts11
-rw-r--r--examples/gtk3/js/media-player/style.scss1
-rw-r--r--examples/gtk3/js/media-player/widget/MediaPlayer.scss56
-rw-r--r--examples/gtk3/js/media-player/widget/MediaPlayer.tsx94
-rw-r--r--examples/gtk3/js/notifications/README.md5
-rw-r--r--examples/gtk3/js/notifications/app.ts9
-rw-r--r--examples/gtk3/js/notifications/notifications/Notification.scss125
-rw-r--r--examples/gtk3/js/notifications/notifications/Notification.tsx107
-rw-r--r--examples/gtk3/js/notifications/notifications/NotificationPopups.tsx105
-rw-r--r--examples/gtk3/js/notifications/style.scss1
-rw-r--r--examples/gtk3/js/popover/app.tsx104
-rw-r--r--examples/gtk3/js/simple-bar/README.md12
-rw-r--r--examples/gtk3/js/simple-bar/app.ts13
-rw-r--r--examples/gtk3/js/simple-bar/style.scss106
-rw-r--r--examples/gtk3/js/simple-bar/widget/Bar.tsx161
-rw-r--r--examples/gtk3/lua/simple-bar/README.md13
-rw-r--r--examples/gtk3/lua/simple-bar/init.lua24
-rw-r--r--examples/gtk3/lua/simple-bar/lib.lua25
-rw-r--r--examples/gtk3/lua/simple-bar/style.scss106
-rw-r--r--examples/gtk3/lua/simple-bar/widget/Bar.lua198
-rw-r--r--examples/gtk3/py/.gitignore1
-rw-r--r--examples/gtk3/py/simple-bar/README.md13
-rw-r--r--examples/gtk3/py/simple-bar/__init__.py0
-rwxr-xr-xexamples/gtk3/py/simple-bar/app.py36
-rw-r--r--examples/gtk3/py/simple-bar/style.scss106
-rw-r--r--examples/gtk3/py/simple-bar/versions.py15
-rw-r--r--examples/gtk3/py/simple-bar/widget/Bar.py251
-rw-r--r--examples/gtk3/py/simple-bar/widget/__init__.py0
-rw-r--r--examples/gtk3/vala/simple-bar/README.md13
-rw-r--r--examples/gtk3/vala/simple-bar/app.in.vala30
-rw-r--r--examples/gtk3/vala/simple-bar/flake.nix42
-rw-r--r--examples/gtk3/vala/simple-bar/meson.build45
-rw-r--r--examples/gtk3/vala/simple-bar/style.scss106
-rw-r--r--examples/gtk3/vala/simple-bar/widget/Bar.vala253
41 files changed, 2360 insertions, 0 deletions
diff --git a/examples/gtk3/js/.gitignore b/examples/gtk3/js/.gitignore
new file mode 100644
index 0000000..d53b85b
--- /dev/null
+++ b/examples/gtk3/js/.gitignore
@@ -0,0 +1,7 @@
+@girs/
+tsconfig.json
+env.d.ts
+dist/
+node_modules/
+package.json
+package-lock.json
diff --git a/examples/gtk3/js/applauncher/README.md b/examples/gtk3/js/applauncher/README.md
new file mode 100644
index 0000000..682adf1
--- /dev/null
+++ b/examples/gtk3/js/applauncher/README.md
@@ -0,0 +1,5 @@
+# Applauncher
+
+![launcher](https://github.com/user-attachments/assets/2695e3bb-dff4-478a-b392-279fe638bfd3)
+
+Using [Apps](https://aylur.github.io/astal/guide/libraries/apps).
diff --git a/examples/gtk3/js/applauncher/app.ts b/examples/gtk3/js/applauncher/app.ts
new file mode 100644
index 0000000..d6c9e1c
--- /dev/null
+++ b/examples/gtk3/js/applauncher/app.ts
@@ -0,0 +1,9 @@
+import { App } from "astal/gtk3"
+import style from "./style.scss"
+import Applauncher from "./widget/Applauncher"
+
+App.start({
+ instanceName: "launcher",
+ css: style,
+ main: Applauncher,
+})
diff --git a/examples/gtk3/js/applauncher/style.scss b/examples/gtk3/js/applauncher/style.scss
new file mode 100644
index 0000000..ba13eed
--- /dev/null
+++ b/examples/gtk3/js/applauncher/style.scss
@@ -0,0 +1 @@
+@use "./widget/Applauncher.scss"
diff --git a/examples/gtk3/js/applauncher/widget/Applauncher.scss b/examples/gtk3/js/applauncher/widget/Applauncher.scss
new file mode 100644
index 0000000..ae2453d
--- /dev/null
+++ b/examples/gtk3/js/applauncher/widget/Applauncher.scss
@@ -0,0 +1,59 @@
+@use "sass:string";
+
+@function gtkalpha($c, $a) {
+ @return string.unquote("alpha(#{$c},#{$a})");
+}
+
+// https://gitlab.gnome.org/GNOME/gtk/-/blob/gtk-3-24/gtk/theme/Adwaita/_colors-public.scss
+$fg-color: #{"@theme_fg_color"};
+$bg-color: #{"@theme_bg_color"};
+
+window#launcher {
+ all: unset;
+
+ box.Applauncher {
+ background-color: $bg-color;
+ border-radius: 11px;
+ margin: 1rem;
+ padding: .8rem;
+ box-shadow: 2px 3px 8px 0 gtkalpha(black, .4);
+
+ entry {
+ margin-bottom: .8rem;
+ }
+
+ button {
+ min-width: 0;
+ min-height: 0;
+ padding: .5rem;
+
+ icon {
+ font-size: 3em;
+ margin-right: .3rem;
+ }
+
+ label.name {
+ font-weight: bold;
+ font-size: 1.1em
+ }
+
+ label.description {
+ color: gtkalpha($fg-color, .8);
+ }
+ }
+
+ box.not-found {
+ padding: 1rem;
+
+ icon {
+ font-size: 6em;
+ color: gtkalpha($fg-color, .7);
+ }
+
+ label {
+ color: gtkalpha($fg-color, .9);
+ font-size: 1.2em;
+ }
+ }
+ }
+}
diff --git a/examples/gtk3/js/applauncher/widget/Applauncher.tsx b/examples/gtk3/js/applauncher/widget/Applauncher.tsx
new file mode 100644
index 0000000..c7bac68
--- /dev/null
+++ b/examples/gtk3/js/applauncher/widget/Applauncher.tsx
@@ -0,0 +1,87 @@
+import Apps from "gi://AstalApps"
+import { App, Astal, Gdk, Gtk } from "astal/gtk3"
+import { Variable } from "astal"
+
+const MAX_ITEMS = 8
+
+function hide() {
+ App.get_window("launcher")!.hide()
+}
+
+function AppButton({ app }: { app: Apps.Application }) {
+ return <button
+ className="AppButton"
+ onClicked={() => { hide(); app.launch() }}>
+ <box>
+ <icon icon={app.iconName} />
+ <box valign={Gtk.Align.CENTER} vertical>
+ <label
+ className="name"
+ truncate
+ xalign={0}
+ label={app.name}
+ />
+ {app.description && <label
+ className="description"
+ wrap
+ xalign={0}
+ label={app.description}
+ />}
+ </box>
+ </box>
+ </button>
+}
+
+export default function Applauncher() {
+ const { CENTER } = Gtk.Align
+ const apps = new Apps.Apps()
+
+ const text = Variable("")
+ const list = text(text => apps.fuzzy_query(text).slice(0, MAX_ITEMS))
+ const onEnter = () => {
+ apps.fuzzy_query(text.get())?.[0].launch()
+ hide()
+ }
+
+ return <window
+ name="launcher"
+ anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.BOTTOM}
+ exclusivity={Astal.Exclusivity.IGNORE}
+ keymode={Astal.Keymode.ON_DEMAND}
+ application={App}
+ onShow={() => text.set("")}
+ onKeyPressEvent={function (self, event: Gdk.Event) {
+ if (event.get_keyval()[1] === Gdk.KEY_Escape)
+ self.hide()
+ }}>
+ <box>
+ <eventbox widthRequest={4000} expand onClick={hide} />
+ <box hexpand={false} vertical>
+ <eventbox heightRequest={100} onClick={hide} />
+ <box widthRequest={500} className="Applauncher" vertical>
+ <entry
+ placeholderText="Search"
+ text={text()}
+ onChanged={self => text.set(self.text)}
+ onActivate={onEnter}
+ />
+ <box spacing={6} vertical>
+ {list.as(list => list.map(app => (
+ <AppButton app={app} />
+ )))}
+ </box>
+ <box
+ halign={CENTER}
+ className="not-found"
+ vertical
+ visible={list.as(l => l.length === 0)}>
+ <icon icon="system-search-symbolic" />
+ <label label="No match found" />
+ </box>
+ </box>
+ <eventbox expand onClick={hide} />
+ </box>
+ <eventbox widthRequest={4000} expand onClick={hide} />
+ </box>
+ </window>
+}
diff --git a/examples/gtk3/js/media-player/README.md b/examples/gtk3/js/media-player/README.md
new file mode 100644
index 0000000..4e3d237
--- /dev/null
+++ b/examples/gtk3/js/media-player/README.md
@@ -0,0 +1,5 @@
+# Media Player
+
+![mpris](https://github.com/user-attachments/assets/891e9706-74db-4505-bd83-c3628d7b4fd0)
+
+Using [Mpris](https://aylur.github.io/astal/guide/libraries/mpris).
diff --git a/examples/gtk3/js/media-player/app.ts b/examples/gtk3/js/media-player/app.ts
new file mode 100644
index 0000000..5b7558a
--- /dev/null
+++ b/examples/gtk3/js/media-player/app.ts
@@ -0,0 +1,11 @@
+import { App, Widget } from "astal/gtk3"
+import style from "./style.scss"
+import MprisPlayers from "./widget/MediaPlayer"
+
+App.start({
+ instanceName: "players",
+ css: style,
+ main: () => {
+ new Widget.Window({}, MprisPlayers())
+ }
+})
diff --git a/examples/gtk3/js/media-player/style.scss b/examples/gtk3/js/media-player/style.scss
new file mode 100644
index 0000000..2e2f625
--- /dev/null
+++ b/examples/gtk3/js/media-player/style.scss
@@ -0,0 +1 @@
+@use "./widget/MediaPlayer.scss";
diff --git a/examples/gtk3/js/media-player/widget/MediaPlayer.scss b/examples/gtk3/js/media-player/widget/MediaPlayer.scss
new file mode 100644
index 0000000..e1597c2
--- /dev/null
+++ b/examples/gtk3/js/media-player/widget/MediaPlayer.scss
@@ -0,0 +1,56 @@
+// https://gitlab.gnome.org/GNOME/gtk/-/blob/gtk-3-24/gtk/theme/Adwaita/_colors-public.scss
+$fg-color: #{"@theme_fg_color"};
+$bg-color: #{"@theme_bg_color"};
+
+window {
+ all: unset;
+}
+
+box.MediaPlayer {
+ padding: .6rem;
+ background-color: $bg-color;
+
+ box.cover-art {
+ min-width: 120px;
+ min-height: 120px;
+ border-radius: 9px;
+ margin-right: .6rem;
+ background-size: contain;
+ background-position: center;
+ }
+
+ box.title {
+ label {
+ font-weight: bold;
+ font-size: 1.1em;
+ }
+ }
+
+ scale {
+ padding: 0;
+ margin: .4rem 0;
+
+ trough {
+ min-height: 8px;
+ }
+
+ highlight {
+ background-color: $fg-color;
+ }
+
+ slider {
+ all: unset;
+ }
+ }
+
+ centerbox.actions {
+ min-width: 220px;
+
+ button {
+ min-width: 0;
+ min-height: 0;
+ padding: .4rem;
+ margin: 0 .2rem;
+ }
+ }
+}
diff --git a/examples/gtk3/js/media-player/widget/MediaPlayer.tsx b/examples/gtk3/js/media-player/widget/MediaPlayer.tsx
new file mode 100644
index 0000000..06c7e77
--- /dev/null
+++ b/examples/gtk3/js/media-player/widget/MediaPlayer.tsx
@@ -0,0 +1,94 @@
+import { Astal, Gtk } from "astal/gtk3"
+import Mpris from "gi://AstalMpris"
+import { bind } from "astal"
+
+function lengthStr(length: number) {
+ const min = Math.floor(length / 60)
+ const sec = Math.floor(length % 60)
+ const sec0 = sec < 10 ? "0" : ""
+ return `${min}:${sec0}${sec}`
+}
+
+
+function MediaPlayer({ player }: { player: Mpris.Player }) {
+ const { START, END } = Gtk.Align
+
+ const title = bind(player, "title").as(t =>
+ t || "Unknown Track")
+
+ const artist = bind(player, "artist").as(a =>
+ a || "Unknown Artist")
+
+ const coverArt = bind(player, "coverArt").as(c =>
+ `background-image: url('${c}')`)
+
+ const playerIcon = bind(player, "entry").as(e =>
+ Astal.Icon.lookup_icon(e) ? e : "audio-x-generic-symbolic")
+
+ const position = bind(player, "position").as(p => player.length > 0
+ ? p / player.length : 0)
+
+ const playIcon = bind(player, "playbackStatus").as(s =>
+ s === Mpris.PlaybackStatus.PLAYING
+ ? "media-playback-pause-symbolic"
+ : "media-playback-start-symbolic"
+ )
+
+ return <box className="MediaPlayer">
+ <box className="cover-art" css={coverArt} />
+ <box vertical>
+ <box className="title">
+ <label truncate hexpand halign={START} label={title} />
+ <icon icon={playerIcon} />
+ </box>
+ <label halign={START} valign={START} vexpand wrap label={artist} />
+ <slider
+ visible={bind(player, "length").as(l => l > 0)}
+ onDragged={({ value }) => player.position = value * player.length}
+ value={position}
+ />
+ <centerbox className="actions">
+ <label
+ hexpand
+ className="position"
+ halign={START}
+ visible={bind(player, "length").as(l => l > 0)}
+ label={bind(player, "position").as(lengthStr)}
+ />
+ <box>
+ <button
+ onClicked={() => player.previous()}
+ visible={bind(player, "canGoPrevious")}>
+ <icon icon="media-skip-backward-symbolic" />
+ </button>
+ <button
+ onClicked={() => player.play_pause()}
+ visible={bind(player, "canControl")}>
+ <icon icon={playIcon} />
+ </button>
+ <button
+ onClicked={() => player.next()}
+ visible={bind(player, "canGoNext")}>
+ <icon icon="media-skip-forward-symbolic" />
+ </button>
+ </box>
+ <label
+ className="length"
+ hexpand
+ halign={END}
+ visible={bind(player, "length").as(l => l > 0)}
+ label={bind(player, "length").as(l => l > 0 ? lengthStr(l) : "0:00")}
+ />
+ </centerbox>
+ </box>
+ </box>
+}
+
+export default function MprisPlayers() {
+ const mpris = Mpris.get_default()
+ return <box vertical>
+ {bind(mpris, "players").as(arr => arr.map(player => (
+ <MediaPlayer player={player} />
+ )))}
+ </box>
+}
diff --git a/examples/gtk3/js/notifications/README.md b/examples/gtk3/js/notifications/README.md
new file mode 100644
index 0000000..60dad60
--- /dev/null
+++ b/examples/gtk3/js/notifications/README.md
@@ -0,0 +1,5 @@
+# Notifications Popups
+
+![notifs](https://github.com/user-attachments/assets/0df0eddc-5c74-4af0-a694-48dc8ec6bb44)
+
+A replacement for dunst and other daemons using [Notifd](https://aylur.github.io/astal/guide/libraries/notifd).
diff --git a/examples/gtk3/js/notifications/app.ts b/examples/gtk3/js/notifications/app.ts
new file mode 100644
index 0000000..ed53292
--- /dev/null
+++ b/examples/gtk3/js/notifications/app.ts
@@ -0,0 +1,9 @@
+import { App } from "astal/gtk3"
+import style from "./style.scss"
+import NotificationPopups from "./notifications/NotificationPopups"
+
+App.start({
+ instanceName: "notifications",
+ css: style,
+ main: () => App.get_monitors().map(NotificationPopups),
+})
diff --git a/examples/gtk3/js/notifications/notifications/Notification.scss b/examples/gtk3/js/notifications/notifications/Notification.scss
new file mode 100644
index 0000000..a32f08b
--- /dev/null
+++ b/examples/gtk3/js/notifications/notifications/Notification.scss
@@ -0,0 +1,125 @@
+@use "sass:string";
+
+@function gtkalpha($c, $a) {
+ @return string.unquote("alpha(#{$c},#{$a})");
+}
+
+// https://gitlab.gnome.org/GNOME/gtk/-/blob/gtk-3-24/gtk/theme/Adwaita/_colors-public.scss
+$fg-color: #{"@theme_fg_color"};
+$bg-color: #{"@theme_bg_color"};
+$error: red;
+
+window.NotificationPopups {
+ all: unset;
+}
+
+eventbox.Notification {
+
+ &:first-child>box {
+ margin-top: 1rem;
+ }
+
+ &:last-child>box {
+ margin-bottom: 1rem;
+ }
+
+ // eventboxes can not take margins so we style its inner box instead
+ >box {
+ min-width: 400px;
+ border-radius: 13px;
+ background-color: $bg-color;
+ margin: .5rem 1rem .5rem 1rem;
+ box-shadow: 2px 3px 8px 0 gtkalpha(black, .4);
+ border: 1pt solid gtkalpha($fg-color, .03);
+ }
+
+ &.critical>box {
+ border: 1pt solid gtkalpha($error, .4);
+
+ .header {
+
+ .app-name {
+ color: gtkalpha($error, .8);
+
+ }
+
+ .app-icon {
+ color: gtkalpha($error, .6);
+ }
+ }
+ }
+
+ .header {
+ padding: .5rem;
+ color: gtkalpha($fg-color, 0.5);
+
+ .app-icon {
+ margin: 0 .4rem;
+ }
+
+ .app-name {
+ margin-right: .3rem;
+ font-weight: bold;
+
+ &:first-child {
+ margin-left: .4rem;
+ }
+ }
+
+ .time {
+ margin: 0 .4rem;
+ }
+
+ button {
+ padding: .2rem;
+ min-width: 0;
+ min-height: 0;
+ }
+ }
+
+ separator {
+ margin: 0 .4rem;
+ background-color: gtkalpha($fg-color, .1);
+ }
+
+ .content {
+ margin: 1rem;
+ margin-top: .5rem;
+
+ .summary {
+ font-size: 1.2em;
+ color: $fg-color;
+ }
+
+ .body {
+ color: gtkalpha($fg-color, 0.8);
+ }
+
+ .image {
+ border: 1px solid gtkalpha($fg-color, .02);
+ margin-right: .5rem;
+ border-radius: 9px;
+ min-width: 100px;
+ min-height: 100px;
+ background-size: cover;
+ background-position: center;
+ }
+ }
+
+ .actions {
+ margin: 1rem;
+ margin-top: 0;
+
+ button {
+ margin: 0 .3rem;
+
+ &:first-child {
+ margin-left: 0;
+ }
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+ }
+}
diff --git a/examples/gtk3/js/notifications/notifications/Notification.tsx b/examples/gtk3/js/notifications/notifications/Notification.tsx
new file mode 100644
index 0000000..5149d5b
--- /dev/null
+++ b/examples/gtk3/js/notifications/notifications/Notification.tsx
@@ -0,0 +1,107 @@
+import { GLib } from "astal"
+import { Gtk, Astal } from "astal/gtk3"
+import { type EventBox } from "astal/gtk3/widget"
+import Notifd from "gi://AstalNotifd"
+
+const isIcon = (icon: string) =>
+ !!Astal.Icon.lookup_icon(icon)
+
+const fileExists = (path: string) =>
+ GLib.file_test(path, GLib.FileTest.EXISTS)
+
+const time = (time: number, format = "%H:%M") => GLib.DateTime
+ .new_from_unix_local(time)
+ .format(format)!
+
+const urgency = (n: Notifd.Notification) => {
+ const { LOW, NORMAL, CRITICAL } = Notifd.Urgency
+ // match operator when?
+ switch (n.urgency) {
+ case LOW: return "low"
+ case CRITICAL: return "critical"
+ case NORMAL:
+ default: return "normal"
+ }
+}
+
+type Props = {
+ setup(self: EventBox): void
+ onHoverLost(self: EventBox): void
+ notification: Notifd.Notification
+}
+
+export default function Notification(props: Props) {
+ const { notification: n, onHoverLost, setup } = props
+ const { START, CENTER, END } = Gtk.Align
+
+ return <eventbox
+ className={`Notification ${urgency(n)}`}
+ setup={setup}
+ onHoverLost={onHoverLost}>
+ <box vertical>
+ <box className="header">
+ {(n.appIcon || n.desktopEntry) && <icon
+ className="app-icon"
+ visible={Boolean(n.appIcon || n.desktopEntry)}
+ icon={n.appIcon || n.desktopEntry}
+ />}
+ <label
+ className="app-name"
+ halign={START}
+ truncate
+ label={n.appName || "Unknown"}
+ />
+ <label
+ className="time"
+ hexpand
+ halign={END}
+ label={time(n.time)}
+ />
+ <button onClicked={() => n.dismiss()}>
+ <icon icon="window-close-symbolic" />
+ </button>
+ </box>
+ <Gtk.Separator visible />
+ <box className="content">
+ {n.image && fileExists(n.image) && <box
+ valign={START}
+ className="image"
+ css={`background-image: url('${n.image}')`}
+ />}
+ {n.image && isIcon(n.image) && <box
+ expand={false}
+ valign={START}
+ className="icon-image">
+ <icon icon={n.image} expand halign={CENTER} valign={CENTER} />
+ </box>}
+ <box vertical>
+ <label
+ className="summary"
+ halign={START}
+ xalign={0}
+ label={n.summary}
+ truncate
+ />
+ {n.body && <label
+ className="body"
+ wrap
+ useMarkup
+ halign={START}
+ xalign={0}
+ justifyFill
+ label={n.body}
+ />}
+ </box>
+ </box>
+ {n.get_actions().length > 0 && <box className="actions">
+ {n.get_actions().map(({ label, id }) => (
+ <button
+ hexpand
+ onClicked={() => n.invoke(id)}>
+ <label label={label} halign={CENTER} hexpand />
+ </button>
+ ))}
+ </box>}
+ </box>
+ </eventbox>
+}
diff --git a/examples/gtk3/js/notifications/notifications/NotificationPopups.tsx b/examples/gtk3/js/notifications/notifications/NotificationPopups.tsx
new file mode 100644
index 0000000..13fdd88
--- /dev/null
+++ b/examples/gtk3/js/notifications/notifications/NotificationPopups.tsx
@@ -0,0 +1,105 @@
+import { Astal, Gtk, Gdk } from "astal/gtk3"
+import Notifd from "gi://AstalNotifd"
+import Notification from "./Notification"
+import { type Subscribable } from "astal/binding"
+import { Variable, bind, timeout } from "astal"
+
+// see comment below in constructor
+const TIMEOUT_DELAY = 5000
+
+// The purpose if this class is to replace Variable<Array<Widget>>
+// with a Map<number, Widget> type in order to track notification widgets
+// by their id, while making it conviniently bindable as an array
+class NotifiationMap implements Subscribable {
+ // the underlying map to keep track of id widget pairs
+ private map: Map<number, Gtk.Widget> = new Map()
+
+ // it makes sense to use a Variable under the hood and use its
+ // reactivity implementation instead of keeping track of subscribers ourselves
+ private var: Variable<Array<Gtk.Widget>> = Variable([])
+
+ // notify subscribers to rerender when state changes
+ private notifiy() {
+ this.var.set([...this.map.values()].reverse())
+ }
+
+ constructor() {
+ const notifd = Notifd.get_default()
+
+ /**
+ * uncomment this if you want to
+ * ignore timeout by senders and enforce our own timeout
+ * note that if the notification has any actions
+ * they might not work, since the sender already treats them as resolved
+ */
+ // notifd.ignoreTimeout = true
+
+ notifd.connect("notified", (_, id) => {
+ this.set(id, Notification({
+ notification: notifd.get_notification(id)!,
+
+ // once hovering over the notification is done
+ // destroy the widget without calling notification.dismiss()
+ // so that it acts as a "popup" and we can still display it
+ // in a notification center like widget
+ // but clicking on the close button will close it
+ onHoverLost: () => this.delete(id),
+
+ // notifd by default does not close notifications
+ // until user input or the timeout specified by sender
+ // which we set to ignore above
+ setup: () => timeout(TIMEOUT_DELAY, () => {
+ /**
+ * uncomment this if you want to "hide" the notifications
+ * after TIMEOUT_DELAY
+ */
+ // this.delete(id)
+ })
+ }))
+ })
+
+ // notifications can be closed by the outside before
+ // any user input, which have to be handled too
+ notifd.connect("resolved", (_, id) => {
+ this.delete(id)
+ })
+ }
+
+ private set(key: number, value: Gtk.Widget) {
+ // in case of replacecment destroy previous widget
+ this.map.get(key)?.destroy()
+ this.map.set(key, value)
+ this.notifiy()
+ }
+
+ private delete(key: number) {
+ this.map.get(key)?.destroy()
+ this.map.delete(key)
+ this.notifiy()
+ }
+
+ // needed by the Subscribable interface
+ get() {
+ return this.var.get()
+ }
+
+ // needed by the Subscribable interface
+ subscribe(callback: (list: Array<Gtk.Widget>) => void) {
+ return this.var.subscribe(callback)
+ }
+}
+
+export default function NotificationPopups(gdkmonitor: Gdk.Monitor) {
+ const { TOP, RIGHT } = Astal.WindowAnchor
+ const notifs = new NotifiationMap()
+
+ return <window
+ className="NotificationPopups"
+ gdkmonitor={gdkmonitor}
+ exclusivity={Astal.Exclusivity.EXCLUSIVE}
+ anchor={TOP | RIGHT}>
+ <box vertical noImplicitDestroy>
+ {bind(notifs)}
+ </box>
+ </window>
+}
diff --git a/examples/gtk3/js/notifications/style.scss b/examples/gtk3/js/notifications/style.scss
new file mode 100644
index 0000000..7ef0168
--- /dev/null
+++ b/examples/gtk3/js/notifications/style.scss
@@ -0,0 +1 @@
+@use "./notifications/Notification.scss";
diff --git a/examples/gtk3/js/popover/app.tsx b/examples/gtk3/js/popover/app.tsx
new file mode 100644
index 0000000..6358297
--- /dev/null
+++ b/examples/gtk3/js/popover/app.tsx
@@ -0,0 +1,104 @@
+import { App, Astal, Gdk, Gtk } from "astal/gtk3"
+
+const { TOP, RIGHT, BOTTOM, LEFT } = Astal.WindowAnchor
+
+type PopupProps = {
+ child?: unknown
+ marginBottom?: number
+ marginTop?: number
+ marginLeft?: number
+ marginRight?: number
+ halign?: Gtk.Align
+ valign?: Gtk.Align
+}
+
+function Popup({
+ child,
+ marginBottom,
+ marginTop,
+ marginLeft,
+ marginRight,
+ halign = Gtk.Align.CENTER,
+ valign = Gtk.Align.CENTER,
+}: PopupProps) {
+ return (
+ <window
+ visible={false}
+ css="background-color: transparent"
+ keymode={Astal.Keymode.EXCLUSIVE}
+ anchor={TOP | RIGHT | BOTTOM | LEFT}
+ exclusivity={Astal.Exclusivity.IGNORE}
+ // close when click occurs otside of child
+ onButtonPressEvent={(self, event) => {
+ const [, _x, _y] = event.get_coords()
+ const { x, y, width, height } = self
+ .get_child()!
+ .get_allocation()
+
+ const xOut = _x < x || _x > x + width
+ const yOut = _y < y || _y > y + height
+
+ // clicked outside
+ if (xOut || yOut) self.hide()
+ }}
+ // close when hitting Escape
+ onKeyPressEvent={(self, event: Gdk.Event) => {
+ if (event.get_keyval()[1] === Gdk.KEY_Escape) {
+ self.hide()
+ }
+ }}
+ >
+ <box
+ className="Popup"
+ onButtonPressEvent={() => true} // make sure click event does not bubble up
+ // child can be positioned with `halign` `valign` and margins
+ expand
+ halign={halign}
+ valign={valign}
+ marginBottom={marginBottom}
+ marginTop={marginTop}
+ marginStart={marginLeft}
+ marginEnd={marginRight}
+ >
+ {child}
+ </box>
+ </window>
+ )
+}
+
+App.start({
+ instanceName: "popup-example",
+ css: `
+ .Popup {
+ background-color: @theme_bg_color;
+ box-shadow: 2px 3px 7px 0 rgba(0,0,0,0.4);
+ border-radius: 12px;
+ padding: 12px;
+ }
+ `,
+ main() {
+ const popup = (
+ <Popup
+ marginTop={36}
+ marginRight={60}
+ valign={Gtk.Align.START}
+ halign={Gtk.Align.END}
+ >
+ <button onClicked={() => popup.hide()}>
+ Click me to close the popup
+ </button>
+ </Popup>
+ )
+
+ return (
+ <window
+ anchor={TOP | LEFT | RIGHT}
+ exclusivity={Astal.Exclusivity.EXCLUSIVE}
+ >
+ <button onClicked={() => popup.show()} halign={Gtk.Align.END}>
+ Click to open popup
+ </button>
+ </window>
+ )
+ },
+})
diff --git a/examples/gtk3/js/simple-bar/README.md b/examples/gtk3/js/simple-bar/README.md
new file mode 100644
index 0000000..f92b20e
--- /dev/null
+++ b/examples/gtk3/js/simple-bar/README.md
@@ -0,0 +1,12 @@
+# Simple Bar Example
+
+![simple-bar](https://github.com/user-attachments/assets/a306c864-56b7-44c4-8820-81f424f32b9b)
+
+A simple bar for Hyprland using
+
+- [Battery library](https://aylur.github.io/astal/guide/libraries/battery).
+- [Hyprland library](https://aylur.github.io/astal/guide/libraries/hyprland).
+- [Mpris library](https://aylur.github.io/astal/guide/libraries/mpris).
+- [Network library](https://aylur.github.io/astal/guide/libraries/network).
+- [Tray library](https://aylur.github.io/astal/guide/libraries/tray).
+- [WirePlumber library](https://aylur.github.io/astal/guide/libraries/wireplumber).
diff --git a/examples/gtk3/js/simple-bar/app.ts b/examples/gtk3/js/simple-bar/app.ts
new file mode 100644
index 0000000..4b7ea48
--- /dev/null
+++ b/examples/gtk3/js/simple-bar/app.ts
@@ -0,0 +1,13 @@
+import { App } from "astal/gtk3"
+import style from "./style.scss"
+import Bar from "./widget/Bar"
+
+App.start({
+ css: style,
+ instanceName: "js",
+ requestHandler(request, res) {
+ print(request)
+ res("ok")
+ },
+ main: () => App.get_monitors().map(Bar),
+})
diff --git a/examples/gtk3/js/simple-bar/style.scss b/examples/gtk3/js/simple-bar/style.scss
new file mode 100644
index 0000000..f5f771a
--- /dev/null
+++ b/examples/gtk3/js/simple-bar/style.scss
@@ -0,0 +1,106 @@
+@use "sass:color";
+
+$bg: #212223;
+$fg: #f1f1f1;
+$accent: #378DF7;
+$radius: 7px;
+
+window.Bar {
+ border: none;
+ box-shadow: none;
+ background-color: $bg;
+ color: $fg;
+ font-size: 1.1em;
+ font-weight: bold;
+
+ label {
+ margin: 0 8px;
+ }
+
+ .Workspaces {
+ button {
+ all: unset;
+ background-color: transparent;
+
+ &:hover label {
+ background-color: color.adjust($fg, $alpha: -0.84);
+ border-color: color.adjust($accent, $alpha: -0.8);
+ }
+
+ &:active label {
+ background-color: color.adjust($fg, $alpha: -0.8)
+ }
+ }
+
+ label {
+ transition: 200ms;
+ padding: 0 8px;
+ margin: 2px;
+ border-radius: $radius;
+ border: 1pt solid transparent;
+ }
+
+ .focused label {
+ color: $accent;
+ border-color: $accent;
+ }
+ }
+
+ .SysTray {
+ margin-right: 8px;
+
+ button {
+ padding: 0 4px;
+ }
+ }
+
+ .FocusedClient {
+ color: $accent;
+ }
+
+ .Media .Cover {
+ min-height: 1.2em;
+ min-width: 1.2em;
+ border-radius: $radius;
+ background-position: center;
+ background-size: contain;
+ }
+
+ .Battery label {
+ padding-left: 0;
+ margin-left: 0;
+ }
+
+ .AudioSlider {
+ * {
+ all: unset;
+ }
+
+ icon {
+ margin-right: .6em;
+ }
+
+ & {
+ margin: 0 1em;
+ }
+
+ trough {
+ background-color: color.adjust($fg, $alpha: -0.8);
+ border-radius: $radius;
+ }
+
+ highlight {
+ background-color: $accent;
+ min-height: .8em;
+ border-radius: $radius;
+ }
+
+ slider {
+ background-color: $fg;
+ border-radius: $radius;
+ min-height: 1em;
+ min-width: 1em;
+ margin: -.2em;
+ }
+ }
+}
diff --git a/examples/gtk3/js/simple-bar/widget/Bar.tsx b/examples/gtk3/js/simple-bar/widget/Bar.tsx
new file mode 100644
index 0000000..6592f6a
--- /dev/null
+++ b/examples/gtk3/js/simple-bar/widget/Bar.tsx
@@ -0,0 +1,161 @@
+import { App } from "astal/gtk3"
+import { Variable, GLib, bind } from "astal"
+import { Astal, Gtk, Gdk } from "astal/gtk3"
+import Hyprland from "gi://AstalHyprland"
+import Mpris from "gi://AstalMpris"
+import Battery from "gi://AstalBattery"
+import Wp from "gi://AstalWp"
+import Network from "gi://AstalNetwork"
+import Tray from "gi://AstalTray"
+
+function SysTray() {
+ const tray = Tray.get_default()
+
+ return <box className="SysTray">
+ {bind(tray, "items").as(items => items.map(item => (
+ <menubutton
+ tooltipMarkup={bind(item, "tooltipMarkup")}
+ usePopover={false}
+ actionGroup={bind(item, "actionGroup").as(ag => ["dbusmenu", ag])}
+ menuModel={bind(item, "menuModel")}>
+ <icon gicon={bind(item, "gicon")} />
+ </menubutton>
+ )))}
+ </box>
+}
+
+function Wifi() {
+ const network = Network.get_default()
+ const wifi = bind(network, "wifi")
+
+ return <box visible={wifi.as(Boolean)}>
+ {wifi.as(wifi => wifi && (
+ <icon
+ tooltipText={bind(wifi, "ssid").as(String)}
+ className="Wifi"
+ icon={bind(wifi, "iconName")}
+ />
+ ))}
+ </box>
+
+}
+
+function AudioSlider() {
+ const speaker = Wp.get_default()?.audio.defaultSpeaker!
+
+ return <box className="AudioSlider" css="min-width: 140px">
+ <icon icon={bind(speaker, "volumeIcon")} />
+ <slider
+ hexpand
+ onDragged={({ value }) => speaker.volume = value}
+ value={bind(speaker, "volume")}
+ />
+ </box>
+}
+
+function BatteryLevel() {
+ const bat = Battery.get_default()
+
+ return <box className="Battery"
+ visible={bind(bat, "isPresent")}>
+ <icon icon={bind(bat, "batteryIconName")} />
+ <label label={bind(bat, "percentage").as(p =>
+ `${Math.floor(p * 100)} %`
+ )} />
+ </box>
+}
+
+function Media() {
+ const mpris = Mpris.get_default()
+
+ return <box className="Media">
+ {bind(mpris, "players").as(ps => ps[0] ? (
+ <box>
+ <box
+ className="Cover"
+ valign={Gtk.Align.CENTER}
+ css={bind(ps[0], "coverArt").as(cover =>
+ `background-image: url('${cover}');`
+ )}
+ />
+ <label
+ label={bind(ps[0], "title").as(() =>
+ `${ps[0].title} - ${ps[0].artist}`
+ )}
+ />
+ </box>
+ ) : (
+ "Nothing Playing"
+ ))}
+ </box>
+}
+
+function Workspaces() {
+ const hypr = Hyprland.get_default()
+
+ return <box className="Workspaces">
+ {bind(hypr, "workspaces").as(wss => wss
+ .filter(ws => !(ws.id >= -99 && ws.id <= -2)) // filter out special workspaces
+ .sort((a, b) => a.id - b.id)
+ .map(ws => (
+ <button
+ className={bind(hypr, "focusedWorkspace").as(fw =>
+ ws === fw ? "focused" : "")}
+ onClicked={() => ws.focus()}>
+ {ws.id}
+ </button>
+ ))
+ )}
+ </box>
+}
+
+function FocusedClient() {
+ const hypr = Hyprland.get_default()
+ const focused = bind(hypr, "focusedClient")
+
+ return <box
+ className="Focused"
+ visible={focused.as(Boolean)}>
+ {focused.as(client => (
+ client && <label label={bind(client, "title").as(String)} />
+ ))}
+ </box>
+}
+
+function Time({ format = "%H:%M - %A %e." }) {
+ const time = Variable<string>("").poll(1000, () =>
+ GLib.DateTime.new_now_local().format(format)!)
+
+ return <label
+ className="Time"
+ onDestroy={() => time.drop()}
+ label={time()}
+ />
+}
+
+export default function Bar(monitor: Gdk.Monitor) {
+ const { TOP, LEFT, RIGHT } = Astal.WindowAnchor
+
+ return <window
+ className="Bar"
+ gdkmonitor={monitor}
+ exclusivity={Astal.Exclusivity.EXCLUSIVE}
+ anchor={TOP | LEFT | RIGHT}>
+ <centerbox>
+ <box hexpand halign={Gtk.Align.START}>
+ <Workspaces />
+ <FocusedClient />
+ </box>
+ <box>
+ <Media />
+ </box>
+ <box hexpand halign={Gtk.Align.END} >
+ <SysTray />
+ <Wifi />
+ <AudioSlider />
+ <BatteryLevel />
+ <Time />
+ </box>
+ </centerbox>
+ </window>
+}
diff --git a/examples/gtk3/lua/simple-bar/README.md b/examples/gtk3/lua/simple-bar/README.md
new file mode 100644
index 0000000..48cc27c
--- /dev/null
+++ b/examples/gtk3/lua/simple-bar/README.md
@@ -0,0 +1,13 @@
+# Simple Bar Example
+
+![simple-bar](https://github.com/user-attachments/assets/a306c864-56b7-44c4-8820-81f424f32b9b)
+
+A simple bar for Hyprland using
+
+- [Battery library](https://aylur.github.io/astal/guide/libraries/battery).
+- [Hyprland library](https://aylur.github.io/astal/guide/libraries/hyprland).
+- [Mpris library](https://aylur.github.io/astal/guide/libraries/mpris).
+- [Network library](https://aylur.github.io/astal/guide/libraries/network).
+- [Tray library](https://aylur.github.io/astal/guide/libraries/tray).
+- [WirePlumber library](https://aylur.github.io/astal/guide/libraries/wireplumber).
+- [dart-sass](https://sass-lang.com/dart-sass/) as the css precompiler
diff --git a/examples/gtk3/lua/simple-bar/init.lua b/examples/gtk3/lua/simple-bar/init.lua
new file mode 100644
index 0000000..8c412fb
--- /dev/null
+++ b/examples/gtk3/lua/simple-bar/init.lua
@@ -0,0 +1,24 @@
+local astal = require("astal")
+local App = require("astal.gtk3.app")
+
+local Bar = require("widget.Bar")
+local src = require("lib").src
+
+local scss = src("style.scss")
+local css = "/tmp/style.css"
+
+astal.exec("sass " .. scss .. " " .. css)
+
+App:start({
+ instance_name = "lua",
+ css = css,
+ request_handler = function(msg, res)
+ print(msg)
+ res("ok")
+ end,
+ main = function()
+ for _, mon in pairs(App.monitors) do
+ Bar(mon)
+ end
+ end,
+})
diff --git a/examples/gtk3/lua/simple-bar/lib.lua b/examples/gtk3/lua/simple-bar/lib.lua
new file mode 100644
index 0000000..d94db5c
--- /dev/null
+++ b/examples/gtk3/lua/simple-bar/lib.lua
@@ -0,0 +1,25 @@
+local Variable = require("astal").Variable
+
+local M = {}
+
+function M.src(path)
+ local str = debug.getinfo(2, "S").source:sub(2)
+ local src = str:match("(.*/)") or str:match("(.*\\)") or "./"
+ return src .. path
+end
+
+---@generic T, R
+---@param arr T[]
+---@param func fun(T, integer): R
+---@return R[]
+function M.map(arr, func)
+ local new_arr = {}
+ for i, v in ipairs(arr) do
+ new_arr[i] = func(v, i)
+ end
+ return new_arr
+end
+
+M.date = Variable(""):poll(1000, "date")
+
+return M
diff --git a/examples/gtk3/lua/simple-bar/style.scss b/examples/gtk3/lua/simple-bar/style.scss
new file mode 100644
index 0000000..f5f771a
--- /dev/null
+++ b/examples/gtk3/lua/simple-bar/style.scss
@@ -0,0 +1,106 @@
+@use "sass:color";
+
+$bg: #212223;
+$fg: #f1f1f1;
+$accent: #378DF7;
+$radius: 7px;
+
+window.Bar {
+ border: none;
+ box-shadow: none;
+ background-color: $bg;
+ color: $fg;
+ font-size: 1.1em;
+ font-weight: bold;
+
+ label {
+ margin: 0 8px;
+ }
+
+ .Workspaces {
+ button {
+ all: unset;
+ background-color: transparent;
+
+ &:hover label {
+ background-color: color.adjust($fg, $alpha: -0.84);
+ border-color: color.adjust($accent, $alpha: -0.8);
+ }
+
+ &:active label {
+ background-color: color.adjust($fg, $alpha: -0.8)
+ }
+ }
+
+ label {
+ transition: 200ms;
+ padding: 0 8px;
+ margin: 2px;
+ border-radius: $radius;
+ border: 1pt solid transparent;
+ }
+
+ .focused label {
+ color: $accent;
+ border-color: $accent;
+ }
+ }
+
+ .SysTray {
+ margin-right: 8px;
+
+ button {
+ padding: 0 4px;
+ }
+ }
+
+ .FocusedClient {
+ color: $accent;
+ }
+
+ .Media .Cover {
+ min-height: 1.2em;
+ min-width: 1.2em;
+ border-radius: $radius;
+ background-position: center;
+ background-size: contain;
+ }
+
+ .Battery label {
+ padding-left: 0;
+ margin-left: 0;
+ }
+
+ .AudioSlider {
+ * {
+ all: unset;
+ }
+
+ icon {
+ margin-right: .6em;
+ }
+
+ & {
+ margin: 0 1em;
+ }
+
+ trough {
+ background-color: color.adjust($fg, $alpha: -0.8);
+ border-radius: $radius;
+ }
+
+ highlight {
+ background-color: $accent;
+ min-height: .8em;
+ border-radius: $radius;
+ }
+
+ slider {
+ background-color: $fg;
+ border-radius: $radius;
+ min-height: 1em;
+ min-width: 1em;
+ margin: -.2em;
+ }
+ }
+}
diff --git a/examples/gtk3/lua/simple-bar/widget/Bar.lua b/examples/gtk3/lua/simple-bar/widget/Bar.lua
new file mode 100644
index 0000000..155b9b8
--- /dev/null
+++ b/examples/gtk3/lua/simple-bar/widget/Bar.lua
@@ -0,0 +1,198 @@
+local astal = require("astal")
+local Widget = require("astal.gtk3.widget")
+local Variable = astal.Variable
+local GLib = astal.require("GLib")
+local bind = astal.bind
+local Mpris = astal.require("AstalMpris")
+local Battery = astal.require("AstalBattery")
+local Wp = astal.require("AstalWp")
+local Network = astal.require("AstalNetwork")
+local Tray = astal.require("AstalTray")
+local Hyprland = astal.require("AstalHyprland")
+local map = require("lib").map
+
+local function SysTray()
+ local tray = Tray.get_default()
+
+ return Widget.Box({
+ class_name = "SysTray",
+ bind(tray, "items"):as(function(items)
+ return map(items, function(item)
+ return Widget.MenuButton({
+ tooltip_markup = bind(item, "tooltip_markup"),
+ use_popover = false,
+ menu_model = bind(item, "menu-model"),
+ action_group = bind(item, "action-group"):as(function(ag)
+ return { "dbusmenu", ag }
+ end),
+ Widget.Icon({
+ gicon = bind(item, "gicon"),
+ }),
+ })
+ end)
+ end),
+ })
+end
+
+local function FocusedClient()
+ local hypr = Hyprland.get_default()
+ local focused = bind(hypr, "focused-client")
+
+ return Widget.Box({
+ class_name = "Focused",
+ visible = focused,
+ focused:as(function(client)
+ return client and Widget.Label({
+ label = bind(client, "title"):as(tostring),
+ })
+ end),
+ })
+end
+
+local function Wifi()
+ local network = Network.get_default()
+ local wifi = bind(network, "wifi")
+
+ return Widget.Box({
+ visible = wifi:as(function(v)
+ return v ~= nil
+ end),
+ wifi:as(function(w)
+ return Widget.Icon({
+ tooltip_text = bind(w, "ssid"):as(tostring),
+ class_name = "Wifi",
+ icon = bind(w, "icon-name"),
+ })
+ end),
+ })
+end
+
+local function AudioSlider()
+ local speaker = Wp.get_default().audio.default_speaker
+
+ return Widget.Box({
+ class_name = "AudioSlider",
+ css = "min-width: 140px;",
+ Widget.Icon({
+ icon = bind(speaker, "volume-icon"),
+ }),
+ Widget.Slider({
+ hexpand = true,
+ on_dragged = function(self)
+ speaker.volume = self.value
+ end,
+ value = bind(speaker, "volume"),
+ }),
+ })
+end
+
+local function BatteryLevel()
+ local bat = Battery.get_default()
+
+ return Widget.Box({
+ class_name = "Battery",
+ visible = bind(bat, "is-present"),
+ Widget.Icon({
+ icon = bind(bat, "battery-icon-name"),
+ }),
+ Widget.Label({
+ label = bind(bat, "percentage"):as(function(p)
+ return tostring(math.floor(p * 100)) .. " %"
+ end),
+ }),
+ })
+end
+
+local function Media()
+ local player = Mpris.Player.new("spotify")
+
+ return Widget.Box({
+ class_name = "Media",
+ visible = bind(player, "available"),
+ Widget.Box({
+ class_name = "Cover",
+ valign = "CENTER",
+ css = bind(player, "cover-art"):as(function(cover)
+ return "background-image: url('" .. (cover or "") .. "');"
+ end),
+ }),
+ Widget.Label({
+ label = bind(player, "metadata"):as(function()
+ return (player.title or "") .. " - " .. (player.artist or "")
+ end),
+ }),
+ })
+end
+
+local function Workspaces()
+ local hypr = Hyprland.get_default()
+
+ return Widget.Box({
+ class_name = "Workspaces",
+ bind(hypr, "workspaces"):as(function(wss)
+ table.sort(wss, function(a, b)
+ return a.id < b.id
+ end)
+
+ return map(wss, function(ws)
+ if not (ws.id >= -99 and ws.id <= -2) then -- filter out special workspaces
+ return Widget.Button({
+ class_name = bind(hypr, "focused-workspace"):as(function(fw)
+ return fw == ws and "focused" or ""
+ end),
+ on_clicked = function()
+ ws:focus()
+ end,
+ label = bind(ws, "id"):as(function(v)
+ return type(v) == "number" and string.format("%.0f", v) or v
+ end),
+ })
+ end
+ end)
+ end),
+ })
+end
+
+local function Time(format)
+ local time = Variable(""):poll(1000, function()
+ return GLib.DateTime.new_now_local():format(format)
+ end)
+
+ return Widget.Label({
+ class_name = "Time",
+ on_destroy = function()
+ time:drop()
+ end,
+ label = time(),
+ })
+end
+
+return function(gdkmonitor)
+ local WindowAnchor = astal.require("Astal", "3.0").WindowAnchor
+
+ return Widget.Window({
+ class_name = "Bar",
+ gdkmonitor = gdkmonitor,
+ anchor = WindowAnchor.TOP + WindowAnchor.LEFT + WindowAnchor.RIGHT,
+ exclusivity = "EXCLUSIVE",
+
+ Widget.CenterBox({
+ Widget.Box({
+ halign = "START",
+ Workspaces(),
+ FocusedClient(),
+ }),
+ Widget.Box({
+ Media(),
+ }),
+ Widget.Box({
+ halign = "END",
+ SysTray(),
+ Wifi(),
+ AudioSlider(),
+ BatteryLevel(),
+ Time("%H:%M - %A %e."),
+ }),
+ }),
+ })
+end
diff --git a/examples/gtk3/py/.gitignore b/examples/gtk3/py/.gitignore
new file mode 100644
index 0000000..c18dd8d
--- /dev/null
+++ b/examples/gtk3/py/.gitignore
@@ -0,0 +1 @@
+__pycache__/
diff --git a/examples/gtk3/py/simple-bar/README.md b/examples/gtk3/py/simple-bar/README.md
new file mode 100644
index 0000000..48cc27c
--- /dev/null
+++ b/examples/gtk3/py/simple-bar/README.md
@@ -0,0 +1,13 @@
+# Simple Bar Example
+
+![simple-bar](https://github.com/user-attachments/assets/a306c864-56b7-44c4-8820-81f424f32b9b)
+
+A simple bar for Hyprland using
+
+- [Battery library](https://aylur.github.io/astal/guide/libraries/battery).
+- [Hyprland library](https://aylur.github.io/astal/guide/libraries/hyprland).
+- [Mpris library](https://aylur.github.io/astal/guide/libraries/mpris).
+- [Network library](https://aylur.github.io/astal/guide/libraries/network).
+- [Tray library](https://aylur.github.io/astal/guide/libraries/tray).
+- [WirePlumber library](https://aylur.github.io/astal/guide/libraries/wireplumber).
+- [dart-sass](https://sass-lang.com/dart-sass/) as the css precompiler
diff --git a/examples/gtk3/py/simple-bar/__init__.py b/examples/gtk3/py/simple-bar/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/examples/gtk3/py/simple-bar/__init__.py
diff --git a/examples/gtk3/py/simple-bar/app.py b/examples/gtk3/py/simple-bar/app.py
new file mode 100755
index 0000000..d95dc0e
--- /dev/null
+++ b/examples/gtk3/py/simple-bar/app.py
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+import sys
+import versions
+import subprocess
+from gi.repository import AstalIO, Astal, Gio
+from widget.Bar import Bar
+from pathlib import Path
+
+scss = str(Path(__file__).parent.resolve() / "style.scss")
+css = "/tmp/style.css"
+
+
+class App(Astal.Application):
+ def do_astal_application_request(
+ self, msg: str, conn: Gio.SocketConnection
+ ) -> None:
+ print(msg)
+ AstalIO.write_sock(conn, "hello")
+
+ def do_activate(self) -> None:
+ self.hold()
+ subprocess.run(["sass", scss, css])
+ self.apply_css(css, True)
+ for mon in self.get_monitors():
+ self.add_window(Bar(mon))
+
+
+instance_name = "python"
+app = App(instance_name=instance_name)
+
+if __name__ == "__main__":
+ try:
+ app.acquire_socket()
+ app.run(None)
+ except Exception as e:
+ print(AstalIO.send_message(instance_name, "".join(sys.argv[1:])))
diff --git a/examples/gtk3/py/simple-bar/style.scss b/examples/gtk3/py/simple-bar/style.scss
new file mode 100644
index 0000000..f5f771a
--- /dev/null
+++ b/examples/gtk3/py/simple-bar/style.scss
@@ -0,0 +1,106 @@
+@use "sass:color";
+
+$bg: #212223;
+$fg: #f1f1f1;
+$accent: #378DF7;
+$radius: 7px;
+
+window.Bar {
+ border: none;
+ box-shadow: none;
+ background-color: $bg;
+ color: $fg;
+ font-size: 1.1em;
+ font-weight: bold;
+
+ label {
+ margin: 0 8px;
+ }
+
+ .Workspaces {
+ button {
+ all: unset;
+ background-color: transparent;
+
+ &:hover label {
+ background-color: color.adjust($fg, $alpha: -0.84);
+ border-color: color.adjust($accent, $alpha: -0.8);
+ }
+
+ &:active label {
+ background-color: color.adjust($fg, $alpha: -0.8)
+ }
+ }
+
+ label {
+ transition: 200ms;
+ padding: 0 8px;
+ margin: 2px;
+ border-radius: $radius;
+ border: 1pt solid transparent;
+ }
+
+ .focused label {
+ color: $accent;
+ border-color: $accent;
+ }
+ }
+
+ .SysTray {
+ margin-right: 8px;
+
+ button {
+ padding: 0 4px;
+ }
+ }
+
+ .FocusedClient {
+ color: $accent;
+ }
+
+ .Media .Cover {
+ min-height: 1.2em;
+ min-width: 1.2em;
+ border-radius: $radius;
+ background-position: center;
+ background-size: contain;
+ }
+
+ .Battery label {
+ padding-left: 0;
+ margin-left: 0;
+ }
+
+ .AudioSlider {
+ * {
+ all: unset;
+ }
+
+ icon {
+ margin-right: .6em;
+ }
+
+ & {
+ margin: 0 1em;
+ }
+
+ trough {
+ background-color: color.adjust($fg, $alpha: -0.8);
+ border-radius: $radius;
+ }
+
+ highlight {
+ background-color: $accent;
+ min-height: .8em;
+ border-radius: $radius;
+ }
+
+ slider {
+ background-color: $fg;
+ border-radius: $radius;
+ min-height: 1em;
+ min-width: 1em;
+ margin: -.2em;
+ }
+ }
+}
diff --git a/examples/gtk3/py/simple-bar/versions.py b/examples/gtk3/py/simple-bar/versions.py
new file mode 100644
index 0000000..0e57708
--- /dev/null
+++ b/examples/gtk3/py/simple-bar/versions.py
@@ -0,0 +1,15 @@
+import gi
+
+gi.require_version("AstalIO", "0.1")
+gi.require_version("Astal", "3.0")
+gi.require_version("Gtk", "3.0")
+gi.require_version("Gdk", "3.0")
+gi.require_version("Gio", "2.0")
+gi.require_version("GObject", "2.0")
+
+gi.require_version("AstalBattery", "0.1")
+gi.require_version("AstalWp", "0.1")
+gi.require_version("AstalNetwork", "0.1")
+gi.require_version("AstalTray", "0.1")
+gi.require_version("AstalMpris", "0.1")
+gi.require_version("AstalHyprland", "0.1")
diff --git a/examples/gtk3/py/simple-bar/widget/Bar.py b/examples/gtk3/py/simple-bar/widget/Bar.py
new file mode 100644
index 0000000..555ab85
--- /dev/null
+++ b/examples/gtk3/py/simple-bar/widget/Bar.py
@@ -0,0 +1,251 @@
+import math
+from gi.repository import (
+ AstalIO,
+ Astal,
+ Gtk,
+ Gdk,
+ GLib,
+ GObject,
+ AstalBattery as Battery,
+ AstalWp as Wp,
+ AstalNetwork as Network,
+ AstalTray as Tray,
+ AstalMpris as Mpris,
+ AstalHyprland as Hyprland,
+)
+
+SYNC = GObject.BindingFlags.SYNC_CREATE
+
+
+class Workspaces(Gtk.Box):
+ def __init__(self) -> None:
+ super().__init__()
+ Astal.widget_set_class_names(self, ["Workspaces"])
+ hypr = Hyprland.get_default()
+ hypr.connect("notify::workspaces", self.sync)
+ hypr.connect("notify::focused-workspace", self.sync)
+ self.sync()
+
+ def sync(self, *_):
+ hypr = Hyprland.get_default()
+ for child in self.get_children():
+ child.destroy()
+
+ for ws in hypr.get_workspaces():
+ if not (ws.get_id() >= -99 and ws.get_id() <= -2): # filter out special workspaces
+ self.add(self.button(ws))
+
+ def button(self, ws):
+ hypr = Hyprland.get_default()
+ btn = Gtk.Button(visible=True)
+ btn.add(Gtk.Label(visible=True, label=ws.get_id()))
+
+ if hypr.get_focused_workspace() == ws:
+ Astal.widget_set_class_names(btn, ["focused"])
+
+ btn.connect("clicked", lambda *_: ws.focus())
+ return btn
+
+
+class FocusedClient(Gtk.Label):
+ def __init__(self) -> None:
+ super().__init__()
+ Astal.widget_set_class_names(self, ["Focused"])
+ Hyprland.get_default().connect("notify::focused-client", self.sync)
+ self.sync()
+
+ def sync(self, *_):
+ client = Hyprland.get_default().get_focused_client()
+ if client is None:
+ return self.set_label("")
+
+ client.bind_property("title", self, "label", SYNC)
+
+
+class Media(Gtk.Box):
+ def __init__(self) -> None:
+ super().__init__()
+ self.players = {}
+ mpris = Mpris.get_default()
+ Astal.widget_set_class_names(self, ["Media"])
+ mpris.connect("notify::players", self.sync)
+ self.sync()
+
+ def sync(self):
+ mpris = Mpris.get_default()
+ for child in self.get_children():
+ child.destroy()
+
+ if len(mpris.get_players()) == 0:
+ self.add(Gtk.Label(visible=True, label="Nothing Playing"))
+ return
+
+ player = mpris.get_players()[0]
+ label = Gtk.Label(visible=True)
+ cover = Gtk.Box(valign=Gtk.Align.CENTER)
+ Astal.widget_set_class_names(cover, ["Cover"])
+
+ self.add(cover)
+ self.add(label)
+
+ player.bind_property(
+ "title",
+ label,
+ "label",
+ SYNC,
+ lambda *_: f"{player.get_artist()} - {player.get_title()}",
+ )
+
+ def on_cover_art(*_):
+ Astal.widget_set_css(
+ cover, f"background-image: url('{player.get_cover_art()}')"
+ )
+
+ id = player.connect("notify::cover-art", on_cover_art)
+ cover.connect("destroy", lambda _: player.disconnect(id))
+ on_cover_art()
+
+
+class SysTray(Gtk.Box):
+ def __init__(self) -> None:
+ super().__init__()
+ Astal.widget_set_class_names(self, ["SysTray"])
+ self.items = {}
+ tray = Tray.get_default()
+ tray.connect("item_added", self.add_item)
+ tray.connect("item_removed", self.remove_item)
+
+ def add_item(self, _: Tray.Tray, id: str):
+ if id in self.items:
+ return
+
+ item = Tray.get_default().get_item(id)
+ btn = Gtk.MenuButton(use_popover=False, visible=True)
+ icon = Astal.Icon(visible=True)
+
+ item.bind_property("tooltip-markup", btn, "tooltip-markup", SYNC)
+ item.bind_property("gicon", icon, "gicon", SYNC)
+ item.bind_property("menu-model", btn, "menu-model", SYNC)
+ btn.insert_action_group("dbusmenu", item.get_action_group())
+
+ def on_action_group(*args):
+ btn.insert_action_group("dbusmenu", item.get_action_group())
+
+ item.connect("notify::action-group", on_action_group)
+
+ btn.add(icon)
+ self.add(btn)
+ self.items[id] = btn
+
+ def remove_item(self, _: Tray.Tray, id: str):
+ if id in self.items:
+ del self.items[id]
+
+
+class Wifi(Astal.Icon):
+ def __init__(self) -> None:
+ super().__init__()
+ Astal.widget_set_class_names(self, ["Wifi"])
+ wifi = Network.get_default().get_wifi()
+ if wifi:
+ wifi.bind_property("ssid", self, "tooltip-text", SYNC)
+ wifi.bind_property("icon-name", self, "icon", SYNC)
+
+
+class AudioSlider(Gtk.Box):
+ def __init__(self) -> None:
+ super().__init__()
+ Astal.widget_set_class_names(self, ["AudioSlider"])
+ Astal.widget_set_css(self, "min-width: 140px")
+
+ icon = Astal.Icon()
+ slider = Astal.Slider(hexpand=True)
+
+ self.add(icon)
+ self.add(slider)
+
+ speaker = Wp.get_default().get_audio().get_default_speaker()
+ speaker.bind_property("volume-icon", icon, "icon", SYNC)
+ speaker.bind_property("volume", slider, "value", SYNC)
+ slider.connect("dragged", lambda *_: speaker.set_volume(slider.get_value()))
+
+
+class BatteryLevel(Gtk.Box):
+ def __init__(self) -> None:
+ super().__init__()
+ Astal.widget_set_class_names(self, ["Battery"])
+
+ icon = Astal.Icon()
+ label = Astal.Label()
+
+ self.add(icon)
+ self.add(label)
+
+ bat = Battery.get_default()
+ bat.bind_property("is-present", self, "visible", SYNC)
+ bat.bind_property("battery-icon-name", icon, "icon", SYNC)
+ bat.bind_property(
+ "percentage",
+ label,
+ "label",
+ SYNC,
+ lambda _, value: f"{math.floor(value * 100)}%",
+ )
+
+
+class Time(Astal.Label):
+ def __init__(self, format="%H:%M - %A %e."):
+ super().__init__()
+ self.format = format
+ self.interval = AstalIO.Time.interval(1000, self.sync)
+ self.connect("destroy", self.interval.cancel)
+ Astal.widget_set_class_names(self, ["Time"])
+
+ def sync(self):
+ self.set_label(GLib.DateTime.new_now_local().format(self.format))
+
+
+class Left(Gtk.Box):
+ def __init__(self) -> None:
+ super().__init__(hexpand=True, halign=Gtk.Align.START)
+ self.add(Workspaces())
+ self.add(FocusedClient())
+
+
+class Center(Gtk.Box):
+ def __init__(self) -> None:
+ super().__init__()
+ self.add(Media())
+
+
+class Right(Gtk.Box):
+ def __init__(self) -> None:
+ super().__init__(hexpand=True, halign=Gtk.Align.END)
+ self.add(SysTray())
+ self.add(Wifi())
+ self.add(AudioSlider())
+ self.add(BatteryLevel())
+ self.add(Time())
+
+
+class Bar(Astal.Window):
+ def __init__(self, monitor: Gdk.Monitor):
+ super().__init__(
+ anchor=Astal.WindowAnchor.LEFT
+ | Astal.WindowAnchor.RIGHT
+ | Astal.WindowAnchor.TOP,
+ gdkmonitor=monitor,
+ exclusivity=Astal.Exclusivity.EXCLUSIVE,
+ )
+
+ Astal.widget_set_class_names(self, ["Bar"])
+
+ self.add(
+ Astal.CenterBox(
+ start_widget=Left(),
+ center_widget=Center(),
+ end_widget=Right(),
+ )
+ )
+
+ self.show_all()
diff --git a/examples/gtk3/py/simple-bar/widget/__init__.py b/examples/gtk3/py/simple-bar/widget/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/examples/gtk3/py/simple-bar/widget/__init__.py
diff --git a/examples/gtk3/vala/simple-bar/README.md b/examples/gtk3/vala/simple-bar/README.md
new file mode 100644
index 0000000..48cc27c
--- /dev/null
+++ b/examples/gtk3/vala/simple-bar/README.md
@@ -0,0 +1,13 @@
+# Simple Bar Example
+
+![simple-bar](https://github.com/user-attachments/assets/a306c864-56b7-44c4-8820-81f424f32b9b)
+
+A simple bar for Hyprland using
+
+- [Battery library](https://aylur.github.io/astal/guide/libraries/battery).
+- [Hyprland library](https://aylur.github.io/astal/guide/libraries/hyprland).
+- [Mpris library](https://aylur.github.io/astal/guide/libraries/mpris).
+- [Network library](https://aylur.github.io/astal/guide/libraries/network).
+- [Tray library](https://aylur.github.io/astal/guide/libraries/tray).
+- [WirePlumber library](https://aylur.github.io/astal/guide/libraries/wireplumber).
+- [dart-sass](https://sass-lang.com/dart-sass/) as the css precompiler
diff --git a/examples/gtk3/vala/simple-bar/app.in.vala b/examples/gtk3/vala/simple-bar/app.in.vala
new file mode 100644
index 0000000..b04a1fa
--- /dev/null
+++ b/examples/gtk3/vala/simple-bar/app.in.vala
@@ -0,0 +1,30 @@
+class App : Astal.Application {
+ public static App instance;
+
+ public override void request (string msg, SocketConnection conn) {
+ print(@"$msg\n");
+ AstalIO.write_sock.begin(conn, "ok");
+ }
+
+ public override void activate () {
+ foreach (var mon in this.monitors)
+ add_window(new Bar(mon));
+
+ apply_css("@STYLE@");
+ }
+
+ public static void main(string[] args) {
+ var instance_name = "vala";
+
+ App.instance = new App() {
+ instance_name = instance_name
+ };
+
+ try {
+ App.instance.acquire_socket();
+ App.instance.run(null);
+ } catch (Error err) {
+ print(AstalIO.send_message(instance_name, string.joinv(" ", args)));
+ }
+ }
+}
diff --git a/examples/gtk3/vala/simple-bar/flake.nix b/examples/gtk3/vala/simple-bar/flake.nix
new file mode 100644
index 0000000..d13c649
--- /dev/null
+++ b/examples/gtk3/vala/simple-bar/flake.nix
@@ -0,0 +1,42 @@
+{
+ inputs = {
+ nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
+ astal.url = "github:aylur/astal";
+ };
+
+ outputs = {
+ self,
+ nixpkgs,
+ astal,
+ }: let
+ system = "x86_64-linux";
+ pkgs = nixpkgs.legacyPackages.${system};
+ in {
+ packages.${system} = {
+ default = pkgs.stdenv.mkDerivation {
+ name = "simple-bar";
+ src = ./.;
+
+ nativeBuildInputs = with pkgs; [
+ meson
+ ninja
+ pkg-config
+ vala
+ gobject-introspection
+ dart-sass
+ ];
+
+ buildInputs = [
+ astal.packages.${system}.io
+ astal.packages.${system}.astal3
+ astal.packages.${system}.battery
+ astal.packages.${system}.wireplumber
+ astal.packages.${system}.network
+ astal.packages.${system}.tray
+ astal.packages.${system}.mpris
+ astal.packages.${system}.hyprland
+ ];
+ };
+ };
+ };
+}
diff --git a/examples/gtk3/vala/simple-bar/meson.build b/examples/gtk3/vala/simple-bar/meson.build
new file mode 100644
index 0000000..5a0ef4c
--- /dev/null
+++ b/examples/gtk3/vala/simple-bar/meson.build
@@ -0,0 +1,45 @@
+project('simple-bar', 'vala', 'c')
+
+bindir = get_option('prefix') / get_option('bindir')
+libdir = get_option('prefix') / get_option('libdir')
+
+pkgconfig_deps = [
+ dependency('glib-2.0'),
+ dependency('gobject-2.0'),
+ dependency('gtk+-3.0'),
+ dependency('libnm'),
+ dependency('astal-io-0.1'),
+ dependency('astal-3.0'),
+ dependency('astal-battery-0.1'),
+ dependency('astal-wireplumber-0.1'),
+ dependency('astal-network-0.1'),
+ dependency('astal-tray-0.1'),
+ dependency('astal-mpris-0.1'),
+ dependency('astal-hyprland-0.1'),
+]
+
+# needed for GLib.Math
+deps = pkgconfig_deps + meson.get_compiler('c').find_library('m')
+
+main = configure_file(
+ input: 'app.in.vala',
+ output: 'app.vala',
+ configuration: {
+ 'STYLE': run_command(
+ find_program('sass'),
+ meson.project_source_root() / 'style.scss',
+ ).stdout(),
+ },
+)
+
+sources = files(
+ 'widget/Bar.vala',
+)
+
+executable(
+ 'simple-bar',
+ [sources, main],
+ dependencies: deps,
+ install: true,
+ install_dir: bindir,
+)
diff --git a/examples/gtk3/vala/simple-bar/style.scss b/examples/gtk3/vala/simple-bar/style.scss
new file mode 100644
index 0000000..f5f771a
--- /dev/null
+++ b/examples/gtk3/vala/simple-bar/style.scss
@@ -0,0 +1,106 @@
+@use "sass:color";
+
+$bg: #212223;
+$fg: #f1f1f1;
+$accent: #378DF7;
+$radius: 7px;
+
+window.Bar {
+ border: none;
+ box-shadow: none;
+ background-color: $bg;
+ color: $fg;
+ font-size: 1.1em;
+ font-weight: bold;
+
+ label {
+ margin: 0 8px;
+ }
+
+ .Workspaces {
+ button {
+ all: unset;
+ background-color: transparent;
+
+ &:hover label {
+ background-color: color.adjust($fg, $alpha: -0.84);
+ border-color: color.adjust($accent, $alpha: -0.8);
+ }
+
+ &:active label {
+ background-color: color.adjust($fg, $alpha: -0.8)
+ }
+ }
+
+ label {
+ transition: 200ms;
+ padding: 0 8px;
+ margin: 2px;
+ border-radius: $radius;
+ border: 1pt solid transparent;
+ }
+
+ .focused label {
+ color: $accent;
+ border-color: $accent;
+ }
+ }
+
+ .SysTray {
+ margin-right: 8px;
+
+ button {
+ padding: 0 4px;
+ }
+ }
+
+ .FocusedClient {
+ color: $accent;
+ }
+
+ .Media .Cover {
+ min-height: 1.2em;
+ min-width: 1.2em;
+ border-radius: $radius;
+ background-position: center;
+ background-size: contain;
+ }
+
+ .Battery label {
+ padding-left: 0;
+ margin-left: 0;
+ }
+
+ .AudioSlider {
+ * {
+ all: unset;
+ }
+
+ icon {
+ margin-right: .6em;
+ }
+
+ & {
+ margin: 0 1em;
+ }
+
+ trough {
+ background-color: color.adjust($fg, $alpha: -0.8);
+ border-radius: $radius;
+ }
+
+ highlight {
+ background-color: $accent;
+ min-height: .8em;
+ border-radius: $radius;
+ }
+
+ slider {
+ background-color: $fg;
+ border-radius: $radius;
+ min-height: 1em;
+ min-width: 1em;
+ margin: -.2em;
+ }
+ }
+}
diff --git a/examples/gtk3/vala/simple-bar/widget/Bar.vala b/examples/gtk3/vala/simple-bar/widget/Bar.vala
new file mode 100644
index 0000000..28b32ef
--- /dev/null
+++ b/examples/gtk3/vala/simple-bar/widget/Bar.vala
@@ -0,0 +1,253 @@
+class Workspaces : Gtk.Box {
+ AstalHyprland.Hyprland hypr = AstalHyprland.get_default();
+ public Workspaces() {
+ Astal.widget_set_class_names(this, {"Workspaces"});
+ hypr.notify["workspaces"].connect(sync);
+ sync();
+ }
+
+ void sync() {
+ foreach (var child in get_children())
+ child.destroy();
+
+ foreach (var ws in hypr.workspaces) {
+ // filter out special workspaces
+ if (!(ws.id >= -99 && ws.id <= -2)) {
+ add(button(ws));
+ }
+ }
+ }
+
+ Gtk.Button button(AstalHyprland.Workspace ws) {
+ var btn = new Gtk.Button() {
+ visible = true,
+ label = ws.id.to_string()
+ };
+
+ hypr.notify["focused-workspace"].connect(() => {
+ var focused = hypr.focused_workspace == ws;
+ if (focused) {
+ Astal.widget_set_class_names(btn, {"focused"});
+ } else {
+ Astal.widget_set_class_names(btn, {});
+ }
+ });
+
+ btn.clicked.connect(ws.focus);
+ return btn;
+ }
+}
+
+class FocusedClient : Gtk.Box {
+ public FocusedClient() {
+ Astal.widget_set_class_names(this, {"Focused"});
+ AstalHyprland.get_default().notify["focused-client"].connect(sync);
+ sync();
+ }
+
+ void sync() {
+ foreach (var child in get_children())
+ child.destroy();
+
+ var client = AstalHyprland.get_default().focused_client;
+ if (client == null)
+ return;
+
+ var label = new Gtk.Label(client.title) { visible = true };
+ client.bind_property("title", label, "label", BindingFlags.SYNC_CREATE);
+ add(label);
+ }
+}
+
+class Media : Gtk.Box {
+ AstalMpris.Mpris mpris = AstalMpris.get_default();
+
+ public Media() {
+ Astal.widget_set_class_names(this, {"Media"});
+ mpris.notify["players"].connect(sync);
+ sync();
+ }
+
+ void sync() {
+ foreach (var child in get_children())
+ child.destroy();
+
+ if (mpris.players.length() == 0) {
+ add(new Gtk.Label("Nothing Playing"));
+ return;
+ }
+
+ var player = mpris.players.nth_data(0);
+ var label = new Gtk.Label(null);
+ var cover = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0) {
+ valign = Gtk.Align.CENTER
+ };
+
+ Astal.widget_set_class_names(cover, {"Cover"});
+ player.bind_property("title", label, "label", BindingFlags.SYNC_CREATE, (_, src, ref trgt) => {
+ var title = player.title;
+ var artist = player.artist;
+ trgt.set_string(@"$artist - $title");
+ return true;
+ });
+
+ var id = player.notify["cover-art"].connect(() => {
+ var art = player.cover_art;
+ Astal.widget_set_css(cover, @"background-image: url('$art')");
+ });
+
+ cover.destroy.connect(() => player.disconnect(id));
+ add(cover);
+ add(label);
+ }
+}
+
+class SysTray : Gtk.Box {
+ HashTable<string, Gtk.Widget> items = new HashTable<string, Gtk.Widget>(str_hash, str_equal);
+ AstalTray.Tray tray = AstalTray.get_default();
+
+ public SysTray() {
+ Astal.widget_set_class_names(this, { "SysTray" });
+ tray.item_added.connect(add_item);
+ tray.item_removed.connect(remove_item);
+ }
+
+ void add_item(string id) {
+ if (items.contains(id))
+ return;
+
+ var item = tray.get_item(id);
+ var btn = new Gtk.MenuButton() { use_popover = false, visible = true };
+ var icon = new Astal.Icon() { visible = true };
+
+ item.bind_property("tooltip-markup", btn, "tooltip-markup", BindingFlags.SYNC_CREATE);
+ item.bind_property("gicon", icon, "gicon", BindingFlags.SYNC_CREATE);
+ item.bind_property("menu-model", btn, "menu-model", BindingFlags.SYNC_CREATE);
+ btn.insert_action_group("dbusmenu", item.action_group);
+ item.notify["action-group"].connect(() => {
+ btn.insert_action_group("dbusmenu", item.action_group);
+ });
+
+ btn.add(icon);
+ add(btn);
+ items.set(id, btn);
+ }
+
+ void remove_item(string id) {
+ if (items.contains(id)) {
+ items.remove(id);
+ }
+ }
+}
+
+class Wifi : Astal.Icon {
+ public Wifi() {
+ Astal.widget_set_class_names(this, {"Wifi"});
+ var wifi = AstalNetwork.get_default().get_wifi();
+ if (wifi != null) {
+ wifi.bind_property("ssid", this, "tooltip-text", BindingFlags.SYNC_CREATE);
+ wifi.bind_property("icon-name", this, "icon", BindingFlags.SYNC_CREATE);
+ }
+ }
+}
+
+class AudioSlider : Gtk.Box {
+ Astal.Icon icon = new Astal.Icon();
+ Astal.Slider slider = new Astal.Slider() { hexpand = true };
+
+ public AudioSlider() {
+ add(icon);
+ add(slider);
+ Astal.widget_set_class_names(this, {"AudioSlider"});
+ Astal.widget_set_css(this, "min-width: 140px");
+
+ var speaker = AstalWp.get_default().audio.default_speaker;
+ speaker.bind_property("volume-icon", icon, "icon", BindingFlags.SYNC_CREATE);
+ speaker.bind_property("volume", slider, "value", BindingFlags.SYNC_CREATE);
+ slider.dragged.connect(() => speaker.volume = slider.value);
+ }
+}
+
+class Battery : Gtk.Box {
+ Astal.Icon icon = new Astal.Icon();
+ Astal.Label label = new Astal.Label();
+
+ public Battery() {
+ add(icon);
+ add(label);
+ Astal.widget_set_class_names(this, {"Battery"});
+
+ var bat = AstalBattery.get_default();
+ bat.bind_property("is-present", this, "visible", BindingFlags.SYNC_CREATE);
+ bat.bind_property("battery-icon-name", icon, "icon", BindingFlags.SYNC_CREATE);
+ bat.bind_property("percentage", label, "label", BindingFlags.SYNC_CREATE, (_, src, ref trgt) => {
+ var p = Math.floor(src.get_double() * 100);
+ trgt.set_string(@"$p%");
+ return true;
+ });
+ }
+}
+
+class Time : Astal.Label {
+ string format;
+ AstalIO.Time interval;
+
+ void sync() {
+ label = new DateTime.now_local().format(format);
+ }
+
+ public Time(string format = "%H:%M - %A %e.") {
+ this.format = format;
+ interval = AstalIO.Time.interval(1000, null);
+ interval.now.connect(sync);
+ destroy.connect(interval.cancel);
+ Astal.widget_set_class_names(this, {"Time"});
+ }
+}
+
+class Left : Gtk.Box {
+ public Left() {
+ Object(hexpand: true, halign: Gtk.Align.START);
+ add(new Workspaces());
+ add(new FocusedClient());
+ }
+}
+
+class Center : Gtk.Box {
+ public Center() {
+ add(new Media());
+ }
+}
+
+class Right : Gtk.Box {
+ public Right() {
+ Object(hexpand: true, halign: Gtk.Align.END);
+ add(new SysTray());
+ add(new Wifi());
+ add(new AudioSlider());
+ add(new Battery());
+ add(new Time());
+ }
+}
+
+class Bar : Astal.Window {
+ public Bar(Gdk.Monitor monitor) {
+ Object(
+ anchor: Astal.WindowAnchor.TOP
+ | Astal.WindowAnchor.LEFT
+ | Astal.WindowAnchor.RIGHT,
+ exclusivity: Astal.Exclusivity.EXCLUSIVE,
+ gdkmonitor: monitor
+ );
+
+ Astal.widget_set_class_names(this, {"Bar"});
+
+ add(new Astal.CenterBox() {
+ start_widget = new Left(),
+ center_widget = new Center(),
+ end_widget = new Right(),
+ });
+
+ show_all();
+ }
+}