diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..9ac159d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +apps/.config/JetBrains/**/settingsSync +apps/.config/JetBrains/**/fus/ +apps/.config/JetBrains/**/*.db +apps/.config/JetBrains/**/*.db-shm +apps/.config/JetBrains/**/*.db-wal +apps/.config/obsidian/ +apps/.config/kwalletrc diff --git a/.stowrc b/.stowrc new file mode 100755 index 0000000..1943577 --- /dev/null +++ b/.stowrc @@ -0,0 +1 @@ +--target=/home/end diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/apps/.config/.gitignore b/apps/.config/.gitignore new file mode 100644 index 0000000..e51a0ce --- /dev/null +++ b/apps/.config/.gitignore @@ -0,0 +1,44 @@ +# Browser/Electron caches +*/Cache/ +*/Code Cache/ +*/GPUCache/ +*/DawnGraphiteCache/ +*/DawnWebGPUCache/ +*/Crashpad/ +*/IndexedDB/ +*/Local Storage/ +*/Session Storage/ +*/Shared Dictionary/ +*/WebStorage/ +*/.org.chromium.Chromium.* + +# Logs and runtime state +*.log +*/Cookies +*/Cookies-journal +*/DIPS +*/DIPS-wal +*/SharedStorage +*/SharedStorage-wal +*/TransportSecurity +*/Trust Tokens +*/Trust Tokens-journal +*/Network Persistent State + +# Secrets +github-copilot/apps.json + +# JetBrains runtime +JetBrains/*/event-log-metadata/ +JetBrains/*/workspace/ +JetBrains/*/tasks/ +JetBrains/*/settingsSync/ +JetBrains/*/*.db +JetBrains/*/options/recentProjects.xml +JetBrains/*/options/trusted-paths.xml +JetBrains/*/options/usage.statistics.xml +JetBrains/*/options/features.usage.statistics.xml +JetBrains/*/options/window.state.xml +# AGS runtime / build artifacts +ags/node_modules/ +ags/@girs/ diff --git a/apps/.config/ags/app.ts b/apps/.config/ags/app.ts new file mode 100644 index 0000000..fe7697b --- /dev/null +++ b/apps/.config/ags/app.ts @@ -0,0 +1,17 @@ +import app from "ags/gtk4/app" +import style from "./style.scss" +import Bar from "./widget/Bar" +import NotificationCenter from "./widget/NotificationCenter" +import NotificationPopup from "./widget/NotificationPopup" +import ClockCenter from "./widget/ClockCenter" + +app.start({ + css: style, + main() { + const monitors = app.get_monitors() + monitors.map(Bar) + monitors.map(NotificationCenter) + monitors.map(NotificationPopup) + monitors.map(ClockCenter) + }, +}) diff --git a/apps/.config/ags/env.d.ts b/apps/.config/ags/env.d.ts new file mode 100644 index 0000000..792ebfd --- /dev/null +++ b/apps/.config/ags/env.d.ts @@ -0,0 +1,21 @@ +declare const SRC: string + +declare module "inline:*" { + const content: string + export default content +} + +declare module "*.scss" { + const content: string + export default content +} + +declare module "*.blp" { + const content: string + export default content +} + +declare module "*.css" { + const content: string + export default content +} diff --git a/apps/.config/ags/package.json b/apps/.config/ags/package.json new file mode 100644 index 0000000..cbf736c --- /dev/null +++ b/apps/.config/ags/package.json @@ -0,0 +1,10 @@ +{ + "dependencies": { + "ags": "*", + "gnim": "*" + }, + "prettier": { + "semi": false, + "tabWidth": 2 + } +} diff --git a/apps/.config/ags/style.scss b/apps/.config/ags/style.scss new file mode 100644 index 0000000..65c7317 --- /dev/null +++ b/apps/.config/ags/style.scss @@ -0,0 +1,530 @@ +// palette +$bg: #0b0d1a; +$surface: #0f1120; +$surface2: #141628; +$overlay: #1a1d35; +$purple: #c792ea; +$purple-dim: #9a70c7; +$cyan: #4ec9b0; +$green: #99c794; +$pink: #ec5f89; +$yellow: #fac863; +$red: #f97b58; +$text: #cdd6f4; +$subtext: #7f849c; +$border: rgba(78, 201, 176, 0.15); + +* { + font-family: "Maple Mono NF", monospace; + font-size: 13px; + color: $text; + -gtk-icon-style: regular; +} + +// bar +window.bar { + background: transparent; +} + +.bar-centerbox { + background: $surface; + border-radius: 12px; + margin: 6px 8px 0; + border: 1px solid $border; + padding: 2px 6px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5); +} + + +// modules +.module { + background: $surface2; + border-radius: 8px; + padding: 1px 8px; + margin: 2px 2px; + border: 1px solid $border; + label { + color: $text; + } +} + +.module-icon { + font-size: 14px; + margin-right: 4px; +} + +.workspaces { + background: transparent; + border: none; + padding: 0; + margin: 0 4px; + + .ws-dot { + font-size: 8px; + margin: 0 2px; + color: $subtext; + transition: all 200ms ease; + &.active { + color: $purple; + font-size: 11px; + } + &.focused { + color: $cyan; + } + } +} + +.window-title { + background: transparent; + border: none; + padding: 0 6px; + color: $subtext; + font-size: 12px; +} + +.clock-module { + .time { + color: $text; + font-weight: bold; + font-size: 14px; + } + .date { + color: $subtext; + font-size: 11px; + margin-left: 6px; + } +} + +.media-module { + min-width: 120px; + .media-icon { + color: $cyan; + } + .media-title { + color: $text; + font-size: 12px; + } + .media-btn { + background: transparent; + border: none; + padding: 0 3px; + color: $subtext; + border-radius: 6px; + &:hover { + color: $purple; + background: $overlay; + } + } +} + +.volume-module { + .vol-icon { + color: $cyan; + } + .vol-muted { + color: $pink; + } +} + +.brightness-module { + .bright-icon { + color: $yellow; + } +} + +.mullvad-module { + &.on { + box-shadow: inset 0 0 0 1px $green; + border-radius: 9px; + } + &.off { + box-shadow: inset 0 0 0 1px $red; + border-radius: 9px; + } +} + +.tailscale-module { + &.on { + box-shadow: inset 0 0 0 1px $green; + border-radius: 9px; + } + &.off { + box-shadow: inset 0 0 0 1px $red; + border-radius: 9px; + } +} + +.vpn-status-label { + font-size: 12px; + font-weight: bold; +} +.vpn-detail { + color: $subtext; + font-size: 11px; +} + +.network-module { + .net-icon { + color: $green; + } + .net-disconnected { + color: $pink; + } + .net-strength { + color: $subtext; + font-size: 11px; + } + .net-wired { + color: $cyan; + } +} + +.battery-module { + .bat-icon { + color: $green; + } + .bat-charging { + color: $yellow; + } + .bat-low { + color: $pink; + } + .bat-label { + font-size: 12px; + } +} + +.sysstat-module { + .stat-icon { + color: $purple; + } + .stat-label { + font-size: 12px; + } +} + +.weather-module { + .weather-text { + font-size: 12px; + } + .unit-toggle { + background: $overlay; + border: 1px solid $border; + border-radius: 6px; + padding: 0 4px; + margin: 0; + min-height: 0; + min-width: 0; + font-size: 11px; + color: $subtext; + &:hover { + color: $cyan; + border-color: $cyan; + } + } +} + +.notif-bell { + .bell-icon { + color: $subtext; + } + .bell-count { + color: $pink; + font-size: 10px; + font-weight: bold; + } + &.has-notifs .bell-icon { + color: $purple; + } +} + +// tray +.tray-module { + background: $surface2; + border-radius: 8px; + padding: 1px 4px; + margin: 2px 2px; + border: 1px solid $border; + + .tray-item { + background: transparent; + border: none; + padding: 1px 2px; + border-radius: 6px; + min-height: 0; + min-width: 0; + &:hover { + background: $overlay; + } + > button { + background: transparent; + border: none; + padding: 0; + margin: 0; + box-shadow: none; + min-height: 0; + min-width: 0; + &:hover, + &:active, + &:checked { + background: transparent; + box-shadow: none; + } + } + > button > image { + opacity: 0; + min-width: 0; + min-height: 0; + } + } +} + +// volume popover +.open-mixer-btn { + background: $overlay; + border: 1px solid $border; + border-radius: 8px; + padding: 4px 10px; + color: $subtext; + margin-top: 4px; + &:hover { + color: $cyan; + border-color: $cyan; + } +} + +.vol-pct { + color: $subtext; + font-size: 11px; +} +.mic-icon { + color: $green; +} + +// notifications +window.notification-popup { + background: transparent; +} + +.notif-popup-inner { + padding: 8px; +} + +.notif-toast { + background: $surface; + border: 1px solid $border; + border-radius: 14px; + padding: 12px 14px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5); + min-width: 320px; + + .notif-app { + color: $purple; + font-size: 11px; + font-weight: bold; + } + .notif-close { + background: transparent; + border: none; + padding: 0 4px; + color: $subtext; + border-radius: 6px; + font-size: 11px; + &:hover { + color: $pink; + background: $overlay; + } + } + .notif-summary { + color: $text; + font-size: 13px; + font-weight: bold; + } + .notif-body { + color: $subtext; + font-size: 12px; + } +} + +window.notification-center { + background: rgba(22, 22, 30, 0.85); + border-left: 1px solid $border; +} + +.notif-center-inner { + padding: 16px 12px; + min-width: 340px; +} + +.notif-center-header { + margin-bottom: 4px; + .notif-center-title { + font-size: 14px; + font-weight: bold; + color: $text; + } + .notif-clear-all { + background: $overlay; + border: 1px solid $border; + border-radius: 8px; + padding: 2px 10px; + color: $subtext; + font-size: 11px; + &:hover { + color: $pink; + border-color: $pink; + } + } +} + +.notif-card { + background: $surface; + border: 1px solid $border; + border-radius: 12px; + padding: 10px 12px; + + .notif-app { + color: $purple; + font-size: 11px; + font-weight: bold; + } + .notif-close { + background: transparent; + border: none; + padding: 0 4px; + color: $subtext; + border-radius: 6px; + font-size: 11px; + &:hover { + color: $pink; + background: $overlay; + } + } + .notif-summary { + color: $text; + font-size: 13px; + font-weight: bold; + } + .notif-body { + color: $subtext; + font-size: 12px; + } +} + +.notif-empty { + color: $subtext; + font-size: 12px; +} + +// clock popover +.clock-tz-label { + color: $subtext; + font-size: 12px; +} +.clock-section-title { + color: $subtext; + font-size: 11px; + font-weight: bold; +} + +.tz-btn { + background: transparent; + border: none; + border-radius: 6px; + padding: 3px 8px; + color: $text; + font-size: 12px; + &:hover { + background: $overlay; + color: $purple; + } +} + +calendar { + background: $surface2; + border: 1px solid $border; + border-radius: 10px; + padding: 4px; + color: $text; + + &:selected { + background: $purple; + color: $bg; + border-radius: 6px; + } + &.highlight { + color: $cyan; + font-weight: bold; + } + header { + color: $text; + font-weight: bold; + } + button { + background: transparent; + border: none; + color: $subtext; + border-radius: 4px; + } + button:hover { + background: $overlay; + color: $text; + } + button.day { + color: $text; + } + button.day:selected { + background: $purple; + color: $bg; + } + button.day.today { + color: $purple; + font-weight: bold; + } + button.day.other-month { + color: $subtext; + opacity: 0.5; + } +} + +.clock-center-inner { + padding: 14px 12px; + min-width: 260px; +} + +// popovers — reset GTK4's internal `contents` node padding +popover > contents { + padding: 0; + margin: 0; + background: transparent; + border: none; + box-shadow: none; +} + +popover { + background: $surface; + border: 1px solid $border; + border-radius: 12px; + padding: 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); + + scale { + min-width: 160px; + trough { + background: $overlay; + border-radius: 8px; + min-height: 6px; + } + highlight { + background: $purple; + border-radius: 8px; + } + slider { + background: $text; + border-radius: 50%; + min-width: 14px; + min-height: 14px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); + } + } +} diff --git a/apps/.config/ags/tsconfig.json b/apps/.config/ags/tsconfig.json new file mode 100644 index 0000000..2bb1f01 --- /dev/null +++ b/apps/.config/ags/tsconfig.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "strict": true, + "module": "ES2022", + "target": "ES2020", + "lib": ["ES2023"], + "moduleResolution": "Bundler", + // "checkJs": true, + // "allowJs": true, + "jsx": "react-jsx", + "jsxImportSource": "ags/gtk4" + } +} diff --git a/apps/.config/ags/widget/Bar.tsx b/apps/.config/ags/widget/Bar.tsx new file mode 100644 index 0000000..51ffa80 --- /dev/null +++ b/apps/.config/ags/widget/Bar.tsx @@ -0,0 +1,64 @@ +import { Astal, Gtk, Gdk } from "ags/gtk4" +import app from "ags/gtk4/app" + +import Workspaces from "./modules/Workspaces" +import WindowTitle from "./modules/WindowTitle" +import Clock from "./modules/Clock" +import Media from "./modules/Media" +import SysStats from "./modules/SysStats" +import Weather from "./modules/Weather" +import Network from "./modules/Network" +import Battery from "./modules/Battery" +import Brightness from "./modules/Brightness" +import Volume from "./modules/Volume" +import NotifBell from "./modules/NotifBell" +import Tray from "./modules/Tray" +import Mullvad from "./modules/Mullvad" +import Tailscale from "./modules/Tailscale" + +export default function Bar(gdkmonitor: Gdk.Monitor) { + const { TOP, LEFT, RIGHT } = Astal.WindowAnchor + + return ( + + + + {/* Left */} + + + + + + {/* Center */} + + + + + + {/* Right */} + + + + + + + + + + + + + + + + ) +} diff --git a/apps/.config/ags/widget/ClockCenter.tsx b/apps/.config/ags/widget/ClockCenter.tsx new file mode 100644 index 0000000..1c1c8a0 --- /dev/null +++ b/apps/.config/ags/widget/ClockCenter.tsx @@ -0,0 +1,90 @@ +import { Astal, Gtk, Gdk } from "ags/gtk4" +import app from "ags/gtk4/app" +import { execAsync } from "ags/process" +import { createState, createComputed, For } from "ags" +import { interval } from "ags/time" + +const TIMEZONES = [ + "America/Phoenix", "America/New_York", "America/Chicago", + "America/Denver", "America/Los_Angeles", "America/Anchorage", + "Pacific/Honolulu", "Europe/London", "Europe/Paris", + "Europe/Berlin", "Europe/Rome", "Europe/Moscow", + "Asia/Dubai", "Asia/Kolkata", "Asia/Bangkok", + "Asia/Shanghai", "Asia/Tokyo", "Asia/Seoul", + "Australia/Sydney", "Pacific/Auckland", +] + +export default function ClockCenter(gdkmonitor: Gdk.Monitor) { + const { TOP } = Astal.WindowAnchor + + const [time, setTime] = createState("") + const [date, setDate] = createState("") + const [tz, setTz] = createState("") + + function pollTime() { execAsync(["date", "+%H:%M:%S"]).then(s => setTime(s.trim())).catch(() => {}) } + function pollDate() { execAsync(["date", "+%A, %B %d"]).then(s => setDate(s.trim())).catch(() => {}) } + function pollTz() { execAsync(["date", "+%Z"]).then(s => setTz(s.trim())).catch(() => {}) } + + pollTime(); pollDate(); pollTz() + interval(1000, pollTime) + interval(60000, pollDate) + interval(10000, pollTz) + + const [search, setSearch] = createState("") + const filtered = createComputed(() => { + const q = search().toLowerCase() + return TIMEZONES.filter(t => t.toLowerCase().includes(q)) + }) + + const win = ( + { + if (!isActive) app.toggle_window("clock-center") + }} + > + + { + if (keyval === Gdk.KEY_Escape) app.toggle_window("clock-center") + }} + /> + + + + + + ) as unknown as Astal.Window + + return win +} diff --git a/apps/.config/ags/widget/NotificationCenter.tsx b/apps/.config/ags/widget/NotificationCenter.tsx new file mode 100644 index 0000000..deefe32 --- /dev/null +++ b/apps/.config/ags/widget/NotificationCenter.tsx @@ -0,0 +1,98 @@ +import { Astal, Gtk, Gdk } from "ags/gtk4" +import app from "ags/gtk4/app" +import { For } from "ags" +import { history, removeNotif, clearAll, type NotifEntry } from "./notifStore" + +function NotifCard({ entry }: { entry: NotifEntry }) { + const rightClick = new Gtk.GestureClick() + rightClick.button = 3 + rightClick.connect("pressed", () => { + removeNotif(entry.id) + try { entry.notif.dismiss() } catch {} + }) + + // Left click only fires when clicking the card background (not child widgets) + const leftClick = new Gtk.GestureClick() + leftClick.button = 1 + leftClick.propagation_phase = Gtk.PropagationPhase.TARGET + leftClick.connect("pressed", () => { + try { entry.notif.invoke("default") } catch {} + app.toggle_window("notification-center") + }) + + const box = ( + + + + {entry.summary + ? + ) as unknown as Gtk.Box + + box.add_controller(leftClick) + box.add_controller(rightClick) + return box +} + +export default function NotificationCenter(gdkmonitor: Gdk.Monitor) { + const { RIGHT, TOP, BOTTOM } = Astal.WindowAnchor + + return ( + + + { + if (keyval === Gdk.KEY_Escape) app.toggle_window("notification-center") + }} + /> + + + + + ) +} diff --git a/apps/.config/ags/widget/NotificationPopup.tsx b/apps/.config/ags/widget/NotificationPopup.tsx new file mode 100644 index 0000000..202c981 --- /dev/null +++ b/apps/.config/ags/widget/NotificationPopup.tsx @@ -0,0 +1,69 @@ +import { Astal, Gtk, Gdk } from "ags/gtk4" +import app from "ags/gtk4/app" +import Notifd from "gi://AstalNotifd" +import Pango from "gi://Pango" +import { createBinding, For } from "ags" +import { timeout } from "ags/time" +import { addNotif } from "./notifStore" + +type N = ReturnType["notifications"][0] + +function Toast({ notif }: { notif: N }) { + const gesture = new Gtk.GestureClick() + gesture.button = 3 + gesture.connect("pressed", () => { try { notif.dismiss() } catch {} }) + + const box = ( + + + + {notif.summary + ? + ) as unknown as Gtk.Box + + box.add_controller(gesture) + return box +} + +export default function NotificationPopup(gdkmonitor: Gdk.Monitor) { + const notifd = Notifd.get_default() + const notifs = createBinding(notifd, "notifications") + const { TOP, RIGHT } = Astal.WindowAnchor + + notifd.connect("notified", (_self: typeof notifd, id: number) => { + const notif = notifd.get_notification(id) + if (!notif) return + addNotif(notif) + const ms = notif.expire_timeout > 0 ? notif.expire_timeout : 10000 + timeout(ms, () => { try { notif.dismiss() } catch {} }) + }) + + return ( + n.length > 0)} + application={app} + > + + [...ns].slice(-3).reverse())} id={n => n.id}> + {n => } + + + + ) +} diff --git a/apps/.config/ags/widget/modules/Battery.tsx b/apps/.config/ags/widget/modules/Battery.tsx new file mode 100644 index 0000000..265199d --- /dev/null +++ b/apps/.config/ags/widget/modules/Battery.tsx @@ -0,0 +1,78 @@ +import { Gtk } from "ags/gtk4" +import { execAsync } from "ags/process" +import { createState } from "ags" +import { interval } from "ags/time" + +interface BatState { + pct: number + charging: boolean + timeLeft: string +} + +function parseBat(out: string): BatState { + const lines = out.split("\n") + let pct = 100, charging = false, timeLeft = "" + for (const line of lines) { + const l = line.trim() + if (l.startsWith("percentage:")) pct = parseInt(l.split(":")[1]) || 100 + if (l.startsWith("state:")) charging = l.includes("charging") && !l.includes("discharging") + if (l.startsWith("time to empty:") || l.startsWith("time to full:")) + timeLeft = `~${l.split(":").slice(1).join(":").trim()}` + } + return { pct, charging, timeLeft } +} + +export default function Battery() { + const [bat, setBat] = createState({ pct: 100, charging: false, timeLeft: "" }) + + function poll() { + execAsync(["upower", "-i", "/org/freedesktop/UPower/devices/battery_BAT1"]) + .then(out => setBat(parseBat(out))) + .catch(() => {}) + } + + poll() + interval(5000, poll) + + const popover = new Gtk.Popover() + popover.set_has_arrow(false) + + const content = ( + + + ) as unknown as Gtk.Widget + + popover.set_child(content) + + const btn = ( + + ) as unknown as Gtk.Button + + popover.set_parent(btn) + return btn +} diff --git a/apps/.config/ags/widget/modules/Brightness.tsx b/apps/.config/ags/widget/modules/Brightness.tsx new file mode 100644 index 0000000..9d131f4 --- /dev/null +++ b/apps/.config/ags/widget/modules/Brightness.tsx @@ -0,0 +1,52 @@ +import { Gtk } from "ags/gtk4" +import { execAsync } from "ags/process" +import { createState } from "ags" +import { interval } from "ags/time" + +export default function Brightness() { + const [brightness, setBrightness] = createState({ cur: 0, max: 100 }) + + function poll() { + execAsync(["brightnessctl", "info"]) + .then(out => { + const cur = parseInt(out.match(/Current brightness: (\d+)/)?.[1] ?? "0") + const max = parseInt(out.match(/Max brightness: (\d+)/)?.[1] ?? "100") + setBrightness({ cur, max }) + }).catch(() => {}) + } + + poll() + interval(2000, poll) + + const pct = brightness(b => Math.round((b.cur / b.max) * 100)) + const icon = pct(p => p > 66 ? "󰃠" : p > 33 ? "󰃟" : "󰃞") + + const popover = new Gtk.Popover() + popover.set_has_arrow(false) + + const content = ( + + + ) as unknown as Gtk.Widget + + popover.set_child(content) + + const btn = ( + + ) as unknown as Gtk.Button + + popover.set_parent(btn) + return btn +} diff --git a/apps/.config/ags/widget/modules/Clock.tsx b/apps/.config/ags/widget/modules/Clock.tsx new file mode 100644 index 0000000..084929b --- /dev/null +++ b/apps/.config/ags/widget/modules/Clock.tsx @@ -0,0 +1,27 @@ +import { createState } from "ags" +import { interval } from "ags/time" +import { execAsync } from "ags/process" +import { Gtk } from "ags/gtk4" +import app from "ags/gtk4/app" + +export default function Clock() { + const [time, setTime] = createState("") + const [date, setDate] = createState("") + + function pollTime() { execAsync(["date", "+%H:%M:%S"]).then(s => setTime(s.trim())).catch(() => {}) } + function pollDate() { execAsync(["date", "+%a %b %d"]).then(s => setDate(s.trim())).catch(() => {}) } + + pollTime(); pollDate() + interval(1000, pollTime) + interval(60000, pollDate) + + return ( + + ) +} diff --git a/apps/.config/ags/widget/modules/Media.tsx b/apps/.config/ags/widget/modules/Media.tsx new file mode 100644 index 0000000..0334886 --- /dev/null +++ b/apps/.config/ags/widget/modules/Media.tsx @@ -0,0 +1,55 @@ +import { Gtk } from "ags/gtk4" +import { execAsync } from "ags/process" +import { createState } from "ags" +import { interval } from "ags/time" + +interface MprisInfo { + title: string + artist: string + playing: boolean + player: string +} + +const empty: MprisInfo = { title: "", artist: "", playing: false, player: "" } + +export default function Media() { + const [media, setMedia] = createState(empty) + + function poll() { + execAsync("playerctl metadata --format '{{title}}|||{{artist}}|||{{status}}|||{{playerName}}'") + .then(out => { + const [title, artist, status, player] = out.trim().split("|||") + setMedia({ title: title || "", artist: artist || "", playing: status === "Playing", player: player || "" }) + }) + .catch(() => setMedia(empty)) + } + + poll() + interval(2000, poll) + + return ( + m.title !== "")} + > + + ) +} diff --git a/apps/.config/ags/widget/modules/Mullvad.tsx b/apps/.config/ags/widget/modules/Mullvad.tsx new file mode 100644 index 0000000..71de25f --- /dev/null +++ b/apps/.config/ags/widget/modules/Mullvad.tsx @@ -0,0 +1,76 @@ +import { Gtk } from "ags/gtk4" +import { execAsync } from "ags/process" +import { interval } from "ags/time" +import { createState } from "ags" + +const FAVORITES = [ + { label: "Phoenix", cmd: "mullvad relay set location us phx" }, + { label: "Los Angeles", cmd: "mullvad relay set location us lax" }, + { label: "Switzerland", cmd: "mullvad relay set location ch" }, +] + +export default function Mullvad() { + const [status, setStatus] = createState({ connected: false, location: "", ip: "" }) + + function poll() { + execAsync("mullvad status").then(out => { + const connected = out.toLowerCase().startsWith("connected") + const locMatch = out.match(/location:\s+(.+?)\./) + const ipMatch = out.match(/IPv4:\s+([\d.]+)/) + setStatus({ connected, location: locMatch?.[1]?.trim() ?? "", ip: ipMatch?.[1] ?? "" }) + }).catch(() => {}) + } + + poll() + interval(5000, poll) + + const popover = new Gtk.Popover() + popover.set_has_arrow(false) + + const favBtns = FAVORITES.map(({ label, cmd }) => ( + + )) + + const content = ( + + + ) as unknown as Gtk.Widget + + popover.set_child(content) + + const icon = new Gtk.Image({ iconName: "mullvad-vpn", pixelSize: 16 }) + + const btn = ( + + ) as unknown as Gtk.Button + + btn.set_child(icon) + + const gesture = new Gtk.GestureClick() + gesture.button = 3 + gesture.connect("pressed", () => popover.popup()) + btn.add_controller(gesture) + popover.set_parent(btn) + + return btn +} diff --git a/apps/.config/ags/widget/modules/Network.tsx b/apps/.config/ags/widget/modules/Network.tsx new file mode 100644 index 0000000..11cb7ba --- /dev/null +++ b/apps/.config/ags/widget/modules/Network.tsx @@ -0,0 +1,60 @@ +import { Gtk } from "ags/gtk4" +import { execAsync } from "ags/process" +import { createState } from "ags" +import { interval } from "ags/time" + +type NetState = { type: "none" | "wifi" | "wired" | "rfkill"; ssid: string; strength: number; ip: string } + +export default function Network() { + const [net, setNet] = createState({ type: "none", ssid: "", strength: 0, ip: "" }) + const [showIp, setShowIp] = createState(false) + + function poll() { + execAsync(["bash", "-c", "rfkill list wifi | grep -q 'Hard blocked: yes\\|Soft blocked: yes' && echo rfkill; nmcli -t -f device,type,state dev 2>/dev/null | grep ':ethernet:connected' | head -1; echo '---'; nmcli -t -f active,ssid,signal dev wifi 2>/dev/null | grep '^yes' | head -1; echo '---'; ip -4 route get 1.1.1.1 2>/dev/null | grep -oP '(?<=src )[\\d.]+'"]) + .then(out => { + if (out.startsWith("rfkill")) { setNet({ type: "rfkill", ssid: "", strength: 0, ip: "" }); return } + const [eth, wifi, ip] = out.split("---").map((s: string) => s.trim()) + if (eth) { setNet({ type: "wired", ssid: "", strength: 0, ip: ip || "" }); return } + const parts = wifi?.split(":") ?? [] + if (parts.length >= 3 && parts[0] === "yes") + setNet({ type: "wifi", ssid: parts[1], strength: parseInt(parts[2]) || 0, ip: ip || "" }) + else + setNet({ type: "none", ssid: "", strength: 0, ip: "" }) + }).catch(() => {}) + } + + poll() + interval(5000, poll) + + const icon = net(n => { + if (n.type === "rfkill") return "󰖪" + if (n.type === "wired") return "󰈀" + if (n.type === "none") return "󰤭" + if (n.strength > 75) return "󰤨" + if (n.strength > 50) return "󰤥" + if (n.strength > 25) return "󰤢" + return "󰤟" + }) + + const labelText = net(n => { + if (n.type === "rfkill") return "rfkill" + if (showIp()) return n.ip || "no ip" + if (n.type === "wired") return "wired" + if (n.type === "wifi") return n.ssid || "connected" + return "offline" + }) + + return ( + + ) +} diff --git a/apps/.config/ags/widget/modules/NotifBell.tsx b/apps/.config/ags/widget/modules/NotifBell.tsx new file mode 100644 index 0000000..b25db9b --- /dev/null +++ b/apps/.config/ags/widget/modules/NotifBell.tsx @@ -0,0 +1,25 @@ +import { Gtk } from "ags/gtk4" +import app from "ags/gtk4/app" +import { history } from "../notifStore" + +export default function NotifBell() { + return ( + + ) +} diff --git a/apps/.config/ags/widget/modules/SysStats.tsx b/apps/.config/ags/widget/modules/SysStats.tsx new file mode 100644 index 0000000..6ef5e0f --- /dev/null +++ b/apps/.config/ags/widget/modules/SysStats.tsx @@ -0,0 +1,35 @@ +import { Gtk } from "ags/gtk4" +import { createState } from "ags" +import { interval } from "ags/time" +import { execAsync } from "ags/process" + +export default function SysStats() { + const [cpu, setCpu] = createState("0") + const [ram, setRam] = createState("0") + + function pollCpu() { + execAsync(["bash", "-c", "grep -m1 '^cpu ' /proc/stat | awk '{u=$2+$4; t=$2+$3+$4+$5; if(t>0) print int(u/t*100); else print 0}'"]).then(s => setCpu(s.trim())).catch(() => {}) + } + + function pollRam() { + execAsync(["bash", "-c", "free | awk '/^Mem:/{printf \"%d\", $3/$2*100}'"]).then(s => setRam(s.trim())).catch(() => {}) + } + + pollCpu() + pollRam() + interval(2000, pollCpu) + interval(5000, pollRam) + + return ( + + + + + + + ) +} diff --git a/apps/.config/ags/widget/modules/Tailscale.tsx b/apps/.config/ags/widget/modules/Tailscale.tsx new file mode 100644 index 0000000..3c22ea9 --- /dev/null +++ b/apps/.config/ags/widget/modules/Tailscale.tsx @@ -0,0 +1,60 @@ +import { Gtk } from "ags/gtk4" +import { execAsync } from "ags/process" +import { interval } from "ags/time" +import { createState } from "ags" + +export default function Tailscale() { + const [status, setStatus] = createState({ up: false, ip: "", peers: 0 }) + + function poll() { + execAsync("tailscale status --json").then(out => { + try { + const j = JSON.parse(out) + setStatus({ + up: j.BackendState === "Running", + ip: j.TailscaleIPs?.[0] ?? "", + peers: Object.keys(j.Peer ?? {}).length, + }) + } catch {} + }).catch(() => {}) + } + + poll() + interval(5000, poll) + + const popover = new Gtk.Popover() + popover.set_has_arrow(false) + + const content = ( + + + ) as unknown as Gtk.Widget + + popover.set_child(content) + + const icon = new Gtk.Image({ iconName: "tailscale", pixelSize: 16 }) + + const btn = ( + + ) as unknown as Gtk.Button + + btn.set_child(icon) + + const gesture = new Gtk.GestureClick() + gesture.button = 3 + gesture.connect("pressed", () => popover.popup()) + btn.add_controller(gesture) + popover.set_parent(btn) + + return btn +} diff --git a/apps/.config/ags/widget/modules/Tray.tsx b/apps/.config/ags/widget/modules/Tray.tsx new file mode 100644 index 0000000..c21c3a3 --- /dev/null +++ b/apps/.config/ags/widget/modules/Tray.tsx @@ -0,0 +1,36 @@ +import { Gtk } from "ags/gtk4" +import AstalTray from "gi://AstalTray" +import { createBinding, For } from "ags" + +export default function Tray() { + const tray = AstalTray.get_default() + const items = createBinding(tray, "items") + + return ( + i.length > 0)}> + item.item_id}> + {item => { + const btn = new Gtk.Button() + btn.add_css_class("tray-item") + btn.valign = Gtk.Align.CENTER + btn.child = new Gtk.Image({ gicon: item.gicon, pixel_size: 16 }) + btn.connect("clicked", () => item.activate(0, 0)) + + if (item.menu_model) { + const pop = new Gtk.PopoverMenu({ menu_model: item.menu_model }) + if (item.action_group) + pop.insert_action_group("dbusmenu", item.action_group) + pop.set_parent(btn) + + const gesture = new Gtk.GestureClick() + gesture.button = 3 + gesture.connect("pressed", () => pop.popup()) + btn.add_controller(gesture) + } + + return btn + }} + + + ) +} diff --git a/apps/.config/ags/widget/modules/Volume.tsx b/apps/.config/ags/widget/modules/Volume.tsx new file mode 100644 index 0000000..70e452b --- /dev/null +++ b/apps/.config/ags/widget/modules/Volume.tsx @@ -0,0 +1,85 @@ +import { Gtk } from "ags/gtk4" +import { execAsync } from "ags/process" +import { createState } from "ags" +import { interval } from "ags/time" + +export default function Volume() { + const [spk, setSpk] = createState({ vol: 0, muted: false }) + const [mic, setMic] = createState({ vol: 0, muted: false }) + + function pollSpk() { + execAsync(["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"]) + .then(out => { + const match = out.match(/Volume: ([\d.]+)/) + setSpk({ vol: match ? Math.round(parseFloat(match[1]) * 100) : 0, muted: out.includes("[MUTED]") }) + }).catch(() => {}) + } + + function pollMic() { + execAsync(["wpctl", "get-volume", "@DEFAULT_AUDIO_SOURCE@"]) + .then(out => { + const match = out.match(/Volume: ([\d.]+)/) + setMic({ vol: match ? Math.round(parseFloat(match[1]) * 100) : 0, muted: out.includes("[MUTED]") }) + }).catch(() => {}) + } + + pollSpk(); pollMic() + interval(1000, pollSpk) + interval(1000, pollMic) + + const spkIcon = spk(({ vol, muted }) => { + if (muted) return "󰝟" + if (vol === 0) return "󰕿" + if (vol < 50) return "󰖀" + return "󰕾" + }) + + const popover = new Gtk.Popover() + popover.set_has_arrow(false) + + const content = ( + + + + v.vol)} + widthRequest={180} + onNotifyValue={self => execAsync(["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", `${self.value / 100}`]).catch(() => {})} + /> + + + v.vol)} + widthRequest={180} + onNotifyValue={self => execAsync(["wpctl", "set-volume", "@DEFAULT_AUDIO_SOURCE@", `${self.value / 100}`]).catch(() => {})} + /> + + + ) as unknown as Gtk.Widget + + popover.set_child(content) + + const btn = ( + + ) as unknown as Gtk.Button + + popover.set_parent(btn) + return btn +} diff --git a/apps/.config/ags/widget/modules/Weather.tsx b/apps/.config/ags/widget/modules/Weather.tsx new file mode 100644 index 0000000..bad8457 --- /dev/null +++ b/apps/.config/ags/widget/modules/Weather.tsx @@ -0,0 +1,49 @@ +import { Gtk } from "ags/gtk4" +import GLib from "gi://GLib" +import { execAsync } from "ags/process" +import { interval } from "ags/time" +import { createState, createEffect } from "ags" + +const UNIT_FILE = `${GLib.get_home_dir()}/.config/ags/.weather-unit` +const LOCATION = "Phoenix" + +function readUnit(): "m" | "u" { + try { + const [ok, contents] = GLib.file_get_contents(UNIT_FILE) + return ok && new TextDecoder().decode(contents).trim() === "u" ? "u" : "m" + } catch { return "m" } +} + +export default function Weather() { + const [unit, setUnit] = createState<"m" | "u">(readUnit()) + const [text, setText] = createState("") + + function fetch() { + execAsync(`curl -sf 'wttr.in/${LOCATION}?format=%c%t&${unit()}'`) + .then(s => setText(s.trim().replace(/\s+/g, " ").replace(/\+(\d)/g, "$1"))) + .catch(() => {}) + } + + createEffect(() => { + unit() // subscribe — re-fetch when unit changes + fetch() + }) + + interval(1800000, fetch) + + return ( + t !== "")}> + + ) +} diff --git a/apps/.config/ags/widget/modules/WindowTitle.tsx b/apps/.config/ags/widget/modules/WindowTitle.tsx new file mode 100644 index 0000000..55b1f9b --- /dev/null +++ b/apps/.config/ags/widget/modules/WindowTitle.tsx @@ -0,0 +1,32 @@ +import { createState } from "ags" +import { interval } from "ags/time" +import { execAsync } from "ags/process" +import { Gtk } from "ags/gtk4" + +interface NiriWindow { + title: string | null + app_id: string | null +} + +export default function WindowTitle() { + const [win, setWin] = createState({ title: null, app_id: null }) + + function poll() { + execAsync(["niri", "msg", "--json", "focused-window"]) + .then(out => { try { setWin(JSON.parse(out)) } catch {} }) + .catch(() => setWin({ title: null, app_id: null })) + } + + poll() + interval(1000, poll) + + return ( +