diff options
Diffstat (limited to 'examples/gtk3')
69 files changed, 3548 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 + + + +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..8206250 --- /dev/null +++ b/examples/gtk3/js/applauncher/widget/Applauncher.tsx @@ -0,0 +1,91 @@ +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 width = Variable(1000) + + 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={(self) => { + text.set("") + width.set(self.get_current_monitor().workarea.width) + }} + onKeyPressEvent={function (self, event: Gdk.Event) { + if (event.get_keyval()[1] === Gdk.KEY_Escape) + self.hide() + }}> + <box> + <eventbox widthRequest={width(w => w / 2)} 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={width(w => w / 2)} 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 + + + +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 + + + +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/osd/README.md b/examples/gtk3/js/osd/README.md new file mode 100644 index 0000000..ee1d497 --- /dev/null +++ b/examples/gtk3/js/osd/README.md @@ -0,0 +1,7 @@ +# On Screen Display + + + +A simple widget that pops up when screen brightness or audio changes + +Uses the [WirePlumber library](https://aylur.github.io/astal/guide/libraries/wireplumber). diff --git a/examples/gtk3/js/osd/app.ts b/examples/gtk3/js/osd/app.ts new file mode 100644 index 0000000..50e314e --- /dev/null +++ b/examples/gtk3/js/osd/app.ts @@ -0,0 +1,11 @@ +import { App } from "astal/gtk3" +import style from "./style.scss" +import OSD from "./osd/OSD" + +App.start({ + instanceName: "osd-example", + css: style, + main() { + App.get_monitors().map(OSD) + }, +}) diff --git a/examples/gtk3/js/osd/osd/OSD.scss b/examples/gtk3/js/osd/osd/OSD.scss new file mode 100644 index 0000000..d0fe4d1 --- /dev/null +++ b/examples/gtk3/js/osd/osd/OSD.scss @@ -0,0 +1,30 @@ +$fg-color: #{"@theme_fg_color"}; +$bg-color: #{"@theme_bg_color"}; + +window.OSD { + box.OSD { + border-radius: 100px; + background-color: $bg-color; + padding: 13px 16px; + margin: 13px; + box-shadow: 3px 3px 7px 0 rgba(0,0,0,.4); + } + + icon { + font-size: 4rem; + } + + label { + font-size: 2.4rem; + } + + levelbar { + trough { + margin: 1 .6rem; + } + + block { + min-height: 2rem; + } + } +} diff --git a/examples/gtk3/js/osd/osd/OSD.tsx b/examples/gtk3/js/osd/osd/OSD.tsx new file mode 100644 index 0000000..df28da5 --- /dev/null +++ b/examples/gtk3/js/osd/osd/OSD.tsx @@ -0,0 +1,69 @@ +import { App, Astal, Gdk, Gtk } from "astal/gtk3" +import { timeout } from "astal/time" +import Variable from "astal/variable" +import Brightness from "./brightness" +import Wp from "gi://AstalWp" + +function OnScreenProgress({ visible }: { visible: Variable<boolean> }) { + const brightness = Brightness.get_default() + const speaker = Wp.get_default()!.get_default_speaker() + + const iconName = Variable("") + const value = Variable(0) + + let count = 0 + function show(v: number, icon: string) { + visible.set(true) + value.set(v) + iconName.set(icon) + count++ + timeout(2000, () => { + count-- + if (count === 0) visible.set(false) + }) + } + + return ( + <revealer + setup={(self) => { + self.hook(brightness, "notify::screen", () => + show(brightness.screen, "display-brightness-symbolic"), + ) + + if (speaker) { + self.hook(speaker, "notify::volume", () => + show(speaker.volume, speaker.volumeIcon), + ) + } + }} + revealChild={visible()} + transitionType={Gtk.RevealerTransitionType.SLIDE_UP} + > + <box className="OSD"> + <icon icon={iconName()} /> + <levelbar valign={Gtk.Align.CENTER} widthRequest={100} value={value()} /> + <label label={value(v => `${Math.floor(v * 100)}%`)} /> + </box> + </revealer> + ) +} + +export default function OSD(monitor: Gdk.Monitor) { + const visible = Variable(false) + + return ( + <window + gdkmonitor={monitor} + className="OSD" + namespace="osd" + application={App} + layer={Astal.Layer.OVERLAY} + keymode={Astal.Keymode.ON_DEMAND} + anchor={Astal.WindowAnchor.BOTTOM} + > + <eventbox onClick={() => visible.set(false)}> + <OnScreenProgress visible={visible} /> + </eventbox> + </window> + ) +} diff --git a/examples/gtk3/js/osd/osd/brightness.ts b/examples/gtk3/js/osd/osd/brightness.ts new file mode 100644 index 0000000..cf5060a --- /dev/null +++ b/examples/gtk3/js/osd/osd/brightness.ts @@ -0,0 +1,45 @@ +import GObject, { register, property } from "astal/gobject" +import { monitorFile, readFileAsync } from "astal/file" +import { exec, execAsync } from "astal/process" + +const get = (args: string) => Number(exec(`brightnessctl ${args}`)) +const screen = exec(`bash -c "ls -w1 /sys/class/backlight | head -1"`) + +@register({ GTypeName: "Brightness" }) +export default class Brightness extends GObject.Object { + static instance: Brightness + static get_default() { + if (!this.instance) + this.instance = new Brightness() + + return this.instance + } + + #screenMax = get("max") + #screen = get("get") / (get("max") || 1) + + @property(Number) + get screen() { return this.#screen } + + set screen(percent) { + if (percent < 0) + percent = 0 + + if (percent > 1) + percent = 1 + + execAsync(`brightnessctl set ${Math.floor(percent * 100)}% -q`).then(() => { + this.#screen = percent + this.notify("screen") + }) + } + + constructor() { + super() + monitorFile(`/sys/class/backlight/${screen}/brightness`, async f => { + const v = await readFileAsync(f) + this.#screen = Number(v) / this.#screenMax + this.notify("screen") + }) + } +} diff --git a/examples/gtk3/js/osd/style.scss b/examples/gtk3/js/osd/style.scss new file mode 100644 index 0000000..ba6f06d --- /dev/null +++ b/examples/gtk3/js/osd/style.scss @@ -0,0 +1 @@ +@use "./osd/OSD.scss"; diff --git a/examples/gtk3/js/popover/Popover.tsx b/examples/gtk3/js/popover/Popover.tsx new file mode 100644 index 0000000..38ea01e --- /dev/null +++ b/examples/gtk3/js/popover/Popover.tsx @@ -0,0 +1,90 @@ +import { Astal, Gdk, Gtk, Widget } from "astal/gtk3" + +const { TOP, BOTTOM, LEFT, RIGHT } = Astal.WindowAnchor + +type PopoverProps = Pick< + Widget.WindowProps, + | "name" + | "namespace" + | "className" + | "visible" + | "child" + | "marginBottom" + | "marginTop" + | "marginLeft" + | "marginRight" + | "halign" + | "valign" +> & { + onClose?(self: Widget.Window): void +} + +/** + * Full screen window widget where you can space the child widget + * using margins and alignment properties. + * + * NOTE: Child widgets will assume they can span across the full window width + * this means that setting `wrap` or `ellipsize` on labels for example will not work + * without explicitly setting its `max_width_chars` property. + * For a workaround see Popover2.tsx + */ +export default function Popover({ + child, + marginBottom, + marginTop, + marginLeft, + marginRight, + halign = Gtk.Align.CENTER, + valign = Gtk.Align.CENTER, + onClose, + ...props +}: PopoverProps) { + return ( + <window + {...props} + css="background-color: transparent" + keymode={Astal.Keymode.EXCLUSIVE} + anchor={TOP | BOTTOM | LEFT | RIGHT} + exclusivity={Astal.Exclusivity.IGNORE} + onNotifyVisible={(self) => { + if (!self.visible) onClose?.(self) + }} + // 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.visible = false + } + }} + // close when hitting Escape + onKeyPressEvent={(self, event: Gdk.Event) => { + if (event.get_keyval()[1] === Gdk.KEY_Escape) { + self.visible = false + } + }} + > + <box + // make sure click event does not bubble up + onButtonPressEvent={() => true} + // 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> + ) +} diff --git a/examples/gtk3/js/popover/Popover2.tsx b/examples/gtk3/js/popover/Popover2.tsx new file mode 100644 index 0000000..e058079 --- /dev/null +++ b/examples/gtk3/js/popover/Popover2.tsx @@ -0,0 +1,66 @@ +import { Astal, Gdk, Widget } from "astal/gtk3" +import Variable from "astal/variable" + +type Popover2Props = Pick< + Widget.WindowProps, + | "name" + | "namespace" + | "className" + | "visible" + | "child" +> & { + onClose?(self: Widget.Window): void +} + +/** + * Full screen window where the child is positioned to center. + * + * NOTE: Workaround for the label wrap issue by padding the window + * with eventboxes and only anchoring to TOP | BOTTOM. + */ +export default function Popover2({ + child, + onClose, + ...props +}: Popover2Props) { + let win: Widget.Window + + const width = Variable(1000) + const hide = () => (win.visible = false) + + return ( + <window + {...props} + setup={self => win = self} + css="background-color: transparent" + keymode={Astal.Keymode.EXCLUSIVE} + anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.BOTTOM} + exclusivity={Astal.Exclusivity.IGNORE} + onNotifyVisible={(self) => { + // instead of anchoring to all sides we set the width explicitly + // otherwise label wrapping won't work correctly without setting their width + if (self.visible) { + width.set(self.get_current_monitor().workarea.width) + } else { + onClose?.(self) + } + }} + // close when hitting Escape + onKeyPressEvent={(self, event: Gdk.Event) => { + if (event.get_keyval()[1] === Gdk.KEY_Escape) { + self.visible = false + } + }} + > + <box> + <eventbox widthRequest={width(w => w / 2)} expand onClick={hide} /> + <box hexpand={false} vertical> + <eventbox expand onClick={hide} /> + {child} + <eventbox expand onClick={hide} /> + </box> + <eventbox widthRequest={width(w => w / 2)} expand onClick={hide} /> + </box> + </window> + ) +} diff --git a/examples/gtk3/js/popover/app.tsx b/examples/gtk3/js/popover/app.tsx new file mode 100644 index 0000000..5386b66 --- /dev/null +++ b/examples/gtk3/js/popover/app.tsx @@ -0,0 +1,72 @@ +import { App, Astal, Gtk } from "astal/gtk3" +import { Variable } from "astal" +import Popover from "./Popover" +import Popover2 from "./Popover2" +const { TOP, RIGHT, LEFT } = Astal.WindowAnchor + +const lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean quis semper risus." + +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 visible1 = Variable(false); + const visible2 = Variable(false); + + const _popover1 = <Popover + className="Popup" + onClose={() => visible1.set(false)} + visible={visible1()} + marginTop={36} + marginRight={60} + valign={Gtk.Align.START} + halign={Gtk.Align.END} + > + <box className="popup" vertical> + {/* maxWidthChars is needed to make wrap work */} + <label label={lorem} wrap maxWidthChars={8} /> + <button onClicked={() => visible1.set(false)}> + Click me to close the popup + </button> + </box> + </Popover> + + + const _popover2 = <Popover2 + className="Popup" + onClose={() => visible2.set(false)} + visible={visible2()} + > + <box className="popup" vertical> + {/* maxWidthChars is needed, wrap will work as intended */} + <label label={lorem} wrap /> + <button onClicked={() => visible2.set(false)}> + Click me to close the popup + </button> + </box> + </Popover2> + + return ( + <window + anchor={TOP | LEFT | RIGHT} + exclusivity={Astal.Exclusivity.EXCLUSIVE} + > + <box halign={Gtk.Align.END}> + <button onClicked={() => visible2.set(true)} halign={Gtk.Align.END}> + Click to open popover2 + </button> + <button onClicked={() => visible1.set(true)} halign={Gtk.Align.END}> + Click to open popover + </button> + </box> + </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 + + + +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/applauncher/README.md b/examples/gtk3/lua/applauncher/README.md new file mode 100644 index 0000000..682adf1 --- /dev/null +++ b/examples/gtk3/lua/applauncher/README.md @@ -0,0 +1,5 @@ +# Applauncher + + + +Using [Apps](https://aylur.github.io/astal/guide/libraries/apps). diff --git a/examples/gtk3/lua/applauncher/init.lua b/examples/gtk3/lua/applauncher/init.lua new file mode 100644 index 0000000..0cd6db5 --- /dev/null +++ b/examples/gtk3/lua/applauncher/init.lua @@ -0,0 +1,16 @@ +local astal = require("astal") +local App = require("astal.gtk3.app") + +local AppLauncher = require("widget.Applauncher") +local src = require("lib").src + +local scss = src("style.scss") +local css = "/tmp/style.css" + +astal.exec("sass " .. scss .. " " .. css) + +App:start({ + instance_name = "launcher", + css = css, + main = AppLauncher, +}) diff --git a/examples/gtk3/lua/applauncher/lib.lua b/examples/gtk3/lua/applauncher/lib.lua new file mode 100644 index 0000000..8a50bdd --- /dev/null +++ b/examples/gtk3/lua/applauncher/lib.lua @@ -0,0 +1,38 @@ +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 array T[] +---@param func fun(T, i: integer): R +---@return R[] +function M.map(array, func) + local new_arr = {} + for i, v in ipairs(array) do + new_arr[i] = func(v, i) + end + return new_arr +end + +---@generic T +---@param array T[] +---@param start integer +---@param stop? integer +---@return T[] +function M.slice(array, start, stop) + local new_arr = {} + + stop = stop or #array + + for i = start, stop do + table.insert(new_arr, array[i]) + end + + return new_arr +end + +return M diff --git a/examples/gtk3/lua/applauncher/style.scss b/examples/gtk3/lua/applauncher/style.scss new file mode 100644 index 0000000..ba13eed --- /dev/null +++ b/examples/gtk3/lua/applauncher/style.scss @@ -0,0 +1 @@ +@use "./widget/Applauncher.scss" diff --git a/examples/gtk3/lua/applauncher/widget/Applauncher.lua b/examples/gtk3/lua/applauncher/widget/Applauncher.lua new file mode 100644 index 0000000..78f7fa5 --- /dev/null +++ b/examples/gtk3/lua/applauncher/widget/Applauncher.lua @@ -0,0 +1,118 @@ +local astal = require("astal") + +local Apps = astal.require("AstalApps") +local App = require("astal.gtk3.app") +local Astal = require("astal.gtk3").Astal +local Gdk = require("astal.gtk3").Gdk +local Variable = astal.Variable +local Widget = require("astal.gtk3.widget") + +local slice = require("lib").slice +local map = require("lib").map + +local MAX_ITEMS = 8 + +local function hide() + local launcher = App:get_window("launcher") + if launcher then launcher:hide() end +end + +local function AppButton(app) + return Widget.Button({ + class_name = "AppButton", + on_clicked = function() + hide() + app:launch() + end, + Widget.Box({ + Widget.Icon({ icon = app.icon_name }), + Widget.Box({ + valign = "CENTER", + vertical = true, + Widget.Label({ + class_name = "name", + wrap = true, + xalign = 0, + label = app.name, + }), + app.description and Widget.Label({ + class_name = "description", + wrap = true, + xalign = 0, + label = app.description, + }), + }), + }), + }) +end + +return function() + local apps = Apps.Apps() + + local text = Variable("") + local list = text( + function(t) return slice(apps:fuzzy_query(t), 1, MAX_ITEMS) end + ) + + local on_enter = function() + local found = apps:fuzzy_query(text:get())[1] + if found then + found:launch() + hide() + end + end + + return Widget.Window({ + name = "launcher", + anchor = Astal.WindowAnchor.TOP + Astal.WindowAnchor.BOTTOM, + exclusivity = "IGNORE", + keymode = "ON_DEMAND", + application = App, + on_show = function() text:set("") end, + on_key_press_event = function(self, event) + if event.keyval == Gdk.KEY_Escape then self:hide() end + end, + Widget.Box({ + Widget.EventBox({ + expand = true, + on_click = hide, + width_request = 4000, + }), + Widget.Box({ + hexpand = false, + vertical = true, + Widget.EventBox({ on_click = hide, height_request = 100 }), + Widget.Box({ + vertical = true, + width_request = 500, + class_name = "Applauncher", + Widget.Entry({ + placeholder_text = "Search", + text = text(), + on_changed = function(self) text:set(self.text) end, + on_activate = on_enter, + }), + Widget.Box({ + spacing = 6, + vertical = true, + list:as(function(l) return map(l, AppButton) end), + }), + Widget.Box({ + halign = "CENTER", + class_name = "not-found", + vertical = true, + visible = list:as(function(l) return #l == 0 end), + Widget.Icon({ icon = "system-search-symbolic" }), + Widget.Label({ label = "No match found" }), + }), + }), + Widget.EventBox({ expand = true, on_click = hide }), + }), + Widget.EventBox({ + width_request = 4000, + expand = true, + on_click = hide, + }), + }), + }) +end diff --git a/examples/gtk3/lua/applauncher/widget/Applauncher.scss b/examples/gtk3/lua/applauncher/widget/Applauncher.scss new file mode 100644 index 0000000..38b5be1 --- /dev/null +++ b/examples/gtk3/lua/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: 0.8rem; + box-shadow: 2px 3px 8px 0 gtkalpha(black, 0.4); + + entry { + margin-bottom: 0.8rem; + } + + button { + min-width: 0; + min-height: 0; + padding: 0.5rem; + + icon { + font-size: 3em; + margin-right: 0.3rem; + } + + label.name { + font-weight: bold; + font-size: 1.1em; + } + + label.description { + color: gtkalpha($fg-color, 0.8); + } + } + + box.not-found { + padding: 1rem; + + icon { + font-size: 6em; + color: gtkalpha($fg-color, 0.7); + } + + label { + color: gtkalpha($fg-color, 0.9); + font-size: 1.2em; + } + } + } +} diff --git a/examples/gtk3/lua/media-player/README.md b/examples/gtk3/lua/media-player/README.md new file mode 100644 index 0000000..4e3d237 --- /dev/null +++ b/examples/gtk3/lua/media-player/README.md @@ -0,0 +1,5 @@ +# Media Player + + + +Using [Mpris](https://aylur.github.io/astal/guide/libraries/mpris). diff --git a/examples/gtk3/lua/media-player/init.lua b/examples/gtk3/lua/media-player/init.lua new file mode 100644 index 0000000..16ccbfb --- /dev/null +++ b/examples/gtk3/lua/media-player/init.lua @@ -0,0 +1,19 @@ +local astal = require("astal") +local App = require("astal.gtk3.app") +local Widget = require("astal.gtk3.widget") + +local MprisPlayers = require("widget.MediaPlayer") +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, + main = function() + Widget.Window({ MprisPlayers() }) + end, +}) diff --git a/examples/gtk3/lua/media-player/lib.lua b/examples/gtk3/lua/media-player/lib.lua new file mode 100644 index 0000000..8a50bdd --- /dev/null +++ b/examples/gtk3/lua/media-player/lib.lua @@ -0,0 +1,38 @@ +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 array T[] +---@param func fun(T, i: integer): R +---@return R[] +function M.map(array, func) + local new_arr = {} + for i, v in ipairs(array) do + new_arr[i] = func(v, i) + end + return new_arr +end + +---@generic T +---@param array T[] +---@param start integer +---@param stop? integer +---@return T[] +function M.slice(array, start, stop) + local new_arr = {} + + stop = stop or #array + + for i = start, stop do + table.insert(new_arr, array[i]) + end + + return new_arr +end + +return M diff --git a/examples/gtk3/lua/media-player/style.scss b/examples/gtk3/lua/media-player/style.scss new file mode 100644 index 0000000..be398dd --- /dev/null +++ b/examples/gtk3/lua/media-player/style.scss @@ -0,0 +1 @@ +@use './widget/MediaPlayer.scss'; diff --git a/examples/gtk3/lua/media-player/widget/MediaPlayer.lua b/examples/gtk3/lua/media-player/widget/MediaPlayer.lua new file mode 100644 index 0000000..fbad3e0 --- /dev/null +++ b/examples/gtk3/lua/media-player/widget/MediaPlayer.lua @@ -0,0 +1,144 @@ +local astal = require("astal") + +local Astal = astal.require("Astal", "3.0") + +local bind = astal.bind +local Widget = require("astal.gtk3.widget") +local lookup_icon = Astal.Icon.lookup_icon + +local map = require("lib").map + +local Mpris = astal.require("AstalMpris") + +---@param length integer +local function length_str(length) + local min = math.floor(length / 60) + local sec = math.floor(length % 60) + + return string.format("%d:%s%d", min, sec < 10 and "0" or "", sec) +end + +local function MediaPlayer(player) + local title = bind(player, "title"):as( + function(t) return t or "Unknown Track" end + ) + + local artist = bind(player, "artist"):as( + function(a) return a or "Unknown Artist" end + ) + + local cover_art = bind(player, "cover-art"):as( + function(c) return string.format("background-image: url('%s');", c) end + ) + + local player_icon = bind(player, "entry"):as( + function(e) return lookup_icon(e) and e or "audio-x-generic-symbolic" end + ) + + local position = bind(player, "position"):as( + function(p) return player.length > 0 and p / player.length or 0 end + ) + + local play_icon = bind(player, "playback-status"):as( + function(s) + return s == "PLAYING" and "media-playback-pause-symbolic" + or "media-playback-start-symbolic" + end + ) + + return Widget.Box({ + class_name = "MediaPlayer", + Widget.Box({ + class_name = "cover-art", + css = cover_art, + }), + Widget.Box({ + vertical = true, + Widget.Box({ + class_name = "title", + Widget.Label({ + ellipsize = "END", + hexpand = true, + halign = "START", + label = title, + }), + Widget.Icon({ + icon = player_icon, + }), + }), + Widget.Label({ + halign = "START", + valign = "START", + vexpand = true, + wrap = true, + label = artist, + }), + Widget.Slider({ + visible = bind(player, "length"):as( + function(l) return l > 0 end + ), + on_dragged = function(event) + player.position = event.value * player.length + end, + value = position, + }), + Widget.CenterBox({ + class_name = "actions", + Widget.Label({ + hexpand = true, + class_name = "position", + halign = "START", + visible = bind(player, "length"):as( + function(l) return l > 0 end + ), + label = bind(player, "position"):as(length_str), + }), + Widget.Box({ + Widget.Button({ + on_clicked = function() player:previous() end, + visible = bind(player, "can-go-previous"), + Widget.Icon({ + icon = "media-skip-backward-symbolic", + }), + }), + Widget.Button({ + on_clicked = function() player:play_pause() end, + visible = bind(player, "can-control"), + Widget.Icon({ + icon = play_icon, + }), + }), + Widget.Button({ + on_clicked = function() player:next() end, + visible = bind(player, "can-go-next"), + Widget.Icon({ + icon = "media-skip-forward-symbolic", + }), + }), + }), + Widget.Label({ + class_name = "length", + hexpand = true, + halign = "END", + visible = bind(player, "length"):as( + function(l) return l > 0 end + ), + label = bind(player, "length"):as( + function(l) return l > 0 and length_str(l) or "0:00" end + ), + }), + }), + }), + }) +end + +return function() + local mpris = Mpris.get_default() + + return Widget.Box({ + vertical = true, + bind(mpris, "players"):as( + function(players) return map(players, MediaPlayer) end + ), + }) +end diff --git a/examples/gtk3/lua/media-player/widget/MediaPlayer.scss b/examples/gtk3/lua/media-player/widget/MediaPlayer.scss new file mode 100644 index 0000000..e1597c2 --- /dev/null +++ b/examples/gtk3/lua/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/lua/notifications/README.md b/examples/gtk3/lua/notifications/README.md new file mode 100644 index 0000000..60dad60 --- /dev/null +++ b/examples/gtk3/lua/notifications/README.md @@ -0,0 +1,5 @@ +# Notifications Popups + + + +A replacement for dunst and other daemons using [Notifd](https://aylur.github.io/astal/guide/libraries/notifd). diff --git a/examples/gtk3/lua/notifications/init.lua b/examples/gtk3/lua/notifications/init.lua new file mode 100644 index 0000000..886e9ab --- /dev/null +++ b/examples/gtk3/lua/notifications/init.lua @@ -0,0 +1,20 @@ +local astal = require("astal") +local App = require("astal.gtk3.app") + +local NotificationPopups = require("notifications.NotificationPopups") +local src = require("lib").src + +local scss = src("style.scss") +local css = "/tmp/style.css" + +astal.exec("sass " .. scss .. " " .. css) + +App:start({ + instance_name = "notifications", + css = css, + main = function() + for _, mon in pairs(App.monitors) do + NotificationPopups(mon) + end + end, +}) diff --git a/examples/gtk3/lua/notifications/lib.lua b/examples/gtk3/lua/notifications/lib.lua new file mode 100644 index 0000000..289fc7e --- /dev/null +++ b/examples/gtk3/lua/notifications/lib.lua @@ -0,0 +1,74 @@ +local astal = require("astal") +local Variable = require("astal").Variable +local Gtk = require("astal.gtk3").Gtk +local GLib = astal.require("GLib") + +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 array T[] +---@param func fun(T, i: integer): R +---@return R[] +function M.map(array, func) + local new_arr = {} + for i, v in ipairs(array) do + new_arr[i] = func(v, i) + end + return new_arr +end + +---@param path string +---@return boolean +function M.file_exists(path) return GLib.file_test(path, "EXISTS") end + +function M.varmap(initial) + local map = initial + local var = Variable() + + local function notify() + local arr = {} + for _, value in pairs(map) do + table.insert(arr, value) + end + var:set(arr) + end + + local function delete(key) + if Gtk.Widget:is_type_of(map[key]) then map[key]:destroy() end + + map[key] = nil + end + + notify() + + return setmetatable({ + set = function(key, value) + delete(key) + map[key] = value + notify() + end, + delete = function(key) + delete(key) + notify() + end, + get = function() return var:get() end, + subscribe = function(callback) return var:subscribe(callback) end, + }, { + __call = function() return var() end, + }) +end + +---@param time number +---@param format? string +function M.time(time, format) + format = format or "%H:%M" + return GLib.DateTime.new_from_unix_local(time):format(format) +end + +return M diff --git a/examples/gtk3/lua/notifications/notifications/Notification.lua b/examples/gtk3/lua/notifications/notifications/Notification.lua new file mode 100644 index 0000000..39d36f5 --- /dev/null +++ b/examples/gtk3/lua/notifications/notifications/Notification.lua @@ -0,0 +1,105 @@ +local Widget = require("astal.gtk3").Widget +local Gtk = require("astal.gtk3").Gtk +local Astal = require("astal.gtk3").Astal + +local map = require("lib").map +local time = require("lib").time +local file_exists = require("lib").file_exists + +local function is_icon(icon) return Astal.Icon.lookup_icon(icon) ~= nil end + +---@param props { setup?: function, on_hover_lost?: function, notification: any } +return function(props) + local n = props.notification + + local header = Widget.Box({ + class_name = "header", + (n.app_icon or n.desktop_entry) and Widget.Icon({ + class_name = "app-icon", + icon = n.app_icon or n.desktop_entry, + }), + Widget.Label({ + class_name = "app-name", + halign = "START", + ellipsize = "END", + label = n.app_name or "Unknown", + }), + Widget.Label({ + class_name = "time", + hexpand = true, + halign = "END", + label = time(n.time), + }), + Widget.Button({ + on_clicked = function() n:dismiss() end, + Widget.Icon({ icon = "window-close-symbolic" }), + }), + }) + + local content = Widget.Box({ + class_name = "content", + (n.image and file_exists(n.image)) and Widget.Box({ + valign = "START", + class_name = "image", + css = string.format("background-image: url('%s')", n.image), + }), + n.image and is_icon(n.image) and Widget.Box({ + valign = "START", + class_name = "icon-image", + Widget.Icon({ + icon = n.image, + hexpand = true, + vexpand = true, + halign = "CENTER", + valign = "CENTER", + }), + }), + Widget.Box({ + vertical = true, + Widget.Label({ + class_name = "summary", + halign = "START", + xalign = 0, + ellipsize = "END", + label = n.summary, + }), + Widget.Label({ + class_name = "body", + wrap = true, + use_markup = true, + halign = "START", + xalign = 0, + justify = "FILL", + label = n.body, + }), + }), + }) + + return Widget.EventBox({ + class_name = string.format("Notification %s", string.lower(n.urgency)), + setup = props.setup, + on_hover_lost = props.on_hover_lost, + Widget.Box({ + vertical = true, + header, + Gtk.Separator({ visible = true }), + content, + #n.actions > 0 and Widget.Box({ + class_name = "actions", + map(n.actions, function(action) + local label, id = action.label, action.id + + return Widget.Button({ + hexpand = true, + on_clicked = function() return n:invoke(id) end, + Widget.Label({ + label = label, + halign = "CENTER", + hexpand = true, + }), + }) + end), + }), + }), + }) +end diff --git a/examples/gtk3/lua/notifications/notifications/Notification.scss b/examples/gtk3/lua/notifications/notifications/Notification.scss new file mode 100644 index 0000000..089d587 --- /dev/null +++ b/examples/gtk3/lua/notifications/notifications/Notification.scss @@ -0,0 +1,126 @@ +@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; + padding: .2rem; + + button { + margin: 0 .3rem; + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } + } + } +} diff --git a/examples/gtk3/lua/notifications/notifications/NotificationPopups.lua b/examples/gtk3/lua/notifications/notifications/NotificationPopups.lua new file mode 100644 index 0000000..c5f9e1b --- /dev/null +++ b/examples/gtk3/lua/notifications/notifications/NotificationPopups.lua @@ -0,0 +1,57 @@ +local astal = require("astal") +local Widget = require("astal.gtk3").Widget + +local Notifd = astal.require("AstalNotifd") +local Notification = require("notifications.Notification") +local timeout = astal.timeout + +local TIMEOUT_DELAY = 5000 + +local varmap = require("lib").varmap +local notifd = Notifd.get_default() + +local function NotificationMap() + local notif_map = varmap({}) + + notifd.on_notified = function(_, id) + notif_map.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 + on_hover_lost = function() notif_map.delete(id) end, + setup = function() + timeout(TIMEOUT_DELAY, function() + -- uncomment this if you want to "hide" the notifications + -- after TIMEOUT_DELAY + + -- NotificationMap.delete(id) + end) + end, + }) + ) + end + + notifd.on_resolved = function(_, id) notif_map.delete(id) end + + return notif_map +end + +return function(gdkmonitor) + local Anchor = astal.require("Astal").WindowAnchor + local notifs = NotificationMap() + + return Widget.Window({ + class_name = "NotificationPopups", + gdkmonitor = gdkmonitor, + anchor = Anchor.TOP + Anchor.RIGHT, + Widget.Box({ + vertical = true, + notifs(), + }), + }) +end diff --git a/examples/gtk3/lua/notifications/style.scss b/examples/gtk3/lua/notifications/style.scss new file mode 100644 index 0000000..7ef0168 --- /dev/null +++ b/examples/gtk3/lua/notifications/style.scss @@ -0,0 +1 @@ +@use "./notifications/Notification.scss"; 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 + + + +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..6f2dcea --- /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 array T[] +---@param func fun(T, i: integer): R +---@return R[] +function M.map(array, func) + local new_arr = {} + for i, v in ipairs(array) 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..3f685a2 --- /dev/null +++ b/examples/gtk3/lua/simple-bar/widget/Bar.lua @@ -0,0 +1,203 @@ +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 Anchor = astal.require("Astal").WindowAnchor + + return Widget.Window({ + class_name = "Bar", + gdkmonitor = gdkmonitor, + anchor = Anchor.TOP + Anchor.LEFT + Anchor.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/lua/stylua.toml b/examples/gtk3/lua/stylua.toml new file mode 100644 index 0000000..4141934 --- /dev/null +++ b/examples/gtk3/lua/stylua.toml @@ -0,0 +1,4 @@ +indent_type = "Tabs" +indent_width = 4 +column_width = 80 +collapse_simple_statement = "Always" 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 + + + +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 + + + +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(); + } +} |