From 1fe518ff2bcdefc594341166733c920644eff982 Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Fri, 3 Jul 2026 16:14:36 -0400 Subject: [PATCH] Add AGS notification, quick settings, and sysmonitor widgets --- ags/app.ts | 24 ++- ags/lib/utils.ts | 17 ++ ags/style.css | 382 +++++++++++++++++++++++++++++++++++ ags/widget/Media.tsx | 2 +- ags/widget/Notifications.tsx | 142 +++++++++++++ ags/widget/QuickSettings.tsx | 334 ++++++++++++++++++++++++++++++ ags/widget/SysMonitor.tsx | 137 +++++++++++++ ags/widget/WallPicker.tsx | 106 ++++++++++ 8 files changed, 1141 insertions(+), 3 deletions(-) create mode 100644 ags/lib/utils.ts create mode 100644 ags/widget/Notifications.tsx create mode 100644 ags/widget/QuickSettings.tsx create mode 100644 ags/widget/SysMonitor.tsx create mode 100644 ags/widget/WallPicker.tsx diff --git a/ags/app.ts b/ags/app.ts index 9f2a7cd..279ac07 100644 --- a/ags/app.ts +++ b/ags/app.ts @@ -1,10 +1,30 @@ import app from "ags/gtk3/app" import style from "./style.css" import Media from "./widget/Media" +import NotificationCenter from "./widget/Notifications" +import QuickSettings from "./widget/QuickSettings" +import SysMonitor from "./widget/SysMonitor" +import WallPicker from "./widget/WallPicker" + +const SHOW_DESKTOP_MEDIA = false + +function start(name: string, build: () => unknown) { + try { + build() + } catch (error) { + console.error(`Failed to start widget "${name}":`, error) + } +} app.start({ css: style, main() { - app.get_monitors().map(Media) + if (SHOW_DESKTOP_MEDIA) { + app.get_monitors().map(Media) + } + start("notification-center", NotificationCenter) + start("quick-settings", QuickSettings) + start("sys-monitor", SysMonitor) + start("wall-picker", WallPicker) }, -}) \ No newline at end of file +}) diff --git a/ags/lib/utils.ts b/ags/lib/utils.ts new file mode 100644 index 0000000..692ca3d --- /dev/null +++ b/ags/lib/utils.ts @@ -0,0 +1,17 @@ +import app from "ags/gtk3/app" +import { execAsync } from "ags/process" + +export function sh(command: string) { + return execAsync(["bash", "-c", command]).catch((error) => { + console.error(error) + return "" + }) +} + +export function shell(command: string): string[] { + return ["bash", "-c", command] +} + +export function toggleWindow(name: string) { + app.toggle_window(name) +} diff --git a/ags/style.css b/ags/style.css index 0c27619..6544158 100644 --- a/ags/style.css +++ b/ags/style.css @@ -79,3 +79,385 @@ border: none; box-shadow: none; } + +.NotificationCenter, +.QuickSettings, +.SysMonitor, +.Calendar, +.WallPicker { + background-color: transparent; +} + +.panel { + background-color: alpha(@background, 0.92); + border-radius: 12px; + border: 2px solid alpha(@color4, 0.5); + padding: 14px; + font-family: "JetBrainsMono Nerd Font", sans-serif; + color: @foreground; +} + +.panel-title { + color: @foreground; + font-size: 15px; + font-weight: bold; +} + +.panel-header { + margin-bottom: 2px; +} + +.panel-icon-btn { + color: @color5; + font-size: 13px; + padding: 3px 6px; + border-radius: 8px; + transition: all 0.2s; +} + +.panel-icon-btn:hover { + background-color: alpha(@color4, 0.25); + color: @color4; +} + +.panel-icon-btn.active { + background-color: @accent; + color: @background; +} + +.notification-center { + min-width: 360px; + min-height: 420px; +} + +.notification-scroll { + min-height: 360px; +} + +.notification-item { + background-color: alpha(@color0, 0.6); + border-radius: 10px; + border-left: 3px solid @accent; + padding: 10px 12px; +} + +.notification-item-app { + color: @accent; + font-size: 12px; + font-weight: bold; +} + +.notification-item-close { + color: @color5; + font-size: 12px; + padding: 0 4px; + transition: all 0.2s; +} + +.notification-item-close:hover { + color: @color1; +} + +.notification-item-summary { + color: @foreground; + font-size: 13px; + font-weight: bold; + margin-top: 2px; +} + +.notification-item-body { + color: @foreground; + font-size: 12px; + opacity: 0.8; + margin-top: 2px; +} + +.notification-empty { + opacity: 0.6; +} + +.notification-empty-icon { + font-size: 42px; + color: @color4; + margin-bottom: 8px; +} + +.notification-empty-text { + font-size: 13px; + color: @foreground; +} + +.quick-settings { + min-width: 320px; +} + +.qs-tile { + background-color: alpha(@color0, 0.6); + border-radius: 10px; + padding: 8px 4px; + transition: all 0.2s; +} + +.qs-tile:hover { + background-color: alpha(@color4, 0.25); +} + +.qs-tile.active { + background-color: @accent; + color: @background; +} + +.qs-tile-icon { + font-size: 15px; +} + +.qs-tile-label { + font-size: 10px; +} + +.qs-slider-row { + padding: 2px 4px; +} + +.qs-slider-icon { + color: @color5; + font-size: 14px; + min-width: 20px; +} + +.qs-slider trough { + min-height: 8px; + border-radius: 8px; + background-color: alpha(@foreground, 0.2); +} + +.qs-slider highlight { + border-radius: 8px; + background-color: @accent; +} + +.qs-slider slider { + background-color: @foreground; + border-radius: 50%; + min-width: 14px; + min-height: 14px; + margin: -4px; +} + +.sys-monitor { + min-width: 320px; +} + +.sys-metric-icon { + font-size: 16px; + color: @color5; + min-width: 22px; +} + +.sys-metric-name { + font-size: 13px; + font-weight: bold; +} + +.sys-metric-detail { + font-size: 12px; + opacity: 0.8; +} + +.sys-metric-bar { + min-height: 8px; +} + +.sys-metric-bar trough { + min-height: 8px; + border-radius: 8px; + background-color: alpha(@foreground, 0.2); +} + +.sys-metric-bar block.filled { + border-radius: 8px; + background-color: @accent; +} + +.calendar { + min-width: 280px; +} + +.calendar-time { + font-size: 40px; + font-weight: bold; + color: @foreground; +} + +.calendar-date { + font-size: 14px; + color: @accent; +} + +.calendar-month { + font-size: 14px; + font-weight: bold; + color: @accent; +} + +.calendar-days { + font-family: "JetBrainsMono Nerd Font", monospace; + font-size: 14px; + color: @foreground; +} + +.wall-picker { + min-width: 680px; + min-height: 460px; +} + +.wall-scroll { + min-height: 400px; +} + +.wall-thumb { + padding: 0; + border-radius: 10px; + border: 2px solid transparent; + transition: all 0.2s; +} + +.wall-thumb:hover { + border: 2px solid @accent; +} + +.wall-thumb-image { + min-width: 200px; + min-height: 112px; + border-radius: 8px; + background-size: cover; + background-position: center; + background-repeat: no-repeat; +} + +.wall-empty { + opacity: 0.6; +} + +.wall-empty-icon { + font-size: 42px; + color: @color4; + margin-bottom: 8px; +} + +.wall-empty-text { + font-size: 13px; +} + +.control-center { + min-width: 360px; +} + +.cc-header { + margin-bottom: 2px; +} + +.cc-uptime { + font-size: 13px; + color: @foreground; +} + +.cc-uptime-icon { + color: @accent; +} + +.cc-clock { + font-size: 22px; + font-weight: bold; + color: @foreground; +} + +.cc-date { + font-size: 12px; + color: @accent; +} + +.cc-media { + background-color: alpha(@color0, 0.6); + border-radius: 10px; + padding: 10px; +} + +.cc-media-cover { + min-width: 84px; + min-height: 84px; + border-radius: 8px; + background-size: cover; + background-position: center; + background-repeat: no-repeat; +} + +.cc-media-info { + margin-left: 10px; +} + +.cc-media-title { + font-size: 13px; + font-weight: bold; + color: @foreground; +} + +.cc-media-artist { + font-size: 12px; + opacity: 0.8; +} + +.cc-media-seek { + margin: 6px 0; +} + +.cc-media-seek trough { + min-height: 4px; + border-radius: 4px; + background-color: alpha(@foreground, 0.2); +} + +.cc-media-seek highlight { + border-radius: 4px; + background-color: @accent; +} + +.cc-media-seek slider { + background-color: transparent; + min-width: 0; + min-height: 0; + border: none; + box-shadow: none; +} + +.cc-media-controls button { + color: @color5; + font-size: 14px; + padding: 0 4px; + transition: all 0.2s; +} + +.cc-media-controls button:hover { + color: @accent; +} + +.cc-calendar { + margin-top: 2px; +} + +.cc-calendar-month { + font-size: 13px; + font-weight: bold; + color: @accent; +} + +.cc-calendar-time { + font-size: 13px; + color: @foreground; +} + +.cc-calendar-grid { + font-family: "JetBrainsMono Nerd Font", monospace; + font-size: 13px; + color: @foreground; +} diff --git a/ags/widget/Media.tsx b/ags/widget/Media.tsx index fe8c421..b50041c 100644 --- a/ags/widget/Media.tsx +++ b/ags/widget/Media.tsx @@ -15,7 +15,7 @@ export default function Media(gdkmonitor: Gdk.Monitor) { status: "Stopped", length: 0, position: 0 - }, 1000, pollCmd, (stdout) => { + }, 1000, ["bash", "-c", pollCmd], (stdout) => { const parts = stdout.split("|||"); const title = parts[0]?.trim() || "No Media"; const artist = parts[1]?.trim() || ""; diff --git a/ags/widget/Notifications.tsx b/ags/widget/Notifications.tsx new file mode 100644 index 0000000..d31fc8f --- /dev/null +++ b/ags/widget/Notifications.tsx @@ -0,0 +1,142 @@ +import app from "ags/gtk3/app" +import { Astal, Gtk } from "ags/gtk3" +import { createPoll } from "ags/time" +import { For } from "ags" +import { sh, shell } from "../lib/utils" + +export const NOTIFICATION_WINDOW = "notification-center" + +type Notification = { + id: number + appName: string + summary: string + body: string +} + +function parseNotifications(stdout: string): Notification[] { + try { + const parsed = JSON.parse(stdout) + const group = parsed?.data?.[0] ?? [] + return group.map((item: any) => ({ + id: item.id?.value ?? 0, + appName: item["app-name"]?.value ?? "", + summary: item.summary?.value ?? "", + body: item.body?.value ?? "", + })) + } catch { + return [] + } +} + +const notifications = createPoll( + [], + 1000, + shell("makoctl list -j 2>/dev/null || echo '{}'"), + (stdout) => parseNotifications(stdout), +) + +const doNotDisturb = createPoll( + false, + 1000, + shell("makoctl mode 2>/dev/null"), + (stdout) => stdout.split("\n").includes("do-not-disturb"), +) + +const dismiss = (id: number) => sh(`makoctl dismiss -n ${id}`) +const clearAll = () => sh("makoctl dismiss -a") +const toggleDoNotDisturb = () => sh("makoctl mode -t do-not-disturb") + +function NotificationItem({ item }: { item: Notification }) { + return ( + + + + + ) +} + +export default function NotificationCenter() { + const { TOP, RIGHT } = Astal.WindowAnchor + const hasNotifications = notifications.as((list) => list.length > 0) + const isEmpty = notifications.as((list) => list.length === 0) + + return ( + + + + + + + + item.id}> + {(item: Notification) => } + + + + + + + + + ) +} diff --git a/ags/widget/QuickSettings.tsx b/ags/widget/QuickSettings.tsx new file mode 100644 index 0000000..f537853 --- /dev/null +++ b/ags/widget/QuickSettings.tsx @@ -0,0 +1,334 @@ +import app from "ags/gtk3/app" +import { Astal, Gtk } from "ags/gtk3" +import { createState } from "ags" +import { createPoll, interval } from "ags/time" +import { sh, shell } from "../lib/utils" + +export const QUICKSETTINGS_WINDOW = "quick-settings" + +const closePanel = () => app.toggle_window(QUICKSETTINGS_WINDOW) + +function pollBool(command: string, intervalMs: number) { + const [value, setValue] = createState(false) + const refresh = () => sh(command).then((out) => setValue(out.trim() === "on")) + refresh() + interval(intervalMs, refresh) + return [value, setValue] as const +} + +const volume = createPoll( + 0, + 1000, + shell("pamixer --get-volume 2>/dev/null || echo 0"), + (stdout) => Number(stdout.trim()) || 0, +) + +const [muted, setMuted] = pollBool( + "pamixer --get-mute 2>/dev/null | grep -qx true && echo on || echo off", + 1000, +) + +const brightness = createPoll( + 0, + 2000, + shell("brightnessctl -m 2>/dev/null | cut -d, -f4 | tr -d '%' || echo 0"), + (stdout) => Number(stdout.trim()) || 0, +) + +const [bluetoothOn] = pollBool( + "bluetoothctl show 2>/dev/null | grep -q 'Powered: yes' && echo on || echo off", + 2000, +) + +const [doNotDisturb, setDoNotDisturb] = pollBool( + "makoctl mode 2>/dev/null | grep -qx do-not-disturb && echo on || echo off", + 2000, +) + +const [nightLight, setNightLight] = pollBool( + "pgrep -x hyprsunset >/dev/null && echo on || echo off", + 2000, +) + +const clock = createPoll("", 1000, shell("date '+%H:%M'"), (stdout) => stdout.trim()) +const today = createPoll("", 10000, shell("date '+%A, %B %-d'"), (stdout) => stdout.trim()) + +type Media = { + title: string + artist: string + artUrl: string + status: string + length: number + position: number +} + +const mediaCommand = + "playerctl metadata -f '{{title}}|||{{artist}}|||{{mpris:artUrl}}|||{{status}}|||{{mpris:length}}|||{{position}}' " + + "2>/dev/null || echo 'No Media||||||Stopped|||0|||0'" + +const media = createPoll( + { title: "No Media", artist: "", artUrl: "", status: "Stopped", length: 0, position: 0 }, + 1000, + shell(mediaCommand), + (stdout) => { + const parts = stdout.split("|||") + return { + title: parts[0]?.trim() || "No Media", + artist: parts[1]?.trim() || "", + artUrl: (parts[2]?.trim() || "").replace(/^file:\/\//, ""), + status: parts[3]?.trim() || "Stopped", + length: Number(parts[4]) || 0, + position: Number(parts[5]) || 0, + } + }, +) + +function Header() { + return ( + + + + + + + + ) +} + +function Tile({ + icon, + label, + active, + onClicked, +}: { + icon: string + label: string + active?: any + onClicked: () => void +}) { + return ( + + ) +} + +function Toggles() { + return ( + + + { closePanel(); sh("omarchy-launch-wifi") }} /> + { + closePanel() + sh("omarchy-launch-bluetooth") + }} + /> + { + setDoNotDisturb(!doNotDisturb.get()) + sh("makoctl mode -t do-not-disturb") + }} + /> + + + { + setNightLight(!nightLight.get()) + sh("omarchy-toggle-nightlight") + }} + /> + { closePanel(); sh("omarchy-capture-screenrecording") }} /> + { closePanel(); sh("hyprpicker -a") }} /> + + + ) +} + +function VolumeSlider() { + return ( + + + { + self.connect("value-changed", () => { + if (self.dragging) { + sh(`pamixer --set-volume ${Math.round(self.value)}`) + } + }) + }} + /> + + ) +} + +function BrightnessSlider() { + return ( + + + ) +} + +function MediaPlayer() { + return ( + m.title !== "No Media")}> + + m.artUrl + ? `background-image: url('${m.artUrl}');` + : "background-color: alpha(@color0, 0.5);", + )} + /> + + + + ) +} + +const monthName = createPoll("", 60000, shell("date '+%B %Y'"), (stdout) => stdout.trim()) +const monthGrid = createPoll( + "", + 60000, + shell("cal | sed '1d'"), + (stdout) => stdout.replace(/\s+$/, ""), +) + +function CalendarSection() { + return ( + + + + + ) +} + +export default function QuickSettings() { + const { TOP } = Astal.WindowAnchor + + return ( + + +
+ + + + + + + + ) +} diff --git a/ags/widget/SysMonitor.tsx b/ags/widget/SysMonitor.tsx new file mode 100644 index 0000000..5399773 --- /dev/null +++ b/ags/widget/SysMonitor.tsx @@ -0,0 +1,137 @@ +import app from "ags/gtk3/app" +import { Astal, Gtk } from "ags/gtk3" +import { createPoll } from "ags/time" +import { shell } from "../lib/utils" + +export const SYSMONITOR_WINDOW = "sys-monitor" + +let previousIdle = 0 +let previousTotal = 0 + +const cpuUsage = createPoll( + 0, + 2000, + shell("grep '^cpu ' /proc/stat"), + (stdout) => { + const values = stdout.trim().split(/\s+/).slice(1).map(Number) + const idle = (values[3] || 0) + (values[4] || 0) + const total = values.reduce((sum, value) => sum + value, 0) + const idleDelta = idle - previousIdle + const totalDelta = total - previousTotal + previousIdle = idle + previousTotal = total + if (totalDelta <= 0) return 0 + return Math.round((1 - idleDelta / totalDelta) * 100) + }, +) + +type Usage = { percent: number; used: string; total: string } + +const memory = createPoll( + { percent: 0, used: "0", total: "0" }, + 2000, + shell("free -m | awk '/^Mem:/ {print $3\" \"$2}'"), + (stdout) => { + const [used, total] = stdout.trim().split(/\s+/).map(Number) + if (!total) return { percent: 0, used: "0", total: "0" } + return { + percent: Math.round((used / total) * 100), + used: `${(used / 1024).toFixed(1)}G`, + total: `${(total / 1024).toFixed(1)}G`, + } + }, +) + +const disk = createPoll( + { percent: 0, used: "0", total: "0" }, + 30000, + shell("df -h --output=pcent,used,size / | tail -1"), + (stdout) => { + const [percent, used, total] = stdout.trim().split(/\s+/) + return { + percent: Number(percent.replace("%", "")) || 0, + used: used || "0", + total: total || "0", + } + }, +) + +const temperature = createPoll( + 0, + 2000, + shell("cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null || echo 0"), + (stdout) => Math.round(Number(stdout.trim()) / 1000) || 0, +) + +function Metric({ + icon, + name, + percent, + detail, +}: { + icon: string + name: string + percent: any + detail: any +}) { + return ( + + + + value / 100)} + /> + + ) +} + +export default function SysMonitor() { + const { TOP, RIGHT } = Astal.WindowAnchor + + return ( + + + + + ) +} diff --git a/ags/widget/WallPicker.tsx b/ags/widget/WallPicker.tsx new file mode 100644 index 0000000..7f85e06 --- /dev/null +++ b/ags/widget/WallPicker.tsx @@ -0,0 +1,106 @@ +import app from "ags/gtk3/app" +import { Astal, Gtk } from "ags/gtk3" +import { createPoll } from "ags/time" +import { For } from "ags" +import { sh, shell } from "../lib/utils" + +export const WALLPICKER_WINDOW = "wall-picker" + +const COLUMNS = 3 + +type Wallpaper = { name: string; path: string } + +const findCommand = + "find -L \"$HOME/wallpapers\" -maxdepth 1 -type f " + + "\\( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.webp' -o -iname '*.gif' \\) " + + "2>/dev/null | sort" + +const wallpapers = createPoll( + [], + 5000, + shell(findCommand), + (stdout) => + stdout + .split("\n") + .filter((line) => line.length > 0) + .map((path) => ({ path, name: path.split("/").pop() ?? path })), +) + +const rows = wallpapers.as((list) => { + const chunks: Wallpaper[][] = [] + for (let index = 0; index < list.length; index += COLUMNS) { + chunks.push(list.slice(index, index + COLUMNS)) + } + return chunks +}) + +const setWallpaper = (path: string) => { + app.toggle_window(WALLPICKER_WINDOW) + sh(`"$HOME/scripts/blob_wallpaper.sh" "${path}"`) +} + +function Thumbnail({ wall }: { wall: Wallpaper }) { + return ( + + ) +} + +export default function WallPicker() { + const hasWalls = wallpapers.as((list) => list.length > 0) + const isEmpty = wallpapers.as((list) => list.length === 0) + + return ( + + + + + + + + row.map((wall) => wall.name).join("|")}> + {(row: Wallpaper[]) => ( + + {row.map((wall) => ( + + ))} + + )} + + + + + + + + + ) +}