Add AGS notification, quick settings, and sysmonitor widgets
This commit is contained in:
@@ -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() || "";
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user