feat: Ghost Shell v2 — AGS v3 statusbar + violet-chaton v2 palette

- AGS v3.1.0 (Astal/GTK3) Ghost Shell avec ghost mode (heartbeat + hover reveal)
- Modules : clock, battery, volume (interactif), network, MPRIS, CPU/RAM, systray
- Brain Power panel (Super + B) — lecture live focus/todos/session
- tetardtek_ prompt avec curseur clignotant
- Palette violet-chaton v2 documentée (Mitsuri Kanroji gradient magenta → green)
- Autostart COSMIC via .desktop
- Archive AGS v1 conservée pour référence
This commit is contained in:
Tetardtek-Cortex
2026-03-26 06:54:17 +01:00
parent 7e9d12e640
commit 932b174c36
30 changed files with 2557 additions and 0 deletions

74
ags-v3/widget/Bar.tsx Normal file
View File

@@ -0,0 +1,74 @@
import app from "ags/gtk3/app"
import { Astal, Gtk, Gdk } from "ags/gtk3"
import Clock from "./modules/Clock"
import Battery from "./modules/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 Prompt from "./modules/Prompt"
export default function Bar(gdkmonitor: Gdk.Monitor) {
const { TOP, LEFT, RIGHT } = Astal.WindowAnchor
let hideTimeout: number | null = null
function scheduleHide(win: Astal.Window) {
if (hideTimeout) clearTimeout(hideTimeout)
hideTimeout = setTimeout(() => {
win.visible = false
hideTimeout = null
}, 800)
}
function cancelHide() {
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
}
return (
<window
class="Bar"
name="bar"
visible={false}
gdkmonitor={gdkmonitor}
exclusivity={Astal.Exclusivity.EXCLUSIVE}
anchor={TOP | LEFT | RIGHT}
application={app}
layer={Astal.Layer.TOP}
>
<eventbox
onHover={() => cancelHide()}
onHoverLost={(self) => {
const win = self.get_toplevel() as Astal.Window
scheduleHide(win)
}}
>
<centerbox>
<box $type="start" class="modules-left" halign={Gtk.Align.START}>
<Prompt />
<label class="separator" label="│" />
<SystemStats />
<label class="separator" label="│" />
<Media />
</box>
<box $type="center" class="modules-center">
<Clock />
</box>
<box $type="end" class="modules-right" halign={Gtk.Align.END}>
<SysTray />
<label class="separator" label="│" />
<Network />
<label class="separator" label="│" />
<Volume />
<label class="separator" label="│" />
<Battery />
</box>
</centerbox>
</eventbox>
</window>
)
}

View File

@@ -0,0 +1,27 @@
import app from "ags/gtk3/app"
import { Astal, Gtk, Gdk } from "ags/gtk3"
export default function Heartbeat(gdkmonitor: Gdk.Monitor) {
const { TOP, LEFT, RIGHT } = Astal.WindowAnchor
return (
<window
class="Heartbeat"
gdkmonitor={gdkmonitor}
exclusivity={Astal.Exclusivity.NONE}
anchor={TOP | LEFT | RIGHT}
application={app}
layer={Astal.Layer.OVERLAY}
>
<eventbox
onHover={() => {
// reveal the bar
const bar = app.get_window("bar")
if (bar) bar.visible = true
}}
>
<box class="heartbeat-line" />
</eventbox>
</window>
)
}

View File

@@ -0,0 +1,29 @@
import AstalBattery from "gi://AstalBattery"
import { createBinding } from "ags"
export default function Battery() {
const bat = AstalBattery.get_default()
const text = createBinding(bat, "percentage")((p: number) => {
const pct = Math.round(p * 100)
let icon = ""
if (bat.charging) 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 = 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

@@ -0,0 +1,32 @@
import { createPoll } from "ags/time"
function formatTime(): string {
const now = new Date()
return now.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
}
function formatDate(): string {
const now = new Date()
return now.toLocaleDateString("fr-FR", {
weekday: "short",
day: "numeric",
month: "short",
})
}
export default function Clock() {
const time = createPoll("", 1000, () => formatTime())
const date = createPoll("", 60000, () => formatDate())
return (
<box>
<label class="clock" label={time} />
<label class="separator" label="│" />
<label class="date" label={date} />
</box>
)
}

View File

@@ -0,0 +1,60 @@
import AstalMpris from "gi://AstalMpris"
import { createBinding, For } from "ags"
export default function Media() {
const mpris = AstalMpris.get_default()
const players = createBinding(mpris, "players")
return (
<box class="media-module">
<For each={players}>
{(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 ? "󰐊" : "󰏤"
)
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) + "..."
: display
})
const cls = status((s: AstalMpris.PlaybackStatus) =>
s === AstalMpris.PlaybackStatus.PLAYING ? "media" : "media paused"
)
return (
<box class={cls}>
<button
class="media-prev"
onClicked={() => player.previous()}
>
<label label="󰒮" />
</button>
<button
class="media-play"
onClicked={() => player.play_pause()}
>
<label label={icon} />
</button>
<button
class="media-next"
onClicked={() => player.next()}
>
<label label="󰒭" />
</button>
<label class="media-text" label={label} />
</box>
)
}}
</For>
</box>
)
}

View File

@@ -0,0 +1,31 @@
import AstalNetwork from "gi://AstalNetwork"
import { createBinding } from "ags"
export default function Network() {
const net = AstalNetwork.get_default()
const text = createBinding(net, "primary")((type: AstalNetwork.Primary) => {
if (type === AstalNetwork.Primary.WIFI) {
const wifi = net.wifi
if (!wifi) return "󰤮 offline"
const ssid = wifi.ssid || "wifi"
const strength = wifi.strength
let icon = "󰤯"
if (strength > 80) icon = "󰤨"
else if (strength > 60) icon = "󰤥"
else if (strength > 40) icon = "󰤢"
else if (strength > 20) icon = "󰤟"
return `${icon} ${ssid}`
}
if (type === AstalNetwork.Primary.WIRED) return "󰈀 eth"
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"
})
return <label class={cls} label={text} />
}

View File

@@ -0,0 +1,15 @@
import { createPoll } from "ags/time"
export default function Prompt() {
const cursor = createPoll("_", 600, () => {
// alternate between _ and empty to create blink
return Date.now() % 1200 < 600 ? "_" : " "
})
return (
<box>
<label class="prompt-name" label="tetardtek" />
<label class="prompt-cursor" label={cursor} />
</box>
)
}

View File

@@ -0,0 +1,24 @@
import AstalTray from "gi://AstalTray"
import Gtk from "gi://Gtk?version=3.0"
import { createBinding, For } from "ags"
export default function SysTray() {
const tray = AstalTray.get_default()
const items = createBinding(tray, "items")
return (
<box class="systray">
<For each={items}>
{(item) => (
<button
class="systray-item"
tooltipText={createBinding(item, "tooltipMarkup")}
onClicked={() => item.activate(0, 0)}
>
<icon pixelSize={16} gicon={createBinding(item, "gicon")} />
</button>
)}
</For>
</box>
)
}

View File

@@ -0,0 +1,31 @@
import { createPoll } from "ags/time"
import { exec } from "ags/process"
function getCpuUsage(): string {
try {
const out = exec("bash -c \"top -bn1 | grep 'Cpu(s)' | awk '{print 100 - $8}'\"")
return `${Math.round(parseFloat(out))}`
} catch {
return "?"
}
}
function getRamUsage(): string {
try {
return exec("bash -c \"free | awk '/Mem:/ {printf \\\"%.0f\\\", $3/$2 * 100}'\"")
} catch {
return "?"
}
}
export default function SystemStats() {
const cpu = createPoll("0", 3000, () => getCpuUsage())
const ram = createPoll("0", 5000, () => getRamUsage())
return (
<box>
<label class="cpu module" label={cpu((v: string) => ` ${v}%`)} />
<label class="ram module" label={ram((v: string) => `󰍛 ${v}%`)} />
</box>
)
}

View File

@@ -0,0 +1,41 @@
import AstalWp from "gi://AstalWp"
import { createBinding } from "ags"
export default function Volume() {
const speaker = AstalWp.get_default()!.defaultSpeaker!
const text = createBinding(speaker, "volume")((v: number) => {
const pct = Math.round(v * 100)
const muted = speaker.mute
let icon = "󰕾"
if (muted) icon = "󰝟"
else if (v > 0.66) icon = "󰕾"
else if (v > 0.33) icon = "󰖀"
else if (v > 0) icon = "󰕿"
else icon = "󰝟"
return `${icon} ${pct}%`
})
const cls = createBinding(speaker, "mute")((m: boolean) =>
m ? "volume muted" : "volume"
)
return (
<button
class={cls}
onClicked={() => {
speaker.mute = !speaker.mute
}}
onScroll={(_self: any, event: any) => {
const delta = event.delta_y
if (delta < 0) {
speaker.volume = Math.min(speaker.volume + 0.05, 1.0)
} else {
speaker.volume = Math.max(speaker.volume - 0.05, 0.0)
}
}}
>
<label label={text} />
</button>
)
}

View File

@@ -0,0 +1,104 @@
import app from "ags/gtk3/app"
import { Astal, Gtk, Gdk } from "ags/gtk3"
import { createPoll } from "ags/time"
import { getBrainState } from "../../lib/brain"
function BrainContent() {
const state = createPoll("", 10000, () => JSON.stringify(getBrainState()))
const focus = state((s: string) => {
try { return JSON.parse(s).focus } catch { return "..." }
})
const todosLabel = state((s: string) => {
try {
const t = JSON.parse(s).todos
return `${t.open} ouverts / ${t.done} faits`
} catch { return "..." }
})
const todosList = state((s: string) => {
try {
const t = JSON.parse(s).todos.top3
return t.map((item: string) => `${item}`).join("\n") || " rien"
} catch { return "..." }
})
const session = state((s: string) => {
try {
const sess = JSON.parse(s).session
return sess || "aucune"
} catch { return "..." }
})
return (
<box orientation={Gtk.Orientation.VERTICAL} class="brain-content">
<box class="brain-section">
<label class="brain-section-title" label=" FOCUS" xalign={0} />
</box>
<label class="brain-focus" label={focus} xalign={0} wrap />
<box class="brain-divider" />
<box class="brain-section">
<label class="brain-section-title" label=" SESSION" xalign={0} />
</box>
<label class="brain-session" label={session} xalign={0} />
<box class="brain-divider" />
<box class="brain-section">
<label class="brain-section-title" label="󰄲 TODOS" xalign={0} />
<label class="brain-todos-count" label={todosLabel} xalign={1} hexpand />
</box>
<label class="brain-todos-list" label={todosList} xalign={0} />
<box class="brain-divider" />
<box class="brain-section">
<label class="brain-section-title" label=" TERMINAL" xalign={0} />
</box>
<label class="brain-terminal-placeholder" label=" bientôt — agent brain-hud" xalign={0} />
</box>
)
}
export default function BrainPower(gdkmonitor: Gdk.Monitor) {
const { TOP, LEFT, BOTTOM } = Astal.WindowAnchor
return (
<window
class="BrainPower"
name="brain-power"
visible={false}
gdkmonitor={gdkmonitor}
exclusivity={Astal.Exclusivity.NONE}
anchor={TOP | LEFT | BOTTOM}
application={app}
layer={Astal.Layer.OVERLAY}
>
<eventbox
onHoverLost={() => {
const win = app.get_window("brain-power")
if (win) win.visible = false
}}
>
<box orientation={Gtk.Orientation.VERTICAL} class="brain-panel">
<box class="brain-header">
<label class="brain-title" label=" BRAIN POWER" hexpand xalign={0} />
<button
class="brain-close"
onClicked={() => {
const win = app.get_window("brain-power")
if (win) win.visible = false
}}
>
<label label="✕" />
</button>
</box>
<BrainContent />
</box>
</eventbox>
</window>
)
}