diff --git a/config/astal/.gitignore b/config/astal/.gitignore
new file mode 100644
index 0000000..298eb4d
--- /dev/null
+++ b/config/astal/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+@girs/
diff --git a/config/astal/app.ts b/config/astal/app.ts
new file mode 100644
index 0000000..df44317
--- /dev/null
+++ b/config/astal/app.ts
@@ -0,0 +1,39 @@
+import { App } from "astal/gtk3"
+import style from "./style.scss"
+
+import notifications from "./components/notifications/handler";
+
+App.start({
+ instanceName: "runner",
+ css: style,
+ main() {
+ notifications.startNotificationHandler( 0, App.get_monitors()[0] )
+ },
+ requestHandler(request, res) {
+ const args = request.trimStart().split( ' ' );
+
+ // Notifications
+ if ( args[ 0 ] === 'notifier' ) {
+ if ( args[ 1 ] == 'show' ) {
+ notifications.openNotificationMenu( 0 );
+ res( 'Showing all open notifications' );
+ } else if ( args[ 1 ] == 'hide' ) {
+ notifications.closeNotificationMenu( 0 );
+ res( 'Hid all notifications' );
+ } else if ( args[ 1 ] == 'clear' ) {
+ notifications.clearAllNotifications( 0 );
+ res( 'Cleared all notifications' );
+ } else if ( args[ 1 ] == 'clear-newest' ) {
+ notifications.clearNewestNotifications( 0 );
+ res( 'Cleared newest notification' );
+ } else if ( args[ 1 ] == 'toggle' ) {
+ notifications.toggleNotificationMenu( 0 );
+ res( 'Toggled notifications' );
+ } else {
+ res( 'Unknown command!' );
+ }
+ } else if ( args[ 0 ] === 'bar' ) {
+
+ }
+ },
+})
diff --git a/config/astal/components/bar/ui/Bar.tsx b/config/astal/components/bar/ui/Bar.tsx
new file mode 100644
index 0000000..97be98e
--- /dev/null
+++ b/config/astal/components/bar/ui/Bar.tsx
@@ -0,0 +1,23 @@
+import { createQuickActionsMenu } from "./QuickActions";
+import { GLib } from "astal";
+import { Astal, Gdk, Gtk } from "astal/gtk3";
+
+const Bar = (gdkmonitor: Gdk.Monitor) => {
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default Bar;
diff --git a/config/astal/components/bar/ui/QuickActions.tsx b/config/astal/components/bar/ui/QuickActions.tsx
new file mode 100644
index 0000000..052ce48
--- /dev/null
+++ b/config/astal/components/bar/ui/QuickActions.tsx
@@ -0,0 +1,76 @@
+import Gtk from "gi://Gtk";
+import AstalNetwork from "gi://AstalNetwork";
+import AstalBluetooth from "gi://AstalBluetooth";
+import { exec } from "gi://GLib";
+
+let quickActionsMenu;
+
+// Toggle WiFi connection
+function toggleWiFi() {
+ const network = AstalNetwork.get_default();
+ const wifi = network.get_wifi()!;
+ const state = wifi.get_state();
+
+ if (state === AstalNetwork.WifiState.DISCONNECTED) {
+ wifi.connect();
+ } else {
+ wifi.disconnect();
+ }
+}
+
+// Toggle Bluetooth power state
+function toggleBluetooth() {
+ const bluetooth = AstalBluetooth.get_default();
+ const adapter = bluetooth.get_adapter();
+ const powered = adapter?.get_powered();
+
+ adapter?.set_powered(!powered);
+}
+
+// Adjust volume or microphone (opens pavucontrol)
+function adjustVolume() {
+ exec("pavucontrol");
+}
+
+function adjustMic() {
+ exec("pavucontrol");
+}
+
+// Power menu
+function showPowerMenu() {
+ exec("power-menu");
+}
+
+// Show WiFi network picker
+function pickWiFi() {
+ const wifi = AstalNetwork.get_default().get_wifi();
+ wifi.show_picker();
+}
+
+// Show Bluetooth device picker
+function pickBluetooth() {
+ const bluetooth = AstalBluetooth.get_default();
+ bluetooth.show_picker();
+}
+
+// Create menu using JSX
+function createQuickActionsMenu() {
+ quickActionsMenu = (
+
+ );
+
+ return quickActionsMenu;
+}
+
+// Export the function
+export { createQuickActionsMenu };
diff --git a/config/astal/components/bar/ui/modules/Calendar.tsx b/config/astal/components/bar/ui/modules/Calendar.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/config/astal/components/bar/ui/modules/Hyprland.tsx b/config/astal/components/bar/ui/modules/Hyprland.tsx
new file mode 100644
index 0000000..a08b626
--- /dev/null
+++ b/config/astal/components/bar/ui/modules/Hyprland.tsx
@@ -0,0 +1,57 @@
+import AstalTray from "gi://AstalTray";
+import { bind } from "astal";
+import AstalHyprland from "gi://AstalHyprland";
+
+const SysTray = () => {
+ const tray = AstalTray.get_default();
+
+ return
+ {bind(tray, "items").as( items => items.map( item => (
+
+ ) ) ) }
+
+}
+
+
+const HyprlandWorkspace = () => {
+ const hypr = AstalHyprland.get_default()
+
+ return
+ {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 => (
+
+ ))
+ )}
+
+}
+
+
+const HyprlandActiveWindow = () => {
+ const hypr = AstalHyprland.get_default();
+ const focused = bind( hypr, "focusedClient" );
+
+ return
+ {focused.as( client => (
+ client &&
+ ))}
+
+}
+
+export default {
+ HyprlandWorkspace,
+ HyprlandActiveWindow,
+ SysTray
+}
diff --git a/config/astal/components/bar/ui/modules/QuickView.tsx b/config/astal/components/bar/ui/modules/QuickView.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/config/astal/components/bar/ui/modules/SystemInfo.tsx b/config/astal/components/bar/ui/modules/SystemInfo.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/config/astal/components/bar/ui/quickactions.scss b/config/astal/components/bar/ui/quickactions.scss
new file mode 100644
index 0000000..dc4a40e
--- /dev/null
+++ b/config/astal/components/bar/ui/quickactions.scss
@@ -0,0 +1,37 @@
+@import "colors";
+
+.toggle-row {
+ background-color: $bg;
+ border-radius: 12px;
+ margin: 6px 0;
+ overflow: hidden;
+ border: 1px solid $border;
+
+ button {
+ padding: 10px 16px;
+ font-size: 14px;
+ transition: background 0.2s ease;
+ border: none;
+ background: transparent;
+
+ &:hover {
+ background-color: $hover;
+ }
+ }
+
+ .toggle {
+ flex: 1;
+ background-color: transparent;
+ text-align: left;
+ &.active {
+ background-color: $accent;
+ color: white;
+ }
+ }
+
+ .arrow {
+ width: 40px;
+ background-color: transparent;
+ text-align: center;
+ }
+}
diff --git a/config/astal/components/bar/util/hyprland.ts b/config/astal/components/bar/util/hyprland.ts
new file mode 100644
index 0000000..47a1481
--- /dev/null
+++ b/config/astal/components/bar/util/hyprland.ts
@@ -0,0 +1,68 @@
+import AstalHyprland from "gi://AstalHyprland";
+
+const getAvailableWorkspaces = (): number[] => {
+ const workspaces: number[] = [];
+ AstalHyprland.get_default().get_workspaces().forEach( val => {
+ workspaces.push( val.get_id() );
+ } )
+ return workspaces;
+}
+
+const getCurrentWorkspaceID = (): number => {
+ return AstalHyprland.get_default().get_focused_workspace().get_id();
+}
+
+const getCurrentWindowTitle = (): string => {
+ return AstalHyprland.get_default().get_focused_client().get_title();
+}
+
+/**
+ * Get the workspace ID of a window by its address
+ * @param address - The address of the window
+ * @returns The workspace ID
+ */
+const getWorkspaceIDOfWindowByAddress = ( address: string ): number => {
+ AstalHyprland.get_default().get_clients().forEach( client => {
+ if ( client.get_address() === address ) {
+ return client.get_workspace().get_id();
+ }
+ } );
+
+ return -1;
+}
+
+
+interface HyprlandSubscriptions {
+ [key: string]: ( data: string ) => void;
+}
+const hooks: HyprlandSubscriptions = {};
+
+/**
+ * Add an event listener for Hyprland events.
+ * @param event - A hyprland IPC event. See https://wiki.hyprland.org/IPC/. Useful events include: urgent, windowtitlev2, workspace, createworkspacev2, destroyworkspacev2, activewindowv2
+ * @param cb - The callback function
+ */
+const subscribeToUpdates = ( event: string, cb: ( data: string ) => void ): void => {
+ hooks[ event ] = cb;
+}
+
+/**
+ * Listen to events. Must be called at some point if events are to be listened for
+ */
+const listen = () => {
+ AstalHyprland.get_default().connect( "event", ( name: string, data: string ) => {
+ if ( hooks[ name ] ) {
+ hooks[ name ]( data );
+ }
+ } );
+}
+
+
+export default {
+ getAvailableWorkspaces,
+ getCurrentWorkspaceID,
+ getCurrentWindowTitle,
+ getWorkspaceIDOfWindowByAddress,
+ subscribeToUpdates,
+ listen
+}
diff --git a/config/astal/components/bar/util/sysinfo.ts b/config/astal/components/bar/util/sysinfo.ts
new file mode 100644
index 0000000..e69de29
diff --git a/config/astal/components/notifications/handler.tsx b/config/astal/components/notifications/handler.tsx
new file mode 100644
index 0000000..79386ca
--- /dev/null
+++ b/config/astal/components/notifications/handler.tsx
@@ -0,0 +1,206 @@
+/*
+* dotfiles - handler.ts
+*
+* Created by Janis Hutz 03/21/2025, Licensed under the GPL V3 License
+* https://janishutz.com, development@janishutz.com
+*
+*
+*/
+
+import { Astal, Gtk, Gdk } from "astal/gtk3"
+import Notifd from "gi://AstalNotifd";
+import Notification from "./notifications";
+import { type Subscribable } from "astal/binding";
+import { Variable, bind, timeout } from "astal"
+
+// Config
+const TIMEOUT_DELAY = 5000;
+
+class Notifier implements Subscribable {
+ private display: Map = new Map();
+ private notifications: Map = new Map();
+
+ private notifd: Notifd.Notifd;
+ private subscriberData: Variable = Variable( [] );
+ private instanceID: number;
+ private menuOpen: boolean;
+
+ /**
+ * Sets up the notifier
+ */
+ constructor( id: number ) {
+ this.instanceID = id;
+ this.menuOpen = false;
+ this.notifd = Notifd.get_default();
+ this.notifd.ignoreTimeout = true;
+
+ this.notifd.connect( 'notified', ( _, id ) => {
+ this.add( id );
+ } );
+
+ this.notifd.connect( 'resolved', ( _, id ) => {
+ this.delete( id );
+ } );
+ }
+
+ private notify () {
+ this.subscriberData.set( [ ...this.display.values() ].reverse() );
+ }
+
+ private add ( id: number ) {
+ this.notifications.set( id, this.notifd.get_notification( id )! );
+ this.show( id );
+ }
+
+ /**
+ * Show an element on screen
+ * @param id - The id of the element to be shown
+ */
+ private show ( id: number ) {
+ this.set( id, Notification( {
+ notification: this.notifications.get( id )!,
+ onHoverLost: () => {
+ if ( !this.menuOpen ) {
+ this.hide( id );
+ }
+ },
+ setup: () => timeout( TIMEOUT_DELAY, () => {
+ if ( !this.menuOpen ) {
+ this.hide( id );
+ }
+ } ),
+ id: id,
+ delete: deleteHelper,
+ instanceID: this.instanceID
+ } ) )
+ }
+
+ /**
+ * Set a selected widget to be shown
+ * @param id - The id of the element to be referenced for later
+ * @param widget - A GTK widget instance
+ */
+ private set ( id: number, widget: Gtk.Widget ) {
+ this.display.get( id )?.destroy();
+ this.display.set( id, widget );
+ this.notify();
+ }
+
+ /**
+ * Hide, not delete notification (= send to notification centre)
+ * @param id - The id of the notification to hide
+ */
+ private hide ( id: number ) {
+ this.display.get( id )?.destroy();
+ this.display.delete( id );
+ this.notify();
+ }
+
+ /**
+ * Delete a notification (from notification centre too)
+ * @param id - The id of the notification to hide
+ */
+ delete ( id: number ) {
+ this.hide( id );
+ this.notifications.get( id )?.dismiss();
+ this.notifications.delete( id );
+ if ( this.notifications.size == 0 ) {
+ this.menuOpen = false;
+ }
+ }
+
+ openNotificationMenu () {
+ // Show all notifications that have not been cleared
+ if ( this.notifications.size > 0 ) {
+ this.menuOpen = true;
+ this.notifications.forEach( ( _, id ) => {
+ this.show( id );
+ } )
+ }
+ }
+
+ hideNotifications () {
+ this.menuOpen = false;
+ this.notifications.forEach( ( _, id ) => {
+ this.hide( id );
+ } );
+ }
+
+ toggleNotificationMenu () {
+ if ( this.menuOpen ) {
+ this.hideNotifications();
+ } else {
+ this.openNotificationMenu();
+ }
+ }
+
+ clearAllNotifications () {
+ this.menuOpen = false;
+ this.notifications.forEach( ( _, id ) => {
+ this.delete( id );
+ } )
+ }
+
+ clearNewestNotification () {
+ this.delete( [ ...this.notifications.keys() ][0] );
+ }
+
+ subscribe(callback: (value: unknown) => void): () => void {
+ return this.subscriberData.subscribe( callback );
+ }
+
+ get() {
+ return this.subscriberData.get();
+ }
+}
+
+const notifiers: Map = new Map();
+const deleteHelper = ( id: number, instanceID: number ) => {
+ notifiers.get( instanceID )?.delete( id );
+}
+
+const openNotificationMenu = ( id: number ) => {
+ notifiers.get( id )?.openNotificationMenu();
+}
+
+const closeNotificationMenu = ( id: number ) => {
+ notifiers.get( id )?.hideNotifications();
+}
+
+const toggleNotificationMenu = ( id: number ) => {
+ notifiers.get( id )?.toggleNotificationMenu();
+}
+
+const clearAllNotifications = ( id: number ) => {
+ notifiers.get( id )?.clearAllNotifications();
+}
+
+const clearNewestNotifications = ( id: number ) => {
+ notifiers.get( id )?.clearNewestNotification();
+}
+
+const startNotificationHandler = (id: number, gdkmonitor: Gdk.Monitor) => {
+ const { TOP, RIGHT } = Astal.WindowAnchor
+ const notifier: Notifier = new Notifier( id );
+ notifiers.set( id, notifier );
+
+ return
+
+ {bind(notifier)}
+
+
+}
+
+
+export default {
+ startNotificationHandler,
+ openNotificationMenu,
+ closeNotificationMenu,
+ clearAllNotifications,
+ clearNewestNotifications,
+ toggleNotificationMenu
+}
diff --git a/config/astal/components/notifications/notifications.scss b/config/astal/components/notifications/notifications.scss
new file mode 100644
index 0000000..a32f08b
--- /dev/null
+++ b/config/astal/components/notifications/notifications.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/config/astal/components/notifications/notifications.tsx b/config/astal/components/notifications/notifications.tsx
new file mode 100644
index 0000000..de4e7b2
--- /dev/null
+++ b/config/astal/components/notifications/notifications.tsx
@@ -0,0 +1,112 @@
+// From astal examples
+
+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 = {
+ delete( id: number, instanceID: number ): void
+ setup(self: EventBox): void
+ onHoverLost(self: EventBox): void
+ notification: Notifd.Notification
+ id: number
+ instanceID: number
+}
+
+export default function Notification(props: Props) {
+ const { notification: n, onHoverLost, setup, id: id, delete: del, instanceID: instance } = props
+ const { START, CENTER, END } = Gtk.Align
+
+ return
+
+
+ {(n.appIcon || n.desktopEntry) && }
+
+
+
+
+
+
+ {n.image && fileExists(n.image) && }
+ {n.image && isIcon(n.image) &&
+
+ }
+
+
+ {n.body && }
+
+
+ {n.get_actions().length > 0 &&
+ {n.get_actions().map(({ label, id }) => (
+
+ ))}
+ }
+
+
+}
diff --git a/config/astal/env.d.ts b/config/astal/env.d.ts
new file mode 100644
index 0000000..467c0a4
--- /dev/null
+++ b/config/astal/env.d.ts
@@ -0,0 +1,21 @@
+declare const SRC: string
+
+declare module "inline:*" {
+ const content: string
+ export default content
+}
+
+declare module "*.scss" {
+ const content: string
+ export default content
+}
+
+declare module "*.blp" {
+ const content: string
+ export default content
+}
+
+declare module "*.css" {
+ const content: string
+ export default content
+}
diff --git a/config/astal/package.json b/config/astal/package.json
new file mode 100644
index 0000000..44226f2
--- /dev/null
+++ b/config/astal/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "astal-shell",
+ "dependencies": {
+ "astal": "/usr/share/astal/gjs"
+ }
+}
diff --git a/config/astal/style.scss b/config/astal/style.scss
new file mode 100644
index 0000000..1d0d3a9
--- /dev/null
+++ b/config/astal/style.scss
@@ -0,0 +1,20 @@
+// 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.Bar {
+ background: transparent;
+ color: $fg-color;
+ font-weight: bold;
+
+ >centerbox {
+ background: $bg-color;
+ border-radius: 10px;
+ margin: 8px;
+ }
+
+ button {
+ border-radius: 8px;
+ margin: 2px;
+ }
+}
diff --git a/config/astal/tsconfig.json b/config/astal/tsconfig.json
new file mode 100644
index 0000000..9471e35
--- /dev/null
+++ b/config/astal/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "experimentalDecorators": true,
+ "strict": true,
+ "target": "ES2022",
+ "module": "ES2022",
+ "moduleResolution": "Bundler",
+ // "checkJs": true,
+ // "allowJs": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "astal/gtk3",
+ }
+}