Compare commits

..

No commits in common. "71a8ee189f791bbd6d2b4425b674d08916c4d491" and "69aff76fc42ae2d131df87be023817a71df9295f" have entirely different histories.

11 changed files with 439 additions and 6 deletions

View file

@ -52,5 +52,15 @@ vim.api.nvim_create_autocmd("User", {
end,
})
-- CONTRACT: le démarrage ordinaire ne met pas les plugins à jour et ne
-- réécrit pas lazy-lock.json ; les mises à jour restent une action explicite.
-- Lancer la mise à jour à l'ouverture de Neovim
vim.api.nvim_create_autocmd("VimEnter", {
group = lazy_update_group,
once = true,
callback = function()
vim.schedule(function()
require("lazy").update({
show = true,
})
end)
end,
})

View file

@ -7,6 +7,7 @@ import "windows"
import "datecenter"
import "notifications"
import "status"
import "systemmonitor"
import "theme"
PanelWindow {
@ -43,6 +44,7 @@ PanelWindow {
function toggleDateCenter() {
const opening = !dateCenter.visible && !dateCenter.opening;
controlCenter.visible = false;
systemMonitor.visible = false;
windowStrip.closeMenu();
if (opening) dateCenter.requestOpen();
else dateCenter.requestClose();
@ -74,6 +76,7 @@ PanelWindow {
maxWidth: Math.max(0, leftArea.width - x - 8)
onMenuOpened: {
controlCenter.visible = false;
systemMonitor.visible = false;
dateCenter.requestClose();
}
}
@ -107,19 +110,29 @@ PanelWindow {
wifiDevice: controlCenter.wifiDevice
adapter: controlCenter.adapter
controlCenterOpen: controlCenter.visible
systemMonitorOpen: systemMonitor.visible
onOpenPageRequested: page => {
systemMonitor.visible = false;
dateCenter.requestClose();
windowStrip.closeMenu();
controlCenter.currentPage = page;
controlCenter.visible = true;
}
onToggleControlCenterRequested: {
systemMonitor.visible = false;
const opening = !controlCenter.visible;
dateCenter.requestClose();
windowStrip.closeMenu();
controlCenter.currentPage = 0;
controlCenter.visible = opening;
}
onToggleSystemMonitorRequested: {
const opening = !systemMonitor.visible;
controlCenter.visible = false;
dateCenter.requestClose();
windowStrip.closeMenu();
systemMonitor.visible = opening;
}
}
}
@ -129,6 +142,11 @@ PanelWindow {
onThresholdApplied: rightStatusArea.refreshBatteryThreshold()
}
SystemMonitor {
id: systemMonitor
barWindow: bar
}
DateCenter {
id: dateCenter
barWindow: bar
@ -138,5 +156,7 @@ PanelWindow {
NotificationToastArea {
barWindow: bar
service: bar.notificationService
monitorOpen: systemMonitor.visible
monitorWidth: systemMonitor.implicitWidth
}
}

View file

@ -18,6 +18,7 @@ Bar
│ ├── Battery
│ ├── Updates
│ ├── Tray
│ ├── System Monitor
│ └── Control Center
└── Control Center
├── Wi-Fi
@ -46,8 +47,9 @@ est 60 % ; le helper root est documenté dans `bin/root/README.md`.
Dépendances essentielles : `quickshell`, SwayFX 0.6, `scenefx`, une Nerd Font,
NetworkManager, BlueZ, PipeWire, TLP/`tlpctl`, `checkupdates`, `yay`, `df`,
`python3`, `swaylock` et le helper batterie pour le réglage du seuil. Le
backlight utilise le noeud machine `amdgpu_bl1` dans `BrightnessSlider.qml`.
Les scans Wi-Fi et Bluetooth
backlight utilise le noeud machine `amdgpu_bl1` dans `BrightnessSlider.qml` ;
le System Monitor résout dynamiquement les noeuds DRM AMD et hwmon
`k10temp`/`amdgpu` par identité, noms et labels. Les scans Wi-Fi et Bluetooth
ne sont possédés que par les pages ouvertes.
Le gestionnaire de fonds d'écran local est décrit dans

View file

@ -0,0 +1,53 @@
#!/usr/bin/python3
"""Résout les capteurs de cette session sans figer les numéros sysfs."""
from pathlib import Path
import re
DRM = Path("/sys/class/drm")
HWMON = Path("/sys/class/hwmon")
def read(path):
try:
return path.read_text(encoding="ascii").strip()
except (OSError, UnicodeError):
return ""
def gpu_busy():
# INVARIANT: seul un noeud cardN dont l'identité PCI est AMD est retenu.
for card in sorted(DRM.glob("card[0-9]*")):
if not re.fullmatch(r"card\d+", card.name):
continue
device = card / "device"
candidate = device / "gpu_busy_percent"
if read(device / "vendor").lower() == "0x1002" and candidate.is_file():
return candidate
return None
def temperature(name, label):
for monitor in sorted(HWMON.glob("hwmon[0-9]*")):
if read(monitor / "name") != name:
continue
# CONTRACT: préférer le libellé physique ; temp1 sert aux pilotes sans labels.
inputs = sorted(monitor.glob("temp*_input"))
for candidate in inputs:
if read(candidate.with_name(candidate.name.replace("_input", "_label"))).lower() == label:
return candidate
fallback = monitor / "temp1_input"
if fallback.is_file():
return fallback
return None
def main():
# Format fixe sur trois lignes, contrôlé côté QML. Aucun chemin utilisateur.
for path in (gpu_busy(), temperature("k10temp", "tctl"), temperature("amdgpu", "edge")):
print(path or "")
if __name__ == "__main__":
main()

View file

@ -5,8 +5,12 @@ PopupWindow {
id: area
required property var barWindow
required property var service
property bool monitorOpen: false
property int monitorWidth: 400
anchor.window: barWindow
anchor.rect.x: barWindow.width - implicitWidth - 8
// Laisser les notifications visibles sans recouvrir le moniteur ouvert.
anchor.rect.x: monitorOpen ? barWindow.width - monitorWidth - implicitWidth - 16
: barWindow.width - implicitWidth - 8
anchor.rect.y: barWindow.height + 8
anchor.edges: Edges.Top | Edges.Left
anchor.gravity: Edges.Bottom | Edges.Right

View file

@ -1,5 +1,6 @@
import QtQuick
import "../controlcenter"
import "../systemmonitor"
Row {
id: area
@ -7,8 +8,10 @@ Row {
required property var wifiDevice
required property var adapter
required property bool controlCenterOpen
required property bool systemMonitorOpen
signal openPageRequested(int page)
signal toggleControlCenterRequested()
signal toggleSystemMonitorRequested()
function refreshBatteryThreshold() { batteryIndicator.refreshThreshold() }
spacing: 5
height: 26
@ -18,5 +21,9 @@ Row {
BatteryIndicator { id: batteryIndicator; onActivated: area.openPageRequested(5) }
UpdatesIndicator { }
Tray { barWindow: area.barWindow }
SystemMonitorButton {
open: area.systemMonitorOpen
onToggled: area.toggleSystemMonitorRequested()
}
ControlCenterButton { open: area.controlCenterOpen; onToggled: area.toggleControlCenterRequested() }
}

View file

@ -0,0 +1,31 @@
import QtQuick
import "../theme"
Column {
id: row
required property string label
required property string value
required property real percentage
property color accent: Theme.mauve
width: parent.width
spacing: 6
Row {
width: parent.width
Text { id: labelText; text: row.label; color: Theme.foreground; font.pixelSize: 12 }
Item { width: Math.max(0, parent.width - valueText.width - labelText.width); height: 1 }
Text { id: valueText; text: row.value; color: Theme.subtext1; font.pixelSize: 12 }
}
Rectangle {
width: parent.width
height: 5
radius: 3
color: Theme.buttonBackground
Rectangle {
width: parent.width * Math.max(0, Math.min(100, row.percentage)) / 100
height: parent.height
radius: 3
color: row.accent
}
}
}

View file

@ -0,0 +1,177 @@
import QtQuick
import Quickshell
import Quickshell.Io
Item {
id: metrics
property bool active: false
property real cpuPercent: -1
property real ramPercent: -1
property real ramUsedGiB: -1
property real ramTotalGiB: -1
property real gpuPercent: -1
property real cpuTemp: -1
property real gpuTemp: -1
property real diskPercent: -1
property real diskUsedGiB: -1
property real diskTotalGiB: -1
property string loadAverage: ""
property string uptimeText: ""
property var previousCpu: null
property string gpuPath: ""
property string cpuTempPath: ""
property string gpuTempPath: ""
function numberFromFile(view, divisor) {
const raw = view.text().trim();
if (!/^[0-9]+$/.test(raw)) return -1;
const value = Number(raw) / divisor;
return Number.isFinite(value) ? value : -1;
}
// CONTRACT: la première lecture CPU constitue seulement la référence ; seul un delta positif produit un pourcentage.
function updateCpu() {
const line = cpuFile.text().split("\n")[0].trim().split(/\s+/);
if (line[0] !== "cpu" || line.length < 6) return;
const values = line.slice(1).map(Number);
if (values.some(value => !Number.isFinite(value))) return;
const total = values.reduce((sum, value) => sum + value, 0);
const idle = values[3] + values[4]; // idle + iowait
if (previousCpu) {
const totalDelta = total - previousCpu.total;
const idleDelta = idle - previousCpu.idle;
if (totalDelta > 0 && idleDelta >= 0)
cpuPercent = Math.max(0, Math.min(100, 100 * (1 - idleDelta / totalDelta)));
}
previousCpu = { total: total, idle: idle };
}
function updateMemory() {
const entries = {};
for (const line of memoryFile.text().split("\n")) {
const match = line.match(/^(MemTotal|MemAvailable):\s+([0-9]+) kB$/);
if (match) entries[match[1]] = Number(match[2]);
}
if (!(entries.MemTotal > 0) || !(entries.MemAvailable >= 0)) return;
const used = Math.max(0, entries.MemTotal - entries.MemAvailable);
ramPercent = Math.min(100, used * 100 / entries.MemTotal);
ramUsedGiB = used / 1048576;
ramTotalGiB = entries.MemTotal / 1048576;
}
function updateLoad() {
const fields = loadFile.text().trim().split(/\s+/);
if (fields.length >= 3 && fields.slice(0, 3).every(value => /^[0-9]+\.[0-9]+$/.test(value)))
loadAverage = fields.slice(0, 3).join(" ");
}
function updateUptime() {
const seconds = Number(uptimeFile.text().trim().split(/\s+/)[0]);
if (!Number.isFinite(seconds) || seconds < 0) return;
const minutes = Math.floor(seconds / 60);
const days = Math.floor(minutes / 1440);
const hours = Math.floor((minutes % 1440) / 60);
uptimeText = days > 0 ? days + " j " + hours + " h" : hours + " h " + minutes % 60 + " min";
}
function sample() {
if (!active) return;
cpuFile.reload();
memoryFile.reload();
gpuFile.reload();
cpuTempFile.reload();
gpuTempFile.reload();
loadFile.reload();
uptimeFile.reload();
}
function refreshDisk() {
if (active && !diskReader.running) diskReader.running = true;
}
onActiveChanged: {
if (active) {
previousCpu = null;
cpuPercent = -1;
gpuPercent = -1;
cpuTemp = -1;
gpuTemp = -1;
gpuPath = "";
cpuTempPath = "";
gpuTempPath = "";
// WHY: les numéros cardN et hwmonN changent au redémarrage.
if (!hardwareResolver.running) hardwareResolver.running = true;
sample();
refreshDisk();
} else {
previousCpu = null;
diskReader.running = false;
}
}
// INVARIANT: aucun échantillonnage ni Process df périodique quand le panneau est fermé.
Timer { interval: 1000; repeat: true; running: metrics.active; onTriggered: metrics.sample() }
Timer { interval: 15000; repeat: true; running: metrics.active; onTriggered: metrics.refreshDisk() }
FileView { id: cpuFile; path: "/proc/stat"; onLoaded: metrics.updateCpu() }
FileView { id: memoryFile; path: "/proc/meminfo"; onLoaded: metrics.updateMemory() }
FileView {
id: gpuFile
path: metrics.gpuPath
printErrors: false
onLoaded: metrics.gpuPercent = metrics.numberFromFile(gpuFile, 1)
}
FileView {
id: cpuTempFile
path: metrics.cpuTempPath
printErrors: false
onLoaded: metrics.cpuTemp = metrics.numberFromFile(cpuTempFile, 1000)
}
FileView {
id: gpuTempFile
path: metrics.gpuTempPath
printErrors: false
onLoaded: metrics.gpuTemp = metrics.numberFromFile(gpuTempFile, 1000)
}
FileView { id: loadFile; path: "/proc/loadavg"; onLoaded: metrics.updateLoad() }
FileView { id: uptimeFile; path: "/proc/uptime"; onLoaded: metrics.updateUptime() }
Process {
id: hardwareResolver
command: ["/usr/bin/python3", Quickshell.shellPath("bin/resolve-hardware.py")]
stdout: StdioCollector { id: hardwareOutput; waitForEnd: true }
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0 || exitStatus !== 0) return;
const paths = hardwareOutput.text.trimEnd().split("\n");
if (paths.length !== 3) return;
// CONTRACT: seuls les chemins sysfs résolus par le helper sont chargés.
if (paths[0].startsWith("/sys/class/drm/")) metrics.gpuPath = paths[0];
if (paths[1].startsWith("/sys/class/hwmon/")) metrics.cpuTempPath = paths[1];
if (paths[2].startsWith("/sys/class/hwmon/")) metrics.gpuTempPath = paths[2];
if (metrics.active) {
gpuFile.reload();
cpuTempFile.reload();
gpuTempFile.reload();
}
}
}
Process {
id: diskReader
command: ["df", "-B1", "--output=size,used,pcent,target", "/"]
stdout: StdioCollector { id: diskOutput; waitForEnd: true }
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0 || exitStatus !== 0) return;
const lines = diskOutput.text.trim().split("\n");
if (lines.length < 2) return;
const fields = lines[lines.length - 1].trim().split(/\s+/);
if (fields.length !== 4 || fields[3] !== "/") return;
const total = Number(fields[0]);
const used = Number(fields[1]);
if (!(total > 0) || !(used >= 0)) return;
metrics.diskTotalGiB = total / 1073741824;
metrics.diskUsedGiB = used / 1073741824;
metrics.diskPercent = Math.min(100, used * 100 / total);
}
}
}

View file

@ -0,0 +1,93 @@
import QtQuick
import Quickshell
import "../components"
import "../theme"
PopupWindow {
id: popup
required property var barWindow
anchor.window: barWindow
anchor.rect.x: barWindow.width - implicitWidth - 8
anchor.rect.y: barWindow.height + 6
anchor.edges: Edges.Top | Edges.Left
anchor.gravity: Edges.Bottom | Edges.Right
implicitWidth: 400
implicitHeight: content.implicitHeight + 32
visible: false
// Le panneau est informatif ; laisser les boutons de barre recevoir les clics XOR.
grabFocus: false
color: "transparent"
SystemMetrics { id: metrics; active: popup.visible }
Rectangle {
anchors.fill: parent
radius: 4
color: Theme.popupBackground
border.color: Theme.outline
Column {
id: content
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 16
spacing: 16
Row {
spacing: 8
NerdIcon { text: "󰍛"; font.pixelSize: 18 }
Text { text: "Moniteur système"; color: Theme.foreground; font.pixelSize: 16; font.bold: true }
}
MetricRow {
label: "CPU"
percentage: metrics.cpuPercent
value: (metrics.cpuPercent >= 0 ? Math.round(metrics.cpuPercent) + " %" : "—")
+ (metrics.cpuTemp >= 0 ? " " + Math.round(metrics.cpuTemp) + " °C" : "")
accent: metrics.cpuPercent >= 90 ? Theme.danger : metrics.cpuPercent >= 70 ? Theme.urgent : Theme.mauve
}
MetricRow {
label: "RAM"
percentage: metrics.ramPercent
value: metrics.ramPercent >= 0
? metrics.ramUsedGiB.toFixed(1) + " / " + metrics.ramTotalGiB.toFixed(1)
+ " GiB " + Math.round(metrics.ramPercent) + " %" : "—"
accent: metrics.ramPercent >= 90 ? Theme.danger : metrics.ramPercent >= 75 ? Theme.urgent : Theme.mauve
}
MetricRow {
visible: metrics.gpuPercent >= 0 || metrics.gpuTemp >= 0
label: "GPU"
percentage: metrics.gpuPercent
value: (metrics.gpuPercent >= 0 ? Math.round(metrics.gpuPercent) + " %" : "")
+ (metrics.gpuTemp >= 0 ? (metrics.gpuPercent >= 0 ? " " : "")
+ Math.round(metrics.gpuTemp) + " °C" : "")
}
MetricRow {
label: "Disque /"
percentage: metrics.diskPercent
value: metrics.diskPercent >= 0
? metrics.diskUsedGiB.toFixed(0) + " / " + metrics.diskTotalGiB.toFixed(0)
+ " GiB " + Math.round(metrics.diskPercent) + " %" : "—"
}
Row {
width: parent.width
Text { id: loadLabel; text: "Charge (1/5/15 min)"; color: Theme.foreground; font.pixelSize: 12 }
Item { width: Math.max(0, parent.width - loadValue.width - loadLabel.width); height: 1 }
Text { id: loadValue; text: metrics.loadAverage || "—"; color: Theme.subtext1; font.pixelSize: 12 }
}
Row {
width: parent.width
Text { id: uptimeLabel; text: "En marche"; color: Theme.foreground; font.pixelSize: 12 }
Item { width: Math.max(0, parent.width - uptimeValue.width - uptimeLabel.width); height: 1 }
Text { id: uptimeValue; text: metrics.uptimeText || "—"; color: Theme.subtext1; font.pixelSize: 12 }
}
}
}
}

View file

@ -0,0 +1,35 @@
import QtQuick
import "../components"
import "../status"
import "../theme"
Rectangle {
id: button
required property bool open
signal toggled()
width: 26
height: 26
radius: 4
color: open ? Theme.accent : pointer.containsMouse ? Theme.border : Theme.buttonBackground
NerdIcon {
anchors.centerIn: parent
text: "󰍛"
// WHY: compenser la surface plus faible du glyphe sans agrandir le bouton.
font.pixelSize: 20
color: button.open ? Theme.onAccent : Theme.foreground
}
MouseArea {
id: pointer
anchors.fill: parent
acceptedButtons: Qt.LeftButton
hoverEnabled: true
onClicked: button.toggled()
}
StatusTooltip {
target: button
hovered: pointer.containsMouse && !button.open
message: "Moniteur système"
}
}

View file

@ -41,7 +41,8 @@ n'atteint pas 4,5:1 avec une couleur Catppuccin ; le noir atteint environ
Les 34 usages historiques de Mauve ont été classés par rôle : sélection,
focus, indicateur ou contrôle utilisent maintenant l'accent principal
Lavender. Ce choix rend les contrôles historiques Mauve légèrement plus bleus
Lavender. Les séries CPU/RAM du System Monitor restent intentionnellement
Mauve. Ce choix rend les contrôles historiques Mauve légèrement plus bleus
qu'avant. Les trois anciens Lavender restent l'accent principal.
L'API de thème est `AppearanceController.setManualFlavor(flavor)` et