Add AGS notification, quick settings, and sysmonitor widgets

This commit is contained in:
2026-07-03 16:14:36 -04:00
parent 91d8d5f84b
commit 1fe518ff2b
8 changed files with 1141 additions and 3 deletions
+20
View File
@@ -1,10 +1,30 @@
import app from "ags/gtk3/app" import app from "ags/gtk3/app"
import style from "./style.css" import style from "./style.css"
import Media from "./widget/Media" 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({ app.start({
css: style, css: style,
main() { main() {
if (SHOW_DESKTOP_MEDIA) {
app.get_monitors().map(Media) app.get_monitors().map(Media)
}
start("notification-center", NotificationCenter)
start("quick-settings", QuickSettings)
start("sys-monitor", SysMonitor)
start("wall-picker", WallPicker)
}, },
}) })
+17
View File
@@ -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)
}
+382
View File
@@ -79,3 +79,385 @@
border: none; border: none;
box-shadow: 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;
}
+1 -1
View File
@@ -15,7 +15,7 @@ export default function Media(gdkmonitor: Gdk.Monitor) {
status: "Stopped", status: "Stopped",
length: 0, length: 0,
position: 0 position: 0
}, 1000, pollCmd, (stdout) => { }, 1000, ["bash", "-c", pollCmd], (stdout) => {
const parts = stdout.split("|||"); const parts = stdout.split("|||");
const title = parts[0]?.trim() || "No Media"; const title = parts[0]?.trim() || "No Media";
const artist = parts[1]?.trim() || ""; const artist = parts[1]?.trim() || "";
+142
View File
@@ -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<Notification[]>(
[],
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 (
<box class="notification-item" vertical>
<box class="notification-item-header">
<label
class="notification-item-app"
label={item.appName || "Notification"}
xalign={0}
halign={Gtk.Align.START}
hexpand
/>
<button
class="notification-item-close"
halign={Gtk.Align.END}
onClicked={() => dismiss(item.id)}
>
<label label={""} />
</button>
</box>
<label
class="notification-item-summary"
label={item.summary}
xalign={0}
halign={Gtk.Align.START}
wrap
maxWidthChars={34}
/>
<label
class="notification-item-body"
visible={item.body.length > 0}
label={item.body}
xalign={0}
halign={Gtk.Align.START}
wrap
maxWidthChars={34}
/>
</box>
)
}
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 (
<window
name={NOTIFICATION_WINDOW}
namespace={NOTIFICATION_WINDOW}
class="NotificationCenter"
anchor={TOP | RIGHT}
margin={10}
layer={Astal.Layer.OVERLAY}
exclusivity={Astal.Exclusivity.NORMAL}
visible={false}
application={app}
>
<box class="panel notification-center" vertical spacing={10}>
<box class="panel-header" spacing={8}>
<label class="panel-title" label="Notifications" xalign={0} hexpand halign={Gtk.Align.START} />
<button
class={doNotDisturb.as((on) => (on ? "panel-icon-btn active" : "panel-icon-btn"))}
tooltipText="Do not disturb"
onClicked={toggleDoNotDisturb}
>
<label label={doNotDisturb.as((on) => (on ? "" : ""))} />
</button>
<button class="panel-icon-btn" tooltipText="Clear all" onClicked={clearAll}>
<label label={""} />
</button>
</box>
<scrollable
class="notification-scroll"
visible={hasNotifications}
vexpand
hscroll={Gtk.PolicyType.NEVER}
vscroll={Gtk.PolicyType.AUTOMATIC}
>
<box vertical spacing={8}>
<For each={notifications} id={(item: Notification) => item.id}>
{(item: Notification) => <NotificationItem item={item} />}
</For>
</box>
</scrollable>
<box class="notification-empty" visible={isEmpty} vertical valign={Gtk.Align.CENTER} vexpand>
<label class="notification-empty-icon" label={""} halign={Gtk.Align.CENTER} />
<label class="notification-empty-text" label="You're all caught up" halign={Gtk.Align.CENTER} />
</box>
</box>
</window>
)
}
+334
View File
@@ -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<Media>(
{ 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 (
<box class="cc-header" spacing={8}>
<box class="cc-datetime" vertical hexpand halign={Gtk.Align.START}>
<label class="cc-clock" label={clock} xalign={0} halign={Gtk.Align.START} />
<label class="cc-date" label={today} xalign={0} halign={Gtk.Align.START} />
</box>
<button class="panel-icon-btn" tooltipText="Lock" onClicked={() => { closePanel(); sh("omarchy-system-lock") }}>
<label label={""} />
</button>
<button class="panel-icon-btn" tooltipText="Log out" onClicked={() => { closePanel(); sh("omarchy-system-logout") }}>
<label label={""} />
</button>
<button class="panel-icon-btn" tooltipText="Close" onClicked={closePanel}>
<label label={"\uf00d"} />
</button>
</box>
)
}
function Tile({
icon,
label,
active,
onClicked,
}: {
icon: string
label: string
active?: any
onClicked: () => void
}) {
return (
<button
class={active ? active.as((on: boolean) => (on ? "qs-tile active" : "qs-tile")) : "qs-tile"}
onClicked={onClicked}
hexpand
>
<box vertical spacing={4} halign={Gtk.Align.CENTER}>
<label class="qs-tile-icon" label={icon} />
<label class="qs-tile-label" label={label} />
</box>
</button>
)
}
function Toggles() {
return (
<box vertical spacing={8}>
<box class="qs-tiles" spacing={8} homogeneous>
<Tile icon={""} label="Wi-Fi" onClicked={() => { closePanel(); sh("omarchy-launch-wifi") }} />
<Tile
icon={""}
label="Bluetooth"
active={bluetoothOn}
onClicked={() => {
closePanel()
sh("omarchy-launch-bluetooth")
}}
/>
<Tile
icon={""}
label="Silence"
active={doNotDisturb}
onClicked={() => {
setDoNotDisturb(!doNotDisturb.get())
sh("makoctl mode -t do-not-disturb")
}}
/>
</box>
<box class="qs-tiles" spacing={8} homogeneous>
<Tile
icon={""}
label="Night Light"
active={nightLight}
onClicked={() => {
setNightLight(!nightLight.get())
sh("omarchy-toggle-nightlight")
}}
/>
<Tile icon={""} label="Record" onClicked={() => { closePanel(); sh("omarchy-capture-screenrecording") }} />
<Tile icon={""} label="Pick Color" onClicked={() => { closePanel(); sh("hyprpicker -a") }} />
</box>
</box>
)
}
function VolumeSlider() {
return (
<box class="qs-slider-row" spacing={10}>
<button
class="qs-slider-icon"
onClicked={() => {
setMuted(!muted.get())
sh("pamixer -t")
}}
tooltipText="Toggle mute"
>
<label label={muted.as((m) => (m ? "" : ""))} />
</button>
<slider
class="qs-slider"
hexpand
min={0}
max={100}
step={1}
value={volume}
$={(self) => {
self.connect("value-changed", () => {
if (self.dragging) {
sh(`pamixer --set-volume ${Math.round(self.value)}`)
}
})
}}
/>
</box>
)
}
function BrightnessSlider() {
return (
<box class="qs-slider-row" spacing={10}>
<label class="qs-slider-icon" label={""} />
<slider
class="qs-slider"
hexpand
min={1}
max={100}
step={1}
value={brightness}
$={(self) => {
self.connect("value-changed", () => {
if (self.dragging) {
sh(`brightnessctl set ${Math.round(self.value)}%`)
}
})
}}
/>
</box>
)
}
function MediaPlayer() {
return (
<box class="cc-media" spacing={12} visible={media.as((m) => m.title !== "No Media")}>
<box
class="cc-media-cover"
css={media.as((m) =>
m.artUrl
? `background-image: url('${m.artUrl}');`
: "background-color: alpha(@color0, 0.5);",
)}
/>
<box class="cc-media-info" vertical valign={Gtk.Align.CENTER} hexpand>
<label
class="cc-media-title"
label={media.as((m) => m.title)}
xalign={0}
halign={Gtk.Align.START}
truncate
maxWidthChars={22}
/>
<label
class="cc-media-artist"
label={media.as((m) => m.artist)}
xalign={0}
halign={Gtk.Align.START}
truncate
maxWidthChars={26}
/>
<slider
class="cc-media-seek"
hexpand
min={0}
max={1}
step={0.01}
value={media.as((m) => {
const seconds = m.length / 1000000
return seconds > 0 ? Math.min(m.position / seconds, 1) : 0
})}
$={(self) => {
self.connect("value-changed", () => {
if (self.dragging) {
const seconds = media.get().length / 1000000
sh(`playerctl position ${Math.round(self.value * seconds)}`)
}
})
}}
/>
<box class="cc-media-controls" spacing={14} halign={Gtk.Align.CENTER}>
<button onClicked={() => sh("playerctl previous")}>
<label label={""} />
</button>
<button onClicked={() => sh("playerctl play-pause")}>
<label label={media.as((m) => (m.status === "Playing" ? "" : ""))} />
</button>
<button onClicked={() => sh("playerctl next")}>
<label label={""} />
</button>
</box>
</box>
</box>
)
}
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 (
<box class="cc-calendar" vertical spacing={6}>
<box class="cc-calendar-head" spacing={8}>
<label class="cc-calendar-month" label={monthName} xalign={0} hexpand halign={Gtk.Align.START} />
</box>
<label class="cc-calendar-grid" label={monthGrid} halign={Gtk.Align.CENTER} />
</box>
)
}
export default function QuickSettings() {
const { TOP } = Astal.WindowAnchor
return (
<window
name={QUICKSETTINGS_WINDOW}
namespace={QUICKSETTINGS_WINDOW}
class="QuickSettings"
anchor={TOP}
margin={10}
layer={Astal.Layer.OVERLAY}
exclusivity={Astal.Exclusivity.NORMAL}
visible={false}
application={app}
>
<box class="panel quick-settings control-center" vertical spacing={12}>
<Header />
<CalendarSection />
<Toggles />
<VolumeSlider />
<BrightnessSlider />
<MediaPlayer />
</box>
</window>
)
}
+137
View File
@@ -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<Usage>(
{ 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<Usage>(
{ 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 (
<box class="sys-metric" vertical spacing={4}>
<box spacing={8}>
<label class="sys-metric-icon" label={icon} />
<label class="sys-metric-name" label={name} xalign={0} halign={Gtk.Align.START} hexpand />
<label class="sys-metric-detail" label={detail} halign={Gtk.Align.END} />
</box>
<levelbar
class="sys-metric-bar"
value={percent.as((value: number) => value / 100)}
/>
</box>
)
}
export default function SysMonitor() {
const { TOP, RIGHT } = Astal.WindowAnchor
return (
<window
name={SYSMONITOR_WINDOW}
namespace={SYSMONITOR_WINDOW}
class="SysMonitor"
anchor={TOP | RIGHT}
margin={10}
layer={Astal.Layer.OVERLAY}
exclusivity={Astal.Exclusivity.NORMAL}
visible={false}
application={app}
>
<box class="panel sys-monitor" vertical spacing={12}>
<label class="panel-title" label="System Monitor" xalign={0} halign={Gtk.Align.START} />
<Metric
icon={"\udb80\udf5b"}
name="CPU"
percent={cpuUsage}
detail={cpuUsage.as((value) => `${value}%`)}
/>
<Metric
icon={"\udb81\ude1a"}
name="Memory"
percent={memory.as((value) => value.percent)}
detail={memory.as((value) => `${value.used} / ${value.total}`)}
/>
<Metric
icon={"\udb80\udeca"}
name="Disk"
percent={disk.as((value) => value.percent)}
detail={disk.as((value) => `${value.used} / ${value.total}`)}
/>
<Metric
icon={"\uf2c9"}
name="Temperature"
percent={temperature}
detail={temperature.as((value) => `${value}°C`)}
/>
</box>
</window>
)
}
+106
View File
@@ -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<Wallpaper[]>(
[],
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 (
<button class="wall-thumb" onClicked={() => setWallpaper(wall.path)} tooltipText={wall.name}>
<box
class="wall-thumb-image"
css={`background-image: url('${wall.path}');`}
/>
</button>
)
}
export default function WallPicker() {
const hasWalls = wallpapers.as((list) => list.length > 0)
const isEmpty = wallpapers.as((list) => list.length === 0)
return (
<window
name={WALLPICKER_WINDOW}
namespace={WALLPICKER_WINDOW}
class="WallPicker"
layer={Astal.Layer.OVERLAY}
keymode={Astal.Keymode.ON_DEMAND}
exclusivity={Astal.Exclusivity.NORMAL}
visible={false}
application={app}
>
<box class="panel wall-picker" vertical spacing={12}>
<box class="panel-header" spacing={8}>
<label class="panel-title" label="Wallpapers" xalign={0} hexpand halign={Gtk.Align.START} />
<button
class="panel-icon-btn"
tooltipText="Close"
onClicked={() => app.toggle_window(WALLPICKER_WINDOW)}
>
<label label={""} />
</button>
</box>
<scrollable
class="wall-scroll"
visible={hasWalls}
hscroll={Gtk.PolicyType.NEVER}
vscroll={Gtk.PolicyType.AUTOMATIC}
>
<box vertical spacing={8}>
<For each={rows} id={(row: Wallpaper[]) => row.map((wall) => wall.name).join("|")}>
{(row: Wallpaper[]) => (
<box spacing={8} halign={Gtk.Align.CENTER}>
{row.map((wall) => (
<Thumbnail wall={wall} />
))}
</box>
)}
</For>
</box>
</scrollable>
<box class="wall-empty" visible={isEmpty} vertical valign={Gtk.Align.CENTER}>
<label class="wall-empty-icon" label={""} halign={Gtk.Align.CENTER} />
<label class="wall-empty-text" label="No wallpapers in ~/wallpapers" halign={Gtk.Align.CENTER} />
</box>
</box>
</window>
)
}