feat(ags-v3): desktop adaptation — ultrawide scaling, brain power panel, system stats

- Scaling 16px base pour ultrawide 3440x1440
- Bar: CPU/RAM/GPU visible, media single player (skip playerctld), network tooltip LAN/WAN IPv4
- Volume: class module pour sizing cohérent
- Battery: désactivé (PC fixe)
- Clock: tooltip calendrier + uptime
- BrainPower: panel enrichi (focus, session, intentions, todos, repos git, derniers commits)
- App: BrainPower sur moniteur principal uniquement
- Heartbeat: Layer.TOP pour compatibilité COSMIC
This commit is contained in:
2026-03-26 15:25:03 +01:00
parent e94b841b2a
commit 9eaaa01663
13 changed files with 312 additions and 115 deletions

View File

@@ -1,12 +1,12 @@
import app from "ags/gtk3/app"
import { Astal, Gtk, Gdk } from "ags/gtk3"
import Clock from "./modules/Clock"
import Battery from "./modules/Battery"
// import Battery from "./modules/Battery" // desktop — no battery
import Volume from "./modules/Volume"
import Network from "./modules/Network"
import SystemStats from "./modules/SystemStats"
import Media from "./modules/Media"
import SysTray from "./modules/SysTray"
// import SysTray from "./modules/SysTray" // TODO: needs astal-tray (appmenu-glib-translator)
import Prompt from "./modules/Prompt"
export default function Bar(gdkmonitor: Gdk.Monitor) {
@@ -33,7 +33,7 @@ export default function Bar(gdkmonitor: Gdk.Monitor) {
<window
class="Bar"
name="bar"
visible={false}
visible={true}
gdkmonitor={gdkmonitor}
exclusivity={Astal.Exclusivity.EXCLUSIVE}
anchor={TOP | LEFT | RIGHT}
@@ -42,9 +42,8 @@ export default function Bar(gdkmonitor: Gdk.Monitor) {
>
<eventbox
onHover={() => cancelHide()}
onHoverLost={(self) => {
const win = self.get_toplevel() as Astal.Window
scheduleHide(win)
onHoverLost={(_self) => {
// disabled for debug — auto-hide off
}}
>
<centerbox>
@@ -59,13 +58,10 @@ export default function Bar(gdkmonitor: Gdk.Monitor) {
<Clock />
</box>
<box $type="end" class="modules-right" halign={Gtk.Align.END}>
<SysTray />
<label class="separator" label="│" />
{/* <SysTray /> */}
<Network />
<label class="separator" label="│" />
<Volume />
<label class="separator" label="│" />
<Battery />
</box>
</centerbox>
</eventbox>

View File

@@ -11,7 +11,7 @@ export default function Heartbeat(gdkmonitor: Gdk.Monitor) {
exclusivity={Astal.Exclusivity.NONE}
anchor={TOP | LEFT | RIGHT}
application={app}
layer={Astal.Layer.OVERLAY}
layer={Astal.Layer.TOP}
>
<eventbox
onHover={() => {

View File

@@ -1,38 +1,30 @@
import AstalBattery from "gi://AstalBattery"
import { createBinding, createDerivedBinding } from "ags"
import { createBinding } from "ags"
export default function Battery() {
const bat = AstalBattery.get_default()
const percentage = createBinding(bat, "percentage")
const charging = createBinding(bat, "charging")
const text = createBinding(bat, "percentage")((p: number) => {
const pct = Math.round(p * 100)
const isCharging = bat.charging
let icon = ""
if (isCharging) icon = "󰂄"
else if (pct > 90) icon = "󰁹"
else if (pct > 70) icon = "󰂁"
else if (pct > 50) icon = "󰁿"
else if (pct > 30) icon = "󰁽"
else if (pct > 10) icon = "󰁻"
else icon = "󰂃"
return `${icon} ${pct}%`
})
const text = createDerivedBinding(
[percentage, charging],
(p: number, isCharging: boolean) => {
const pct = Math.round(p * 100)
let icon = ""
if (isCharging) icon = "󰂄"
else if (pct > 90) icon = "󰁹"
else if (pct > 70) icon = "󰂁"
else if (pct > 50) icon = "󰁿"
else if (pct > 30) icon = "󰁽"
else if (pct > 10) icon = "󰁻"
else icon = "󰂃"
return `${icon} ${pct}%`
},
)
const cls = createDerivedBinding(
[percentage, charging],
(p: number, isCharging: boolean) => {
const pct = Math.round(p * 100)
if (isCharging) return "battery charging"
if (pct <= 10) return "battery low"
if (pct <= 20) return "battery warning"
return "battery"
},
)
const cls = createBinding(bat, "percentage")((p: number) => {
const pct = Math.round(p * 100)
if (bat.charging) return "battery charging"
if (pct <= 10) return "battery low"
if (pct <= 20) return "battery warning"
return "battery"
})
return <label class={cls} label={text} />
}

View File

@@ -1,4 +1,5 @@
import { createPoll } from "ags/time"
import { exec } from "ags/process"
function formatTime(): string {
const now = new Date()
@@ -10,6 +11,16 @@ function formatTime(): string {
}
function formatDate(): string {
const now = new Date()
return now.toLocaleDateString("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})
}
function formatDateShort(): string {
const now = new Date()
return now.toLocaleDateString("fr-FR", {
weekday: "short",
@@ -18,15 +29,44 @@ function formatDate(): string {
})
}
function getCalendar(): string {
try {
return exec("bash -c \"cal -h\"").trim()
} catch {
return ""
}
}
function getUptime(): string {
try {
return exec("bash -c \"uptime -p | sed 's/up //'\"").trim()
} catch {
return ""
}
}
function buildTooltip(): string {
const full = formatDate()
const cal = getCalendar()
const up = getUptime()
return `${full}\n\n${cal}\n\n⏱ uptime: ${up}`
}
export default function Clock() {
const time = createPoll("", 1000, () => formatTime())
const date = createPoll("", 60000, () => formatDate())
const date = createPoll("", 60000, () => formatDateShort())
return (
<box>
<label class="clock" label={time} />
<label class="separator" label="│" />
<label class="date" label={date} />
<eventbox
onHover={(self) => {
self.tooltipText = buildTooltip()
}}
>
<label class="date" label={date} hasTooltip />
</eventbox>
</box>
)
}

View File

@@ -5,24 +5,27 @@ export default function Media() {
const mpris = AstalMpris.get_default()
const players = createBinding(mpris, "players")
// Filter to only show non-playerctld players
const filtered = players((list: AstalMpris.Player[]) =>
list.filter((p) => p.busName && !p.busName.includes("playerctld")).slice(0, 1)
)
return (
<box class="media-module">
<For each={players}>
<For each={filtered}>
{(player) => {
const title = createBinding(player, "title")
const artist = createBinding(player, "artist")
const status = createBinding(player, "playbackStatus")
const icon = status((s: AstalMpris.PlaybackStatus) =>
s === AstalMpris.PlaybackStatus.PLAYING ? "󰐊" : "󰏤"
s === AstalMpris.PlaybackStatus.PLAYING ? "" : ""
)
const label = title((t: string) => {
const a = player.artist
const display = a ? `${a}${t}` : t
// truncate long titles
return display.length > 40
? display.substring(0, 37) + "..."
return display.length > 45
? display.substring(0, 42) + "..."
: display
})
@@ -32,23 +35,14 @@ export default function Media() {
return (
<box class={cls}>
<button
class="media-prev"
onClicked={() => player.previous()}
>
<label label="󰒮" />
<button class="media-prev" onClicked={() => player.previous()}>
<label label="⏮" />
</button>
<button
class="media-play"
onClicked={() => player.play_pause()}
>
<button class="media-play" onClicked={() => player.play_pause()}>
<label label={icon} />
</button>
<button
class="media-next"
onClicked={() => player.next()}
>
<label label="󰒭" />
<button class="media-next" onClicked={() => player.next()}>
<label label="⏭" />
</button>
<label class="media-text" label={label} />
</box>

View File

@@ -1,5 +1,16 @@
import AstalNetwork from "gi://AstalNetwork"
import { createBinding } from "ags"
import { exec } from "ags/process"
function getIPs(): string {
try {
const lan = exec("bash -c \"hostname -I | awk '{print $1}'\"").trim()
const wan = exec("bash -c \"curl -4 -s --max-time 2 ifconfig.me\"").trim()
return `LAN: ${lan || "?"}\nWAN: ${wan || "?"}`
} catch {
return "IPs unavailable"
}
}
export default function Network() {
const net = AstalNetwork.get_default()
@@ -17,15 +28,23 @@ export default function Network() {
else if (strength > 20) icon = "󰤟"
return `${icon} ${ssid}`
}
if (type === AstalNetwork.Primary.WIRED) return "󰈀 eth"
if (type === AstalNetwork.Primary.WIRED) return "󰈀 wired"
return "󰤮 offline"
})
const cls = createBinding(net, "primary")((type: AstalNetwork.Primary) => {
if (type === AstalNetwork.Primary.WIFI) return "network wifi"
if (type === AstalNetwork.Primary.WIRED) return "network wired"
return "network disconnected"
if (type === AstalNetwork.Primary.WIFI) return "network module wifi"
if (type === AstalNetwork.Primary.WIRED) return "network module wired"
return "network module disconnected"
})
return <label class={cls} label={text} />
return (
<eventbox
onHover={(self) => {
self.tooltipText = getIPs()
}}
>
<label class={cls} label={text} hasTooltip />
</eventbox>
)
}

View File

@@ -1,5 +1,6 @@
import GLib from "gi://GLib"
import { createPoll } from "ags/time"
import { exec } from "ags/process"
function readProc(path: string): string | null {
try {
@@ -16,9 +17,9 @@ function getCpuUsage(): string {
const content = readProc("/proc/stat")
if (!content) return "?"
const line = content.split("\n")[0] // "cpu user nice system idle ..."
const line = content.split("\n")[0]
const parts = line.split(/\s+/).slice(1).map(Number)
const idle = parts[3] + parts[4] // idle + iowait
const idle = parts[3] + parts[4]
const total = parts.reduce((a, b) => a + b, 0)
const dIdle = idle - prevIdle
@@ -30,31 +31,59 @@ function getCpuUsage(): string {
return `${Math.round(((dTotal - dIdle) / dTotal) * 100)}`
}
function getRamUsage(): string {
function getRamUsage(): { pct: string; used: string; total: string } {
const content = readProc("/proc/meminfo")
if (!content) return "?"
if (!content) return { pct: "?", used: "?", total: "?" }
const lines = content.split("\n")
let total = 0, available = 0
let totalKb = 0, availableKb = 0
for (const line of lines) {
if (line.startsWith("MemTotal:")) total = parseInt(line.split(/\s+/)[1])
if (line.startsWith("MemAvailable:")) available = parseInt(line.split(/\s+/)[1])
if (total && available) break
if (line.startsWith("MemTotal:")) totalKb = parseInt(line.split(/\s+/)[1])
if (line.startsWith("MemAvailable:")) availableKb = parseInt(line.split(/\s+/)[1])
if (totalKb && availableKb) break
}
if (!total) return "?"
return `${Math.round(((total - available) / total) * 100)}`
if (!totalKb) return { pct: "?", used: "?", total: "?" }
const usedGb = ((totalKb - availableKb) / 1048576).toFixed(1)
const totalGb = (totalKb / 1048576).toFixed(0)
const pct = `${Math.round(((totalKb - availableKb) / totalKb) * 100)}`
return { pct, used: usedGb, total: totalGb }
}
function getGpuInfo(): string {
try {
const out = exec("bash -c \"nvidia-smi --query-gpu=utilization.gpu,temperature.gpu --format=csv,noheader,nounits 2>/dev/null\"")
const [usage, temp] = out.trim().split(", ")
return `GPU ${usage}% · ${temp}°C`
} catch {
return ""
}
}
export default function SystemStats() {
const cpu = createPoll("0", 3000, () => getCpuUsage())
const ram = createPoll("0", 5000, () => getRamUsage())
const ram = createPoll("", 5000, () => {
const r = getRamUsage()
return JSON.stringify(r)
})
const ramLabel = ram((v: string) => {
try {
const r = JSON.parse(v)
return `RAM ${r.used}/${r.total}G`
} catch { return "RAM ?" }
})
const gpu = createPoll("", 5000, () => getGpuInfo())
return (
<box>
<label class="cpu module" label={cpu((v: string) => ` ${v}%`)} />
<label class="ram module" label={ram((v: string) => `󰍛 ${v}%`)} />
<label class="cpu module" label={cpu((v: string) => `CPU ${v}%`)} />
<label class="separator" label="│" />
<label class="ram module" label={ramLabel} />
<label class="separator" label="│" />
<label class="gpu module" label={gpu((v: string) => v || "GPU ?")} />
</box>
)
}

View File

@@ -20,7 +20,7 @@ export default function Volume() {
})
const cls = mute((m: boolean) =>
m ? "volume muted" : "volume"
m ? "volume module muted" : "volume module"
)
return (

View File

@@ -6,15 +6,40 @@ import { getBrainState } from "../../lib/brain"
function BrainContent() {
const state = createPoll("", 10000, () => JSON.stringify(getBrainState()))
const version = state((s: string) => {
try { return `kernel v${JSON.parse(s).kernelVersion}` } catch { return "" }
})
const focus = state((s: string) => {
try { return JSON.parse(s).focus } catch { return "..." }
})
const todosLabel = state((s: string) => {
const session = state((s: string) => {
try {
const sess = JSON.parse(s).session
return sess || "aucune session active"
} catch { return "..." }
})
const intentionsTitle = state((s: string) => {
try {
const i = JSON.parse(s).intentions
return ` INTENTIONS (${i.active} active${i.active > 1 ? "s" : ""})`
} catch { return " INTENTIONS" }
})
const intentionsList = state((s: string) => {
try {
const names = JSON.parse(s).intentions.names
return names.map((n: string) => `${n}`).join("\n") || " aucune"
} catch { return "..." }
})
const todosTitle = state((s: string) => {
try {
const t = JSON.parse(s).todos
return `${t.open} ouverts / ${t.done} faits`
} catch { return "..." }
} catch { return "" }
})
const todosList = state((s: string) => {
@@ -24,22 +49,32 @@ function BrainContent() {
} catch { return "..." }
})
const session = state((s: string) => {
const reposStatus = state((s: string) => {
try {
const sess = JSON.parse(s).session
return sess || "aucune"
const repos = JSON.parse(s).repos
return repos.map((r: any) => ` ${r.name.padEnd(14)} ${r.status}`).join("\n")
} catch { return "..." }
})
const commitsList = state((s: string) => {
try {
const commits = JSON.parse(s).commits
return commits.map((c: string) => ` ${c}`).join("\n") || " aucun"
} catch { return "..." }
})
return (
<box orientation={Gtk.Orientation.VERTICAL} class="brain-content">
{/* FOCUS */}
<box class="brain-section">
<label class="brain-section-title" label=" FOCUS" xalign={0} />
<label class="brain-version" label={version} xalign={1} hexpand />
</box>
<label class="brain-focus" label={focus} xalign={0} wrap />
<box class="brain-divider" />
{/* SESSION */}
<box class="brain-section">
<label class="brain-section-title" label=" SESSION" xalign={0} />
</box>
@@ -47,18 +82,36 @@ function BrainContent() {
<box class="brain-divider" />
{/* INTENTIONS */}
<box class="brain-section">
<label class="brain-section-title" label={intentionsTitle} xalign={0} />
</box>
<label class="brain-intentions-list" label={intentionsList} xalign={0} />
<box class="brain-divider" />
{/* TODOS */}
<box class="brain-section">
<label class="brain-section-title" label="󰄲 TODOS" xalign={0} />
<label class="brain-todos-count" label={todosLabel} xalign={1} hexpand />
<label class="brain-todos-count" label={todosTitle} xalign={1} hexpand />
</box>
<label class="brain-todos-list" label={todosList} xalign={0} />
<box class="brain-divider" />
{/* REPOS */}
<box class="brain-section">
<label class="brain-section-title" label=" TERMINAL" xalign={0} />
<label class="brain-section-title" label=" REPOS" xalign={0} />
</box>
<label class="brain-terminal-placeholder" label=" bientôt — agent brain-hud" xalign={0} />
<label class="brain-repos-status" label={reposStatus} xalign={0} />
<box class="brain-divider" />
{/* COMMITS */}
<box class="brain-section">
<label class="brain-section-title" label=" DERNIERS COMMITS" xalign={0} />
</box>
<label class="brain-commits-list" label={commitsList} xalign={0} />
</box>
)
}