Compare commits

...

31 Commits
main ... ags

Author SHA1 Message Date
Joshua Yuen 0bffd9b81c
Adjust low battery icon threshold to 20% 2026-10-06 20:22:12 -04:00
Joshua Yuen 9631148ed8
Tighten slider spacing and lift section outlines in dropdown 2026-10-06 20:22:12 -04:00
Joshua Yuen 06659df98e
Restyle Bluetooth device rows to match wifi menu 2026-10-06 20:22:12 -04:00
Joshua Yuen d19e300982
Revert "Adjust AGS color palette to fit better"
This reverts commit c3384bd25cd64d8d4014363d0564534138497cc5.
2026-10-06 20:22:12 -04:00
Joshua Yuen 76ed841922
Adjust AGS color palette to fit better 2026-10-06 20:22:12 -04:00
Joshua Yuen c2be4aaabf
Switch to the colored Nix snowflake 2026-10-06 20:22:11 -04:00
Joshua Yuen 15c2ab76df
Use string-form mkEnableOption for AGS option 2026-10-06 20:22:11 -04:00
Joshua Yuen eb4f3d655f
Show friendly app names and make dictation notifications toast-only 2026-10-06 20:22:11 -04:00
Joshua Yuen c9a0df6f62
Keep notifications in dropdown after toast timeout and polish toasts 2026-10-06 20:22:11 -04:00
Joshua Yuen c1de6da8d3
Port AlfheimOS quicksettings styling with scrollable popup 2026-10-06 20:22:11 -04:00
Joshua Yuen e9d9d31b33
Add do not disturb toggle and bar indicator 2026-10-06 20:22:10 -04:00
Joshua Yuen 1ad7a8501e
Make notifications always visible with plain header 2026-10-06 20:22:10 -04:00
Joshua Yuen 67f251d959
Add media player card and bar center track button 2026-10-06 20:22:10 -04:00
Joshua Yuen f95ee4d35c
Add popup header with avatar and system actions 2026-10-06 20:22:10 -04:00
Joshua Yuen 33cc7c7b12
Add brightness slider polled from brightnessctl 2026-10-06 20:22:10 -04:00
Joshua Yuen c8e3d61d6b
Add bluetooth toggle with device list and discovery 2026-10-06 20:22:09 -04:00
Joshua Yuen 4dda5b3fde
Fix clock layout shift with tabular digits and fixed width 2026-10-06 20:22:09 -04:00
Joshua Yuen 3b873cba7d
Polish bar spacing and clock module with custom clock icon 2026-10-06 20:22:09 -04:00
Joshua Yuen f6ddf3d4b7
Add battery and notifications to QuickSettings popup 2026-10-06 20:22:09 -04:00
Joshua Yuen 578797498e
Move wifi into QuickSettings popup with collapsible network list 2026-10-06 20:22:09 -04:00
Joshua Yuen aabe5b15db
Add QuickSettings popup with power actions and volume/mic sliders 2026-10-06 20:22:09 -04:00
Joshua Yuen 560db85f6d
Add nix logo, clock format, and curved module separators 2026-10-06 20:22:08 -04:00
Joshua Yuen e9f5c62d77
Restyle AGS bar as filled full-width bar with inverse corners 2026-10-06 20:22:08 -04:00
Joshua Yuen e3fcfc6812
Add AGS notification support 2026-10-06 20:22:08 -04:00
Joshua Yuen bb48892e75
Fix nix fmt 2026-10-06 20:22:08 -04:00
Joshua Yuen b5d2a1a87b
Add AGS power controls and locking 2026-10-06 20:22:08 -04:00
Joshua Yuen 31786db83d
Add AGS battery widget 2026-10-06 20:22:07 -04:00
Joshua Yuen 60397bc527
Add AGS WiFi widget 2026-10-06 20:22:07 -04:00
Joshua Yuen 4217f4f701
Add AGS volume widget 2026-10-06 20:22:07 -04:00
Joshua Yuen b6fbe0da48
Added AGS workspaces and clock 2026-10-06 20:22:07 -04:00
Joshua Yuen eee0d5b4ec
Build AGS scaffolding 2026-10-06 20:22:07 -04:00
28 changed files with 2298 additions and 2 deletions

View File

@ -21,4 +21,6 @@
modules.display.scale = 2;
modules.steam.remotePlay.enable = true;
hardware.bluetooth.enable = true;
}

2
modules/home/ags/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
@girs/

View File

@ -0,0 +1,12 @@
import { App } from "astal/gtk3"
import style from "./style.scss"
import Bar from "./widget/Bar"
import Notifications from "./widget/Notifications"
App.start({
css: style,
main() {
App.get_monitors().map(Bar)
Notifications(App.get_monitors()[0])
},
})

View File

@ -0,0 +1 @@
export const MAX_TIMEOUT = 10000

View File

@ -0,0 +1,615 @@
@use "palette" as *;
// theme vars ported from AlfheimOS, mapped to the stylix palette
$radius: 12px; // keep in sync with RADIUS in Bar.tsx
$spacing: 8px;
$padding: 8px;
$border-width: 1px;
// one step lighter than the card fill so section outlines read against
// the dropdown background and the widgets they surround
$border-color: $base03;
$border: $border-width solid $border-color;
$transition: 180ms;
$fg: $base05;
$primary-bg: $base0D;
$primary-fg: $base00;
$widget-bg: transparentize($base02, 0.45);
$hover-bg: transparentize($base02, 0.2);
$hover-overlay: transparentize($base05, 0.92);
$active-hover: transparentize($primary-bg, 0.15);
$active-gradient: linear-gradient(to right, $primary-bg, darken($primary-bg, 4%));
$toast-bg: transparentize($base02, 0.15);
$bar-alpha: 0.7;
$bar-bg: transparentize($base01, 1 - $bar-alpha);
$module-bg: transparentize($base02, 0.4);
* {
all: unset;
font-family: $font-family;
font-size: $font-size;
}
.Bar,
.popup-window {
background: transparent;
}
.bar {
background: $bar-bg;
padding: 0;
color: $base05;
label,
icon {
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
-gtk-icon-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}
}
.bar-corners {
background: transparent;
color: $bar-bg;
}
.corner {
background: transparent;
color: $module-bg;
}
.bar .first,
.bar .center,
.bar .end,
.bar .last {
background: $module-bg;
padding: 2px 10px;
&:hover {
background: $base03;
}
}
.bar .first {
border-radius: 0 0 ($radius * 2.5) 0;
margin: 0 2px 0 0;
}
.bar .center {
border-radius: $radius;
margin: 2px;
}
.bar .end {
border-radius: ($radius * 0.75) ($radius * 2) ($radius * 0.75) ($radius * 2);
margin: 0 2px 2px;
}
.bar .last {
border-radius: 0 0 0 ($radius * 2.5);
margin: 0 0 0 2px;
}
.logo icon {
min-width: 20px;
min-height: 20px;
}
.clock {
font-weight: bold;
font-feature-settings: "tnum";
}
.clock icon {
font-size: 12px;
}
.ws {
min-width: 20px;
border-radius: 6px;
margin: 0 2px;
padding: 0 6px;
}
.ws.active {
background: $primary-bg;
color: $primary-fg;
}
.ws:hover {
background: transparentize($base05, 0.88);
}
.quicksettings icon {
font-size: 12px;
}
.quicksettings .notif-count,
.quicksettings .bt-count {
font-size: 0.8em;
}
.battery-level {
font-size: 0.85em;
}
// ---------- quicksettings popup ----------
.dropdown {
background: $base01;
border: $border-width solid transparentize($base02, 0.5);
border-radius: 10px;
padding: 10px 12px;
color: $fg;
}
scrollbar {
background: transparent;
border: none;
padding: 0 2px;
slider {
background: $base03;
border-radius: 999px;
min-width: 6px;
min-height: 6px;
margin: 2px;
}
}
.header {
margin-bottom: 6px;
color: transparentize($fg, 0.15);
.avatar {
padding: 7px;
border-radius: 10px;
background-color: $widget-bg;
border: $border;
}
.username {
font-weight: bold;
}
button {
padding: 7px 8px;
border-radius: 8px;
icon {
font-size: 16px;
}
&:hover {
background: $hover-bg;
}
}
}
.battery-section {
margin-bottom: 6px;
icon {
font-size: 16px;
}
}
.battery-percent {
font-weight: bold;
margin: 0 6px;
}
.battery-status {
font-size: 0.85em;
opacity: 0.7;
}
// volume, microphone and brightness rows on one widget card
.sliders-box {
background-color: $widget-bg;
border: $border;
border-radius: 10px;
padding: 6px;
.volume,
.microphone,
.brightness {
padding: 0 4px;
}
button {
padding: 5px 7px;
border-radius: 6px;
icon {
font-size: 16px;
}
&:hover {
background: $hover-bg;
}
}
}
// shared slider look: bordered trough, accent gradient highlight,
// thumb only appears on hover, .muted turns the highlight gray
scale {
trough {
border-radius: 999px;
border: $border;
background-color: $base01;
min-height: 8px;
highlight {
border-radius: 999px;
background-image: $active-gradient;
min-height: 8px;
}
slider {
background-color: transparent;
border: $border-width solid transparent;
border-radius: 999px;
min-width: 10px;
min-height: 10px;
}
}
&:hover {
trough {
background-color: transparentize($base01, 0.4);
}
trough slider {
background-color: $base04;
border-color: $border-color;
}
}
&.muted trough highlight {
background-image: none;
background-color: transparentize($fg, 0.2);
}
}
// icon + label pill with a detached arrow
.toggle-button {
margin: 4px 0;
background-color: $widget-bg;
border: $border;
border-radius: 10px;
font-weight: bold;
button:first-child {
padding: 8px 10px;
border-radius: 10px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
button:last-child {
padding: 4px 8px;
border-radius: 10px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
icon {
font-size: 12px;
}
}
.icon {
font-size: 16px;
margin-right: 8px;
}
button:hover {
background: $hover-overlay;
}
&.active {
background-color: $primary-bg;
label,
icon {
color: $primary-fg;
}
button:hover {
background: $active-hover;
}
}
}
// arrow-less toggle pill (do not disturb)
.simple-toggle {
margin: 4px 0;
padding: 9px 10px;
border-radius: 10px;
background-color: $widget-bg;
border: $border;
font-weight: bold;
.icon {
font-size: 16px;
margin-right: 8px;
}
&:hover {
background: $hover-bg;
}
&.active {
background-color: $primary-bg;
label,
icon {
color: $primary-fg;
}
&:hover {
background: $active-hover;
}
}
}
.menu {
background-color: $widget-bg;
border: $border;
border-radius: 10px;
padding: 8px;
margin: 4px 0 8px;
.title-box {
margin: 2px 0 4px;
.icon {
font-size: 14px;
margin: 0 4px 0 2px;
}
.title {
font-weight: bold;
opacity: 0.85;
}
}
.menu-separator {
background: $border-color;
min-height: 1px;
margin: 4px 0;
}
button {
padding: 5px 7px;
border-radius: 6px;
icon {
font-size: 16px;
}
&:hover {
background: $hover-overlay;
}
}
}
.wifi-status,
.wifi-empty {
font-size: 0.85em;
opacity: 0.7;
margin: 2px 0;
}
.wifi-password {
margin: 4px 0;
padding: 5px 8px;
border-radius: 6px;
background: $base01;
color: $fg;
}
.wifi-row {
margin: 1px 0;
icon {
font-size: 16px;
}
.wifi-row-ssid {
margin: 0 6px;
}
}
.device-row {
margin: 1px 0;
.device-name {
margin: 0 6px;
}
.device-battery {
font-size: 0.85em;
opacity: 0.7;
margin-right: 6px;
}
.device-connecting {
font-size: 14px;
opacity: 0.7;
margin-right: 2px;
}
}
// media player card
.player {
background-color: $widget-bg;
border: $border;
border-radius: 10px;
padding: 8px;
margin: 4px 0;
.cover {
padding: 12px;
border-radius: 10px;
background-color: $hover-bg;
background-size: cover;
background-position: center;
}
.player-title {
font-weight: bold;
}
.player-artist {
font-size: 0.9em;
color: transparentize($fg, 0.2);
}
scale {
margin: 4px 0;
trough {
border: none;
}
trough highlight {
background-image: linear-gradient($fg, $fg);
}
}
.player-ctl {
padding: 4px 6px;
border-radius: 999px;
icon {
font-size: 14px;
}
&:hover {
background: $hover-overlay;
}
}
.play-pause {
background-color: $primary-bg;
icon {
color: $primary-fg;
}
&:hover {
background: $active-hover;
}
}
.player-position,
.player-length {
font-size: 0.85em;
opacity: 0.7;
}
}
.notif-section {
margin-top: 10px;
border-top: 1px solid $border-color;
padding-top: 8px;
}
.notif-header {
label {
font-weight: bold;
margin: 0 2px;
}
}
.notif-clear {
padding: 4px 10px;
border-radius: 6px;
font-size: 0.85em;
&:hover {
background: $hover-bg;
}
}
.notif-empty {
font-size: 0.85em;
opacity: 0.7;
}
.notifications {
.notif {
background-color: $widget-bg;
border: $border;
border-radius: 10px;
padding: 8px 10px;
&:hover {
background: $hover-bg;
}
&.critical {
border-color: $base08;
}
// popup toasts are more opaque than list entries and become
// completely opaque while hovered
&.toast {
background-color: $toast-bg;
&:hover {
background-color: $base02;
}
}
}
.notif-header {
margin-bottom: 4px;
.notif-app-name {
font-weight: bold;
margin: 0 6px;
}
.notif-time {
font-size: 0.85em;
opacity: 0.7;
margin-right: 4px;
}
.notif-close {
border-radius: 6px;
padding: 0 2px;
&:hover {
background: $hover-overlay;
}
}
}
.notif-content {
.notif-image {
margin-right: 8px;
}
.notif-summary {
font-weight: bold;
}
.notif-text {
font-size: 0.9em;
opacity: 0.85;
}
}
.notif-action {
background: $base01;
border-radius: 6px;
margin-top: 6px;
padding: 2px 8px;
&:hover {
background: $hover-bg;
}
}
}

View File

@ -0,0 +1,113 @@
import { App, Astal, Gdk, Gtk } from "astal/gtk3"
import Workspaces from "./Workspaces"
import Logo from "./Logo"
import Clock from "./Clock"
import { MediaButton } from "./Media"
import QuickSettings from "./QuickSettings"
const RADIUS = 12
// paints the concave sliver below the bar at the screen edge: the r x r
// square minus the quarter disk where the wallpaper shows through
function Corner({ side }: { side: "left" | "right" }) {
return (
<drawingarea
className="corner"
widthRequest={RADIUS}
heightRequest={RADIUS}
halign={side === "left" ? Gtk.Align.START : Gtk.Align.END}
onDraw={(self, cr) => {
const w = self.get_allocated_width()
const h = self.get_allocated_height()
// bar color comes from the parent strip so the sliver gets the
// same bar-bg + module-bg stack as the pills
const bar = self
.get_parent()!
.get_style_context()
.get_color(Gtk.StateFlags.NORMAL)
const mod = self
.get_style_context()
.get_color(Gtk.StateFlags.NORMAL)
const trace = () => {
if (side === "left") {
cr.moveTo(0, 0)
cr.lineTo(w, 0)
cr.arcNegative(w, h, w, -Math.PI / 2, -Math.PI)
} else {
cr.moveTo(w, 0)
cr.lineTo(0, 0)
cr.arc(0, h, w, -Math.PI / 2, 0)
}
cr.closePath()
}
cr.setSourceRGBA(bar.red, bar.green, bar.blue, bar.alpha)
trace()
cr.fillPreserve()
cr.setSourceRGBA(mod.red, mod.green, mod.blue, mod.alpha)
cr.fill()
}}
/>
)
}
export default function Bar(gdkmonitor: Gdk.Monitor) {
const { TOP, LEFT, RIGHT } = Astal.WindowAnchor
const quicksettings = QuickSettings(gdkmonitor)
const right: Array<JSX.Element> = [quicksettings.button]
const popups: Array<JSX.Element> = [quicksettings.popup]
return [
(
<window
className="Bar"
namespace="ags-bar"
gdkmonitor={gdkmonitor}
exclusivity={Astal.Exclusivity.EXCLUSIVE}
anchor={TOP | LEFT | RIGHT}
application={App}
>
<box vertical>
<centerbox className="bar">
<box
className="bar-left"
halign={Gtk.Align.START}
valign={Gtk.Align.FILL}
>
<Logo />
<box className="center">
<Workspaces />
</box>
</box>
<box
className="bar-center"
halign={Gtk.Align.CENTER}
valign={Gtk.Align.CENTER}
>
<MediaButton />
</box>
<box
className="bar-right"
halign={Gtk.Align.END}
valign={Gtk.Align.FILL}
>
{right}
<box className="clock last">
<Clock />
</box>
</box>
</centerbox>
<box className="bar-corners">
<Corner side="left" />
<box hexpand />
<Corner side="right" />
</box>
</box>
</window>
),
...popups,
]
}

View File

@ -0,0 +1,61 @@
import Gtk from "gi://Gtk?version=3.0"
import AstalBattery from "gi://AstalBattery"
import { type Binding, Variable, bind } from "astal"
const battery = AstalBattery.get_default()
const CHARGING = 1
const DISCHARGING = 2
const FULLY_CHARGED = 4
function fmtTime(seconds: number) {
if (!seconds || seconds <= 0) return null
const h = Math.floor(seconds / 3600)
const m = Math.round((seconds % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
const status = Variable.derive(
[bind(battery, "state"), bind(battery, "time-to-empty"), bind(battery, "time-to-full")],
(state, toEmpty, toFull) => {
if (state === FULLY_CHARGED) return "fully charged"
if (state === CHARGING) {
const t = fmtTime(toFull)
return t ? `charging · ${t} to full` : "charging"
}
if (state === DISCHARGING) {
const t = fmtTime(toEmpty)
return t ? `${t} left` : "on battery"
}
return ""
},
)
export const batteryIcon: Binding<string> | null = battery.isBattery
? bind(battery, "icon-name")
: null
export const batteryPercent: Binding<string> | null = battery.isBattery
? bind(battery, "percentage").as((p) => `${Math.round(p * 100)}%`)
: null
export function BatterySection() {
if (!battery.isBattery) return <box />
return (
<box className="battery-section">
<icon icon={bind(battery, "icon-name")} />
<label
className="battery-percent"
label={bind(battery, "percentage").as((p) => `${Math.round(p * 100)}%`)}
/>
<label
className="battery-status"
label={status()}
visible={status().as((text) => text !== "")}
hexpand
halign={Gtk.Align.START}
/>
</box>
)
}

View File

@ -0,0 +1,195 @@
import Gtk from "gi://Gtk?version=3.0"
import Gio from "gi://Gio"
import AstalBluetooth from "gi://AstalBluetooth"
import { bind, Variable } from "astal"
import { ArrowToggleButton, Menu } from "./ToggleButton"
const bt = AstalBluetooth.get_default()
function power(on: boolean) {
if (bt.adapter) bt.adapter.powered = on
}
function setDiscovery(on: boolean) {
if (!bt.adapter) return
try {
if (on) bt.adapter.start_discovery()
else bt.adapter.stop_discovery()
} catch {
// expected when already in the requested state: bluez stops
// discovery itself on connect, auto-starts it, etc.
}
}
// bluez only registers devices that are paired or seen during discovery;
// the list is mirrored into a Variable refreshed on device add/remove
const devices = Variable<AstalBluetooth.Device[]>(bt.get_devices())
const refreshDevices = () => devices.set(bt.get_devices())
bt.connect("notify::devices", refreshDevices)
bt.connect("device_added", refreshDevices)
bt.connect("device_removed", refreshDevices)
const btIcon = bind(bt, "is_powered").as((p) =>
p ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic",
)
const connectedCount = devices().as(
(ds) => ds.filter((d) => d.connected).length,
)
const pillLabel = Variable.derive(
[bind(bt, "is_powered"), devices],
(powered, ds) => {
if (!powered) return "Disabled"
const connected = ds.filter((d) => d.connected)
if (connected.length === 1) return connected[0].alias
return `${connected.length} Connected`
},
)
// ported from AlfheimOS: icon, name, battery, busy icon while connecting,
// whole row toggles the bluez connected state
function DeviceItem(device: AstalBluetooth.Device) {
const battery = bind(device, "battery_percentage")
const connecting = bind(device, "connecting")
const connected = Variable.derive(
[bind(device, "connected"), connecting],
(c, busy) => c && !busy,
)
// bluez reports br-connection-busy when attempts pile up, so debounce
let lastAttempt = 0
function toggle() {
if (connecting.get()) return
// skip retries within the debounce window
const now = Date.now()
if (now - lastAttempt < 1500) return
lastAttempt = now
const state = !device.connected
// bluez rejects connects while the controller is busy scanning
if (state) setDiscovery(false)
// vala async: begin with a callback, finish() throws
const done = (_source: AstalBluetooth.Device, res: Gio.AsyncResult) => {
try {
if (state) device.connect_device_finish(res)
else device.disconnect_device_finish(res)
} catch (e) {
console.error(e)
}
}
if (state) device.connect_device(done)
else device.disconnect_device(done)
}
return (
<button className="device-row" onClicked={toggle}>
<box>
<icon
icon={
device.icon
? `${device.icon}-symbolic`
: "bluetooth-active-symbolic"
}
/>
<label
className="device-name"
label={device.alias ?? device.address}
halign={Gtk.Align.START}
hexpand
/>
<label
className="device-battery"
visible={battery.as((p) => p > 0)}
label={battery.as((p) => `${Math.round(p * 100)}%`)}
/>
<icon
className="device-connecting"
icon="view-refresh-symbolic"
visible={connecting}
/>
<icon
visible={connected()}
icon="object-select-symbolic"
/>
</box>
</button>
)
}
export function BluetoothSection({ open }: { open: Variable<boolean> }) {
if (!bt.adapter) return null
const expanded = Variable(false)
open.subscribe((v) => {
if (!v) {
expanded.set(false)
setDiscovery(false)
}
})
const toggle = (
<ArrowToggleButton
open={expanded}
icon={btIcon}
label={pillLabel()}
active={bind(bt, "is_powered")}
onMainClick={() => {
if (bt.is_powered) {
power(false)
expanded.set(false)
setDiscovery(false)
} else {
power(true)
}
}}
onArrowClick={() => {
const next = !expanded.get()
expanded.set(next)
if (next) {
if (!bt.is_powered) power(true)
setDiscovery(true)
} else {
setDiscovery(false)
}
}}
/>
)
const menu = (
<Menu open={expanded} icon="bluetooth-active-symbolic" title="Bluetooth">
<label
className="wifi-empty"
label="bluetooth is off"
visible={bind(bt, "is_powered").as((p) => !p)}
/>
<label
className="wifi-empty"
label="searching for devices…"
visible={Variable.derive(
[bind(bt, "is_powered"), devices],
(powered, ds) => powered && ds.filter((d) => d.name).length === 0,
)()}
/>
<box vertical visible={bind(bt, "is_powered")}>
{devices().as((ds) =>
ds.filter((d) => d.name).map(DeviceItem),
)}
</box>
</Menu>
)
return { toggle, menu }
}
// bar cluster indicator: shown while bluetooth is powered, with a count
// badge when devices are connected
export const bluetoothIndicator: JSX.Element | null = bt.adapter ? (
<box className="bt-indicator" spacing={3} visible={bind(bt, "is_powered")}>
<icon icon="bluetooth-active-symbolic" />
<label
className="bt-count"
visible={connectedCount.as((n) => n > 0)}
label={connectedCount.as((n) => `${n}`)}
/>
</box>
) : null

View File

@ -0,0 +1,30 @@
import { execAsync, Variable, bind } from "astal"
// no astal.brightness lib in nixpkgs: poll brightnessctl for the current
// level as a 0..1 fraction, pushing changes through brightnessctl set
const percent = Variable(0).poll(
2000,
["sh", "-c", "echo $(( $(brightnessctl get) * 100 / $(brightnessctl max) ))"],
(out) => parseInt(out) / 100,
)
export function BrightnessRow() {
return (
<box className="brightness" spacing={6}>
<button>
<icon icon="display-brightness-symbolic" />
</button>
<slider
hexpand
value={bind(percent)}
onDragged={(self) => {
const pct = Math.round(self.value * 100)
percent.set(pct / 100)
execAsync(["brightnessctl", "set", `${pct}%`]).catch(
console.error,
)
}}
/>
</box>
)
}

View File

@ -0,0 +1,51 @@
import GLib from "gi://GLib"
import Pango from "gi://Pango"
import Gtk from "gi://Gtk?version=3.0"
import { Variable, bind } from "astal"
const time = Variable("").poll(1000, () => {
const now = GLib.DateTime.new_now_local()
const clock = now.format("%I:%M %p")!.replace(/^0/, "")
return `${now.format("%a, %d %b")}, ${clock}`
})
// widest possible tick: days 1-7 always cover every weekday, sampled across
// all months with a double-digit hour in both meridiems; measured once at
// realize with the label's own font (tnum forced on) so the fixed
// widthRequest stops the bar from shifting every minute
function widestTick(self: Gtk.Label): number {
const layout = self.create_pango_layout("")
const attrs = new Pango.AttrList()
attrs.insert(Pango.attr_font_features_new("tnum"))
layout.set_attributes(attrs)
let widest = 0
for (let month = 1; month <= 12; month++) {
for (let day = 1; day <= 7; day++) {
for (const hour of [10, 22]) {
const d = GLib.DateTime.new_local(2026, month, day, hour, 0, 0)
const tick =
`${d.format("%a, %d %b")}, ${d.format("%I:%M %p")!.replace(/^0/, "")}`
layout.set_text(tick, -1)
const [w] = layout.get_pixel_size()
widest = Math.max(widest, w)
}
}
}
return Math.ceil(widest) + 2
}
export default function Clock() {
return (
<box spacing={6}>
<icon icon="clock-hands-symbolic" />
<label
className="clock"
label={bind(time)}
onRealize={(self) => {
self.width_request = widestTick(self as Gtk.Label)
}}
/>
</box>
)
}

View File

@ -0,0 +1,128 @@
import { App, Astal, Gdk, Gtk } from "astal/gtk3"
import GLib from "gi://GLib"
import { Variable, bind } from "astal"
const DURATION = 180
interface DropdownProps {
gdkmonitor: Gdk.Monitor
target: Gtk.Widget
open: Variable<boolean>
width?: number
vertical?: boolean
className?: string
// cap the popup height to the screen, scrolling any overflow
maxHeight?: number
children: Array<JSX.Element>
}
export default function Dropdown({
gdkmonitor,
target,
open,
width = 260,
vertical = true,
className = "",
maxHeight,
children,
}: DropdownProps) {
const { TOP, BOTTOM, LEFT, RIGHT } = Astal.WindowAnchor
const GAP = 8
const child = (
<box
className={`dropdown ${className}`}
vertical={vertical}
widthRequest={width}
halign={Gtk.Align.START}
valign={Gtk.Align.START}
>
{maxHeight !== undefined ? (
<scrollable
hexpand
hscroll={Gtk.PolicyType.NEVER}
vscroll={Gtk.PolicyType.AUTOMATIC}
maxContentHeight={maxHeight}
propagateNaturalHeight
>
<box vertical>{children}</box>
</scrollable>
) : (
children
)}
</box>
)
function reposition() {
const toplevel = target.get_toplevel()
const [, tx, ty] = target.translate_coordinates(toplevel, 0, 0)
const { width: bw, height: bh } = target.get_allocation()
const { width: sw } = gdkmonitor.get_geometry()
const x = Math.min(Math.max(tx + bw / 2 - width / 2, GAP), sw - width - GAP)
const y = ty + bh + GAP
child.marginStart = Math.round(x)
child.marginTop = Math.round(y)
}
// the window outlives the animation: it hides once the slide-out
// revealer finished, and hiding the window resolves to open=false
const winVisible = Variable(false)
let closeTimer: number | undefined
open.subscribe((v) => {
if (closeTimer !== undefined) {
GLib.source_remove(closeTimer)
closeTimer = undefined
}
if (v) {
reposition()
winVisible.set(true)
} else if (winVisible.get()) {
closeTimer = GLib.timeout_add(GLib.PRIORITY_DEFAULT, DURATION + 20, () => {
winVisible.set(false)
closeTimer = undefined
return GLib.SOURCE_REMOVE
})
}
})
return (
<window
className="popup-window"
application={App}
gdkmonitor={gdkmonitor}
namespace="ags-bar-popup"
layer={Astal.Layer.OVERLAY}
keymode={Astal.Keymode.EXCLUSIVE}
anchor={TOP | BOTTOM | LEFT | RIGHT}
exclusivity={Astal.Exclusivity.IGNORE}
visible={bind(winVisible)}
onNotifyVisible={(self) => {
if (!self.visible && open.get()) open.set(false)
}}
onButtonPressEvent={(self, e) => {
const event = e as unknown as Gdk.Event
const [, px, py] = event.get_coords()
const { x, y, width: w, height: h } = child.get_allocation()
const xOut = px < x || px > x + w
const yOut = py < y || py > y + h
if (xOut || yOut) open.set(false)
}}
onKeyPressEvent={(_, e) => {
const event = e as unknown as Gdk.Event
if (event.get_keyval()[1] === Gdk.KEY_Escape) open.set(false)
}}
>
<revealer
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={DURATION}
revealChild={bind(open)}
>
{child}
</revealer>
</window>
)
}

View File

@ -0,0 +1,9 @@
import Gtk from "gi://Gtk?version=3.0"
export default function Logo() {
return (
<box className="logo first" valign={Gtk.Align.FILL}>
<icon icon="nix-snowflake" pixelSize={20} />
</box>
)
}

View File

@ -0,0 +1,196 @@
import Gtk from "gi://Gtk?version=3.0"
import GLib from "gi://GLib"
import Pango from "gi://Pango"
import AstalMpris from "gi://AstalMpris"
import { Variable, bind } from "astal"
const mpris = AstalMpris.get_default()
const PREV = "media-skip-backward-symbolic"
const PLAY = "media-playback-start-symbolic"
const PAUSE = "media-playback-pause-symbolic"
const NEXT = "media-skip-forward-symbolic"
const NO_ART = "audio-x-generic-symbolic"
function fmtTime(seconds: number) {
if (!seconds || seconds < 0) return "0:00"
const min = Math.floor(seconds / 60)
const sec = Math.floor(seconds % 60)
return `${min}:${sec < 10 ? "0" : ""}${sec}`
}
function playerIcon(entry: string) {
const name = entry ? `${entry.toLowerCase()}-symbolic` : ""
const theme = Gtk.IconTheme.get_default()!
return name !== "" && theme.has_icon(name) ? name : NO_ART
}
function PlayerCard(player: AstalMpris.Player) {
// position only changes on the bus when seeking, so poll every second;
// the ticker stops itself when the player disconnects so we never
// touch a disposed GObject
const position = Variable(-1)
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1000, () => {
if (!mpris.players.includes(player)) return GLib.SOURCE_REMOVE
position.set(player.available ? player.position : -1)
return GLib.SOURCE_CONTINUE
})
return (
<box className="player" spacing={8}>
<box
className="cover"
css={bind(player, "cover_art").as((c) =>
c !== "" ? `background-image: url('${encodeURI(`file://${c}`)}')` : "",
)}
>
{/* kept allocated (opacity, not visible) so the padded box
stays 48px and the icon sits dead center with no art */}
<icon
pixelSize={24}
icon={NO_ART}
opacity={bind(player, "cover_art").as((c) => (c !== "" ? 0 : 1))}
/>
</box>
<box vertical spacing={4}>
<box spacing={6}>
<label
className="player-title"
hexpand
halign={Gtk.Align.START}
label={bind(player, "title").as((t) => t || "")}
maxWidthChars={20}
ellipsize={Pango.EllipsizeMode.END}
/>
<icon
className="player-icon"
icon={bind(player, "entry").as(playerIcon)}
tooltipText={bind(player, "identity")}
/>
</box>
<label
className="player-artist"
halign={Gtk.Align.START}
label={bind(player, "artist").as((a) => a || "")}
maxWidthChars={24}
ellipsize={Pango.EllipsizeMode.END}
visible={bind(player, "artist").as((a) => (a || "") !== "")}
/>
<slider
value={position().as((p) =>
player.length > 0
? Math.min(1, Math.max(0, p / player.length))
: 0,
)}
onDragged={(self) => {
if (player.length > 0) player.position = self.value * player.length
}}
visible={bind(player, "length").as((l) => l > 0)}
/>
<box className="player-footer" visible={bind(player, "length").as((l) => l > 0)}>
<label
className="player-position"
label={position().as((p) => fmtTime(p))}
/>
<box hexpand />
<button
className="player-ctl"
onClicked={() => player.previous()}
visible={bind(player, "can_go_previous")}
>
<icon icon={PREV} />
</button>
<button
className="player-ctl play-pause"
onClicked={() => player.play_pause()}
visible={bind(player, "can_play")}
>
<icon
icon={bind(player, "playback_status").as((s) =>
s === AstalMpris.PlaybackStatus.PLAYING ? PAUSE : PLAY,
)}
/>
</button>
<button
className="player-ctl"
onClicked={() => player.next()}
visible={bind(player, "can_go_next")}
>
<icon icon={NEXT} />
</button>
<box hexpand />
<label
className="player-length"
label={bind(player, "length").as((l) => fmtTime(l))}
/>
</box>
</box>
</box>
)
}
export function MediaSection() {
const players = bind(mpris, "players")
return (
<box className="media-section" vertical visible={players.as((ps) => ps.length > 0)}>
{players.as((ps) => ps.map((p) => PlayerCard(p)))}
</box>
)
}
// the players property only notifies on connect/disconnect, so the bar
// button tracks the first player directly to follow track changes
let player: AstalMpris.Player | null = null
let hookIds: Array<number> = []
const hasPlayer = Variable(false)
const title = Variable("")
const icon = Variable(NO_ART)
function sync() {
const current = mpris.players[0] ?? null
if (current !== player) {
hookIds.forEach((id) => player?.disconnect(id))
hookIds = []
player = current
if (player) {
hookIds = [
player.connect("notify::title", sync),
player.connect("notify::entry", sync),
]
}
}
hasPlayer.set(player !== null)
title.set(player?.title || "")
icon.set(player ? playerIcon(player.entry) : "")
}
mpris.connect("notify::players", sync)
sync()
export function MediaButton() {
return (
<button
className="media center"
visible={bind(hasPlayer)}
onClicked={() => {
if (player && player.can_play) player.play_pause()
}}
onScroll={(_, e) => {
if (!player) return
if (e.delta_y < 0) player.next()
else player.previous()
}}
>
<box spacing={6}>
<icon className="media-icon" icon={bind(icon)} />
<label
className="media-title"
label={bind(title)}
maxWidthChars={30}
ellipsize={Pango.EllipsizeMode.END}
/>
</box>
</button>
)
}

View File

@ -0,0 +1,238 @@
import GLib from "gi://GLib"
import { App, Astal, Gdk, Gtk } from "astal/gtk3"
import Notifd from "gi://AstalNotifd"
import { Variable, bind } from "astal"
import { MAX_TIMEOUT } from "../notif-config"
const notifd = Notifd.get_default()
export const dndIcon = Variable.derive([bind(notifd, "dontDisturb")], (dnd) =>
dnd
? "notifications-disabled-symbolic"
: "preferences-system-notifications-symbolic",
)
// every notification received this session is kept until it is explicitly
// cleared, so popups that time out remain in the dropdown's list. only
// notifications dismissed directly from their popup skip the list, and
// transient status toasts never enter it at all.
export const history = Variable<Notifd.Notification[]>([])
export const notifCount = bind(history).as((ns) => ns.length)
// apps whose notifications are transient status updates: they surface as
// toasts only and never enter the dropdown history or the bar count
const EPHEMERAL_APPS = new Set(["dictation"])
// irregular ids the generic capitalization rule can't fix up
const FRIENDLY_NAMES: Record<string, string> = {
"notify-send": "Notification",
}
// per-app icons for notifications that don't supply one
const APP_ICONS: Record<string, string> = {
dictation: "audio-input-microphone-symbolic",
}
// apps like discord report a bare binary id; make words of those while
// names with any uppercase ("Spotify") pass through untouched
function displayName(n: Notifd.Notification) {
const raw = n.app_name || "Notification"
const mapped = FRIENDLY_NAMES[raw.toLowerCase()]
if (mapped) return mapped
if (raw === raw.toLowerCase() && /[a-z]/.test(raw)) {
return raw
.split(/[-_ ]+/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")
}
return raw
}
export function DndToggle() {
return (
<button
className={bind(notifd, "dontDisturb").as((v) =>
v ? "simple-toggle active" : "simple-toggle",
)}
onClicked={() => (notifd.dontDisturb = !notifd.dontDisturb)}
>
<box spacing={8}>
<icon className="icon" icon={dndIcon()} />
<label label="Do Not Disturb" />
</box>
</button>
)
}
// popups are timed out entirely client-side: the daemon never resolves them
// on its own, so a popup under the pointer can be kept alive indefinitely
notifd.ignore_timeout = true
notifd.default_timeout = -1
// notifications restored from the daemon's cache on startup are stale
for (const n of notifd.notifications) {
n.dismiss()
}
notifd.connect("notified", (_, id: number) => {
const n = notifd.get_notification(id)
if (!n) return
if (EPHEMERAL_APPS.has(n.app_name.toLowerCase())) return
// re-notified ids update the existing entry instead of adding a duplicate
if (!history.get().some((m) => m.id === id)) {
history.set([...history.get(), n])
}
})
// ids of notifications whose popup is currently under the pointer
const hovered = new Set<number>()
// enforce our own cap for popups, honoring shorter app-requested timeouts.
// critical notifications are exempt and stay until dismissed, as are popups
// being hovered.
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => {
const now = GLib.DateTime.new_now_local().to_unix()
for (const n of [...notifd.notifications]) {
if (n.urgency === Notifd.Urgency.CRITICAL || hovered.has(n.id)) continue
const timeout =
n.expire_timeout > 0
? Math.min(n.expire_timeout, MAX_TIMEOUT)
: MAX_TIMEOUT
if (now - n.time >= timeout / 1000) n.dismiss()
}
return GLib.SOURCE_CONTINUE
})
// drop a notification from the list and withdraw its popup; used when a
// notification is dismissed directly instead of timing out
export function dismissNotification(n: Notifd.Notification) {
hovered.delete(n.id)
history.set(history.get().filter((m) => m.id !== n.id))
notifd.get_notification(n.id)?.dismiss()
}
export function clearNotifications() {
for (const n of [...notifd.notifications]) {
dismissNotification(n)
}
history.set([])
}
function formatTime(n: Notifd.Notification) {
return GLib.DateTime.new_from_unix_local(n.time).format("%H:%M")!
}
export function NotificationCard(n: Notifd.Notification, toast = false) {
const critical = n.urgency === Notifd.Urgency.CRITICAL
const appName = displayName(n)
const appIcon =
n.app_icon ||
APP_ICONS[appName.toLowerCase()] ||
"preferences-system-notifications-symbolic"
return (
<button
className={`notif${critical ? " critical" : ""}${toast ? " toast" : ""}`}
onClicked={() => dismissNotification(n)}
{...(toast
? {
onHover: () => hovered.add(n.id),
onHoverLost: () => hovered.delete(n.id),
}
: {})}
>
<box vertical>
<box className="notif-header">
<icon pixelSize={16} icon={appIcon} />
<label
className="notif-app-name"
label={appName}
halign={Gtk.Align.START}
hexpand
/>
<label className="notif-time" label={formatTime(n)} />
<button
className="notif-close"
onClicked={() => dismissNotification(n)}
>
<icon icon="window-close-symbolic" pixelSize={12} />
</button>
</box>
<box className="notif-content">
{n.image !== "" && (
<icon className="notif-image" pixelSize={36} icon={n.image} />
)}
<box vertical hexpand>
<label
className="notif-summary"
label={n.summary}
halign={Gtk.Align.START}
visible={n.summary !== ""}
/>
<label
className="notif-text"
label={n.body}
wrap
halign={Gtk.Align.START}
visible={n.body !== ""}
/>
</box>
</box>
{n.actions.filter((a) => a.label).length > 0 && (
<box className="notif-actions" homogeneous spacing={4}>
{n.actions
.filter((a) => a.label)
.map((a) => (
<button
className="notif-action"
onClicked={() => a.invoke()}
>
<label label={a.label} />
</button>
))}
</box>
)}
</box>
</button>
)
}
export default function Notifications(gdkmonitor: Gdk.Monitor) {
const { TOP, RIGHT } = Astal.WindowAnchor
return (
<window
className="popup-window"
namespace="ags-notifications"
application={App}
gdkmonitor={gdkmonitor}
layer={Astal.Layer.OVERLAY}
anchor={TOP | RIGHT}
exclusivity={Astal.Exclusivity.IGNORE}
keymode={Astal.Keymode.NONE}
marginTop={54}
marginRight={8}
visible={bind(notifd, "notifications").as((ns) => ns.length > 0)}
onNotifyVisible={(self) => {
if (!self.visible) hovered.clear()
}}
>
<box className="notifications" vertical spacing={8} widthRequest={360}>
{bind(notifd, "notifications").as((ns) =>
[...ns]
.sort((a, b) => b.time - a.time)
.map((n) => NotificationCard(n, true)),
)}
</box>
</window>
)
}

View File

@ -0,0 +1,190 @@
import Gtk from "gi://Gtk?version=3.0"
import GLib from "gi://GLib"
import AstalWp from "gi://AstalWp"
import { execAsync, Variable, bind } from "astal"
import Dropdown from "./Dropdown"
import { wifiIcon, WifiSection } from "./Wifi"
import { bluetoothIndicator, BluetoothSection } from "./Bluetooth"
import { BrightnessRow } from "./Brightness"
import { MediaSection } from "./Media"
import { batteryIcon, batteryPercent, BatterySection } from "./Battery"
import {
clearNotifications,
dndIcon,
DndToggle,
history,
notifCount,
NotificationCard,
} from "./Notifications"
const speaker = AstalWp.get_default().audio.default_speaker!
const mic = AstalWp.get_default().audio.default_microphone
const speakerIcon = Variable.derive(
[bind(speaker, "volume"), bind(speaker, "mute")],
(volume, mute) => {
if (mute || volume <= 0.01) return "audio-volume-muted-symbolic"
if (volume < 0.20) return "audio-volume-low-symbolic"
if (volume < 0.66) return "audio-volume-medium-symbolic"
return "audio-volume-high-symbolic"
},
)
const micIcon = mic
? Variable.derive([bind(mic, "volume"), bind(mic, "mute")], (volume, mute) =>
mute || volume <= 0.01
? "microphone-sensitivity-muted-symbolic"
: "microphone-sensitivity-high-symbolic",
)
: null
const user = GLib.get_user_name()
const actions: { icon: string; cmd?: string[] }[] = [
{ icon: "emblem-system-symbolic" },
{ icon: "system-lock-screen-symbolic", cmd: ["hyprlock"] },
{ icon: "weather-clear-night-symbolic", cmd: ["systemctl", "suspend"] },
{ icon: "system-reboot-symbolic", cmd: ["systemctl", "reboot"] },
{ icon: "system-log-out-symbolic", cmd: ["hyprctl", "dispatch", "exit"] },
{ icon: "system-shutdown-symbolic", cmd: ["systemctl", "poweroff"] },
]
function Header() {
return (
<box className="header" spacing={8}>
<box className="avatar">
<icon icon="nix-snowflake-white" pixelSize={16} />
</box>
<label className="username" label={user} valign={Gtk.Align.CENTER} />
<box hexpand />
{actions.map((action) => (
<button onClicked={() => action.cmd && execAsync(action.cmd)}>
<icon icon={action.icon} />
</button>
))}
</box>
)
}
export default function QuickSettings(gdkmonitor: Gdk.Monitor) {
const open = Variable(false)
const wifiSection = WifiSection({ open })
const btSection = BluetoothSection({ open })
const toggles = [wifiSection?.toggle, btSection?.toggle].filter(
(t) => t !== undefined,
)
const button = (
<button
className="quicksettings end"
onClicked={() => open.set(!open.get())}
onScroll={(_, e) => {
const volume = Math.min(
1,
Math.max(0, speaker.volume - e.delta_y * 0.02),
)
speaker.volume = volume
}}
>
<box spacing={6}>
<box className="notif-indicator" spacing={3}>
<icon icon={dndIcon()} />
<label
className="notif-count"
label={notifCount.as((n) => `${n}`)}
visible={notifCount.as((n) => n > 0)}
/>
</box>
{wifiIcon && <icon icon={wifiIcon} />}
{bluetoothIndicator}
<icon icon={speakerIcon()} />
{micIcon && <icon icon={micIcon()} />}
{batteryIcon && batteryPercent && (
<box spacing={3}>
<icon icon={batteryIcon} />
<label className="battery-level" label={batteryPercent} />
</box>
)}
</box>
</button>
)
const popup = (
<Dropdown
gdkmonitor={gdkmonitor}
target={button}
open={open}
width={280}
maxHeight={gdkmonitor.get_geometry().height - 200}
>
<Header />
<BatterySection />
<box className="sliders-box" vertical spacing={2}>
<box className="volume" spacing={6}>
<button onClicked={() => (speaker.mute = !speaker.mute)}>
<icon icon={speakerIcon()} />
</button>
<slider
hexpand
className={bind(speaker, "mute").as((m) => (m ? "muted" : ""))}
value={bind(speaker, "volume")}
onDragged={(self) => (speaker.volume = self.value)}
/>
</box>
{mic && micIcon && (
<box className="microphone" spacing={6}>
<button onClicked={() => (mic.mute = !mic.mute)}>
<icon icon={micIcon()} />
</button>
<slider
hexpand
className={bind(mic, "mute").as((m) => (m ? "muted" : ""))}
value={bind(mic, "volume")}
onDragged={(self) => (mic.volume = self.value)}
/>
</box>
)}
<BrightnessRow />
</box>
<DndToggle />
{toggles.length > 0 && (
<box homogeneous spacing={6}>
{toggles}
</box>
)}
{wifiSection?.menu}
{btSection?.menu}
<MediaSection />
<box className="notif-section" vertical spacing={6}>
<box className="notif-header-row" spacing={6}>
<box className="notif-header" spacing={6}>
<icon icon="preferences-system-notifications-symbolic" />
<label label="Notifications" />
</box>
<box hexpand />
<button
className="notif-clear"
onClicked={clearNotifications}
>
<label label="Clear" />
</button>
</box>
<box className="notifications" vertical>
<label
className="notif-empty"
label="no notifications"
visible={notifCount.as((n) => n === 0)}
/>
{bind(history).as((ns) =>
[...ns]
.sort((a, b) => b.time - a.time)
.map((n) => NotificationCard(n)),
)}
</box>
</box>
</Dropdown>
)
return { button, popup }
}

View File

@ -0,0 +1,114 @@
import GLib from "gi://GLib"
import Pango from "gi://Pango"
import Gtk from "gi://Gtk?version=3.0"
import { bind, type Binding, type Variable } from "astal"
// rotating arrow button: icon turns 90deg in small steps while the section
// opens, ported from AlfheimOS; a single self-clearing chained timer drives
// the animation so cancellation only ever touches provably live sources
export function Arrow({
open,
onToggle,
}: {
open: Variable<boolean>
onToggle: () => void
}) {
let deg = 0
let timer: number | undefined
const icon = <icon icon="pan-end-symbolic" />
const animate = (target: number) => {
if (timer !== undefined) {
GLib.source_remove(timer)
timer = undefined
}
const tick = () => {
deg += Math.sign(target - deg) * 10
icon.css = `-gtk-icon-transform: rotate(${deg}deg);`
if (deg !== target) {
timer = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 15, tick)
} else {
timer = undefined
}
return GLib.SOURCE_REMOVE
}
if (deg !== target) tick()
}
open.subscribe((v) => animate(v ? 90 : 0))
return (
<button className="arrow" onClicked={onToggle}>
{icon}
</button>
)
}
type ArrowToggleButtonProps = {
open: Variable<boolean>
icon: string | Binding<string>
label: string | Binding<string>
active: Binding<boolean>
onMainClick: () => void
onArrowClick: () => void
}
// icon + label pill with a detached arrow: the pill toggles the feature,
// the arrow rotates open its device menu
export function ArrowToggleButton({
open,
icon,
label,
active,
onMainClick,
onArrowClick,
}: ArrowToggleButtonProps) {
return (
<box
className={active.as((v) => (v ? "toggle-button active" : "toggle-button"))}
hexpand
>
<button className="toggle-pill" hexpand onClicked={onMainClick}>
<box hexpand>
<icon className="icon" icon={icon} />
<label
className="label"
label={label}
maxWidthChars={10}
ellipsize={Pango.EllipsizeMode.END}
/>
</box>
</button>
<Arrow open={open} onToggle={onArrowClick} />
</box>
)
}
type MenuProps = {
open: Variable<boolean>
icon: string | Binding<string>
title: string
children: Array<JSX.Element>
}
// revealer with a titled card header, shown while the section is open
export function Menu({ open, icon, title, children }: MenuProps) {
return (
<revealer
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={180}
revealChild={bind(open)}
>
<box className="menu" vertical>
<box className="title-box">
<icon className="icon" icon={icon} />
<label className="title" label={title} ellipsize={Pango.EllipsizeMode.END} />
</box>
<box className="menu-separator" />
<box className="content" vertical>
{children}
</box>
</box>
</revealer>
)
}

View File

@ -0,0 +1,160 @@
import Gtk from "gi://Gtk?version=3.0"
import AstalNetwork from "gi://AstalNetwork"
import { type Binding, type Variable, Variable, bind } from "astal"
import { ArrowToggleButton, Menu } from "./ToggleButton"
const CONNECTING = [40, 50, 60, 70, 80, 90]
const wifi = AstalNetwork.get_default().wifi
function strongest(aps: AstalNetwork.AccessPoint[]) {
const bySsid = new Map<string, AstalNetwork.AccessPoint>()
for (const ap of aps) {
const ssid = ap.ssid ?? ""
const best = bySsid.get(ssid)
if (!best || ap.strength > best.strength) bySsid.set(ssid, ap)
}
return [...bySsid.values()].sort((a, b) => b.strength - a.strength)
}
export const wifiIcon: Binding<string> | null = wifi
? bind(wifi, "icon-name")
: null
function WifiRow(
ap: AstalNetwork.AccessPoint,
activeSsid: Binding<string>,
connect: (ap: AstalNetwork.AccessPoint) => void,
) {
return (
<button className="wifi-row" onClicked={() => connect(ap)}>
<box>
<icon icon={bind(ap, "icon-name")} />
<label
className="wifi-row-ssid"
label={ap.ssid ?? "(hidden network)"}
halign={Gtk.Align.START}
hexpand
/>
<icon
visible={ap.requires_password}
icon="network-wireless-encrypted-symbolic"
/>
<icon
visible={activeSsid.as((ssid) => ssid === ap.ssid)}
icon="object-select-symbolic"
/>
</box>
</button>
)
}
export function WifiSection({ open }: { open: Variable<boolean> }) {
if (!wifi) return null
const expanded = Variable(false)
const pendingAP = Variable<AstalNetwork.AccessPoint | null>(null)
const failed = Variable("")
const status = Variable.derive(
[bind(wifi, "scanning"), bind(wifi, "state"), bind(wifi, "ssid"), failed],
(scanning, state, ssid, fail) => {
if (scanning) return "scanning…"
if (CONNECTING.includes(state)) return `connecting to ${ssid ?? "network"}…`
return fail
},
)
const activeSsid = bind(wifi, "active-access-point").as((ap) => ap?.ssid ?? "")
function connect(ap: AstalNetwork.AccessPoint) {
if (ap.requires_password) {
failed.set("")
pendingAP.set(ap)
} else {
ap.activate()
.then(() => failed.set(""))
.catch(() => failed.set(`failed to connect to ${ap.ssid ?? "network"}`))
}
}
const passwordEntry = (
<entry
className="wifi-password"
hexpand
visible={pendingAP().as((ap) => ap !== null)}
placeholderText={pendingAP().as(
(ap) => (ap ? `password for ${ap.ssid ?? "network"}` : ""),
)}
onActivate={(self) => {
const ap = pendingAP.get()
if (!ap || !self.text) return
ap.activate(self.text)
.then(() => {
pendingAP.set(null)
self.text = ""
})
.catch(() =>
failed.set(`failed to connect to ${ap.ssid ?? "network"}`)
)
}}
/>
)
open.subscribe((v) => {
if (v) {
wifi.scan()
} else {
expanded.set(false)
pendingAP.set(null)
failed.set("")
passwordEntry.text = ""
}
})
const toggle = (
<ArrowToggleButton
open={expanded}
icon={bind(wifi, "icon-name")}
label={bind(wifi, "ssid").as((ssid) => ssid || "Not Connected")}
active={bind(wifi, "enabled")}
onMainClick={() => {
wifi.enabled = !wifi.enabled
if (wifi.enabled) wifi.scan()
}}
onArrowClick={() => expanded.set(!expanded.get())}
/>
)
const menu = (
<Menu open={expanded} icon={bind(wifi, "icon-name")} title="Wi-Fi">
<label
className="wifi-status"
label={status()}
visible={status().as((text) => text !== "")}
/>
{passwordEntry}
<label
className="wifi-empty"
label="wi-fi is off"
visible={bind(wifi, "enabled").as((enabled) => !enabled)}
/>
<box vertical visible={bind(wifi, "enabled")}>
{bind(wifi, "access-points").as((aps) =>
strongest(aps).map((ap) =>
WifiRow(ap, activeSsid, connect)
),
)}
</box>
<box className="menu-separator" />
<button className="wifi-rescan" onClicked={() => wifi.scan()}>
<box spacing={6}>
<icon icon="view-refresh-symbolic" />
<label label="Rescan" />
</box>
</button>
</Menu>
)
return { toggle, menu }
}

View File

@ -0,0 +1,20 @@
import Hyprland from "gi://AstalHyprland"
import { bind } from "astal"
const hypr = Hyprland.get_default()
const focused = bind(hypr, "focused-workspace").as((ws) => ws?.id ?? -1)
export default function Workspaces() {
return (
<box className="workspaces">
{[1, 2, 3, 4, 5].map((i) => (
<button
className={focused.as((f) => (f === i ? "ws active" : "ws"))}
onClicked={() => hypr.dispatch("workspace", String(i))}
>
<label label={String(i)} />
</button>
))}
</box>
)
}

View File

@ -0,0 +1,96 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.modules.ags;
hyprlandCfg = config.modules.hyprland;
colors = config.lib.stylix.colors.withHashtag;
base16Names = [
"base00"
"base01"
"base02"
"base03"
"base04"
"base05"
"base06"
"base07"
"base08"
"base09"
"base0A"
"base0B"
"base0C"
"base0D"
"base0E"
"base0F"
];
palette = pkgs.writeText "palette.scss" (
concatStringsSep "\n" (
map (name: "$" + name + ": " + colors.${name} + ";") base16Names
++ [
''$font-family: "${config.stylix.fonts.sansSerif.name}";''
"$font-size: ${toString config.stylix.fonts.sizes.desktop}px;"
]
)
);
notifConfig = pkgs.writeText "notif-config.ts" ''
export const MAX_TIMEOUT = ${toString cfg.timeout};
'';
astalDeps =
with pkgs.astal;
[
astal3
io
hyprland
network
battery
wireplumber
notifd
bluetooth
mpris
]
++ [ pkgs.networkmanager ];
ags-bar = pkgs.ags.bundle {
pname = "ags-bar";
version = "0.1.0";
src = ./app;
entry = "app.ts";
dependencies = astalDeps;
postUnpack = ''
cp ${palette} "$sourceRoot/palette.scss"
cp ${notifConfig} "$sourceRoot/notif-config.ts"
'';
};
in
{
config = mkIf cfg.enable {
home.packages = [
ags-bar
pkgs.ags
pkgs.dart-sass
pkgs.nixos-icons
];
# clock face with hands at 12 and 3; adwaita has no such icon.
# fill-only paths: gtk symbolic recoloring turns stroke shapes into solids
xdg.dataFile."icons/hicolor/scalable/actions/clock-hands-symbolic.svg".text = ''
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="currentColor" fill-rule="evenodd" d="M8 1.5A6.5 6.5 0 1 0 8 14.5A6.5 6.5 0 1 0 8 1.5ZM8 3A5 5 0 1 0 8 13A5 5 0 1 0 8 3Z"/>
<rect x="7.4" y="3.8" width="1.2" height="4.8" rx="0.6" fill="currentColor"/>
<rect x="7.4" y="7.4" width="3.4" height="1.2" rx="0.6" fill="currentColor"/>
</svg>
'';
wayland.windowManager.hyprland = mkIf hyprlandCfg.enable {
settings.exec-once = [ "ags-bar" ];
};
};
}

View File

@ -3,6 +3,7 @@
}:
{
imports = [
./ags
./battlenet.nix
./direnv.nix
./discord.nix

View File

@ -45,7 +45,9 @@ let
wav="$dir/clip.wav"
notify() {
notify-send -t 2500 "Dictation" "$1" 2>/dev/null || true
# -a sets the app name shown by the notification daemon; the bar
# treats "Dictation" as transient (toast only, never listed)
notify-send -a "Dictation" -t 2500 "$1" 2>/dev/null || true
}
case "''${1:-}" in

View File

@ -5,5 +5,6 @@
imports = [
./binds.nix
./hyprland.nix
./hyprlock.nix
];
}

View File

@ -0,0 +1,36 @@
{
config,
lib,
...
}:
with lib;
let
cfg = config.modules.hyprland;
colors = config.lib.stylix.colors;
in
{
config = mkIf cfg.enable {
programs.hyprlock = {
enable = true;
settings = {
general = {
hide_cursor = true;
ignore_empty_input = true;
};
label = [
{
monitor = "";
text = "$TIME12";
position = "0, 100";
halign = "center";
valign = "center";
font_size = 48;
font_family = "${config.stylix.fonts.sansSerif.name}";
color = "rgb(${colors.base05})";
}
];
};
};
};
}

View File

@ -13,5 +13,7 @@ in
enable = true;
withUWSM = true;
};
security.pam.services.hyprlock = { };
};
}

View File

@ -3,6 +3,6 @@
}:
{
services = {
upower.enable = true;
};
}

19
modules/options/ags.nix Normal file
View File

@ -0,0 +1,19 @@
{
lib,
...
}:
with lib;
{
options.modules.ags = {
enable = mkEnableOption "the AGS status bar";
timeout = mkOption {
type = types.ints.positive;
default = 10000;
description = ''
Maximum duration notification popups stay on screen, in milliseconds.
Critical notifications are exempt and stay until dismissed.
'';
};
};
}

View File

@ -3,6 +3,7 @@
}:
{
imports = [
./ags.nix
./battlenet.nix
./discord.nix
./dictation.nix

View File

@ -10,6 +10,7 @@
",preferred,auto,auto"
];
};
modules.ags.enable = true;
modules.plasma.enable = false;
modules.display.manager = "gdm";