diff --git a/flake.lock b/flake.lock index 5c4b1ae..a7075cf 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,48 @@ { "nodes": { + "ags": { + "inputs": { + "astal": [ + "astal" + ], + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1775689345, + "narHash": "sha256-tM3s7CX+tgxlYW0Sk3nzVThg2MHn08foIuMxABupxIs=", + "owner": "aylur", + "repo": "ags", + "rev": "bbee2f18939f1ec7ff720e717cf305e73635628f", + "type": "github" + }, + "original": { + "owner": "aylur", + "repo": "ags", + "type": "github" + } + }, + "astal": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1780295699, + "narHash": "sha256-gt9jeb/HOoiUSOTnE5I9K/B9LEbjJW5k37Xq99HOf/Q=", + "owner": "aylur", + "repo": "astal", + "rev": "271851bbc07748100382ae7caf6ef71c70c01bfc", + "type": "github" + }, + "original": { + "owner": "aylur", + "repo": "astal", + "type": "github" + } + }, "base16-schemes": { "flake": false, "locked": { @@ -254,6 +297,8 @@ }, "root": { "inputs": { + "ags": "ags", + "astal": "astal", "darwin": "darwin", "hardware": "hardware", "home-manager": "home-manager", diff --git a/flake.nix b/flake.nix index fe5d6d4..e80c35a 100755 --- a/flake.nix +++ b/flake.nix @@ -35,6 +35,16 @@ inputs.nixpkgs.follows = "nixpkgs"; }; + astal = { + url = "github:aylur/astal"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + ags = { + url = "github:aylur/ags"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.astal.follows = "astal"; + }; }; outputs = inputs: diff --git a/modules/home/wayland/apps/ags/default.nix b/modules/home/wayland/apps/ags/default.nix new file mode 100644 index 0000000..f93120d --- /dev/null +++ b/modules/home/wayland/apps/ags/default.nix @@ -0,0 +1,57 @@ +{ inputs, lib, config, pkgs, ... }: +let + colorsScss = '' + $base00: #${config.colorScheme.palette.base00}; + $base01: #${config.colorScheme.palette.base01}; + $base02: #${config.colorScheme.palette.base02}; + $base03: #${config.colorScheme.palette.base03}; + $base04: #${config.colorScheme.palette.base04}; + $base05: #${config.colorScheme.palette.base05}; + $base06: #${config.colorScheme.palette.base06}; + $base07: #${config.colorScheme.palette.base07}; + $base08: #${config.colorScheme.palette.base08}; + $base09: #${config.colorScheme.palette.base09}; + $base0A: #${config.colorScheme.palette.base0A}; + $base0B: #${config.colorScheme.palette.base0B}; + $base0C: #${config.colorScheme.palette.base0C}; + $base0D: #${config.colorScheme.palette.base0D}; + $base0E: #${config.colorScheme.palette.base0E}; + $base0F: #${config.colorScheme.palette.base0F}; + + + $fg: $base07; + $bg0: $base00; + $bg1: $base01; + + $border-color: $base03; + $border-color-focus: $base04; + $border-radius: ${config.colorScheme.palette.border-radius}px; + $border-width: ${config.colorScheme.palette.border-width}px; + + $gaps-screen: ${config.colorScheme.palette.gaps-screen}px; + $gaps-window: ${config.colorScheme.palette.gaps-window}px; + ''; + configDir = pkgs.runCommandLocal "ags-config" {} '' + mkdir -p "$out" + cp -r ${lib.cleanSource ./src}/. "$out/" + mkdir -p "$out/css" + cat > "$out/css/_colors.scss" <<'EOF' + ${colorsScss} + EOF + ''; +in { + + imports = [ inputs.ags.homeManagerModules.default ]; + config = lib.mkIf (config.usercfg.wm == "Wayland") { + programs.ags = { + enable = true; + configDir = configDir; + extraPackages = with pkgs; [ + inputs.astal.packages.${pkgs.system}.battery + fzf + bluez + custom.amdgpu_top + ]; + }; + }; +} diff --git a/modules/home/wayland/apps/ags/src/app.tsx b/modules/home/wayland/apps/ags/src/app.tsx new file mode 100644 index 0000000..c28f7cd --- /dev/null +++ b/modules/home/wayland/apps/ags/src/app.tsx @@ -0,0 +1,768 @@ +import app from "ags/gtk4/app" +import { Astal } from "ags/gtk4" +import { createState, onCleanup } from "ags" +import { readFileAsync } from "ags/file" +import { execAsync } from "ags/process" +import { createPoll } from "ags/time" +import Gdk from "gi://Gdk?version=4.0" +import GLib from "gi://GLib" +import Gtk from "gi://Gtk?version=4.0" + +import style from "./style.scss" + +type Workspace = { + id: number + occupied: boolean + focused: boolean +} + +type BatteryState = { + available: boolean + percent: number + status: string + remaining: string + icon: string +} + +type SystemState = { + cpu: number + gpu: number + memory: number + memoryText: string + battery: BatteryState +} + +type ConnectivityState = { + wifi: { + label: string + detail: string + icon: string + } + ethernet: { + label: string + detail: string + icon: string + } + bluetooth: { + label: string + detail: string + icon: string + } +} + +type ClockState = { + hour: string + minute: string + month: string + year: string +} + +const SYSTEM_INIT: SystemState = { + cpu: 0, + gpu: 0, + memory: 0, + memoryText: "0 / 0 GiB", + battery: { + available: false, + percent: 0, + status: "No battery", + remaining: "", + icon: "󰂎", + }, +} + +const CONNECTIVITY_INIT: ConnectivityState = { + wifi: { + label: "Wi-Fi", + detail: "Unavailable", + icon: "󰖪", + }, + ethernet: { + label: "Ethernet", + detail: "Disconnected", + icon: "󰈀", + }, + bluetooth: { + label: "Bluetooth", + detail: "Off", + icon: "󰂲", + }, +} + +const CLOCK_INIT: ClockState = { + hour: "--", + minute: "--", + month: "--", + year: "--", +} + +let previousCpuSample: { total: number; idle: number } | null = null +const [powerMenuVisible, setPowerMenuVisible] = createState(false) + +function attachHover(widget: Gtk.Widget, onEnter: () => void, onLeave: () => void) { + const controller = new Gtk.EventControllerMotion() + controller.connect("enter", onEnter) + controller.connect("leave", onLeave) + widget.add_controller(controller) +} + +function clampPercent(value: number) { + if (!Number.isFinite(value)) { + return 0 + } + + return Math.max(0, Math.min(100, Math.round(value))) +} + +async function commandOrEmpty(command: string | string[]) { + try { + return (await execAsync(command)).trim() + } catch { + return "" + } +} + +async function readText(path: string) { + try { + return (await readFileAsync(path)).trim() + } catch { + return "" + } +} + +async function readNumber(path: string) { + const value = Number(await readText(path)) + return Number.isFinite(value) ? value : 0 +} + +function run(command: string) { + execAsync(["bash", "-lc", command]).catch((error) => console.error(error)) +} + +function focusWorkspace(id: number) { + run(`hyprctl dispatch workspace ${id}`) +} + +function mediaAction(action: string) { + run(`playerctl ${action}`) +} + +async function readWeather() { + const line = await commandOrEmpty([ + "bash", + "-lc", + "curl -fsS 'https://wttr.in/?format=%l:+%C+%t' 2>/dev/null | head -n1", + ]) + + return line || "Weather unavailable" +} + +function renderCalendar(now: Date) { + const monthLabel = now.toLocaleString("en-US", { month: "short" }).toUpperCase() + const year = now.getFullYear() + const header = `${monthLabel} ${year}` + const weekdays = "MO TU WE TH FR SA SU" + const first = new Date(year, now.getMonth(), 1) + const lastDay = new Date(year, now.getMonth() + 1, 0).getDate() + const offset = (first.getDay() + 6) % 7 + const slots = Array.from({ length: offset + lastDay }, (_, index) => + index < offset ? " " : String(index - offset + 1).padStart(2, " "), + ) + + const rows = [] + for (let index = 0; index < slots.length; index += 7) { + rows.push(slots.slice(index, index + 7).join(" ")) + } + + return [header, weekdays, ...rows].join("\n").replace(/ /g, "\u2007") +} + +function workspaceIcon(workspace: Workspace) { + if (workspace.focused) { + return "󰮯" + } + + if (workspace.occupied) { + return "󰊠" + } + + return "󰧞" +} + +function wifiIcon(detail: string) { + if (detail === "Off") { + return "󰖩" + } + + const strength = Number(detail.split("%", 1)[0]) + + if (!Number.isFinite(strength) || strength <= 0) { + return "󰖪" + } + + if (strength < 25) { + return "󰤯" + } + + if (strength < 50) { + return "󰤟" + } + + if (strength < 75) { + return "󰤢" + } + + return "󰤨" +} + +function batteryIcon(percent: number, status: string) { + if (status === "Charging") { + return "󰂄" + } + + if (percent <= 10) { + return "󰂎" + } + + if (percent <= 20) { + return "󰁺" + } + + if (percent <= 30) { + return "󰁻" + } + + if (percent <= 40) { + return "󰁼" + } + + if (percent <= 50) { + return "󰁽" + } + + if (percent <= 60) { + return "󰁾" + } + + if (percent <= 70) { + return "󰁿" + } + + if (percent <= 80) { + return "󰂀" + } + + if (percent <= 90) { + return "󰂁" + } + + return "󰂂" +} + +function formatHours(hours: number) { + const totalMinutes = Math.max(0, Math.round(hours * 60)) + const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0") + const mm = String(totalMinutes % 60).padStart(2, "0") + return `${hh}:${mm}` +} + +async function readWorkspaces() { + const [workspacesRaw, monitorsRaw] = await Promise.all([ + commandOrEmpty(["hyprctl", "-j", "workspaces"]), + commandOrEmpty(["hyprctl", "-j", "monitors"]), + ]) + + const workspaces = workspacesRaw ? JSON.parse(workspacesRaw) : [] + const monitors = monitorsRaw ? JSON.parse(monitorsRaw) : [] + const focused = monitors.find((monitor: any) => monitor.focused)?.activeWorkspace?.id ?? 1 + const occupied = new Set( + workspaces + .map((workspace: any) => Number(workspace.id)) + .filter((id: number) => Number.isFinite(id) && id > 0), + ) + + return Array.from({ length: 10 }, (_, index) => { + const id = index + 1 + + return { + id, + occupied: occupied.has(id), + focused: focused === id, + } + }) +} + +async function readSystem() { + const [statRaw, meminfoRaw, gpuRaw] = await Promise.all([ + readText("/proc/stat"), + readText("/proc/meminfo"), + commandOrEmpty(["amdgpu_top", "-J", "-n", "1"]), + ]) + + let cpu = 0 + const statLine = statRaw.split("\n")[0] ?? "" + const cpuValues = statLine + .split(/\s+/) + .slice(1) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)) + + if (cpuValues.length >= 4) { + const total = cpuValues.reduce((sum, value) => sum + value, 0) + const idle = (cpuValues[3] ?? 0) + (cpuValues[4] ?? 0) + + if (previousCpuSample) { + const totalDelta = total - previousCpuSample.total + const idleDelta = idle - previousCpuSample.idle + + if (totalDelta > 0) { + cpu = clampPercent(((totalDelta - idleDelta) / totalDelta) * 100) + } + } + + previousCpuSample = { total, idle } + } + + const memTotal = Number(meminfoRaw.match(/^MemTotal:\s+(\d+)/m)?.[1] ?? 0) + const memAvailable = Number(meminfoRaw.match(/^MemAvailable:\s+(\d+)/m)?.[1] ?? 0) + const memUsed = Math.max(0, memTotal - memAvailable) + const memory = memTotal > 0 ? clampPercent((memUsed / memTotal) * 100) : 0 + const memoryText = `${(memUsed / 1024 / 1024).toFixed(1)} / ${(memTotal / 1024 / 1024).toFixed(1)} GiB` + + let gpu = 0 + + if (gpuRaw) { + try { + const gpuData = JSON.parse(gpuRaw) + const device = gpuData.devices?.[0] ?? {} + + gpu = + clampPercent( + Number( + device.gpu_activity?.GFX?.value ?? + device.GRBM2?.["Command Processor - Graphics"]?.value ?? + device.GRBM2?.["CommandProcessor-Graphics"]?.value ?? + 0, + ), + ) || 0 + } catch { + gpu = 0 + } + } + + const batteryPresent = Boolean(await readText("/sys/class/power_supply/BAT0/status")) + let battery = SYSTEM_INIT.battery + + if (batteryPresent) { + const [status, percent, powerNow, energyNow, energyFull] = await Promise.all([ + readText("/sys/class/power_supply/BAT0/status"), + readNumber("/sys/class/power_supply/BAT0/capacity"), + readNumber("/sys/class/power_supply/BAT0/power_now"), + readNumber("/sys/class/power_supply/BAT0/energy_now"), + readNumber("/sys/class/power_supply/BAT0/energy_full"), + ]) + + let remaining = status + + if (powerNow > 0 && energyNow > 0 && energyFull > 0) { + const hours = + status === "Charging" + ? (energyFull - energyNow) / powerNow + : status === "Discharging" + ? energyNow / powerNow + : 0 + + if (hours > 0) { + remaining = `${formatHours(hours)} ${status === "Charging" ? "to full" : "left"}` + } + } + + battery = { + available: true, + percent: clampPercent(percent), + status, + remaining, + icon: batteryIcon(percent, status), + } + } + + return { + cpu, + gpu, + memory, + memoryText, + battery, + } +} + +async function readConnectivity() { + const deviceStatus = await commandOrEmpty(["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status"]) + const devices = deviceStatus + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [device, type, state, connection] = line.split(":") + return { device, type, state, connection } + }) + + const wifiDevice = devices.find((device) => device.type === "wifi") + const ethernetDevice = devices.find((device) => device.type === "ethernet") + + let wifi = CONNECTIVITY_INIT.wifi + + if (wifiDevice) { + if (wifiDevice.state === "connected") { + const wifiList = await commandOrEmpty([ + "nmcli", + "-t", + "-f", + "IN-USE,SIGNAL,SSID", + "device", + "wifi", + "list", + "--rescan", + "no", + ]) + const activeLine = + wifiList + .split("\n") + .map((line) => line.trim()) + .find((line) => line.startsWith("*:")) ?? "" + const [, signalRaw = "0", ssid = wifiDevice.connection] = activeLine.split(":") + const signal = clampPercent(Number(signalRaw)) + + wifi = { + label: "Wi-Fi", + detail: `${signal}% ${ssid || wifiDevice.connection}`, + icon: wifiIcon(`${signal}%`), + } + } else if (wifiDevice.state === "disconnected") { + wifi = { + label: "Wi-Fi", + detail: "Idle", + icon: "󰖪", + } + } + } + + const ethernet = + ethernetDevice && ethernetDevice.state === "connected" + ? { + label: "Ethernet", + detail: ethernetDevice.connection || ethernetDevice.device, + icon: "󰈀", + } + : CONNECTIVITY_INIT.ethernet + + const bluetoothShow = await commandOrEmpty(["bash", "-lc", "bluetoothctl show 2>/dev/null || true"]) + const bluetoothDevices = await commandOrEmpty([ + "bash", + "-lc", + "bluetoothctl devices Connected 2>/dev/null | cut -d' ' -f3- || true", + ]) + const bluetoothPowered = /Powered:\s+yes/.test(bluetoothShow) + const connectedDevices = bluetoothDevices.split("\n").map((line) => line.trim()).filter(Boolean) + + const bluetooth = bluetoothPowered + ? { + label: "Bluetooth", + detail: connectedDevices[0] ?? "Ready", + icon: connectedDevices.length > 0 ? "󰂱" : "󰂯", + } + : CONNECTIVITY_INIT.bluetooth + + return { + wifi, + ethernet, + bluetooth, + } +} + +function Section(props: { title: string; className?: string; children?: JSX.Element | Array }) { + return ( + + + ) +} + +function StatRow(props: { + icon: any + label: any + value: any + fraction?: any + visible?: any +}) { + return ( + + + + + + ) +} + +function StatusRow(props: { icon: any; label: any; detail: any }) { + return ( + + + ) +} + +function ActionButton(props: { icon: string; tooltip: string; command: string }) { + return ( + + ) +} + +function WorkspacesSection() { + const workspaces = createPoll([], 1500, readWorkspaces) + const workspaceIds = Array.from({ length: 10 }, (_, index) => index + 1) + + return ( +
+ + {workspaceIds.map((id) => { + const workspace = workspaces( + (items) => items.find((item) => item.id === id) ?? { id, occupied: false, focused: false }, + ) + + return ( + + ) + })} + +
+ ) +} + +function MediaSection() { + return ( +
+ + + + + +
+ ) +} + +function SystemSection() { + const system = createPoll(SYSTEM_INIT, 2500, readSystem) + + return ( +
+ `${value.cpu}%`)} + fraction={system((value) => value.cpu / 100)} + /> + `${value.gpu}%`)} + fraction={system((value) => value.gpu / 100)} + /> + `${value.memory}%`)} + fraction={system((value) => value.memory / 100)} + /> + value.battery.icon)} + label={system((value) => value.battery.status)} + value={system((value) => `${value.battery.percent}%`)} + fraction={system((value) => value.battery.percent / 100)} + visible={system((value) => value.battery.available)} + /> +
+ ) +} + +function ConnectivitySection() { + const connectivity = createPoll(CONNECTIVITY_INIT, 5000, readConnectivity) + + return ( +
+ value.wifi.icon)} + label={connectivity((value) => value.wifi.label)} + detail={connectivity((value) => value.wifi.detail)} + /> + value.bluetooth.icon)} + label={connectivity((value) => value.bluetooth.label)} + detail={connectivity((value) => value.bluetooth.detail)} + /> + value.ethernet.icon)} + label={connectivity((value) => value.ethernet.label)} + detail={connectivity((value) => value.ethernet.detail)} + /> +
+ ) +} + +function WidgetsSection() { + return ( +
+ + + + + + +
+ ) +} + +function ClockPowerSection() { + const clock = createPoll(CLOCK_INIT, 1000, () => { + const now = GLib.DateTime.new_now_local() + + return { + hour: now.format("%H") ?? "--", + minute: now.format("%M") ?? "--", + month: now.format("%m") ?? "--", + year: now.format("%y") ?? "--", + } + }) + const [clockHovered, setClockHovered] = createState(false) + + return ( +
+ +
+ ) +} + +function PowerMenu({ gdkmonitor }: { gdkmonitor: Gdk.Monitor }) { + const weather = createPoll("Weather unavailable", 900000, readWeather) + const calendar = createPoll(renderCalendar(new Date()), 60000, () => renderCalendar(new Date())) + const { TOP, BOTTOM, LEFT, RIGHT } = Astal.WindowAnchor + + return ( + + +